Feat v0.3.4 team roles signoff (#18)
Some checks failed
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Failing after 2m45s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (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 #18.
This commit is contained in:
2026-03-28 01:15:33 +00:00
committed by xcaliber
parent dba718b914
commit 0773c86c27
24 changed files with 1039 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
/**
* Team Admin > Members
* v0.3.4: custom team roles support
*/
const { html } = window;
const { useState, useEffect, useCallback } = hooks;
@@ -9,6 +10,16 @@ export default function MembersSection({ teamId }) {
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [users, setUsers] = useState([]);
const [roles, setRoles] = useState(['admin', 'member']);
const [showRoles, setShowRoles] = useState(false);
const [newRole, setNewRole] = useState('');
const loadRoles = useCallback(async () => {
try {
const resp = await sw.api.get(`/api/v1/teams/${teamId}/roles`);
setRoles(resp.data || ['admin', 'member']);
} catch { setRoles(['admin', 'member']); }
}, [teamId]);
const load = useCallback(async () => {
try {
@@ -18,7 +29,7 @@ export default function MembersSection({ teamId }) {
finally { setLoading(false); }
}, [teamId]);
useEffect(() => { load(); }, [load]);
useEffect(() => { load(); loadRoles(); }, [load, loadRoles]);
async function openAdd() {
setShowAdd(true);
@@ -83,8 +94,7 @@ export default function MembersSection({ teamId }) {
</div>
<div class="form-group"><label>Role</label>
<select name="role">
<option value="member">Member</option>
<option value="admin">Admin</option>
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
</div>
</div>
@@ -102,13 +112,58 @@ export default function MembersSection({ teamId }) {
<strong style="flex:1;">${m.username || m.user_id}</strong>
<select value=${m.role || 'member'} style="width:100px;"
onChange=${e => updateRole(m.id || m.user_id, e.target.value)}>
<option value="member">Member</option>
<option value="admin">Admin</option>
${roles.map(r => html`<option key=${r} value=${r}>${r}</option>`)}
</select>
<button class="btn-small btn-danger" onClick=${() => removeMember(m.id || m.user_id)}>Remove</button>
</div>
`)}
</div>
<div style="margin-top:24px;border-top:1px solid var(--border-1);padding-top:12px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h4 style="margin:0;flex:1;">Team Roles</h4>
<button class="btn-small" onClick=${() => setShowRoles(!showRoles)}>
${showRoles ? 'Hide' : 'Manage'}
</button>
</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
${roles.map(r => html`<span key=${r} class="badge">${r}</span>`)}
</div>
${showRoles && html`
<div style="display:flex;gap:8px;align-items:center;">
<input value=${newRole} onInput=${e => setNewRole(e.target.value)}
placeholder="New role name" style="width:150px;" />
<button class="btn-small btn-primary" onClick=${async () => {
if (!newRole.trim()) return;
const updated = [...new Set([...roles, newRole.trim()])];
try {
await sw.api.put('/api/v1/teams/' + teamId + '/roles', { roles: updated });
setRoles(updated);
setNewRole('');
sw.toast('Role added', 'success');
} catch (e) { sw.toast(e.message, 'error'); }
}}>Add Role</button>
</div>
<div class="admin-list" style="margin-top:8px;">
${roles.filter(r => r !== 'admin' && r !== 'member').map(r => html`
<div class="admin-surface-row" key=${r}>
<span style="flex:1;">${r}</span>
<button class="btn-small btn-danger" onClick=${async () => {
const updated = roles.filter(x => x !== r);
try {
await sw.api.put('/api/v1/teams/' + teamId + '/roles', { roles: updated });
setRoles(updated);
sw.toast('Role removed', 'success');
load(); // re-check members
} catch (e) { sw.toast(e.message, 'error'); }
}}>Remove</button>
</div>
`)}
${roles.filter(r => r !== 'admin' && r !== 'member').length === 0 &&
html`<div class="empty-hint">No custom roles</div>`}
</div>
`}
</div>
</div>
`;
}

View File

@@ -288,7 +288,29 @@ function StageForm({ stage, teams, onSave, onCancel }) {
const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false);
const [sla, setSla] = useState(stage?.sla_seconds || '');
// v0.3.4: stage_config fields
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',
};
}
onSave({
name,
stage_mode: mode,
@@ -298,6 +320,7 @@ function StageForm({ stage, teams, onSave, onCancel }) {
assignment_team_id: assignTeam || null,
auto_transition: autoTransition,
sla_seconds: sla ? parseInt(sla, 10) : null,
stage_config: Object.keys(stageConfig).length ? stageConfig : {},
});
}
@@ -351,6 +374,40 @@ function StageForm({ stage, teams, onSave, onCancel }) {
<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>
`}
<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>
@@ -465,6 +522,7 @@ function AssignmentsTab({ teamId }) {
function MonitorTab({ teamId }) {
const [instances, setInstances] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedSignoff, setExpandedSignoff] = useState(null);
const load = useCallback(async () => {
try {
@@ -501,10 +559,13 @@ function MonitorTab({ teamId }) {
<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=${() => setExpandedSignoff(expandedSignoff === inst.id ? null : inst.id)}>Signoffs</button>
<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>
${expandedSignoff === inst.id && html`<${SignoffPanel} instanceId=${inst.id} teamId=${teamId} />`}
</div>
`)}
${instances.length === 0 && html`<div class="empty-hint">No active instances</div>`}
@@ -512,3 +573,61 @@ function MonitorTab({ teamId }) {
</div>
`;
}
// ── Signoff Panel (v0.3.4) ──────────────────
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="btn-small btn-success" onClick=${() => submit('approve')}>Approve</button>
<button class="btn-small btn-danger" 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>${s.user_id}</span>
${s.comment && html`<span class="text-muted">\u2014 ${s.comment}</span>`}
</div>
`)}
</div>
`}
</div>
`;
}