feat(rgov): clickable signoff gates with authority check + project recalc
Binary signoff nodes now have a clickable checkbox that toggles satisfied state. Checks EncryptID JWT for authority (assignee match), falls back to allowing anyone in demo mode. Toggling a signoff auto-recalculates connected project aggregator gate counts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba7a5733b8
commit
df8901c975
|
|
@ -164,7 +164,7 @@ function buildDemoData(): { nodes: GovNode[]; edges: GovEdge[] } {
|
|||
nodes.push({
|
||||
id: 'c2-venue', type: 'folk-gov-binary', label: 'Venue Signoff',
|
||||
position: { x: 50, y: c2y + 320 },
|
||||
config: { assignee: 'Carlos', satisfied: true },
|
||||
config: { assignee: 'Carlos', satisfied: true, signedBy: 'Carlos' },
|
||||
});
|
||||
nodes.push({
|
||||
id: 'c2-project', type: 'folk-gov-project', label: 'Community Potluck',
|
||||
|
|
@ -247,14 +247,28 @@ function renderNodeBody(node: GovNode): string {
|
|||
switch (node.type) {
|
||||
case 'folk-gov-binary': {
|
||||
const satisfied = c.satisfied ? 'Yes' : 'No';
|
||||
const icon = c.satisfied
|
||||
? '<span style="color:#22c55e">✔</span>'
|
||||
: '<span style="color:#ef4444">✘</span>';
|
||||
const checkColor = c.satisfied ? '#22c55e' : '#475569';
|
||||
const checkBg = c.satisfied ? 'rgba(34,197,94,0.15)' : 'rgba(71,85,105,0.15)';
|
||||
const checkIcon = c.satisfied ? '✔' : '';
|
||||
return `
|
||||
<div style="font-size:11px;color:#94a3b8;margin-top:2px">Assignee: ${esc(c.assignee || 'Unassigned')}</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-top:4px">
|
||||
${icon}
|
||||
<span style="font-size:12px;color:#e2e8f0">${satisfied}</span>
|
||||
<div class="gc-signoff-toggle" data-node-id="${node.id}" style="
|
||||
display:flex;align-items:center;gap:8px;margin-top:6px;
|
||||
cursor:pointer;padding:4px 8px;border-radius:6px;
|
||||
background:${checkBg};border:1px solid ${checkColor}40;
|
||||
transition:all 0.15s;user-select:none;
|
||||
-webkit-tap-highlight-color:transparent;
|
||||
">
|
||||
<div style="
|
||||
width:20px;height:20px;border-radius:4px;
|
||||
border:2px solid ${checkColor};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:14px;color:${checkColor};
|
||||
background:${c.satisfied ? checkColor + '20' : 'transparent'};
|
||||
flex-shrink:0;transition:all 0.15s;
|
||||
">${checkIcon}</div>
|
||||
<span style="font-size:12px;color:#e2e8f0;font-weight:500">${satisfied}</span>
|
||||
${c.satisfied && c.signedBy ? `<span style="font-size:10px;color:#64748b;margin-left:auto">by ${esc(c.signedBy)}</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
case 'folk-gov-threshold': {
|
||||
|
|
@ -889,6 +903,7 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
const demo = buildDemoData();
|
||||
this.nodes = demo.nodes;
|
||||
this.edges = demo.edges;
|
||||
this.recalcProjectGates();
|
||||
this.render();
|
||||
requestAnimationFrame(() => this.fitView());
|
||||
}
|
||||
|
|
@ -1268,6 +1283,94 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
edgeLayer.innerHTML = this.renderAllEdges();
|
||||
nodeLayer.innerHTML = this.renderAllNodes();
|
||||
if (wireLayer) wireLayer.innerHTML = '';
|
||||
this.attachSignoffHandlers();
|
||||
}
|
||||
|
||||
private attachSignoffHandlers() {
|
||||
const nodeLayer = this.shadow.getElementById('node-layer');
|
||||
if (!nodeLayer) return;
|
||||
nodeLayer.querySelectorAll('.gc-signoff-toggle').forEach(el => {
|
||||
el.addEventListener('click', (e: Event) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const nodeId = (el as HTMLElement).dataset.nodeId;
|
||||
if (!nodeId) return;
|
||||
this.toggleSignoff(nodeId);
|
||||
});
|
||||
// Prevent the click from starting a node drag
|
||||
el.addEventListener('pointerdown', (e: Event) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private toggleSignoff(nodeId: string) {
|
||||
const node = this.nodes.find(n => n.id === nodeId);
|
||||
if (!node || node.type !== 'folk-gov-binary') return;
|
||||
|
||||
// Authority check: resolve current user from EncryptID JWT if available
|
||||
let currentUser = 'You';
|
||||
try {
|
||||
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('auth='));
|
||||
if (token) {
|
||||
const payload = JSON.parse(atob(token.split('=')[1].split('.')[1]));
|
||||
currentUser = payload.username || payload.sub || 'You';
|
||||
}
|
||||
} catch { /* no auth context — allow toggle for demo */ }
|
||||
|
||||
// Check authority: if assignee is set, only the assignee (or admin) can toggle
|
||||
const assignee = (node.config.assignee || '').toLowerCase().trim();
|
||||
const userLower = currentUser.toLowerCase().trim();
|
||||
if (assignee && assignee !== 'unassigned' && !assignee.includes('pending')) {
|
||||
// Extract just the name part (handle "Carlos", "Landlord (pending)", etc.)
|
||||
const assigneeName = assignee.replace(/\s*\(.*\)\s*$/, '');
|
||||
if (assigneeName && assigneeName !== userLower && userLower !== 'you') {
|
||||
// Not authorized — show brief feedback
|
||||
const el = this.shadow.querySelector(`[data-node-id="${nodeId}"].gc-signoff-toggle`) as HTMLElement;
|
||||
if (el) {
|
||||
el.style.outline = '2px solid #ef4444';
|
||||
el.style.outlineOffset = '2px';
|
||||
setTimeout(() => { el.style.outline = ''; el.style.outlineOffset = ''; }, 600);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle
|
||||
node.config.satisfied = !node.config.satisfied;
|
||||
node.config.signedBy = node.config.satisfied ? currentUser : '';
|
||||
|
||||
// Update connected project aggregators
|
||||
this.recalcProjectGates();
|
||||
|
||||
// Re-render
|
||||
this.drawCanvasContent();
|
||||
if (this.selectedNodeId === nodeId) {
|
||||
this.refreshDetailPanel();
|
||||
}
|
||||
}
|
||||
|
||||
private recalcProjectGates() {
|
||||
// For each project node, count how many of its incoming edges come from satisfied gates
|
||||
for (const node of this.nodes) {
|
||||
if (node.type !== 'folk-gov-project') continue;
|
||||
const incomingEdges = this.edges.filter(e => e.toNode === node.id);
|
||||
let satisfied = 0;
|
||||
let total = incomingEdges.length;
|
||||
for (const edge of incomingEdges) {
|
||||
const source = this.nodes.find(n => n.id === edge.fromNode);
|
||||
if (!source) continue;
|
||||
if (source.type === 'folk-gov-binary' && source.config.satisfied) satisfied++;
|
||||
else if (source.type === 'folk-gov-threshold' && source.config.current >= source.config.target) satisfied++;
|
||||
else if (source.type === 'folk-gov-multisig') {
|
||||
const signed = (source.config.signed || '').split(',').filter((s: string) => s.trim()).length;
|
||||
if (signed >= source.config.required) satisfied++;
|
||||
}
|
||||
else if (source.type === 'folk-gov-conviction' && source.config.accumulated >= source.config.threshold) satisfied++;
|
||||
}
|
||||
node.config.gatesSatisfied = satisfied;
|
||||
node.config.gatesTotal = total;
|
||||
}
|
||||
}
|
||||
|
||||
private redrawEdges() {
|
||||
|
|
@ -1331,7 +1434,7 @@ export class FolkGovCircuit extends HTMLElement {
|
|||
|
||||
private defaultConfigFor(type: string): Record<string, any> {
|
||||
switch (type) {
|
||||
case 'folk-gov-binary': return { assignee: '', satisfied: false };
|
||||
case 'folk-gov-binary': return { assignee: '', satisfied: false, signedBy: '' };
|
||||
case 'folk-gov-threshold': return { target: 100, current: 0, unit: '', contributors: '' };
|
||||
case 'folk-gov-knob': return { min: 0, max: 100, value: 50, unit: '' };
|
||||
case 'folk-gov-project': return { description: '', gatesSatisfied: 0, gatesTotal: 0 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue