Changeset 0.37.15 (#227)

This commit is contained in:
2026-03-23 19:58:17 +00:00
parent b7746c3004
commit d005e8a30f
23 changed files with 1764 additions and 170 deletions

View File

@@ -248,6 +248,16 @@ export function createDomains(restClient) {
updateWorkflow: (id, wfId, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}`, data),
deleteWorkflow: (id, wfId) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}`),
publishWorkflow: (id, wfId) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/publish`, {}),
// Team workflow stages (v0.37.15 — FE wiring for existing BE routes)
workflowStages: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}/stages`),
createWorkflowStage: (id, wfId, data) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/stages`, data),
updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data),
deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`),
reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }),
// Team assignments + monitor (v0.37.15)
assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`),
cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}),
// Team tasks
tasks: (id, opts) => rc.get(`/api/v1/teams/${id}/tasks` + _qs(opts)),
createTask: (id, data) => rc.post(`/api/v1/teams/${id}/tasks`, data),
@@ -265,6 +275,19 @@ export function createDomains(restClient) {
instances: (id, opts) => rc.get(`/api/v1/workflows/${id}/instances` + _qs(opts)),
advance: (id, data) => rc.post(`/api/v1/workflows/${id}/advance`, data),
reject: (id, data) => rc.post(`/api/v1/workflows/${id}/reject`, data),
cancel: (channelId) => rc.post(`/api/v1/channels/${channelId}/workflow/cancel`, {}),
},
// ── 15b. Workflow Assignments (v0.37.15) ─
workflowAssignments: {
mine: () => rc.get('/api/v1/workflow-assignments/mine'),
get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`),
claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}),
unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}),
complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}),
reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }),
cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}),
comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }),
},
// ── 16. Tasks ──────────────────────────

View File

@@ -45,7 +45,7 @@ const NAV_ITEMS = [
{ key: 'personas', label: 'Personas', gate: 'personas' },
{ key: 'profile', label: 'Profile' },
{ key: 'teams', label: 'Teams' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'workflows', label: 'Assignments' },
{ key: 'tasks', label: 'Tasks' },
{ key: 'gitkeys', label: 'Git Keys' },
{ key: 'knowledge', label: 'Knowledge Bases' },
@@ -64,7 +64,7 @@ const BYOK_ITEMS = [
const SECTION_TITLES = {
general: 'General', appearance: 'Appearance', models: 'Models',
personas: 'Personas', profile: 'Profile', teams: 'Teams',
workflows: 'Workflows', tasks: 'Tasks', gitkeys: 'Git Keys',
workflows: 'Assignments', tasks: 'Tasks', gitkeys: 'Git Keys',
data: 'Data & Privacy', providers: 'My Providers', roles: 'Model Roles',
usage: 'My Usage', knowledge: 'Knowledge Bases', memory: 'Memory',
notifications: 'Notifications',

View File

@@ -1,87 +1,137 @@
/**
* Settings > Workflows — read-only view of user's visible workflows
* Settings > Workflows — v0.37.15 rewrite
*
* Replaces read-only workflow list with MyAssignments (E4).
* Shows the user's personal assignment queue across all teams.
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
function _timeAgo(ts) {
if (!ts) return '';
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
export default function WorkflowsSection() {
const [workflows, setWorkflows] = useState([]);
const [assignments, setAssignments] = useState([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState(null);
const [stages, setStages] = useState([]);
const load = useCallback(async () => {
try {
const data = await sw.api.workflows.list();
setWorkflows(data || []);
const data = await sw.api.workflowAssignments.mine();
setAssignments(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, []);
async function openDetail(wf) {
setDetail(wf);
const userId = sw.auth?.user?.id;
const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === userId);
const available = assignments.filter(a => a.status === 'unassigned');
async function claim(id) {
try {
const d = await sw.api.workflows.get(wf.id);
setStages(d.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
await sw.api.workflowAssignments.claim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function unclaim(id) {
try {
await sw.api.workflowAssignments.unclaim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function complete(id) {
try {
await sw.api.workflowAssignments.complete(id);
sw.toast('Assignment completed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (detail) return html`
<div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => { setDetail(null); setStages([]); }}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4>
<span class="badge ${detail.is_active ? 'badge-active' : 'badge-inactive'}">
${detail.is_active ? 'active' : 'inactive'}
</span>
</div>
${detail.description && html`
<p class="text-muted" style="font-size:13px;margin:0 0 12px;">${detail.description}</p>
`}
<div style="display:flex;gap:12px;margin-bottom:16px;font-size:12px;color:var(--text-3);">
<span>Slug: <strong>/${detail.slug}</strong></span>
<span>Entry: <strong>${detail.entry_mode}</strong></span>
<span>Version: <strong>${detail.version || 0}</strong></span>
</div>
<h5 style="margin:0 0 8px;">Stages (${stages.length})</h5>
<div class="admin-list">
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode}</span>
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
</div>
`)}
</div>
</div>
`;
return html`
<div>
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows available.</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openDetail(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
</div>
<span class="badge">${w.entry_mode}</span>
<span class="badge ${w.is_active ? 'badge-active' : 'badge-inactive'}">
${w.is_active ? 'active' : 'inactive'}
</span>
<span class="text-muted" style="font-size:11px;">v${w.version || 0}</span>
</div>
`)}
${mine.length > 0 && html`
<h4 style="margin:0 0 8px;">Claimed (${mine.length})</h4>
<div class="admin-list">
${mine.map(a => html`
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
`)}
</div>
`}
${available.length > 0 && html`
<h4 style="margin:${mine.length > 0 ? '16px' : '0'} 0 8px;">Available (${available.length})</h4>
<div class="admin-list">
${available.map(a => html`
<${AssignmentRow} key=${a.id} assignment=${a} onAction=${load} showTeam />
`)}
</div>
`}
${mine.length === 0 && available.length === 0 && html`
<div class="empty-hint">No assignments</div>
`}
</div>
`;
}
function AssignmentRow({ assignment: a, onAction, showTeam }) {
const userId = sw.auth?.user?.id;
const isMine = a.assigned_to === userId;
async function claim() {
try {
await sw.api.workflowAssignments.claim(a.id);
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function unclaim() {
try {
await sw.api.workflowAssignments.unclaim(a.id);
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function complete() {
try {
await sw.api.workflowAssignments.complete(a.id);
sw.toast('Assignment completed', 'success');
onAction();
} catch (e) { sw.toast(e.message, 'error'); }
}
return html`
<div class="admin-surface-row" style="${isMine ? 'border-left:3px solid var(--accent-1);' : ''}">
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
${showTeam && a.team_name && html`<span class="badge">${a.team_name}</span>`}
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
</div>
<div style="display:flex;gap:4px;">
${a.status === 'unassigned' && html`
<button class="btn-small btn-primary" onClick=${claim}>Claim</button>
`}
${isMine && html`
<button class="btn-small" onClick=${unclaim}>Release</button>
<button class="btn-small btn-primary"
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
<button class="btn-small btn-success" onClick=${complete}>Complete</button>
`}
</div>
</div>
`;

View File

@@ -110,7 +110,7 @@ function TeamAdminSurface() {
// Team picker if multiple teams
if (!selectedTeam && adminTeams.length > 1) {
return html`
<div class="surface-settings">
<div class="surface-settings" style="flex-direction:column;">
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -144,7 +144,7 @@ function TeamAdminSurface() {
// No teams
if (!selectedTeam && adminTeams.length === 0) {
return html`
<div class="surface-settings">
<div class="surface-settings" style="flex-direction:column;">
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
@@ -167,7 +167,7 @@ function TeamAdminSurface() {
}
return html`
<div class="surface-settings">
<div class="surface-settings" style="flex-direction:column;">
${/* Top Bar */``}
<div class="settings-topbar">
<a href="${BASE}/" class="settings-topbar-back" onClick=${backClick}>
@@ -188,7 +188,7 @@ function TeamAdminSurface() {
</span>
</div>
<div class="settings-body">
<div style="display:flex;flex:1;min-height:0;">
${/* Left Nav */``}
<div class="settings-nav">
${SECTIONS.map(s => html`
@@ -202,15 +202,11 @@ function TeamAdminSurface() {
${/* Content */``}
<div class="settings-content">
<div class="settings-content-header">
<h2>${sectionLabel}</h2>
</div>
<div class="settings-content-body">
${SectionComponent && teamId
? html`<${SectionComponent} teamId=${teamId} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
}
</div>
<h2>${sectionLabel}</h2>
${SectionComponent && teamId
? html`<${SectionComponent} teamId=${teamId} />`
: html`<div class="settings-placeholder">Loading\u2026</div>`
}
</div>
</div>
</div>

View File

@@ -1,16 +1,67 @@
/**
* Team Admin > Workflows
* Team Admin > Workflows — v0.37.15 rewrite
*
* Tab layout: Workflows | Assignments | Monitor
* - Workflows: CRUD + inline stage editor (E2)
* - Assignments: Team assignment queue (E1)
* - Monitor: Active instance list with cancel (E3)
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
const ENTRY_MODES = ['public_link', 'team_only'];
const STAGE_MODES = ['chat_only', 'form_only', 'form_chat', 'review'];
const HISTORY_MODES = ['full', 'summary', 'fresh'];
const TABS = ['Workflows', 'Assignments', 'Monitor'];
function _timeAgo(ts) {
if (!ts) return '';
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function _formatDuration(seconds) {
if (!seconds || seconds < 0) return '—';
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
return `${Math.floor(hrs / 24)}d ${hrs % 24}h`;
}
// ── Main Section ────────────────────────────
export default function WorkflowsSection({ teamId }) {
const [tab, setTab] = useState('Workflows');
return html`
<div>
<div class="admin-tabs" style="display:flex;gap:4px;margin-bottom:16px;">
${TABS.map(t => html`
<button key=${t}
class="btn-small ${tab === t ? 'btn-primary' : ''}"
onClick=${() => setTab(t)}>${t}</button>
`)}
</div>
${tab === 'Workflows' && html`<${WorkflowsTab} teamId=${teamId} />`}
${tab === 'Assignments' && html`<${AssignmentsTab} teamId=${teamId} />`}
${tab === 'Monitor' && html`<${MonitorTab} teamId=${teamId} />`}
</div>
`;
}
// ── Tab 1: Workflows ────────────────────────
function WorkflowsTab({ teamId }) {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | workflow
const [stages, setStages] = useState([]);
const [editing, setEditing] = useState(null);
const [showCreate, setShowCreate] = useState(false);
const load = useCallback(async () => {
@@ -23,15 +74,6 @@ export default function WorkflowsSection({ teamId }) {
useEffect(() => { load(); }, [load]);
async function openEdit(wf) {
setEditing(wf);
try {
const detail = await sw.api.teams.workflows(teamId);
const match = (detail || []).find(w => w.id === wf.id);
setStages(match?.stages || wf.stages || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
}
async function createWorkflow(e) {
e.preventDefault();
const form = e.target;
@@ -48,83 +90,10 @@ export default function WorkflowsSection({ teamId }) {
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.updateWorkflow(teamId, editing.id, {
name: form.name.value.trim(),
description: form.description.value.trim(),
entry_mode: form.entry_mode.value,
is_active: form.is_active.checked,
});
sw.toast('Workflow updated', 'success');
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function publishWorkflow(wfId) {
try {
await sw.api.teams.publishWorkflow(teamId, wfId);
sw.toast('Workflow published', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteWorkflow(id) {
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflow(teamId, id);
sw.toast('Workflow deleted', 'success');
setEditing(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (editing) return html`
<form onSubmit=${updateWorkflow}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${() => setEditing(null)}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${editing.name}</h4>
<div style="flex:1"></div>
<button type="button" class="btn-small" onClick=${() => publishWorkflow(editing.id)}>Publish</button>
<button type="button" class="btn-small btn-danger" onClick=${() => deleteWorkflow(editing.id)}>Delete</button>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" value=${editing.name || ''} /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${editing.entry_mode === m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" value=${editing.description || ''} /></div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${editing.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
<h5 style="margin:16px 0 8px;">Stages (${stages.length})</h5>
<div class="admin-list">
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id || i}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode || '\u2014'}</span>
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</div>
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Save</button>
</div>
</form>
`;
if (editing) return html`<${WorkflowEditor} teamId=${teamId} workflow=${editing}
onBack=${() => { setEditing(null); load(); }} />`;
return html`
<div>
@@ -155,7 +124,7 @@ export default function WorkflowsSection({ teamId }) {
<div class="admin-list">
${workflows.length === 0 && html`<div class="empty-hint">No workflows</div>`}
${workflows.map(w => html`
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => openEdit(w)}>
<div class="admin-surface-row" key=${w.id} style="cursor:pointer;" onClick=${() => setEditing(w)}>
<div style="flex:1;min-width:0;">
<strong>${w.name}</strong>
<span class="text-muted" style="margin-left:8px;font-size:11px;">/${w.slug}</span>
@@ -169,3 +138,372 @@ export default function WorkflowsSection({ teamId }) {
</div>
`;
}
// ── Workflow Editor (with Stage Editor) ─────
function WorkflowEditor({ teamId, workflow, onBack }) {
const [stages, setStages] = useState([]);
const [personas, setPersonas] = useState([]);
const [teams, setTeams] = useState([]);
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
const loadStages = useCallback(async () => {
try {
const [s, p] = await Promise.all([
sw.api.teams.workflowStages(teamId, workflow.id),
sw.api.teams.personas(teamId),
]);
setStages(s || []);
setPersonas(p || []);
setTeams(sw.auth?.teams || []);
} catch (e) { sw.toast(e.message, 'error'); }
}, [teamId, workflow.id]);
useEffect(() => { loadStages(); }, [loadStages]);
async function updateWorkflow(e) {
e.preventDefault();
const form = e.target;
try {
await sw.api.teams.updateWorkflow(teamId, workflow.id, {
name: form.name.value.trim(),
description: form.description.value.trim(),
entry_mode: form.entry_mode.value,
is_active: form.is_active.checked,
});
sw.toast('Workflow updated', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function publishWorkflow() {
try {
await sw.api.teams.publishWorkflow(teamId, workflow.id);
sw.toast('Workflow published', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteWorkflow() {
const ok = await sw.confirm('Delete this workflow? All stages, versions, and instances will be deleted.', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflow(teamId, workflow.id);
sw.toast('Workflow deleted', 'success');
onBack();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function addStage(data) {
try {
await sw.api.teams.createWorkflowStage(teamId, workflow.id, data);
setEditingStage(null);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateStage(stageId, data) {
try {
await sw.api.teams.updateWorkflowStage(teamId, workflow.id, stageId, data);
setEditingStage(null);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function deleteStage(stageId) {
const ok = await sw.confirm('Delete this stage?', true);
if (!ok) return;
try {
await sw.api.teams.deleteWorkflowStage(teamId, workflow.id, stageId);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
return html`
<form onSubmit=${updateWorkflow}>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button type="button" class="btn-small" onClick=${onBack}>\u2190 Back</button>
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
<div style="flex:1"></div>
<button type="button" class="btn-small" onClick=${publishWorkflow}>Publish</button>
<button type="button" class="btn-small btn-danger" onClick=${deleteWorkflow}>Delete</button>
</div>
<div class="form-row">
<div class="form-group"><label>Name</label><input name="name" value=${workflow.name || ''} /></div>
<div class="form-group"><label>Entry Mode</label>
<select name="entry_mode">
${ENTRY_MODES.map(m => html`<option key=${m} value=${m} selected=${workflow.entry_mode === m}>${m}</option>`)}
</select>
</div>
</div>
<div class="form-group"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
<label class="toggle-label" style="margin:8px 0;">
<input type="checkbox" name="is_active" checked=${workflow.is_active !== false} />
<span class="toggle-track"></span><span>Active</span>
</label>
<div class="form-row" style="margin-top:16px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Save</button>
</div>
</form>
<!-- Stage Editor -->
<div style="margin-top:24px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h5 style="margin:0;">Stages (${stages.length})</h5>
<div style="flex:1"></div>
<button class="btn-small" onClick=${() => setEditingStage('new')}>+ Add Stage</button>
</div>
<div class="admin-list">
${stages.map((s, i) => html`
<div class="admin-surface-row" key=${s.id || i}>
<span class="text-muted" style="font-size:11px;width:24px;">#${i + 1}</span>
<strong style="flex:1;">${s.name || `Stage ${i + 1}`}</strong>
<span class="badge">${s.stage_mode || '\u2014'}</span>
${s.persona_id && html`<span class="badge badge-active">persona</span>`}
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
<button class="btn-small" onClick=${() => setEditingStage(s.id)}>Edit</button>
<button class="btn-small btn-danger" onClick=${() => deleteStage(s.id)}>\u00d7</button>
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</div>
${editingStage && html`
<${StageForm}
stage=${editingStage === 'new' ? null : stages.find(s => s.id === editingStage)}
personas=${personas}
teams=${teams}
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
onCancel=${() => setEditingStage(null)}
/>
`}
</div>
`;
}
// ── Stage Form ──────────────────────────────
function StageForm({ stage, personas, teams, onSave, onCancel }) {
const [name, setName] = useState(stage?.name || '');
const [mode, setMode] = useState(stage?.stage_mode || 'chat_only');
const [personaId, setPersonaId] = useState(stage?.persona_id || '');
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full');
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
function submit() {
onSave({
name,
stage_mode: mode,
persona_id: personaId || null,
assignment_team_id: assignTeam || null,
history_mode: historyMode,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
});
}
return html`
<div style="margin-top:12px;padding:12px;border:1px solid var(--border-1);border-radius:6px;">
<div class="form-row">
<div class="form-group">
<label>Name</label>
<input value=${name} onInput=${e => setName(e.target.value)} />
</div>
<div class="form-group">
<label>Mode</label>
<select value=${mode} onChange=${e => setMode(e.target.value)}>
${STAGE_MODES.map(m => html`<option key=${m} value=${m}>${m.replace('_', ' ')}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Persona (LLM)</label>
<select value=${personaId} onChange=${e => setPersonaId(e.target.value)}>
<option value="">\u2014 none \u2014</option>
${personas.map(p => html`<option key=${p.id} value=${p.id}>${p.name}</option>`)}
</select>
</div>
<div class="form-group">
<label>Queue to Team</label>
<select value=${assignTeam} onChange=${e => setAssignTeam(e.target.value)}>
<option value="">\u2014 none (visitor stage) \u2014</option>
${teams.map(t => html`<option key=${t.id || t.team_id} value=${t.id || t.team_id}>${t.name || t.team_name}</option>`)}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>History Mode</label>
<select value=${historyMode} onChange=${e => setHistoryMode(e.target.value)}>
${HISTORY_MODES.map(m => html`<option key=${m} value=${m}>${m}</option>`)}
</select>
</div>
<div class="form-group">
<label>SLA (seconds)</label>
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
</div>
</div>
<label class="toggle-label">
<input type="checkbox" checked=${autoTransition} onChange=${e => setAutoTransition(e.target.checked)} />
<span class="toggle-track"></span><span>Auto-advance when complete</span>
</label>
<div class="form-row" style="margin-top:12px;gap:8px;">
<button type="button" class="btn-small btn-primary" onClick=${submit}>
${stage ? 'Update' : 'Add Stage'}
</button>
<button type="button" class="btn-small" onClick=${onCancel}>Cancel</button>
</div>
</div>
`;
}
// ── Tab 2: Assignments ──────────────────────
function AssignmentsTab({ teamId }) {
const [claimed, setClaimed] = useState([]);
const [unassigned, setUnassigned] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [c, u] = await Promise.all([
sw.api.teams.assignments(teamId, { status: 'claimed' }),
sw.api.teams.assignments(teamId, { status: 'unassigned' }),
]);
setClaimed(c || []);
setUnassigned(u || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
// WS live updates
useEffect(() => {
const off1 = sw.on('workflow.assigned', load);
const off2 = sw.on('workflow.claimed', load);
const off3 = sw.on('workflow.unclaimed', load);
return () => { off1(); off2(); off3(); };
}, [teamId]);
async function claim(id) {
try {
await sw.api.workflowAssignments.claim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function unclaim(id) {
try {
await sw.api.workflowAssignments.unclaim(id);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function complete(id) {
try {
await sw.api.workflowAssignments.complete(id);
sw.toast('Assignment completed', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
const userId = sw.auth?.user?.id;
return html`
<div>
<h4 style="margin:0 0 8px;">My Active (${claimed.length})</h4>
<div class="admin-list">
${claimed.map(a => html`
<div class="admin-surface-row" key=${a.id} style="border-left:3px solid var(--accent-1);">
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
${a.sla_breached && html`<span class="badge badge-danger">SLA</span>`}
</div>
<div style="display:flex;gap:4px;">
<button class="btn-small" onClick=${() => unclaim(a.id)}>Release</button>
<button class="btn-small btn-primary"
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
<button class="btn-small btn-success" onClick=${() => complete(a.id)}>Complete</button>
</div>
</div>
`)}
${claimed.length === 0 && html`<div class="empty-hint">No claimed assignments</div>`}
</div>
<h4 style="margin:16px 0 8px;">Available (${unassigned.length})</h4>
<div class="admin-list">
${unassigned.map(a => html`
<div class="admin-surface-row" key=${a.id}>
<div style="flex:1;min-width:0;">
<strong>${a.workflow_name || 'Workflow'}</strong>
<span class="badge">${a.stage_name || `Stage ${a.stage}`}</span>
<span class="text-muted" style="font-size:11px;">${_timeAgo(a.created_at)}</span>
</div>
<button class="btn-small btn-primary" onClick=${() => claim(a.id)}>Claim</button>
</div>
`)}
${unassigned.length === 0 && html`<div class="empty-hint">No available assignments</div>`}
</div>
</div>
`;
}
// ── Tab 3: Monitor ──────────────────────────
function MonitorTab({ teamId }) {
const [instances, setInstances] = useState([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await sw.api.teams.workflowInstances(teamId);
setInstances(data || []);
} catch (e) { sw.toast(e.message, 'error'); }
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
async function cancelInstance(channelId) {
const ok = await sw.confirm('Cancel this workflow instance? All open assignments will be cancelled.', true);
if (!ok) return;
try {
await sw.api.teams.cancelWorkflowInstance(teamId, channelId);
sw.toast('Instance cancelled', 'success');
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
<h4 style="margin:0 0 8px;">Active Instances (${instances.length})</h4>
<div class="admin-list">
${instances.map(inst => html`
<div class="admin-surface-row" key=${inst.channel_id}>
<div style="flex:1;min-width:0;">
<strong>${inst.workflow_name}</strong>
<span class="badge">${inst.stage_name || `Stage ${inst.current_stage}`}</span>
${inst.sla_breached && html`<span class="badge badge-danger">SLA breached</span>`}
<span class="text-muted" style="font-size:11px;">Age: ${_formatDuration(inst.age_seconds)}</span>
</div>
<div style="display:flex;gap:4px;">
<button class="btn-small"
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
<button class="btn-small btn-danger" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
</div>
</div>
`)}
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
</div>
</div>
`;
}