Feat v0.7.10 workflow handoff (#64)
All checks were successful
CI/CD / test-go-pg (push) Successful in 2m54s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m47s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #64.
This commit is contained in:
2026-04-02 23:16:19 +00:00
committed by xcaliber
parent a9cf71b76d
commit f06c6c954b
17 changed files with 818 additions and 36 deletions

View File

@@ -140,6 +140,11 @@ export function createDomains(restClient) {
// Adopt global workflows
availableWorkflows: (id) => rc.get(`/api/v1/teams/${id}/workflows/available`),
adoptWorkflow: (id, wfId) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/adopt`, {}),
// Team assignments
assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)),
// Team workflow instances (cross-workflow)
workflowInstances: (id, opts) => rc.get(`/api/v1/teams/${id}/workflow-instances` + _qs(opts)),
cancelWorkflowInstance: (id, iid) => rc.post(`/api/v1/teams/${id}/workflow-instances/${iid}/cancel`, {}),
},
// ── 9. Workflows ───────────────────────
@@ -154,7 +159,17 @@ 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`, {}),
cancel: (wfId, instanceId) => rc.post(`/api/v1/workflows/${wfId}/instances/${instanceId}/cancel`, {}),
},
// ── 15. Workflow Assignments ─────────
workflowAssignments: {
mine: (opts) => rc.get('/api/v1/assignments/mine' + _qs(opts)),
claim: (id) => rc.post(`/api/v1/assignments/${id}/claim`, {}),
unclaim: (id) => rc.post(`/api/v1/assignments/${id}/unclaim`, {}),
complete: (id, data) => rc.post(`/api/v1/assignments/${id}/complete`, data || {}),
cancel: (id) => rc.post(`/api/v1/assignments/${id}/cancel`, {}),
assign: (id, userId) => rc.post(`/api/v1/assignments/${id}/assign`, { user_id: userId }),
},
// ── 10. Surfaces ──────────────────────

View File

@@ -33,7 +33,9 @@ function _formatDuration(seconds) {
export function AssignmentsTab({ teamId }) {
const [claimed, setClaimed] = useState([]);
const [unassigned, setUnassigned] = useState([]);
const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true);
const [assigningId, setAssigningId] = useState(null);
const load = useCallback(async () => {
try {
@@ -47,7 +49,15 @@ export function AssignmentsTab({ teamId }) {
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
// Load team members for the assign dropdown
const loadMembers = useCallback(async () => {
try {
const data = await sw.api.teams.members(teamId);
setMembers(data || []);
} catch { setMembers([]); }
}, [teamId]);
useEffect(() => { load(); loadMembers(); }, [load, loadMembers]);
// WS live updates
useEffect(() => {
@@ -79,9 +89,16 @@ export function AssignmentsTab({ teamId }) {
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
async function assignTo(assignmentId, userId) {
try {
await sw.api.workflowAssignments.assign(assignmentId, userId);
sw.toast('Assigned', 'success');
setAssigningId(null);
load();
} catch (e) { sw.toast(e.message, 'error'); }
}
const userId = sw.auth?.user?.id;
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
return html`
<div>
@@ -97,7 +114,7 @@ export function AssignmentsTab({ teamId }) {
<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>
onClick=${() => { location.href = sw.base + '/w/' + a.instance_id; }}>Work</button>
<button class="sw-btn sw-btn--success sw-btn--sm" onClick=${() => complete(a.id)}>Complete</button>
</div>
</div>
@@ -114,7 +131,28 @@ export function AssignmentsTab({ teamId }) {
<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 style="display:flex;gap:4px;">
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => claim(a.id)}>Claim</button>
<button class="sw-btn sw-btn--secondary sw-btn--sm"
onClick=${() => setAssigningId(assigningId === a.id ? null : a.id)}>Assign</button>
</div>
${assigningId === a.id && html`
<div style="margin-top:6px;display:flex;gap:4px;align-items:center;font-size:12px;">
<select id=${'assign_' + a.id} style="flex:1;font-size:12px;">
<option value="">Select member\u2026</option>
${members.map(m => html`
<option key=${m.user_id} value=${m.user_id}>
${sw.users?.displayName?.(m.user_id) || m.display_name || m.username || m.user_id}
</option>
`)}
</select>
<button class="sw-btn sw-btn--primary sw-btn--sm"
onClick=${() => {
const sel = document.getElementById('assign_' + a.id);
if (sel?.value) assignTo(a.id, sel.value);
}}>Confirm</button>
</div>
`}
</div>
`)}
${unassigned.length === 0 && html`<div class="empty-hint">No available assignments</div>`}
@@ -157,7 +195,7 @@ export function MonitorTab({ teamId }) {
<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 class="admin-surface-row" key=${inst.id}>
<div style="flex:1;min-width:0;">
<strong>${inst.workflow_name}</strong>
<span class="badge">${inst.stage_name || `Stage ${inst.current_stage}`}</span>
@@ -168,8 +206,8 @@ export function MonitorTab({ teamId }) {
<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>
onClick=${() => { location.href = sw.base + '/w/' + inst.id; }}>Open</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => cancelInstance(inst.id)}>Cancel</button>
</div>
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
</div>