Feat v0.3.4 team roles signoff (#18)
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:
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user