feat: Phase 4 — topic CRUD UI in 'ניהול' tab

Adds a topics-management panel above the sources table. Admins can
create new legal domains (דיני עבודה, דין פלילי, נדל"ן) without
running SQL — slug + name + description + system prompt addendum
all live in one inline form. Per-row actions:

  ✏ Edit          — opens inline form with all fields except slug
                    (slug is locked post-create — the S3 layout
                    depends on it)
  👁 Toggle active — soft-disable: hides the topic from the user-facing
                    <select> while keeping data intact. Eye icon
                    flips between "השבת" and "הפעל"
  🗑 Delete       — hard-delete; only allowed when source_count=0.
                    Otherwise the UI directs the user to soft-disable
                    instead

Any topic mutation (create / rename / toggle / delete) invalidates
the cached _topics list so the user-facing dropdown re-fetches
immediately — admins don't have to refresh the page to see their
own changes.

Service.request() now distinguishes 4xx from 5xx upstream errors:
4xx is rethrown as BadRequest (so the user sees "slug already exists"
instead of a generic 500), 5xx stays as Error.

Backend: depends on shira-hermes commit 49503ca (admin topic
endpoints). No new migration — kb_topic schema from migration 001
is sufficient.

Description field in manifest was rewritten to Hebrew to match the
in-CRM scopeNames label ("מאגר ידע").

Refs Task Master #15

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 17:46:51 +00:00
parent d051b268dd
commit db7b30e3c0
7 changed files with 413 additions and 8 deletions
@@ -72,6 +72,23 @@
{{#ifEqual mode 'manage'}}
<div class="kb-manage" style="display:flex;flex-direction:column;gap:16px;">
<div class="panel panel-default kb-topics-panel" style="padding:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
<h4 style="margin:0;">נושאים (תחומי משפט)</h4>
<div style="display:flex;gap:8px;">
<button type="button" class="btn btn-default btn-sm" data-action="refreshTopics" title="רענן">
<span class="glyphicon glyphicon-refresh"></span>
</button>
<button type="button" class="btn btn-primary btn-sm" data-action="newTopic">
<span class="glyphicon glyphicon-plus"></span> נושא חדש
</button>
</div>
</div>
<div class="kb-topics-table">
<div class="text-muted">טוען נושאים…</div>
</div>
</div>
<div class="panel panel-default kb-sources-panel" style="padding:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
<h4 style="margin:0;">מקורות בנושא<span class="kb-topic-name-suffix"></span></h4>
@@ -16,6 +16,7 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
let _activeJobTopicId = null; // topic this job belongs to (for cross-topic guard)
let _pollIntervalId = null; // setInterval handle so remounts don't double-poll
let _adminSourcesByTopic = {}; // {topicId: [...sources]} cache for the manage tab
let _adminTopics = null; // [...topics admin view] cache (incl. is_active=false)
const SS_ASK = 'kb-last-ask';
const SS_SEARCH = 'kb-last-search';
@@ -156,6 +157,39 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this._reingestSource(id);
},
'click [data-action="refreshTopics"]': function (e) {
e.preventDefault();
this._loadAdminTopics(/* force */ true);
},
'click [data-action="newTopic"]': function (e) {
e.preventDefault();
this._openTopicForm(null);
},
'click [data-action="editTopic"]': function (e) {
e.preventDefault();
const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this._openTopicForm(id);
},
'click [data-action="cancelTopicEdit"]': function (e) {
e.preventDefault();
this._renderTopicsTable();
},
'click [data-action="saveTopic"]': function (e) {
e.preventDefault();
const idAttr = $(e.currentTarget).data('id');
const id = (idAttr === '' || idAttr == null) ? null : parseInt(idAttr, 10);
this._saveTopic(id);
},
'click [data-action="toggleTopicActive"]': function (e) {
e.preventDefault();
const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this._toggleTopicActive(id);
},
'click [data-action="deleteTopic"]': function (e) {
e.preventDefault();
const id = parseInt($(e.currentTarget).data('id'), 10);
if (id) this._deleteTopic(id);
},
},
// Cancel any in-flight ask/search, drop the cached last-result for
@@ -249,6 +283,8 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
this._loadJobsList();
// Phase 3: load the sources table when the manage tab is open.
this._loadAdminSources();
// Phase 4: load the topics admin table (firm-wide, not topic-scoped).
this._loadAdminTopics();
},
_loadJobsList: function () {
@@ -307,6 +343,241 @@ define('modules/knowledge-base/views/kb/index', ['view'], function (Dep) {
$list.html(rows);
},
// ── Phase 4: topics admin (Task #15) ───────────────────────────────
_loadAdminTopics: function (force) {
const self = this;
const $table = this.$el.find('.kb-topics-table');
if (!$table.length) return;
if (!force && _adminTopics) {
this._renderTopicsTable();
return;
}
$table.html('<div class="text-muted">טוען נושאים…</div>');
Espo.Ajax.getRequest('KnowledgeBase/action/adminTopics')
.then(res => {
_adminTopics = (res && res.items) || [];
self._renderTopicsTable();
})
.catch(err => {
console.error('KB: failed to load admin topics', err);
$table.html('<div class="text-muted">שגיאה בטעינת נושאים.</div>');
});
},
_renderTopicsTable: function () {
const $table = this.$el.find('.kb-topics-table');
if (!$table.length) return;
const items = _adminTopics || [];
if (!items.length) {
$table.html('<div class="text-muted">אין נושאים. לחץ "נושא חדש" כדי להוסיף את הראשון.</div>');
return;
}
const rows = items.map(t => {
const activeBadge = t.is_active
? '<span class="label label-success">פעיל</span>'
: '<span class="label label-default">מושבת</span>';
const sources = (t.source_count != null) ? t.source_count : 0;
const desc = t.description ? `<div class="text-muted small">${this.escape(t.description)}</div>` : '';
return `<tr data-id="${t.id}">
<td><code style="direction:ltr;">${this.escape(t.slug)}</code></td>
<td>
<div style="font-weight:500;">${this.escape(t.name)}</div>
${desc}
</td>
<td class="text-center">${sources}</td>
<td class="text-center">${activeBadge}</td>
<td style="white-space:nowrap;text-align:left;">
<button class="btn btn-link btn-sm" data-action="editTopic" data-id="${t.id}" title="ערוך"><span class="glyphicon glyphicon-pencil"></span></button>
<button class="btn btn-link btn-sm" data-action="toggleTopicActive" data-id="${t.id}" title="${t.is_active ? 'השבת' : 'הפעל'}"><span class="glyphicon glyphicon-${t.is_active ? 'eye-close' : 'eye-open'}"></span></button>
<button class="btn btn-link btn-sm" data-action="deleteTopic" data-id="${t.id}" title="מחק" style="color:#d9534f;"><span class="glyphicon glyphicon-trash"></span></button>
</td>
</tr>`;
}).join('');
$table.html(`
<table class="table table-condensed table-hover" style="margin:0;">
<thead>
<tr>
<th style="width:160px;">slug</th>
<th>שם / תיאור</th>
<th style="width:60px;text-align:center;">מקורות</th>
<th style="width:80px;text-align:center;">סטטוס</th>
<th style="width:120px;"></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`);
},
_findTopicById: function (id) {
const arr = _adminTopics || [];
return arr.find(t => t.id === id);
},
_openTopicForm: function (id) {
const isNew = id == null;
const t = isNew ? null : this._findTopicById(id);
if (!isNew && !t) return;
const $tbody = this.$el.find('.kb-topics-table tbody');
if (!$tbody.length) return;
const safe = (v) => this.escape(v == null ? '' : String(v));
// For new topics, prepend a fresh editable row. For edits, replace
// the existing row in place. Either way the form lives inside one
// <td colspan="5"> for layout simplicity.
const formHtml = `
<tr data-id="${isNew ? '' : id}" class="kb-topic-edit-row">
<td colspan="5">
<div style="display:flex;flex-direction:column;gap:8px;padding:8px;background:#fafafa;border-radius:4px;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">slug:</label>
<input type="text" class="form-control" data-edit="slug" value="${safe(t && t.slug)}" ${isNew ? '' : 'disabled'} placeholder="employment-law" style="flex:0 0 240px;direction:ltr;" />
<span class="text-muted small">${isNew ? 'אותיות קטנות, מקפים. לא ניתן לשנות לאחר היצירה.' : 'אינו ניתן לשינוי.'}</span>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">שם:</label>
<input type="text" class="form-control" data-edit="name" value="${safe(t && t.name)}" placeholder="דיני עבודה" style="flex:1 1 240px;" />
</div>
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;padding-top:6px;">תיאור:</label>
<input type="text" class="form-control" data-edit="description" value="${safe(t && t.description)}" placeholder="תיאור קצר של הנושא (אופציונלי)" style="flex:1 1 240px;" />
</div>
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap;">
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;padding-top:6px;">תוספת לפרומפט:</label>
<textarea class="form-control" data-edit="system_prompt_addendum" rows="4" placeholder="הקשר נוסף שיינתן ל-LLM. למשל: 'You are answering legal questions about employment law in Israel. Cite sections explicitly when possible.'" style="flex:1 1 240px;direction:ltr;font-family:monospace;font-size:0.9em;">${safe(t && t.system_prompt_addendum)}</textarea>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<label class="text-muted" style="margin:0;font-weight:normal;min-width:80px;">פעיל:</label>
<input type="checkbox" data-edit="is_active" ${(!t || t.is_active) ? 'checked' : ''} />
<span class="text-muted small">מוסתר ממשתמשים אם לא פעיל.</span>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:4px;">
<button class="btn btn-default btn-sm" data-action="cancelTopicEdit">ביטול</button>
<button class="btn btn-primary btn-sm" data-action="saveTopic" data-id="${isNew ? '' : id}">${isNew ? 'צור' : 'שמור'}</button>
</div>
</div>
</td>
</tr>
`;
if (isNew) {
$tbody.prepend(formHtml);
this.$el.find('.kb-topic-edit-row input[data-edit="slug"]').trigger('focus');
} else {
const $row = this.$el.find(`.kb-topics-table tr[data-id="${id}"]`);
$row.replaceWith(formHtml);
this.$el.find('.kb-topic-edit-row input[data-edit="name"]').trigger('focus');
}
},
_saveTopic: function (id) {
const self = this;
const $row = this.$el.find('.kb-topic-edit-row');
if (!$row.length) return;
const payload = {};
$row.find('[data-edit]').each(function () {
const k = $(this).data('edit');
if ($(this).is(':checkbox')) {
payload[k] = $(this).is(':checked');
} else if (!$(this).is('[disabled]')) {
payload[k] = ($(this).val() || '').trim();
}
});
const isNew = id == null;
const url = isNew
? 'KnowledgeBase/action/createTopic'
: 'KnowledgeBase/action/updateTopic';
if (!isNew) {
payload.id = id;
// PUT semantics: don't send slug (it's locked) — Service whitelists anyway.
delete payload.slug;
}
const $btn = $row.find('[data-action="saveTopic"]');
const oldText = $btn.text();
$btn.prop('disabled', true).text('שומר…');
Espo.Ajax.postRequest(url, payload)
.then(saved => {
if (isNew) {
_adminTopics = (_adminTopics || []).concat([
Object.assign({source_count: 0}, saved),
]);
} else {
const idx = (_adminTopics || []).findIndex(t => t.id === id);
if (idx >= 0) {
_adminTopics[idx] = Object.assign({}, _adminTopics[idx], saved);
}
}
self._renderTopicsTable();
// The user-facing topic dropdown might now be stale (rename
// / new active topic) — invalidate it so afterRender re-fetches.
_topics = null;
_topicsPromise = null;
self._ensureTopicsLoaded().then(() => self._populateTopicPicker());
})
.catch(err => {
$btn.prop('disabled', false).text(oldText);
let msg = '';
try {
const j = JSON.parse(err && err.responseText || '{}');
msg = j.message || j.detail || (err && err.responseText) || 'שגיאה לא ידועה';
} catch (e) {
msg = (err && err.responseText) || 'שגיאה לא ידועה';
}
alert('שמירה נכשלה: ' + String(msg).slice(0, 300));
});
},
_toggleTopicActive: function (id) {
const t = this._findTopicById(id);
if (!t) return;
const next = !t.is_active;
const verb = next ? 'להפעיל' : 'להשבית';
if (!confirm(`${verb} את הנושא "${t.name}"?`)) return;
const self = this;
Espo.Ajax.postRequest('KnowledgeBase/action/updateTopic', {id, is_active: next})
.then(updated => {
const idx = (_adminTopics || []).findIndex(x => x.id === id);
if (idx >= 0) {
_adminTopics[idx] = Object.assign({}, _adminTopics[idx], updated);
}
self._renderTopicsTable();
_topics = null;
_topicsPromise = null;
self._ensureTopicsLoaded().then(() => self._populateTopicPicker());
})
.catch(err => alert('פעולה נכשלה: ' + ((err && err.responseText) || '').slice(0, 300)));
},
_deleteTopic: function (id) {
const t = this._findTopicById(id);
if (!t) return;
const cn = t.source_count || 0;
if (cn > 0) {
alert(`לא ניתן למחוק את "${t.name}" כי יש לו ${cn} מקורות.\n\nניתן להשבית את הנושא במקום זאת — הוא ייעלם מהדרופ-דאון אך הנתונים יישמרו.`);
return;
}
if (!confirm(`למחוק לחלוטין את הנושא "${t.name}" (slug=${t.slug})?\n\nאין לו מקורות, אבל הפעולה בלתי הפיכה.`)) return;
const self = this;
Espo.Ajax.postRequest('KnowledgeBase/action/deleteTopic', {id})
.then(() => {
_adminTopics = (_adminTopics || []).filter(x => x.id !== id);
self._renderTopicsTable();
_topics = null;
_topicsPromise = null;
self._ensureTopicsLoaded().then(() => self._populateTopicPicker());
})
.catch(err => {
let msg = '';
try {
const j = JSON.parse(err && err.responseText || '{}');
msg = j.message || j.detail || 'שגיאה לא ידועה';
} catch (e) {
msg = (err && err.responseText) || 'שגיאה לא ידועה';
}
alert('מחיקה נכשלה: ' + String(msg).slice(0, 300));
});
},
// ── Phase 3: sources table (Task #14) ──────────────────────────────
_loadAdminSources: function (force) {