Feat v0.7.4 docs surface work (#58)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 39s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 39s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #58.
This commit is contained in:
@@ -297,7 +297,7 @@
|
||||
.sw-inline-error {
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: var(--bg-2); border: 1px solid var(--danger);
|
||||
background: var(--bg-secondary); border: 1px solid var(--danger);
|
||||
border-radius: var(--radius); font-size: 13px; color: var(--danger);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@
|
||||
|
||||
.sw-topbar__tab.active {
|
||||
color: var(--text);
|
||||
background: var(--bg-2);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -16,6 +16,17 @@ const { render } = preact;
|
||||
// Preload marked so renderSync works in useMemo
|
||||
if (sw?.markdown) sw.markdown.preload();
|
||||
|
||||
/** Group docs by category, preserving order of first appearance. */
|
||||
function groupByCategory(docs) {
|
||||
const map = new Map();
|
||||
for (const d of docs) {
|
||||
const cat = d.category || '';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat).push(d);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}
|
||||
|
||||
function DocsSurface() {
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [active, setActive] = useState(window.__SECTION__ || 'GETTING-STARTED');
|
||||
@@ -81,7 +92,13 @@ function DocsSurface() {
|
||||
|
||||
function scrollToHeading(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (!el) return;
|
||||
const container = document.querySelector('.docs-content');
|
||||
if (container) {
|
||||
container.scrollTo({ top: el.offsetTop - 16, behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendered HTML via sw.markdown ──
|
||||
@@ -122,13 +139,18 @@ function DocsSurface() {
|
||||
<p>Failed to load docs list.</p>
|
||||
<button onclick=${retryList} class="docs-retry-btn">Retry</button>
|
||||
</div>
|
||||
` : docs.map(d => html`
|
||||
<a key=${d.slug}
|
||||
class="docs-nav-item ${d.slug === active ? 'active' : ''}"
|
||||
onclick=${() => navigate(d.slug)}
|
||||
href="javascript:void(0)">
|
||||
${d.title}
|
||||
</a>
|
||||
` : groupByCategory(docs).map(([cat, items]) => html`
|
||||
<div key=${cat} class="docs-category-group">
|
||||
${cat && html`<div class="docs-category-heading">${cat}</div>`}
|
||||
${items.map(d => html`
|
||||
<a key=${d.slug}
|
||||
class="docs-nav-item ${d.slug === active ? 'active' : ''}"
|
||||
onclick=${() => navigate(d.slug)}
|
||||
href="javascript:void(0)">
|
||||
${d.title}
|
||||
</a>
|
||||
`)}
|
||||
</div>
|
||||
`)}
|
||||
</nav>
|
||||
<main class="docs-content">
|
||||
@@ -184,6 +206,16 @@ style.textContent = `
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.docs-category-group { margin-bottom: 4px; }
|
||||
.docs-category-heading {
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.docs-category-group:first-child .docs-category-heading { padding-top: 0; }
|
||||
.docs-nav-item {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
|
||||
337
src/js/sw/surfaces/team-admin/workflow-editor.js
Normal file
337
src/js/sw/surfaces/team-admin/workflow-editor.js
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Workflow Editor + Stage Form
|
||||
*
|
||||
* Extracted from workflows.js — inline editing for a single workflow
|
||||
* definition and its stages (modes, audiences, SLA, validation, branches).
|
||||
*/
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
|
||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
||||
const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
||||
const AUDIENCES = ['team', 'public', 'system'];
|
||||
|
||||
// ── Workflow Editor (with Stage Editor) ─────
|
||||
|
||||
export function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
const [stages, setStages] = useState([]);
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
||||
|
||||
const loadStages = useCallback(async () => {
|
||||
try {
|
||||
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
|
||||
setStages(s || []);
|
||||
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 {
|
||||
const patch = {
|
||||
name: form.name.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
is_active: form.is_active.checked,
|
||||
};
|
||||
const sth = form.staleness_timeout_hours.value.trim();
|
||||
if (sth !== '') patch.staleness_timeout_hours = parseInt(sth, 10);
|
||||
await sw.api.teams.updateWorkflow(teamId, workflow.id, patch);
|
||||
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="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onBack}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
|
||||
<div style="flex:1"></div>
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${publishWorkflow}>Publish</button>
|
||||
<button type="button" class="sw-btn sw-btn--danger sw-btn--sm" 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-row">
|
||||
<div class="form-group" style="flex:1;"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
|
||||
<div class="form-group" style="max-width:180px;"><label>Staleness (hours)</label>
|
||||
<input type="number" name="staleness_timeout_hours" min="0"
|
||||
value=${workflow.staleness_timeout_hours ?? ''} placeholder="disabled" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
${workflow.entry_mode === 'public_link' && html`
|
||||
<div style="margin:8px 0;">
|
||||
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<input type="text" readOnly value=${window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start'}
|
||||
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--mono);"
|
||||
onClick=${e => e.target.select()} />
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
|
||||
const url = window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start';
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
e.target.textContent = 'Copied!';
|
||||
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
}}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">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="sw-btn sw-btn--secondary sw-btn--sm" 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>
|
||||
<span class="badge">${s.audience || 'team'}</span>
|
||||
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" 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)}
|
||||
teams=${teams}
|
||||
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
||||
onCancel=${() => setEditingStage(null)}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Stage Form ──────────────────────────────
|
||||
|
||||
function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
const [name, setName] = useState(stage?.name || '');
|
||||
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
||||
const [audience, setAudience] = useState(stage?.audience || 'team');
|
||||
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
||||
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||
const [branchRules, setBranchRules] = useState(
|
||||
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
|
||||
);
|
||||
|
||||
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
|
||||
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
|
||||
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
|
||||
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
|
||||
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
|
||||
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assignTeam) return;
|
||||
sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
|
||||
}, [assignTeam]);
|
||||
|
||||
function submit() {
|
||||
const stageConfig = {};
|
||||
if (requiredRole) stageConfig.required_role = requiredRole;
|
||||
if (valApprovals) {
|
||||
stageConfig.validation = {
|
||||
required_approvals: parseInt(valApprovals, 10),
|
||||
...(valRole ? { required_role: valRole } : {}),
|
||||
reject_action: valReject || 'cancel',
|
||||
};
|
||||
}
|
||||
let parsedBranch = null;
|
||||
if (branchRules.trim()) {
|
||||
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
|
||||
}
|
||||
onSave({
|
||||
name,
|
||||
stage_mode: mode,
|
||||
audience,
|
||||
stage_type: stageType,
|
||||
starlark_hook: starlarkHook || null,
|
||||
assignment_team_id: assignTeam || null,
|
||||
auto_transition: autoTransition,
|
||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
|
||||
branch_rules: parsedBranch || [],
|
||||
});
|
||||
}
|
||||
|
||||
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}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Audience</label>
|
||||
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
|
||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stage Type</label>
|
||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${stageType !== 'simple' && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Starlark Hook</label>
|
||||
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
|
||||
placeholder="package_id:entry_point" />
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<div class="form-row">
|
||||
<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 class="form-group">
|
||||
<label>SLA (seconds)</label>
|
||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||
</div>
|
||||
</div>
|
||||
${assignTeam && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Required Role (claim)</label>
|
||||
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
|
||||
<strong style="font-size:12px;">Multi-party Validation</strong>
|
||||
<div class="form-row" style="margin-top:4px;">
|
||||
<div class="form-group">
|
||||
<label>Required Approvals</label>
|
||||
<input type="number" min="0" value=${valApprovals}
|
||||
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Signoff Role</label>
|
||||
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>On Reject</label>
|
||||
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
|
||||
<option value="cancel">Cancel instance</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
|
||||
<div class="form-group" style="margin-top:4px;">
|
||||
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
|
||||
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
|
||||
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
|
||||
</div>
|
||||
</details>
|
||||
<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="sw-btn sw-btn--primary sw-btn--sm" onClick=${submit}>
|
||||
${stage ? 'Update' : 'Add Stage'}
|
||||
</button>
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onCancel}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
239
src/js/sw/surfaces/team-admin/workflow-monitor.js
Normal file
239
src/js/sw/surfaces/team-admin/workflow-monitor.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Workflow Assignments, Monitor, and Signoff
|
||||
*
|
||||
* Extracted from workflows.js — runtime views for active workflow
|
||||
* instances: assignment queue, instance monitoring, and signoff panel.
|
||||
*/
|
||||
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`;
|
||||
}
|
||||
|
||||
function _formatDuration(seconds) {
|
||||
if (!seconds || seconds < 0) return '\u2014';
|
||||
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`;
|
||||
}
|
||||
|
||||
// ── Tab 2: Assignments ──────────────────────
|
||||
|
||||
export 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="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => unclaim(a.id)}>Release</button>
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" 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="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => claim(a.id)}>Claim</button>
|
||||
</div>
|
||||
`)}
|
||||
${unassigned.length === 0 && html`<div class="empty-hint">No available assignments</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Tab 3: Monitor ──────────────────────────
|
||||
|
||||
export function MonitorTab({ teamId }) {
|
||||
const [instances, setInstances] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedSignoff, setExpandedSignoff] = useState(null);
|
||||
|
||||
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="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
|
||||
</div>
|
||||
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
|
||||
</div>
|
||||
`)}
|
||||
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Signoff Panel ──────────────────
|
||||
|
||||
function SignoffPanel({ instanceId, teamId }) {
|
||||
const [signoffs, setSignoffs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.get(`/api/v1/instances/${instanceId}/signoffs`);
|
||||
setSignoffs(resp.data || []);
|
||||
} catch { setSignoffs([]); }
|
||||
finally { setLoading(false); }
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function submit(decision) {
|
||||
try {
|
||||
await sw.api.post(`/api/v1/instances/${instanceId}/signoffs`, { decision, comment });
|
||||
sw.toast(decision === 'approve' ? 'Approved' : 'Rejected', 'success');
|
||||
setComment('');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<span class="text-muted">Loading\u2026</span>`;
|
||||
|
||||
const approvals = signoffs.filter(s => s.decision === 'approve').length;
|
||||
const rejections = signoffs.filter(s => s.decision === 'reject').length;
|
||||
|
||||
return html`
|
||||
<div style="margin-top:8px;padding:8px;border:1px solid var(--border-1);border-radius:4px;font-size:12px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
|
||||
<span>${approvals} approve${approvals !== 1 ? 's' : ''}</span>
|
||||
${rejections > 0 && html`<span style="color:var(--danger);">${rejections} reject${rejections !== 1 ? 's' : ''}</span>`}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<input value=${comment} onInput=${e => setComment(e.target.value)}
|
||||
placeholder="Comment (optional)" style="flex:1;font-size:12px;" />
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => submit('approve')}>Approve</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => submit('reject')}>Reject</button>
|
||||
</div>
|
||||
${signoffs.length > 0 && html`
|
||||
<div style="margin-top:6px;">
|
||||
${signoffs.map(s => html`
|
||||
<div key=${s.id} style="display:flex;gap:4px;align-items:center;font-size:11px;margin-top:2px;">
|
||||
<span class="badge ${s.decision === 'approve' ? 'badge-success' : 'badge-danger'}">${s.decision}</span>
|
||||
<span>${sw.users?.displayName?.(s.user_id) || s.user_id}</span>
|
||||
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2,38 +2,21 @@
|
||||
* Team Admin > Workflows
|
||||
*
|
||||
* Tab layout: Workflows | Assignments | Monitor
|
||||
* - Workflows: CRUD + inline stage editor (E2)
|
||||
* - Assignments: Team assignment queue (E1)
|
||||
* - Monitor: Active instance list with cancel (E3)
|
||||
* - Workflows: CRUD + inline stage editor → workflow-editor.js
|
||||
* - Assignments: Team assignment queue → workflow-monitor.js
|
||||
* - Monitor: Active instance list with cancel → workflow-monitor.js
|
||||
*/
|
||||
import { WorkflowEditor } from './workflow-editor.js';
|
||||
import { AssignmentsTab, MonitorTab } from './workflow-monitor.js';
|
||||
|
||||
const { html } = window;
|
||||
const { useState, useEffect, useCallback } = hooks;
|
||||
const { useState, useCallback, useEffect } = hooks;
|
||||
|
||||
const ENTRY_MODES = ['public_link', 'team_only'];
|
||||
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
|
||||
const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
|
||||
const AUDIENCES = ['team', 'public', 'system'];
|
||||
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`;
|
||||
function _unwrapList(resp) {
|
||||
return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
|
||||
}
|
||||
|
||||
// ── Main Section ────────────────────────────
|
||||
@@ -59,10 +42,6 @@ export default function WorkflowsSection({ teamId }) {
|
||||
|
||||
// ── Tab 1: Workflows ────────────────────────
|
||||
|
||||
function _unwrapList(resp) {
|
||||
return Array.isArray(resp) ? resp : (resp && resp.data ? resp.data : []);
|
||||
}
|
||||
|
||||
function WorkflowsTab({ teamId }) {
|
||||
const [workflows, setWorkflows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -186,537 +165,3 @@ function WorkflowsTab({ teamId }) {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Workflow Editor (with Stage Editor) ─────
|
||||
|
||||
function WorkflowEditor({ teamId, workflow, onBack }) {
|
||||
const [stages, setStages] = useState([]);
|
||||
const [teams, setTeams] = useState([]);
|
||||
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
|
||||
|
||||
const loadStages = useCallback(async () => {
|
||||
try {
|
||||
const s = await sw.api.teams.workflowStages(teamId, workflow.id);
|
||||
setStages(s || []);
|
||||
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 {
|
||||
const patch = {
|
||||
name: form.name.value.trim(),
|
||||
description: form.description.value.trim(),
|
||||
entry_mode: form.entry_mode.value,
|
||||
is_active: form.is_active.checked,
|
||||
};
|
||||
const sth = form.staleness_timeout_hours.value.trim();
|
||||
if (sth !== '') patch.staleness_timeout_hours = parseInt(sth, 10);
|
||||
await sw.api.teams.updateWorkflow(teamId, workflow.id, patch);
|
||||
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="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onBack}>\u2190 Back</button>
|
||||
<h4 style="margin:0;">Edit: ${workflow.name}</h4>
|
||||
<div style="flex:1"></div>
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${publishWorkflow}>Publish</button>
|
||||
<button type="button" class="sw-btn sw-btn--danger sw-btn--sm" 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-row">
|
||||
<div class="form-group" style="flex:1;"><label>Description</label><input name="description" value=${workflow.description || ''} /></div>
|
||||
<div class="form-group" style="max-width:180px;"><label>Staleness (hours)</label>
|
||||
<input type="number" name="staleness_timeout_hours" min="0"
|
||||
value=${workflow.staleness_timeout_hours ?? ''} placeholder="disabled" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
${workflow.entry_mode === 'public_link' && html`
|
||||
<div style="margin:8px 0;">
|
||||
<label style="font-size:11px;color:var(--text-2);display:block;margin-bottom:4px;">Public Entry URL</label>
|
||||
<div style="display:flex;gap:6px;align-items:center;">
|
||||
<input type="text" readOnly value=${window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start'}
|
||||
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-family:var(--mono);"
|
||||
onClick=${e => e.target.select()} />
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
|
||||
const url = window.location.origin + (window.__BASE__ || '') + '/api/v1/public/workflows/' + workflow.id + '/start';
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
e.target.textContent = 'Copied!';
|
||||
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
}}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="form-row" style="margin-top:16px;gap:8px;">
|
||||
<button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">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="sw-btn sw-btn--secondary sw-btn--sm" 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>
|
||||
<span class="badge">${s.audience || 'team'}</span>
|
||||
${s.assignment_team_id && html`<span class="badge">team assign</span>`}
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setEditingStage(s.id)}>Edit</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" 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)}
|
||||
teams=${teams}
|
||||
onSave=${(data) => editingStage === 'new' ? addStage(data) : updateStage(editingStage, data)}
|
||||
onCancel=${() => setEditingStage(null)}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Stage Form ──────────────────────────────
|
||||
|
||||
function StageForm({ stage, teams, onSave, onCancel }) {
|
||||
const [name, setName] = useState(stage?.name || '');
|
||||
const [mode, setMode] = useState(stage?.stage_mode || 'form');
|
||||
const [audience, setAudience] = useState(stage?.audience || 'team');
|
||||
const [stageType, setStageType] = useState(stage?.stage_type || 'simple');
|
||||
const [starlarkHook, setStarlarkHook] = useState(stage?.starlark_hook || '');
|
||||
const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || '');
|
||||
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
|
||||
const [sla, setSla] = useState(stage?.sla_seconds || '');
|
||||
const [branchRules, setBranchRules] = useState(
|
||||
stage?.branch_rules ? (typeof stage.branch_rules === 'string' ? stage.branch_rules : JSON.stringify(stage.branch_rules, null, 2)) : ''
|
||||
);
|
||||
|
||||
const sc = stage?.stage_config ? (typeof stage.stage_config === 'string' ? JSON.parse(stage.stage_config || '{}') : stage.stage_config) : {};
|
||||
const [requiredRole, setRequiredRole] = useState(sc.required_role || '');
|
||||
const [valApprovals, setValApprovals] = useState(sc.validation?.required_approvals || '');
|
||||
const [valRole, setValRole] = useState(sc.validation?.required_role || '');
|
||||
const [valReject, setValReject] = useState(sc.validation?.reject_action || 'cancel');
|
||||
const [teamRoles, setTeamRoles] = useState(['admin', 'member']);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assignTeam) return;
|
||||
sw.api.get(`/api/v1/teams/${assignTeam}/roles`).then(r => setTeamRoles(r.data || ['admin', 'member'])).catch(() => {});
|
||||
}, [assignTeam]);
|
||||
|
||||
function submit() {
|
||||
const stageConfig = {};
|
||||
if (requiredRole) stageConfig.required_role = requiredRole;
|
||||
if (valApprovals) {
|
||||
stageConfig.validation = {
|
||||
required_approvals: parseInt(valApprovals, 10),
|
||||
...(valRole ? { required_role: valRole } : {}),
|
||||
reject_action: valReject || 'cancel',
|
||||
};
|
||||
}
|
||||
let parsedBranch = null;
|
||||
if (branchRules.trim()) {
|
||||
try { parsedBranch = JSON.parse(branchRules); } catch { sw.toast('Invalid branch_rules JSON', 'error'); return; }
|
||||
}
|
||||
onSave({
|
||||
name,
|
||||
stage_mode: mode,
|
||||
audience,
|
||||
stage_type: stageType,
|
||||
starlark_hook: starlarkHook || null,
|
||||
assignment_team_id: assignTeam || null,
|
||||
auto_transition: autoTransition,
|
||||
sla_seconds: sla ? parseInt(sla, 10) : null,
|
||||
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
|
||||
branch_rules: parsedBranch || [],
|
||||
});
|
||||
}
|
||||
|
||||
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}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Audience</label>
|
||||
<select value=${audience} onChange=${e => setAudience(e.target.value)}>
|
||||
${AUDIENCES.map(a => html`<option key=${a} value=${a}>${a}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Stage Type</label>
|
||||
<select value=${stageType} onChange=${e => setStageType(e.target.value)}>
|
||||
${STAGE_TYPES.map(t => html`<option key=${t} value=${t}>${t}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
${stageType !== 'simple' && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1;">
|
||||
<label>Starlark Hook</label>
|
||||
<input value=${starlarkHook} onInput=${e => setStarlarkHook(e.target.value)}
|
||||
placeholder="package_id:entry_point" />
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<div class="form-row">
|
||||
<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 class="form-group">
|
||||
<label>SLA (seconds)</label>
|
||||
<input type="number" value=${sla} onInput=${e => setSla(e.target.value)} placeholder="e.g. 3600" />
|
||||
</div>
|
||||
</div>
|
||||
${assignTeam && html`
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Required Role (claim)</label>
|
||||
<select value=${requiredRole} onChange=${e => setRequiredRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;padding:8px;border:1px dashed var(--border-1);border-radius:4px;">
|
||||
<strong style="font-size:12px;">Multi-party Validation</strong>
|
||||
<div class="form-row" style="margin-top:4px;">
|
||||
<div class="form-group">
|
||||
<label>Required Approvals</label>
|
||||
<input type="number" min="0" value=${valApprovals}
|
||||
onInput=${e => setValApprovals(e.target.value)} placeholder="0 = none" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Signoff Role</label>
|
||||
<select value=${valRole} onChange=${e => setValRole(e.target.value)}>
|
||||
<option value="">\u2014 any member \u2014</option>
|
||||
${teamRoles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>On Reject</label>
|
||||
<select value=${valReject} onChange=${e => setValReject(e.target.value)}>
|
||||
<option value="cancel">Cancel instance</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<details style="margin-top:8px;">
|
||||
<summary style="cursor:pointer;font-size:12px;color:var(--text-muted);">Branch Rules (advanced)</summary>
|
||||
<div class="form-group" style="margin-top:4px;">
|
||||
<textarea rows="3" value=${branchRules} onInput=${e => setBranchRules(e.target.value)}
|
||||
placeholder='[{"field":"priority","op":"eq","value":"high","target_stage":"escalation"}]'
|
||||
style="font-family:monospace;font-size:11px;width:100%;"></textarea>
|
||||
</div>
|
||||
</details>
|
||||
<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="sw-btn sw-btn--primary sw-btn--sm" onClick=${submit}>
|
||||
${stage ? 'Update' : 'Add Stage'}
|
||||
</button>
|
||||
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" 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="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => unclaim(a.id)}>Release</button>
|
||||
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + a.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" 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="sw-btn sw-btn--primary sw-btn--sm" 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 [expandedSignoff, setExpandedSignoff] = useState(null);
|
||||
|
||||
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="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
|
||||
<button class="sw-btn sw-btn--secondary sw-btn--sm"
|
||||
onClick=${() => { location.href = sw.base + '/w/' + inst.channel_id; }}>Open</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.channel_id)}>Cancel</button>
|
||||
</div>
|
||||
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
|
||||
</div>
|
||||
`)}
|
||||
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Signoff Panel ──────────────────
|
||||
|
||||
function SignoffPanel({ instanceId, teamId }) {
|
||||
const [signoffs, setSignoffs] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.get(`/api/v1/instances/${instanceId}/signoffs`);
|
||||
setSignoffs(resp.data || []);
|
||||
} catch { setSignoffs([]); }
|
||||
finally { setLoading(false); }
|
||||
}, [instanceId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function submit(decision) {
|
||||
try {
|
||||
await sw.api.post(`/api/v1/instances/${instanceId}/signoffs`, { decision, comment });
|
||||
sw.toast(decision === 'approve' ? 'Approved' : 'Rejected', 'success');
|
||||
setComment('');
|
||||
load();
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
if (loading) return html`<span class="text-muted">Loading\u2026</span>`;
|
||||
|
||||
const approvals = signoffs.filter(s => s.decision === 'approve').length;
|
||||
const rejections = signoffs.filter(s => s.decision === 'reject').length;
|
||||
|
||||
return html`
|
||||
<div style="margin-top:8px;padding:8px;border:1px solid var(--border-1);border-radius:4px;font-size:12px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
|
||||
<span>${approvals} approve${approvals !== 1 ? 's' : ''}</span>
|
||||
${rejections > 0 && html`<span style="color:var(--danger);">${rejections} reject${rejections !== 1 ? 's' : ''}</span>`}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;align-items:center;">
|
||||
<input value=${comment} onInput=${e => setComment(e.target.value)}
|
||||
placeholder="Comment (optional)" style="flex:1;font-size:12px;" />
|
||||
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => submit('approve')}>Approve</button>
|
||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => submit('reject')}>Reject</button>
|
||||
</div>
|
||||
${signoffs.length > 0 && html`
|
||||
<div style="margin-top:6px;">
|
||||
${signoffs.map(s => html`
|
||||
<div key=${s.id} style="display:flex;gap:4px;align-items:center;font-size:11px;margin-top:2px;">
|
||||
<span class="badge ${s.decision === 'approve' ? 'badge-success' : 'badge-danger'}">${s.decision}</span>
|
||||
<span>${sw.users?.displayName?.(s.user_id) || s.user_id}</span>
|
||||
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user