thatcher 1.0.57 → 1.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.57",
3
+ "version": "1.0.59",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -54,6 +54,16 @@ export class ConfigGeneratorEngine {
54
54
  return this.masterConfig?.system?.multi_tenancy?.enabled === true;
55
55
  }
56
56
 
57
+ updateWorkflow(workflowName, updatedDef) {
58
+ if (!workflowName || typeof workflowName !== 'string') {
59
+ throw new Error('[ConfigGeneratorEngine] updateWorkflow: workflowName required');
60
+ }
61
+ const nextWorkflows = { ...(this.masterConfig.workflows || {}), [workflowName]: updatedDef };
62
+ this.masterConfig = deepFreeze({ ...this.masterConfig, workflows: nextWorkflows });
63
+ this.specCache.clear();
64
+ return this;
65
+ }
66
+
57
67
  registerPlugin(entityName, plugin = {}) {
58
68
  if (!entityName || typeof entityName !== 'string') {
59
69
  throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
@@ -30,6 +30,11 @@ const log = createLogger('[WorkflowEngine]');
30
30
  const LOCKOUT_SECONDS = 300; // 5 minutes default
31
31
  const workflowCache = new Map();
32
32
 
33
+ export function clearWorkflowCache(workflowName) {
34
+ if (workflowName) workflowCache.delete(workflowName);
35
+ else workflowCache.clear();
36
+ }
37
+
33
38
  function getWorkflowDef(workflowName) {
34
39
  if (workflowCache.has(workflowName)) {
35
40
  return workflowCache.get(workflowName);
@@ -114,6 +114,10 @@ export function createServer(options) {
114
114
  return await handleListMemberships(req, res, thatcher, configEngine);
115
115
  }
116
116
 
117
+ if (req.method === 'POST' && entity === 'workflow' && id && action === 'update') {
118
+ return await handleUpdateWorkflow(req, res, id, thatcher, configEngine);
119
+ }
120
+
117
121
  // Check if user has custom route for this
118
122
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
119
123
  const routeExists = await fileExists(userRoutePath);
@@ -341,6 +345,85 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
341
345
  }
342
346
  }
343
347
 
348
+ async function handleUpdateWorkflow(req, res, workflowName, thatcher, configEngineArg) {
349
+ const user = await resolveRequestUser(req);
350
+ if (!user) {
351
+ res.writeHead(401, { 'Content-Type': 'application/json' });
352
+ res.end(JSON.stringify({ error: 'Authentication required' }));
353
+ return;
354
+ }
355
+ const { isPartner } = await import('../ui/permissions-ui.js');
356
+ if (!isPartner(user)) {
357
+ res.writeHead(403, { 'Content-Type': 'application/json' });
358
+ res.end(JSON.stringify({ error: 'Forbidden' }));
359
+ return;
360
+ }
361
+
362
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
363
+ if (!configEngine) {
364
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
365
+ configEngine = getConfigEngineSync();
366
+ }
367
+ const existingDef = configEngine.getConfig().workflows?.[workflowName];
368
+ if (!existingDef) {
369
+ res.writeHead(404);
370
+ res.end(JSON.stringify({ error: `Workflow "${workflowName}" not found` }));
371
+ return;
372
+ }
373
+
374
+ let body;
375
+ try {
376
+ body = await readBody(req);
377
+ } catch (e) {
378
+ res.writeHead(400);
379
+ res.end(JSON.stringify({ error: e.message }));
380
+ return;
381
+ }
382
+ const stages = Array.isArray(body?.stages) ? body.stages : null;
383
+ if (!stages || !stages.length) {
384
+ res.writeHead(400);
385
+ res.end(JSON.stringify({ error: 'stages array required and must be non-empty' }));
386
+ return;
387
+ }
388
+ const seenNames = new Set();
389
+ for (const s of stages) {
390
+ if (!s || typeof s.name !== 'string' || !s.name.trim()) {
391
+ res.writeHead(400);
392
+ res.end(JSON.stringify({ error: 'every stage requires a non-empty name' }));
393
+ return;
394
+ }
395
+ if (seenNames.has(s.name)) {
396
+ res.writeHead(400);
397
+ res.end(JSON.stringify({ error: `duplicate stage name "${s.name}"` }));
398
+ return;
399
+ }
400
+ seenNames.add(s.name);
401
+ }
402
+ const validNames = new Set(stages.map(s => s.name));
403
+ for (const s of stages) {
404
+ for (const target of (s.forward || [])) {
405
+ if (!validNames.has(target)) {
406
+ res.writeHead(400);
407
+ res.end(JSON.stringify({ error: `stage "${s.name}" has a forward transition to unknown stage "${target}"` }));
408
+ return;
409
+ }
410
+ }
411
+ }
412
+
413
+ try {
414
+ const updatedDef = { ...existingDef, stages };
415
+ configEngine.updateWorkflow(workflowName, updatedDef);
416
+ const { clearWorkflowCache } = await import('../lib/workflow-engine.js');
417
+ clearWorkflowCache(workflowName);
418
+ res.writeHead(200, { 'Content-Type': 'application/json' });
419
+ res.end(JSON.stringify({ ok: true, workflow: workflowName, stageCount: stages.length }));
420
+ } catch (err) {
421
+ apiLog.error(err.message);
422
+ res.writeHead(500);
423
+ res.end(JSON.stringify({ error: err.message }));
424
+ }
425
+ }
426
+
344
427
  async function handleListMemberships(req, res, thatcher, configEngineArg) {
345
428
  const user = await resolveRequestUser(req);
346
429
  if (!user) {
@@ -6,6 +6,7 @@ import { renderSettingsTemplates, renderSettingsChecklists, renderSettingsEntity
6
6
  import { renderSettingsNotifications, renderSettingsIntegrations, renderSettingsRecreation, renderSettingsReviewSettings, renderSettingsFileReview, renderSettingsMwrPermissions } from '@/ui/settings/review.js';
7
7
  import { renderChecklistsManagement } from '@/ui/checklist-renderer.js';
8
8
  import { renderJobManagement } from '@/ui/job-management-renderer.js';
9
+ import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-renderer.js';
9
10
  import { isPartner, isManager } from '@/ui/permissions-ui.js';
10
11
  import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
11
12
  import { fileURLToPath } from 'url';
@@ -127,5 +128,19 @@ export async function handleAdminPage(normalized, segments, user) {
127
128
  let logs = []; try { logs = (await list('job_log', {})).slice(0, 20); } catch {}
128
129
  return renderJobManagement(user, jobs, logs);
129
130
  }
131
+ if (normalized === '/admin/workflows') {
132
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
133
+ const engine = getConfigEngineSync();
134
+ const config = engine.getConfig();
135
+ return renderWorkflowList(user, Object.keys(config.workflows || {}));
136
+ }
137
+ if (segments.length === 3 && segments[1] === 'workflows') {
138
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
139
+ const engine = getConfigEngineSync();
140
+ const config = engine.getConfig();
141
+ const workflowDef = config.workflows?.[segments[2]];
142
+ if (!workflowDef) return null;
143
+ return renderWorkflowEditor(user, segments[2], workflowDef);
144
+ }
130
145
  return null;
131
146
  }
@@ -28,13 +28,13 @@ function reqUrl(req) {
28
28
 
29
29
  async function handleEngagementDetail(user, engId) {
30
30
  if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
31
- const engagement = await get('engagement', engId);
31
+ const engagement = await get('engagement', engId, { user });
32
32
  if (!engagement) return null;
33
33
  let client = null; try { client = engagement.client_id ? await get('client', engagement.client_id) : null; } catch {}
34
- let rfis = []; try { rfis = await list('rfi', { engagement_id: engId }); } catch {}
34
+ let rfis = []; try { rfis = await list('rfi', { engagement_id: engId }, { user }); } catch {}
35
35
  let sections = [];
36
36
  try {
37
- sections = await list('rfi_section', { engagement_id: engId }, { sort: { field: 'sort_order', dir: 'ASC' } });
37
+ sections = await list('rfi_section', { engagement_id: engId }, { sort: { field: 'sort_order', dir: 'ASC' }, user });
38
38
  } catch {}
39
39
  let team = null; try { team = engagement.team_id ? await get('team', engagement.team_id) : null; } catch {}
40
40
  let assignedUsers = [];
@@ -49,7 +49,7 @@ async function handleEngagementDetail(user, engId) {
49
49
  }
50
50
  async function handleEngagementList(user, req) {
51
51
  if (!canList(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'list');
52
- let engagements = await list('engagement', {});
52
+ let engagements = await list('engagement', {}, { user });
53
53
  const clientMap = Object.fromEntries((await list('client', {})).map(c => [c.id, c.name]));
54
54
  engagements = engagements.map(e => ({ ...e, client_name: clientMap[e.client_id] || e.client_name || '-' }));
55
55
  const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
@@ -91,14 +91,14 @@ async function handleGenericEntityView(user, entityName, id) {
91
91
  }
92
92
  async function handleClientSubRoute(user, clientId, subRoute) {
93
93
  if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
94
- const client = await get('client', clientId); if (!client) return null;
94
+ const client = await get('client', clientId, { user }); if (!client) return null;
95
95
  if (isClientUser(user) && user.client_id && user.client_id !== clientId) return renderAccessDenied(user, 'client', 'view');
96
96
  if (subRoute === 'dashboard' || subRoute === 'users') return renderClientDashboard(user, client, await getClientDashboardStats(clientId));
97
97
  if (subRoute === 'progress') {
98
- let engagements = []; try { engagements = (await list('engagement', {})).filter(e => e.client_id === clientId); } catch {}
98
+ let engagements = []; try { engagements = (await list('engagement', {}, { user })).filter(e => e.client_id === clientId); } catch {}
99
99
  const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
100
100
  let rfiStats = { total: 0, responded: 0, overdue: 0 };
101
- try { const allRfis = (await list('rfi', {})).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
101
+ try { const allRfis = (await list('rfi', {}, { user })).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
102
102
  return renderClientProgress(user, client, engagements, rfiStats);
103
103
  }
104
104
  return null;
@@ -106,7 +106,7 @@ async function handleClientSubRoute(user, clientId, subRoute) {
106
106
  async function handleGenericEntityEdit(user, entityName, id) {
107
107
  const spec = getSpec(entityName); if (!spec) return null;
108
108
  if (!canEdit(user, entityName)) return renderAccessDenied(user, entityName, 'edit');
109
- const item = await get(entityName, id); if (!item) return null;
109
+ const item = await get(entityName, id, { user }); if (!item) return null;
110
110
  if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'edit');
111
111
  const resolvedSpec = resolveEnumOptions(spec);
112
112
  const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
@@ -131,7 +131,7 @@ export async function handlePage(pathname, req, res) {
131
131
  const user = await getUser();
132
132
  if (!user) { res.writeHead(302, { Location: '/login' }); res.end(); return REDIRECT; }
133
133
  if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
134
- if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
134
+ if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100,user})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
135
135
  if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
136
136
  if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user);
137
137
  if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
@@ -141,7 +141,7 @@ export async function handlePage(pathname, req, res) {
141
141
  let myReviews = [], sharedReviews = [], recentActivity = [];
142
142
  // OR across two columns: busybase eq() is single-column, so fetch + filter in JS.
143
143
  try {
144
- const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } });
144
+ const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' }, user });
145
145
  myReviews = reviews.filter(r => r.created_by === user.id || r.assigned_to === user.id).slice(0, 100);
146
146
  } catch {}
147
147
  // Old JOIN collaborator -> two-step: collaborator rows for this user, then their reviews.
@@ -149,12 +149,12 @@ export async function handlePage(pathname, req, res) {
149
149
  const collabs = await list('collaborator', { user_id: user.id });
150
150
  const ids = new Set(collabs.map(c => c.review_id));
151
151
  if (ids.size) {
152
- const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } });
152
+ const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' }, user });
153
153
  sharedReviews = reviews.filter(r => ids.has(r.id)).slice(0, 100);
154
154
  }
155
155
  } catch {}
156
156
  try {
157
- recentActivity = (await list('audit_logs', { entity_type: 'review' }, { sort: { field: 'created_at', dir: 'DESC' } })).slice(0, 50);
157
+ recentActivity = (await list('audit_logs', { entity_type: 'review' }, { sort: { field: 'created_at', dir: 'DESC' }, user })).slice(0, 50);
158
158
  } catch {}
159
159
  const all = [...myReviews, ...sharedReviews];
160
160
  const stats = { myReviews, sharedReviews, recentActivity, totalReviews: all.length, activeReviews: all.filter(r => (r.status||'open') !== 'archived' && (r.status||'open') !== 'completed' && (r.status||'open') !== 'closed').length, flaggedReviews: all.filter(r => r.flagged).length, overdueReviews: 0 };
@@ -179,11 +179,11 @@ export async function handlePage(pathname, req, res) {
179
179
  let candidates = []; let reviewMap = {};
180
180
  try {
181
181
  // Old: highlights with a non-trivial comment (LIKE '%?' OR length>40), newest 50.
182
- const all = await list('highlight', {}, { sort: { field: 'created_at', dir: 'DESC' } });
182
+ const all = await list('highlight', {}, { sort: { field: 'created_at', dir: 'DESC' }, user });
183
183
  candidates = all.filter(c => c.comment && (c.comment.includes('?') || c.comment.length > 40)).slice(0, 50);
184
184
  const revIds = [...new Set(candidates.map((c) => c.review_id))];
185
185
  if (revIds.length) {
186
- const reviews = await list('review', {});
186
+ const reviews = await list('review', {}, { user });
187
187
  const byId = new Map(reviews.map(r => [r.id, r.name || '-']));
188
188
  reviewMap = Object.fromEntries(revIds.map(id => [id, byId.get(id) || '-']));
189
189
  }
@@ -194,19 +194,19 @@ export async function handlePage(pathname, req, res) {
194
194
  if (segments.length === 2 && (segments[0] === 'engagements' || segments[0] === 'engagement') && segments[1] !== 'new') return handleEngagementDetail(user, segments[1]);
195
195
  if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'letter') {
196
196
  if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
197
- const engagement = await get('engagement', segments[1]); if (!engagement) return null;
197
+ const engagement = await get('engagement', segments[1], { user }); if (!engagement) return null;
198
198
  return renderLetterWorkflow(user, engagement);
199
199
  }
200
200
  if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'report') {
201
201
  if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
202
- const engId = segments[1]; const engagement = await get('engagement', engId); if (!engagement) return null;
202
+ const engId = segments[1]; const engagement = await get('engagement', engId, { user }); if (!engagement) return null;
203
203
  let client=null,team=null,rfis=[],reviews=[],highlights=[],activity=[];
204
204
  try{client=engagement.client_id?await get('client',engagement.client_id):null}catch{}
205
205
  try{team=engagement.team_id?await get('team',engagement.team_id):null}catch{}
206
- try{rfis=await list('rfi',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'}})}catch{}
207
- try{reviews=await list('review',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'}})}catch{}
208
- try{const rids=new Set(reviews.map(r=>r.id));if(rids.size){const allHl=await list('highlight',{});highlights=allHl.filter(h=>rids.has(h.review_id))}}catch{}
209
- try{activity=(await list('audit_logs',{entity_type:'engagement',entity_id:engId},{sort:{field:'created_at',dir:'DESC'}})).slice(0,20)}catch{}
206
+ try{rfis=await list('rfi',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'},user})}catch{}
207
+ try{reviews=await list('review',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'},user})}catch{}
208
+ try{const rids=new Set(reviews.map(r=>r.id));if(rids.size){const allHl=await list('highlight',{},{user});highlights=allHl.filter(h=>rids.has(h.review_id))}}catch{}
209
+ try{activity=(await list('audit_logs',{entity_type:'engagement',entity_id:engId},{sort:{field:'created_at',dir:'DESC'},user})).slice(0,20)}catch{}
210
210
  const{renderFlexupReport}=await lazyRenderer('flexup-report-renderer.js');
211
211
  return renderFlexupReport(user,engagement,client,rfis,reviews,highlights,activity,team);
212
212
  }
@@ -215,7 +215,7 @@ export async function handlePage(pathname, req, res) {
215
215
  if (segments.length === 1 && segments[0] === 'rfi') return handleRfiList(user);
216
216
  if (segments.length === 1 && segments[0] === 'client') {
217
217
  if (!canList(user, 'client')) return renderAccessDenied(user, 'client', 'list');
218
- let clients = await list('client', {});
218
+ let clients = await list('client', {}, { user });
219
219
  if (isClientUser(user) && user.client_id) clients = clients.filter(c => c.id === user.client_id);
220
220
  return renderClientList(user, clients);
221
221
  }
@@ -261,7 +261,7 @@ export async function handlePage(pathname, req, res) {
261
261
  if (segments.length === 2 && segments[0] === 'client' && segments[1] !== 'new') {
262
262
  if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
263
263
  if (isClientUser(user) && user.client_id && user.client_id !== segments[1]) return renderAccessDenied(user, 'client', 'view');
264
- const client = await get('client', segments[1]); if (!client) return null;
264
+ const client = await get('client', segments[1], { user }); if (!client) return null;
265
265
  return renderClientDashboard(user, client, await getClientDashboardStats(segments[1]));
266
266
  }
267
267
  if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1]);
@@ -0,0 +1,109 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc } from '@/ui/render-helpers.js';
3
+
4
+ export function renderWorkflowList(user, workflowNames) {
5
+ const rows = workflowNames.map(name =>
6
+ `<tr data-row data-navigate="/admin/workflows/${esc(name)}" style="cursor:pointer"><td>${esc(name)}</td></tr>`
7
+ ).join('') || '<tr><td>No workflows configured</td></tr>';
8
+ const content = `<div class="page-header"><h1 class="page-title">Workflows</h1></div>
9
+ <div class="table-wrap"><table class="data-table"><thead><tr><th>Name</th></tr></thead><tbody>${rows}</tbody></table></div>`;
10
+ return page(user, 'Workflows | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Workflows' }], content);
11
+ }
12
+
13
+ function stageRow(stage, allStageNames) {
14
+ const forwardOpts = allStageNames.filter(n => n !== stage.name).map(n =>
15
+ `<label style="display:inline-flex;align-items:center;gap:4px;margin-right:10px"><input type="checkbox" name="forward_${esc(stage.name)}" value="${esc(n)}"${(stage.forward || []).includes(n) ? ' checked' : ''}>${esc(n)}</label>`
16
+ ).join('');
17
+ return `<div class="card-clean" style="margin-bottom:12px" data-stage-card="${esc(stage.name)}">
18
+ <div class="card-clean-body">
19
+ <div style="display:flex;gap:12px;align-items:center;margin-bottom:8px">
20
+ <input type="text" name="name_${esc(stage.name)}" value="${esc(stage.name)}" placeholder="stage name" style="width:140px" readonly>
21
+ <input type="text" name="label_${esc(stage.name)}" value="${esc(stage.label || '')}" placeholder="Label" style="flex:1">
22
+ <input type="text" name="role_${esc(stage.name)}" value="${esc((stage.requires_role || []).join(','))}" placeholder="requires_role (comma-separated)" style="width:200px">
23
+ <button type="button" class="btn-ghost-clean" data-action="removeStage" data-args='["${esc(stage.name)}"]'>Remove</button>
24
+ </div>
25
+ <div style="font-size:12px;color:var(--color-text-muted,#666)">Forward transitions:</div>
26
+ <div>${forwardOpts || '<span style="font-size:12px;color:var(--color-text-muted,#666)">(no other stages)</span>'}</div>
27
+ </div>
28
+ </div>`;
29
+ }
30
+
31
+ export function renderWorkflowEditor(user, workflowName, workflowDef) {
32
+ const stages = workflowDef?.stages || [];
33
+ const stageNames = stages.map(s => s.name);
34
+ const stageCards = stages.map(s => stageRow(s, stageNames)).join('');
35
+
36
+ const content = `<div class="page-header"><h1 class="page-title">Workflow: ${esc(workflowName)}</h1></div>
37
+ <form id="workflow-form">
38
+ <div id="stage-list">${stageCards}</div>
39
+ <div style="margin:16px 0;display:flex;gap:8px">
40
+ <input type="text" id="new-stage-name" placeholder="new stage name" style="width:200px">
41
+ <button type="button" class="btn-ghost-clean" data-action="addStage">Add Stage</button>
42
+ </div>
43
+ <button type="button" class="btn-primary-clean" data-action="saveWorkflow" data-args='["${esc(workflowName)}"]'>Save Workflow</button>
44
+ <span id="save-status" style="margin-left:12px;font-size:13px"></span>
45
+ </form>`;
46
+
47
+ const script = `(function(){
48
+ function collectStages(){
49
+ var cards=document.querySelectorAll('[data-stage-card]');
50
+ var stages=[];
51
+ cards.forEach(function(card,idx){
52
+ var origName=card.getAttribute('data-stage-card');
53
+ var nameInput=card.querySelector('input[name^="name_"]');
54
+ var labelInput=card.querySelector('input[name^="label_"]');
55
+ var roleInput=card.querySelector('input[name^="role_"]');
56
+ var checked=Array.prototype.slice.call(card.querySelectorAll('input[type="checkbox"]:checked')).map(function(cb){return cb.value});
57
+ var roles=(roleInput.value||'').split(',').map(function(s){return s.trim()}).filter(Boolean);
58
+ stages.push({name:origName,label:labelInput.value||origName,order:idx,forward:checked,requires_role:roles});
59
+ });
60
+ return stages;
61
+ }
62
+ window.addStage=function(){
63
+ var input=document.getElementById('new-stage-name');
64
+ var name=(input.value||'').trim();
65
+ if(!name)return;
66
+ if(document.querySelector('[data-stage-card="'+name+'"]')){alert('Stage already exists');return}
67
+ var div=document.createElement('div');
68
+ div.className='card-clean';
69
+ div.style.marginBottom='12px';
70
+ div.setAttribute('data-stage-card',name);
71
+ var body=document.createElement('div');
72
+ body.className='card-clean-body';
73
+ var row=document.createElement('div');
74
+ row.style.cssText='display:flex;gap:12px;align-items:center;margin-bottom:8px';
75
+ var nameInput=document.createElement('input');
76
+ nameInput.type='text';nameInput.name='name_'+name;nameInput.value=name;nameInput.readOnly=true;nameInput.style.width='140px';
77
+ var labelInput=document.createElement('input');
78
+ labelInput.type='text';labelInput.name='label_'+name;labelInput.placeholder='Label';labelInput.style.flex='1';
79
+ var roleInput=document.createElement('input');
80
+ roleInput.type='text';roleInput.name='role_'+name;roleInput.placeholder='requires_role (comma-separated)';roleInput.style.width='200px';
81
+ var removeBtn=document.createElement('button');
82
+ removeBtn.type='button';removeBtn.className='btn-ghost-clean';removeBtn.textContent='Remove';
83
+ removeBtn.addEventListener('click',function(){window.removeStage(name)});
84
+ row.appendChild(nameInput);row.appendChild(labelInput);row.appendChild(roleInput);row.appendChild(removeBtn);
85
+ var hint=document.createElement('div');
86
+ hint.style.cssText='font-size:12px;color:var(--color-text-muted,#666)';
87
+ hint.textContent='Forward transitions: (save and reload to link)';
88
+ body.appendChild(row);body.appendChild(hint);
89
+ div.appendChild(body);
90
+ document.getElementById('stage-list').appendChild(div);
91
+ input.value='';
92
+ };
93
+ window.removeStage=function(name){
94
+ var card=document.querySelector('[data-stage-card="'+name+'"]');
95
+ if(card)card.remove();
96
+ };
97
+ window.saveWorkflow=function(workflowName){
98
+ var stages=collectStages();
99
+ var status=document.getElementById('save-status');
100
+ status.textContent='Saving...';
101
+ fetch('/api/workflow/'+workflowName+'/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({stages:stages})})
102
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
103
+ .then(function(res){if(res.ok){status.textContent='Saved';status.style.color='var(--color-success,#22c55e)';setTimeout(function(){location.reload()},500)}else{status.textContent='Error: '+(res.d.error||'save failed');status.style.color='var(--color-danger,#ef4444)'}})
104
+ .catch(function(err){status.textContent='Error: '+err.message;status.style.color='var(--color-danger,#ef4444)'});
105
+ };
106
+ })();`;
107
+
108
+ return page(user, `Workflow: ${workflowName} | Thatcher`, [{ href: '/admin/settings', label: 'Settings' }, { href: '/admin/workflows', label: 'Workflows' }, { label: workflowName }], content, [script]);
109
+ }