Changeset 0.26.0 (#165)

This commit is contained in:
2026-03-10 13:38:01 +00:00
parent dbc1a97343
commit 400f7dd176
48 changed files with 4923 additions and 208 deletions

View File

@@ -8,6 +8,7 @@
const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
workflows: ['workflows'],
routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions', 'channels', 'surfaces'],
monitoring: ['usage', 'audit', 'stats'],
@@ -17,6 +18,7 @@ const ADMIN_LABELS = {
users: 'Users', teams: 'Teams', groups: 'Groups',
providers: 'Providers', models: 'Models', personas: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
workflows: 'Workflows',
settings: 'Settings', storage: 'Storage', extensions: 'Extensions', channels: 'Channels', surfaces: 'Surfaces',
usage: 'Usage', audit: 'Audit', stats: 'Stats',
};
@@ -49,6 +51,7 @@ const ADMIN_LOADERS = {
audit: () => UI.loadAuditLog(),
stats: () => UI.loadAdminStats(),
channels: () => UI.loadAdminChannels(),
workflows: () => typeof _loadAdminWorkflows === 'function' ? _loadAdminWorkflows() : null,
};
// Find which category a section belongs to

View File

@@ -525,11 +525,13 @@ const UI = {
// Chats section shows only direct/group chats not in a project.
// type='channel' and type='dm' belong to the Channels section (App.channels).
// type='workflow' belongs to the Queue section (WorkflowQueue).
// Project chats rendered by renderProjectsSection.
let chats = (App.chats || []).filter(c =>
!c.projectId &&
c.type !== 'channel' &&
c.type !== 'dm'
c.type !== 'dm' &&
c.type !== 'workflow'
);
if (query) chats = chats.filter(c => (c.title || '').toLowerCase().includes(query));
@@ -625,6 +627,9 @@ const UI = {
// Always keep projects section in sync
this.renderProjectsSection();
// Refresh workflow queue section (workflow channels filtered out of chat list)
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
},
toggleFolder(folderId) {

415
src/js/workflow-admin.js Normal file
View File

@@ -0,0 +1,415 @@
// ==========================================
// Chat Switchboard — Workflow Builder (Admin)
// ==========================================
// Admin panel for managing workflow definitions, stages, and publishing.
// Registered as ADMIN_LOADERS.workflows in ui-admin.js.
(function() {
'use strict';
// ── Scaffold HTML ────────────────────────
// Injected directly into adminDynamic by the loader since SCAFFOLDING
// is scoped inside admin-scaffold.js's IIFE and not accessible here.
var SCAFFOLD_HTML =
'<div id="wfAdminList" class="admin-list"></div>' +
'<div id="wfAdminDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">' +
'<button class="btn-small" id="wfBackBtn">&larr; Back</button>' +
'<h4 id="wfDetailName" style="margin:0;flex:1"></h4>' +
'<span id="wfDetailVersion" class="badge"></span>' +
'<span id="wfDetailStatus" class="badge"></span>' +
'</div>' +
'<div class="settings-section" style="margin-bottom:16px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Name</label>' +
'<input type="text" id="wfEditName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>Slug</label>' +
'<input type="text" id="wfEditSlug" style="width:100%" readonly></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:2"><label>Description</label>' +
'<textarea id="wfEditDesc" rows="2" style="width:100%"></textarea></div>' +
'<div class="form-group" style="flex:1"><label>Entry Mode</label>' +
'<select id="wfEditEntry" style="width:100%">' +
'<option value="authenticated">Authenticated</option>' +
'<option value="public_link">Public Link</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:12px;align-items:center">' +
'<label class="toggle-label"><input type="checkbox" id="wfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
'<button class="btn-small btn-primary" id="wfSaveBtn">Save Changes</button>' +
'<button class="btn-small" id="wfPublishBtn">Publish</button>' +
'<button class="btn-small btn-danger" id="wfDeleteBtn" style="margin-left:auto">Delete</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:0 0 8px">Stages</h5>' +
'<div id="wfStageList" class="admin-list"></div>' +
'<button class="btn-small" id="wfAddStageBtn" style="margin-top:8px">+ Add Stage</button>' +
'<div id="wfStageEditor" class="settings-section" style="display:none;margin-top:12px">' +
'<div class="form-row" style="gap:12px">' +
'<div class="form-group" style="flex:2"><label>Stage Name</label>' +
'<input type="text" id="wfStageName" style="width:100%"></div>' +
'<div class="form-group" style="flex:1"><label>History Mode</label>' +
'<select id="wfStageHistory" style="width:100%">' +
'<option value="full">Full</option>' +
'<option value="summary">Summary</option>' +
'<option value="fresh">Fresh</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Persona</label>' +
'<select id="wfStagePersona" style="width:100%">' +
'<option value="">None (no AI for this stage)</option>' +
'</select></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>System Prompt</label>' +
'<textarea id="wfStagePrompt" rows="3" style="width:100%"></textarea></div>' +
'</div>' +
'<div class="form-row" style="gap:12px;margin-top:8px">' +
'<div class="form-group" style="flex:1"><label>Form Template (JSON)</label>' +
'<textarea id="wfStageForm" rows="3" style="width:100%;font-family:monospace;font-size:12px" placeholder=\'{"name":{"description":"Full name","required":true}}\'></textarea></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px;gap:8px">' +
'<button class="btn-small btn-primary" id="wfSaveStageBtn">Save Stage</button>' +
'<button class="btn-small" id="wfCancelStageBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<h5 style="margin:16px 0 8px">Public URL</h5>' +
'<div id="wfPublicUrl" class="text-muted" style="font-size:12px;word-break:break-all"></div>' +
'</div>';
// ── State ───────────────────────────────
var _currentWf = null;
var _editingStageId = null;
var _personaCache = []; // [{id, name, icon}]
// Load personas into the stage editor dropdown.
async function _wfLoadPersonaSelect(selectedId) {
var sel = document.getElementById('wfStagePersona');
if (!sel) return;
// Fetch once per detail session
if (_personaCache.length === 0) {
try {
var resp = await API.adminListPersonas();
_personaCache = (resp.data || resp || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e) {
// Fall back to user-visible personas
try {
var resp2 = await API.listPersonas();
_personaCache = (resp2.data || resp2 || []).map(function(p) {
return { id: p.id, name: p.name, icon: p.icon || '' };
});
} catch (e2) { /* no personas available */ }
}
}
sel.innerHTML = '<option value="">None (no AI for this stage)</option>';
_personaCache.forEach(function(p) {
var opt = document.createElement('option');
opt.value = p.id;
opt.textContent = (p.icon ? p.icon + ' ' : '') + p.name;
sel.appendChild(opt);
});
if (selectedId) sel.value = selectedId;
}
// ── Loader (called from ADMIN_LOADERS.workflows) ──
window._loadAdminWorkflows = async function() {
// Self-scaffold: inject our HTML into adminDynamic
var target = document.getElementById('adminDynamic');
if (target && !document.getElementById('wfAdminList')) {
target.innerHTML = SCAFFOLD_HTML;
}
const list = document.getElementById('wfAdminList');
const detail = document.getElementById('wfAdminDetail');
if (!list) return;
detail.style.display = 'none';
list.style.display = '';
try {
const resp = await API.listWorkflows();
const wfs = resp.data || resp || [];
if (wfs.length === 0) {
list.innerHTML =
'<div class="empty-hint">No workflows yet</div>' +
'<button class="btn-small btn-primary" style="margin-top:8px" ' +
'onclick="_wfCreate()">+ Create Workflow</button>';
return;
}
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
'<button class="btn-small btn-primary" onclick="_wfCreate()">+ New</button></div>';
html += '<table class="admin-table"><thead><tr>' +
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
'</tr></thead><tbody>';
for (const wf of wfs) {
html += '<tr style="cursor:pointer" onclick="_wfOpen(\'' + wf.id + '\')">' +
'<td>' + esc(wf.name) + '</td>' +
'<td><code>' + esc(wf.slug) + '</code></td>' +
'<td>' + esc(wf.entry_mode || 'authenticated') + '</td>' +
'<td>' + (wf.is_active ? '✓' : '—') + '</td>' +
'<td>v' + (wf.version || 1) + '</td>' +
'</tr>';
}
html += '</tbody></table>';
list.innerHTML = html;
} catch (e) {
list.innerHTML = '<div class="empty-hint">Failed to load: ' + esc(e.message) + '</div>';
}
};
// ── Create ──────────────────────────────
window._wfCreate = async function() {
const name = prompt('Workflow name:');
if (!name) return;
try {
const wf = await API.createWorkflow({ name });
UI.toast('Workflow created', 'success');
_wfOpen(wf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
// ── Open Detail ─────────────────────────
window._wfOpen = async function(id) {
const list = document.getElementById('wfAdminList');
const detail = document.getElementById('wfAdminDetail');
try {
const wf = await API.getWorkflow(id);
_currentWf = wf;
list.style.display = 'none';
detail.style.display = '';
document.getElementById('wfDetailName').textContent = wf.name;
document.getElementById('wfDetailVersion').textContent = 'v' + (wf.version || 1);
document.getElementById('wfDetailStatus').textContent = wf.is_active ? 'Active' : 'Inactive';
document.getElementById('wfDetailStatus').className = 'badge ' + (wf.is_active ? 'badge-ok' : 'badge-warn');
document.getElementById('wfEditName').value = wf.name;
document.getElementById('wfEditSlug').value = wf.slug;
document.getElementById('wfEditDesc').value = wf.description || '';
document.getElementById('wfEditEntry').value = wf.entry_mode || 'authenticated';
document.getElementById('wfEditActive').checked = wf.is_active;
// Public URL
const scope = wf.team_id || 'global';
const base = location.origin + (window.__BASE__ || '');
document.getElementById('wfPublicUrl').textContent =
wf.entry_mode === 'public_link'
? base + '/w/' + scope + '/' + wf.slug
: 'Enable "Public Link" entry mode to generate a URL';
// Preload persona cache for stage list display
await _wfLoadPersonaSelect('');
// Load stages
await _wfLoadStages(id);
_wfWireEvents();
} catch (e) {
UI.toast('Failed to load workflow: ' + e.message, 'error');
}
};
// ── Stage List ──────────────────────────
async function _wfLoadStages(wfId) {
const el = document.getElementById('wfStageList');
if (!el) return;
try {
const resp = await API.listStages(wfId);
const stages = resp.data || resp || [];
if (stages.length === 0) {
el.innerHTML = '<div class="empty-hint">No stages — add one below</div>';
return;
}
let html = '';
stages.forEach(function(s, i) {
var personaLabel = '';
if (s.persona_id) {
var p = _personaCache.find(function(x) { return x.id === s.persona_id; });
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
'<span class="badge">persona</span>';
}
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
'<span class="wf-stage-grip">⠿</span>' +
'<span class="wf-stage-ord">' + i + '</span>' +
'<span class="wf-stage-name">' + esc(s.name) + '</span>' +
personaLabel +
'<span class="badge">' + (s.history_mode || 'full') + '</span>' +
'<button class="btn-small" onclick="_wfEditStage(\'' + s.id + '\')">Edit</button>' +
'<button class="btn-small btn-danger" onclick="_wfDeleteStage(\'' + s.id + '\')">×</button>' +
'</div>';
});
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
}
}
// ── Wire Events (called once per detail open) ──
var _wired = false;
function _wfWireEvents() {
if (_wired) return;
_wired = true;
document.getElementById('wfBackBtn').onclick = function() {
_currentWf = null;
_editingStageId = null;
_personaCache = [];
_wired = false;
_loadAdminWorkflows();
};
document.getElementById('wfSaveBtn').onclick = async function() {
if (!_currentWf) return;
try {
await API.updateWorkflow(_currentWf.id, {
name: document.getElementById('wfEditName').value.trim(),
description: document.getElementById('wfEditDesc').value.trim(),
entry_mode: document.getElementById('wfEditEntry').value,
is_active: document.getElementById('wfEditActive').checked,
});
UI.toast('Saved', 'success');
_wfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('wfPublishBtn').onclick = async function() {
if (!_currentWf) return;
try {
const ver = await API.publishWorkflow(_currentWf.id);
UI.toast('Published v' + ver.version_number, 'success');
_wfOpen(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('wfDeleteBtn').onclick = async function() {
if (!_currentWf) return;
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
try {
await API.deleteWorkflow(_currentWf.id);
UI.toast('Deleted', 'success');
_currentWf = null;
_personaCache = [];
_wired = false;
_loadAdminWorkflows();
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
document.getElementById('wfAddStageBtn').onclick = async function() {
_editingStageId = null;
document.getElementById('wfStageName').value = '';
document.getElementById('wfStageHistory').value = 'full';
document.getElementById('wfStagePersona').value = '';
document.getElementById('wfStagePrompt').value = '';
document.getElementById('wfStageForm').value = '';
await _wfLoadPersonaSelect('');
document.getElementById('wfStageEditor').style.display = '';
};
document.getElementById('wfCancelStageBtn').onclick = function() {
document.getElementById('wfStageEditor').style.display = 'none';
_editingStageId = null;
};
document.getElementById('wfSaveStageBtn').onclick = async function() {
if (!_currentWf) return;
var name = document.getElementById('wfStageName').value.trim();
if (!name) { UI.toast('Stage name required', 'error'); return; }
var formRaw = document.getElementById('wfStageForm').value.trim();
var formTemplate = null;
if (formRaw) {
try { formTemplate = JSON.parse(formRaw); }
catch (e) { UI.toast('Invalid JSON in form template', 'error'); return; }
}
var personaId = document.getElementById('wfStagePersona').value || null;
var data = {
name: name,
history_mode: document.getElementById('wfStageHistory').value,
persona_id: personaId,
system_prompt: document.getElementById('wfStagePrompt').value.trim() || undefined,
form_template: formTemplate,
};
try {
if (_editingStageId) {
await API.updateStage(_currentWf.id, _editingStageId, data);
} else {
data.ordinal = document.querySelectorAll('.wf-stage-row').length;
await API.createStage(_currentWf.id, data);
}
UI.toast('Stage saved', 'success');
document.getElementById('wfStageEditor').style.display = 'none';
_editingStageId = null;
await _wfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
}
// ── Edit / Delete Stage ─────────────────
window._wfEditStage = async function(stageId) {
if (!_currentWf) return;
try {
const resp = await API.listStages(_currentWf.id);
const stages = resp.data || resp || [];
const stage = stages.find(function(s) { return s.id === stageId; });
if (!stage) return;
_editingStageId = stageId;
document.getElementById('wfStageName').value = stage.name;
document.getElementById('wfStageHistory').value = stage.history_mode || 'full';
await _wfLoadPersonaSelect(stage.persona_id || '');
document.getElementById('wfStagePrompt').value = stage.system_prompt || '';
document.getElementById('wfStageForm').value =
stage.form_template ? JSON.stringify(stage.form_template, null, 2) : '';
document.getElementById('wfStageEditor').style.display = '';
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
window._wfDeleteStage = async function(stageId) {
if (!_currentWf) return;
if (!confirm('Delete this stage?')) return;
try {
await API.deleteStage(_currentWf.id, stageId);
UI.toast('Stage deleted', 'success');
await _wfLoadStages(_currentWf.id);
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
};
})();

102
src/js/workflow-api.js Normal file
View File

@@ -0,0 +1,102 @@
// ==========================================
// Chat Switchboard — Workflow API Methods
// ==========================================
// Extends the global API object with workflow CRUD, stage management,
// publishing, instance lifecycle, and assignment queue operations.
// Loaded after api.js on the chat and admin surfaces.
(function() {
'use strict';
if (typeof API === 'undefined') return;
// ── Workflow Definitions ────────────────
API.listWorkflows = function(teamId) {
const q = teamId ? `?team_id=${teamId}` : '';
return this._get('/api/v1/workflows' + q);
};
API.getWorkflow = function(id) {
return this._get(`/api/v1/workflows/${id}`);
};
API.createWorkflow = function(data) {
return this._post('/api/v1/workflows', data);
};
API.updateWorkflow = function(id, patch) {
return this._patch(`/api/v1/workflows/${id}`, patch);
};
API.deleteWorkflow = function(id) {
return this._del(`/api/v1/workflows/${id}`);
};
// ── Stages ──────────────────────────────
API.listStages = function(workflowId) {
return this._get(`/api/v1/workflows/${workflowId}/stages`);
};
API.createStage = function(workflowId, data) {
return this._post(`/api/v1/workflows/${workflowId}/stages`, data);
};
API.updateStage = function(workflowId, stageId, data) {
return this._put(`/api/v1/workflows/${workflowId}/stages/${stageId}`, data);
};
API.deleteStage = function(workflowId, stageId) {
return this._del(`/api/v1/workflows/${workflowId}/stages/${stageId}`);
};
API.reorderStages = function(workflowId, orderedIds) {
return this._patch(`/api/v1/workflows/${workflowId}/stages/reorder`, { ordered_ids: orderedIds });
};
// ── Versioning ──────────────────────────
API.publishWorkflow = function(workflowId) {
return this._post(`/api/v1/workflows/${workflowId}/publish`, {});
};
API.getWorkflowVersion = function(workflowId, version) {
return this._get(`/api/v1/workflows/${workflowId}/versions/${version}`);
};
// ── Instances ───────────────────────────
API.startWorkflow = function(workflowId) {
return this._post(`/api/v1/workflows/${workflowId}/start`, {});
};
API.getWorkflowStatus = function(channelId) {
return this._get(`/api/v1/channels/${channelId}/workflow/status`);
};
API.advanceWorkflow = function(channelId, data) {
return this._post(`/api/v1/channels/${channelId}/workflow/advance`, { data });
};
API.rejectWorkflow = function(channelId, reason) {
return this._post(`/api/v1/channels/${channelId}/workflow/reject`, { reason });
};
// ── Assignments ─────────────────────────
API.listMyAssignments = function() {
return this._get('/api/v1/workflow-assignments/mine');
};
API.listTeamAssignments = function(teamId, status) {
return this._get(`/api/v1/teams/${teamId}/assignments?status=${status || 'unassigned'}`);
};
API.claimAssignment = function(id) {
return this._post(`/api/v1/workflow-assignments/${id}/claim`, {});
};
API.completeAssignment = function(id) {
return this._post(`/api/v1/workflow-assignments/${id}/complete`, {});
};
})();

200
src/js/workflow-queue.js Normal file
View File

@@ -0,0 +1,200 @@
// ==========================================
// Chat Switchboard — Workflow Queue UI
// ==========================================
// Sidebar section showing:
// 1. Active workflow channels (type='workflow' from App.chats)
// 2. Claimed assignments from team queues
// Loaded on the chat surface after workflow-api.js.
(function() {
'use strict';
window.WorkflowQueue = {
_loaded: false,
_myAssignments: [],
// ── Init ────────────────────────────
async init() {
if (this._loaded) return;
this._loaded = true;
// Insert sidebar section after channels
var channelsSec = document.getElementById('sbSectionChannels');
if (!channelsSec) return;
var section = document.createElement('div');
section.className = 'sb-section';
section.id = 'sbSectionQueue';
section.innerHTML =
'<div class="sb-section-header" onclick="UI.toggleSidebarSection(\'queue\')">' +
'<span class="sb-section-arrow" id="sbArrowQueue">▾</span>' +
'<span class="sb-section-label">Workflows</span>' +
'<span class="sb-queue-badge" id="sbQueueBadge" style="display:none">0</span>' +
'</div>' +
'<div class="sb-section-body" id="sbBodyQueue"></div>';
channelsSec.parentNode.insertBefore(section, channelsSec.nextSibling);
this.refresh();
},
// ── Refresh ─────────────────────────
// Called after loadChats() and on assignment changes.
async refresh() {
var body = document.getElementById('sbBodyQueue');
var badge = document.getElementById('sbQueueBadge');
if (!body) return;
// 1. Workflow channels from App.chats
var workflows = (App.chats || []).filter(function(c) {
return c.type === 'workflow';
});
// 2. Claimed assignments (async, best-effort)
var assignments = [];
try {
var resp = await API.listMyAssignments();
assignments = resp.data || [];
this._myAssignments = assignments;
} catch (e) {
// Non-critical — show channels even if assignments fail
}
var total = workflows.length + assignments.length;
if (badge) {
badge.textContent = total;
badge.style.display = total > 0 ? '' : 'none';
}
if (total === 0) {
body.innerHTML = '<div class="sb-section-empty">No active workflows</div>';
return;
}
var html = '';
// Workflow channels
workflows.forEach(function(wf) {
var isActive = App.activeId === wf.id;
html += '<div class="sb-queue-item' + (isActive ? ' active' : '') + '" ' +
'onclick="selectChannel(\'' + wf.id + '\')" ' +
'title="' + esc(wf.title) + '">' +
'<span class="sb-queue-icon">⚡</span>' +
'<span class="sb-queue-label">' + esc(wf.title || 'Workflow') + '</span>' +
'</div>';
});
// Claimed assignments (deduplicated against channel list)
if (assignments.length > 0) {
assignments.forEach(function(a) {
var alreadyShown = workflows.some(function(w) { return w.id === a.channel_id; });
if (alreadyShown) return;
html += '<div class="sb-queue-item" ' +
'onclick="WorkflowQueue.openAssignment(\'' + a.id + '\',\'' + a.channel_id + '\')">' +
'<span class="sb-queue-icon">📋</span>' +
'<span class="sb-queue-label">Assignment: Stage ' + a.stage + '</span>' +
'<button class="btn-small" onclick="event.stopPropagation();WorkflowQueue.complete(\'' + a.id + '\')">✓</button>' +
'</div>';
});
}
body.innerHTML = html;
},
// ── Open Assignment ─────────────────
openAssignment(assignmentId, channelId) {
if (typeof selectChannel === 'function') {
selectChannel(channelId);
}
},
// ── Claim ───────────────────────────
async claim(assignmentId) {
try {
await API.claimAssignment(assignmentId);
UI.toast('Assignment claimed', 'success');
await this.refresh();
} catch (e) {
UI.toast('Failed to claim: ' + e.message, 'error');
}
},
// ── Complete ────────────────────────
async complete(assignmentId) {
try {
await API.completeAssignment(assignmentId);
UI.toast('Assignment completed', 'success');
await this.refresh();
} catch (e) {
UI.toast('Failed: ' + e.message, 'error');
}
},
// ── Team Queue Panel ────────────────
async openTeamPanel(teamId) {
try {
var unassigned = await API.listTeamAssignments(teamId, 'unassigned');
var claimed = await API.listTeamAssignments(teamId, 'claimed');
var uItems = unassigned.data || [];
var cItems = claimed.data || [];
if (uItems.length === 0 && cItems.length === 0) {
UI.toast('Queue is empty', 'info');
return;
}
var html = '<div class="wf-queue-panel"><h4>Team Queue</h4>';
if (uItems.length > 0) {
html += '<h5>Unassigned (' + uItems.length + ')</h5>';
html += '<table class="admin-table"><thead><tr><th>Stage</th><th>Created</th><th></th></tr></thead><tbody>';
uItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</td>' +
'<td>' + new Date(a.created_at).toLocaleDateString() + '</td>' +
'<td><button class="btn-small btn-primary" onclick="WorkflowQueue.claim(\'' + a.id + '\')">Claim</button></td></tr>';
});
html += '</tbody></table>';
}
if (cItems.length > 0) {
html += '<h5>In Progress (' + cItems.length + ')</h5>';
html += '<table class="admin-table"><thead><tr><th>Stage</th><th>Assigned</th><th></th></tr></thead><tbody>';
cItems.forEach(function(a) {
html += '<tr><td>Stage ' + a.stage + '</td>' +
'<td>' + (a.assigned_to || '—') + '</td>' +
'<td><button class="btn-small" onclick="WorkflowQueue.complete(\'' + a.id + '\')">Complete</button></td></tr>';
});
html += '</tbody></table>';
}
html += '</div>';
if (typeof showModal === 'function') {
showModal('Team Queue', html);
} else {
var el = document.getElementById('adminDynamic');
if (el) el.innerHTML = html;
}
} catch (e) {
UI.toast('Failed to load queue: ' + e.message, 'error');
}
},
};
// Auto-init when chat surface loads
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
if (typeof API !== 'undefined' && API.accessToken) {
WorkflowQueue.init();
}
}, 2000);
});
})();