Feat v0.7.8 bug fixes admin gaps (#62)
All checks were successful
CI/CD / detect-changes (push) Successful in 5s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 29s
CI/CD / test-sqlite (push) Successful in 2m55s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #62.
This commit is contained in:
2026-04-02 21:36:34 +00:00
committed by xcaliber
parent e4f0bdbd36
commit 3cdfdcf943
12 changed files with 513 additions and 204 deletions

View File

@@ -238,7 +238,7 @@
.bar-chart-label { font-size: 10px; color: var(--text-3); }
/* ── Admin Settings Form (flat sections, prototype match) ── */
.admin-settings-form { max-width: 600px; }
.admin-settings-form { max-width: 600px; padding-bottom: var(--sp-12); }
.admin-settings-form .settings-section {
background: none; border: none; border-radius: 0;
padding: 0 0 16px; margin-bottom: 20px;

View File

@@ -0,0 +1,177 @@
/**
* Shared Stage Form Component
*
* Used by both admin/workflows.js and team-admin/workflow-editor.js
* for creating and editing workflow stages.
*
* Props:
* stage - existing stage object (null for new)
* teams - array of { id, name } for team assignment dropdown
* onSave - (data) => void — called with stage payload
* onCancel - () => void
*/
const { html } = window;
const { useState, useEffect } = hooks;
export const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
export const STAGE_TYPES = ['simple', 'dynamic', 'automated'];
export const AUDIENCES = ['team', 'public', 'system'];
export 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>
`;
}

View File

@@ -146,7 +146,11 @@ export function createDomains(restClient) {
workflows: {
...crud(rc, '/api/v1/workflows'),
update: (id, data) => rc.patch(`/api/v1/workflows/${id}`, data),
stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`),
stages: (id) => rc.get(`/api/v1/workflows/${id}/stages`),
createStage: (id, data) => rc.post(`/api/v1/workflows/${id}/stages`, data),
updateStage: (id, sid, data) => rc.put(`/api/v1/workflows/${id}/stages/${sid}`, data),
deleteStage: (id, sid) => rc.del(`/api/v1/workflows/${id}/stages/${sid}`),
reorderStages: (id, ids) => rc.patch(`/api/v1/workflows/${id}/stages/reorder`, { ordered_ids: ids }),
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),

View File

@@ -10,6 +10,7 @@ import { Dropdown } from '../../primitives/dropdown.js';
const TYPE_OPTIONS = ['all', 'surface', 'extension', 'full', 'workflow', 'library'];
const CORE_IDS = new Set(['admin']);
const IMMUTABLE_SOURCES = new Set(['core', 'bundled']);
function typeBadge(type) {
const cls = type === 'surface' ? 'badge-active'
@@ -115,8 +116,30 @@ export default function PackagesSection() {
input.click();
}
function exportPkg(id) {
window.open(`${BASE}/api/v1/admin/packages/${id}/export`, '_blank');
async function exportPkg(id) {
try {
const resp = await fetch(`${BASE}/api/v1/admin/packages/${id}/export`, {
headers: { 'Authorization': 'Bearer ' + (sw.auth._getToken() || '') },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
sw.toast(err.error || 'Export failed', 'error');
return;
}
const warning = resp.headers.get('X-Export-Warning');
if (warning === 'no-assets') {
sw.toast('Export contains manifest only — asset files are missing', 'warn');
}
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = resp.headers.get('Content-Disposition')?.match(/filename="?([^"]+)"?/)?.[1] || `${id}.pkg`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) { sw.toast('Export failed: ' + e.message, 'error'); }
}
function updatePkg(pkg) {
@@ -302,11 +325,13 @@ export default function PackagesSection() {
${permsId === pkg.id ? 'Close' : 'Permissions'}
</button>
`}
${pkg.source !== 'core' && html`
${!IMMUTABLE_SOURCES.has(pkg.source) && html`
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => updatePkg(pkg)}>Update</button>
`}
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => exportPkg(pkg.id)}>Export</button>
${pkg.source !== 'core' && !pkg.is_system && html`
${!IMMUTABLE_SOURCES.has(pkg.source) && html`
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => exportPkg(pkg.id)}>Export</button>
`}
${!IMMUTABLE_SOURCES.has(pkg.source) && !pkg.is_system && html`
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deletePkg(pkg)}>Delete</button>
`}
</div>

View File

@@ -3,15 +3,17 @@
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
import { StageForm } from '../../components/stage-form.js';
const STAGE_MODES = ['form', 'review', 'delegated', 'automated'];
const ENTRY_MODES = ['public_link', 'team_only'];
export default function WorkflowsSection() {
const [workflows, setWorkflows] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // null | 'new' | workflow
const [editing, setEditing] = useState(null); // null | workflow
const [stages, setStages] = useState([]);
const [editingStage, setEditingStage] = useState(null); // null | 'new' | stage_id
const [teams, setTeams] = useState([]);
const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState(null);
@@ -28,12 +30,21 @@ export default function WorkflowsSection() {
async function openEdit(wf) {
setEditing(wf);
setEditingStage(null);
try {
const detail = await sw.api.workflows.get(wf.id);
setStages(detail.stages || []);
setTeams(sw.auth?.teams || []);
} catch (e) { sw.toast(e.message, 'error'); setStages([]); }
}
async function loadStages() {
try {
const s = await sw.api.workflows.stages(editing.id);
setStages(s || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
async function createWorkflow(e) {
e.preventDefault();
const form = e.target;
@@ -77,6 +88,32 @@ export default function WorkflowsSection() {
} catch (e) { sw.toast(e.message, 'error'); }
}
// ── Stage CRUD ──────────────────────────
async function addStage(data) {
try {
await sw.api.workflows.createStage(editing.id, data);
setEditingStage(null);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
async function updateStage(stageId, data) {
try {
await sw.api.workflows.updateStage(editing.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.workflows.deleteStage(editing.id, stageId);
loadStages();
} catch (e) { sw.toast(e.message, 'error'); }
}
if (loading) return html`<div class="settings-placeholder">Loading\u2026</div>`;
if (error) return html`
@@ -108,23 +145,63 @@ export default function WorkflowsSection() {
<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}>
<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>
<span class="badge">${s.audience || 'team'}</span>
${editing.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;">
<a href=${(window.__BASE__ || '') + '/w/' + (editing.team_id || 'global') + '/' + (editing.slug || '')}
target="_blank" rel="noopener"
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--accent);font-family:var(--mono);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">
${window.location.origin + (window.__BASE__ || '') + '/w/' + (editing.team_id || 'global') + '/' + (editing.slug || '')}
</a>
<button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${(e) => {
const url = window.location.origin + (window.__BASE__ || '') + '/w/' + (editing.team_id || 'global') + '/' + (editing.slug || '');
navigator.clipboard.writeText(url).then(() => {
e.target.textContent = 'Copied!';
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
});
}}>Copy</button>
</div>
`)}
${stages.length === 0 && html`<div class="empty-hint">No stages defined</div>`}
</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>
`;
return html`

View File

@@ -6,11 +6,9 @@
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
import { StageForm } from '../../components/stage-form.js';
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) ─────
@@ -121,11 +119,13 @@ export function WorkflowEditor({ teamId, workflow, onBack }) {
<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()} />
<a href=${(window.__BASE__ || '') + '/w/' + (workflow.team_id || teamId || 'global') + '/' + (workflow.slug || '')}
target="_blank" rel="noopener"
style="flex:1;font-size:11px;padding:4px 8px;background:var(--input-bg);border:1px solid var(--border);border-radius:4px;color:var(--accent);font-family:var(--mono);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;">
${window.location.origin + (window.__BASE__ || '') + '/w/' + (workflow.team_id || teamId || 'global') + '/' + (workflow.slug || '')}
</a>
<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';
const url = window.location.origin + (window.__BASE__ || '') + '/w/' + (workflow.team_id || teamId || 'global') + '/' + (workflow.slug || '');
navigator.clipboard.writeText(url).then(() => {
e.target.textContent = 'Copied!';
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
@@ -175,163 +175,3 @@ export function WorkflowEditor({ teamId, workflow, onBack }) {
`;
}
// ── 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>
`;
}