Changeset 0.37.15 (#227)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user