thatcher 1.0.7 → 1.0.34

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.
Files changed (174) hide show
  1. package/README.md +4 -16
  2. package/package.json +12 -18
  3. package/src/adapters/google-auth.js +4 -43
  4. package/src/adapters/google-drive.js +12 -67
  5. package/src/app/api/audit/dashboard/route.js +14 -11
  6. package/src/app/api/audit/logs/route.js +2 -2
  7. package/src/app/api/audit/permissions/[id]/route.js +6 -3
  8. package/src/app/api/audit/permissions/route.js +10 -7
  9. package/src/app/api/audit/permissions/stats/route.js +8 -5
  10. package/src/app/api/audit/route.js +4 -4
  11. package/src/app/api/audit/stats/route.js +1 -1
  12. package/src/app/api/auth/google/callback/route.js +5 -2
  13. package/src/app/api/auth/google/route.js +4 -1
  14. package/src/app/api/auth/login/route.js +5 -2
  15. package/src/app/api/auth/mwr-bridge/route.js +11 -21
  16. package/src/app/api/auth/password-reset/route.js +15 -14
  17. package/src/app/api/cron/trigger/route.js +4 -1
  18. package/src/app/api/debug/[[...path]]/route.js +6 -5
  19. package/src/app/api/debug/sqlite/route.js +7 -12
  20. package/src/app/api/debug/sync/route.js +5 -5
  21. package/src/app/api/domains/route.js +4 -1
  22. package/src/app/api/email/allocate/batch/route.js +19 -29
  23. package/src/app/api/email/allocate/route.js +16 -17
  24. package/src/app/api/email/receive/route.js +31 -42
  25. package/src/app/api/email/send/route.js +28 -16
  26. package/src/app/api/email/unallocated/route.js +10 -13
  27. package/src/app/api/files/[id]/route.js +4 -1
  28. package/src/app/api/formula/[[...path]]/route.js +0 -4
  29. package/src/app/api/health/route.js +10 -9
  30. package/src/app/api/metrics/route.js +1 -1
  31. package/src/cli.js +4 -4
  32. package/src/config/config-loader.js +1 -21
  33. package/src/config/constants.js +0 -35
  34. package/src/config/env.js +0 -30
  35. package/src/config/spec-helpers.js +6 -95
  36. package/src/engine.js +23 -0
  37. package/src/engine.server.js +8 -78
  38. package/src/index.js +42 -67
  39. package/src/lib/alert-manager.js +7 -3
  40. package/src/lib/api-helpers.js +4 -3
  41. package/src/lib/api.js +10 -10
  42. package/src/lib/audit-logger-enhanced.js +77 -83
  43. package/src/lib/auth-middleware.js +1 -32
  44. package/src/lib/auth-route-helpers.js +5 -0
  45. package/src/lib/busybase-adapter.js +0 -5
  46. package/src/lib/busybase-audit-reads.js +142 -0
  47. package/src/lib/busybase-audit.js +41 -0
  48. package/src/lib/busybase-lucia-adapter.js +72 -0
  49. package/src/lib/busybase-store.js +338 -0
  50. package/src/lib/config-field-helpers.js +0 -17
  51. package/src/lib/config-generator-engine.js +19 -36
  52. package/src/lib/config-helpers.js +0 -21
  53. package/src/lib/crud-action-helpers.js +2 -2
  54. package/src/lib/crud-factory.js +1 -16
  55. package/src/lib/crud-handlers.js +17 -52
  56. package/src/lib/csrf-protection.js +5 -2
  57. package/src/lib/date-utils.js +3 -127
  58. package/src/lib/debug-registry.js +0 -5
  59. package/src/lib/domain-loader.js +4 -1
  60. package/src/lib/email-sender.js +25 -23
  61. package/src/lib/error-handler.js +5 -41
  62. package/src/lib/error-recovery.js +10 -7
  63. package/src/lib/error-resilience.js +7 -5
  64. package/src/lib/events-engine.js +9 -6
  65. package/src/lib/export-sink.js +0 -5
  66. package/src/lib/field-iterator.js +7 -76
  67. package/src/lib/field-registry.js +0 -22
  68. package/src/lib/generic-crud-handler.js +0 -16
  69. package/src/lib/hook-engine.js +5 -53
  70. package/src/lib/hot-reload/checkpoint.js +5 -2
  71. package/src/lib/hot-reload/promise-container.js +7 -3
  72. package/src/lib/hot-reload/route-wrapper.js +4 -1
  73. package/src/lib/hot-reload/supervisor.js +6 -3
  74. package/src/lib/hyperformula-service.js +0 -5
  75. package/src/lib/id-helpers.js +10 -0
  76. package/src/lib/index.js +4 -7
  77. package/src/lib/log-aggregator.js +0 -1
  78. package/src/lib/logger.js +0 -15
  79. package/src/lib/metrics-collector.js +2 -46
  80. package/src/lib/next-compat.js +15 -1
  81. package/src/lib/observability-bootstrap.js +0 -5
  82. package/src/lib/perf-monitor.js +4 -1
  83. package/src/lib/perf-profiler.js +0 -5
  84. package/src/lib/query-string-adapter.js +5 -47
  85. package/src/lib/realtime-server.js +4 -26
  86. package/src/lib/request-tracing.js +0 -5
  87. package/src/lib/resource-monitor.js +4 -1
  88. package/src/lib/response-formatter.js +0 -38
  89. package/src/lib/route-helpers.js +3 -1
  90. package/src/lib/safe-json.js +5 -1
  91. package/src/lib/server-bootstrap.js +10 -9
  92. package/src/lib/stage-pipeline.js +2 -2
  93. package/src/lib/state-transport-client.js +4 -1
  94. package/src/lib/static-server.js +2 -2
  95. package/src/lib/status-helpers.js +2 -51
  96. package/src/lib/tracing.js +0 -5
  97. package/src/lib/utils.js +6 -67
  98. package/src/lib/validate.js +5 -88
  99. package/src/lib/validators.js +0 -38
  100. package/src/lib/with-audit-logging.js +1 -1
  101. package/src/lib/workflow-engine.js +10 -77
  102. package/src/lib/xstate-workflow-engine.js +3 -9
  103. package/src/plugins/index.js +5 -3
  104. package/src/server/server.js +26 -27
  105. package/src/services/collaborator-role.service.js +21 -77
  106. package/src/services/email-sender.js +7 -34
  107. package/src/services/notification-engine.js +12 -33
  108. package/src/services/permission.service.js +10 -73
  109. package/src/ui/advanced-search-renderer.js +3 -2
  110. package/src/ui/advanced-widgets.js +1 -1
  111. package/src/ui/auth-pages.js +10 -9
  112. package/src/ui/auth-styles.js +1 -1
  113. package/src/ui/client.js +1 -1
  114. package/src/ui/component-engine.js +1 -1
  115. package/src/ui/dashboard-renderer.js +5 -5
  116. package/src/ui/dialog-engine.js +6 -9
  117. package/src/ui/dialog-factory.js +2 -2
  118. package/src/ui/engagement-cards.js +7 -7
  119. package/src/ui/engagement-dialogs.js +2 -3
  120. package/src/ui/engagement-grid-renderer.js +20 -19
  121. package/src/ui/entity-renderer.js +56 -42
  122. package/src/ui/file-dialogs.js +25 -9
  123. package/src/ui/flexup-report-renderer.js +21 -18
  124. package/src/ui/format-helpers.js +27 -28
  125. package/src/ui/highlight-threading-renderer.js +9 -9
  126. package/src/ui/job-management-renderer.js +18 -16
  127. package/src/ui/layout.js +51 -25
  128. package/src/ui/ml-console-renderer.js +15 -16
  129. package/src/ui/monitoring-dashboard-client.js +9 -8
  130. package/src/ui/notifications-renderer.js +11 -10
  131. package/src/ui/page-handler-admin.js +32 -23
  132. package/src/ui/page-handler-helpers.js +27 -23
  133. package/src/ui/page-handler-reviews.js +71 -35
  134. package/src/ui/page-handler-rfi.js +28 -14
  135. package/src/ui/page-handler.js +65 -48
  136. package/src/ui/picker-dialogs.js +26 -19
  137. package/src/ui/render-helpers.js +84 -46
  138. package/src/ui/review-comparison-renderer.js +8 -7
  139. package/src/ui/review-detail-panels.js +8 -9
  140. package/src/ui/review-detail-renderer.js +3 -3
  141. package/src/ui/review-detail-script.js +13 -12
  142. package/src/ui/review-mwr-renderer.js +6 -5
  143. package/src/ui/review-renderer.js +7 -6
  144. package/src/ui/review-widgets.js +3 -3
  145. package/src/ui/rfi-detail-renderer.js +37 -20
  146. package/src/ui/rfi-report-renderer.js +14 -9
  147. package/src/ui/settings-renderer-advanced.js +21 -18
  148. package/src/ui/settings-renderer-advanced2.js +20 -19
  149. package/src/ui/settings-renderer-teams.js +4 -4
  150. package/src/ui/settings-renderer.js +4 -4
  151. package/src/ui/spacing-system.js +5 -2
  152. package/src/ui/standalone-login.js +2 -2
  153. package/src/ui/styles.css +2 -1
  154. package/src/ui/styles2.css +75 -5
  155. package/src/ui/test-page.js +4 -4
  156. package/src/ui/widgets.js +20 -15
  157. package/src/lib/audit-logger.js +0 -193
  158. package/src/lib/database-core.js +0 -255
  159. package/src/lib/database-migrations.js +0 -350
  160. package/src/lib/db-backup.js +0 -97
  161. package/src/lib/error-boundary.js +0 -134
  162. package/src/lib/errors.js +0 -69
  163. package/src/lib/health-monitor.js +0 -134
  164. package/src/lib/index-optimizer.js +0 -96
  165. package/src/lib/query-engine-write.js +0 -221
  166. package/src/lib/query-engine.js +0 -446
  167. package/src/ui/collaboration-dialogs.js +0 -31
  168. package/src/ui/global-tags.js +0 -144
  169. package/src/ui/idle-logout.js +0 -156
  170. package/src/ui/letter-dialogs.js +0 -37
  171. package/src/ui/password-reset-page.js +0 -146
  172. package/src/ui/perf-renderer.js +0 -71
  173. package/src/ui/rfi-renderer.js +0 -194
  174. package/src/ui/validation-ui.js +0 -147
@@ -1,6 +1,7 @@
1
1
  import { page } from '@/ui/layout.js';
2
2
  import { canCreate } from '@/ui/permissions-ui.js';
3
- import { esc, stagePill, statusPill, progressBar, STAGE_CONFIG, TABLE_SCRIPT } from '@/ui/render-helpers.js';
3
+ import { esc, stagePill, statusPill, progressBar, STAGE_CONFIG, TABLE_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
4
+ import { SPACING } from '@/ui/spacing-system.js';
4
5
 
5
6
  function engRow(e) {
6
7
  const name = esc(e.name || e.client_name || 'Untitled');
@@ -8,7 +9,7 @@ function engRow(e) {
8
9
  const type = esc(e.type || e.engagement_type || e.repeat_interval || '-');
9
10
  const year = esc(e.year || '-');
10
11
  const team = esc(e.team_name || e.team_id_display || e.team_id || '-');
11
- const deadline = e.deadline ? new Date(e.deadline).toLocaleDateString() : '-';
12
+ const deadline = e.deadline ? new Date(e.deadline).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-';
12
13
  const stageCfg = STAGE_CONFIG.find(s => s.key === e.stage);
13
14
  const stageLbl = stageCfg?.label || (e.stage || '-');
14
15
  // 247420 indicator-rail: 3px left edge tinted by stage color (replaces border)
@@ -38,10 +39,10 @@ export function renderEngagementGrid(user, engagements, options = {}) {
38
39
  engagements.forEach(e => { if (stageCounts[e.stage] !== undefined) stageCounts[e.stage]++; });
39
40
 
40
41
  const stageStats = `<div class="stats-row">${STAGE_CONFIG.map(s =>
41
- `<div class="stat-card stat-card-clickable" data-action="filterByStage" data-args='["${s.key}"]' id="stage-card-${s.key}">
42
+ `<button type="button" class="stat-card stat-card-clickable" data-action="filterByStage" data-args='["${s.key}"]' id="stage-card-${s.key}" aria-label="Filter by ${s.label} stage (${stageCounts[s.key]||0} engagements)">
42
43
  <div class="stat-card-value">${stageCounts[s.key]||0}</div>
43
44
  <div class="stat-card-label">${s.label}</div>
44
- </div>`
45
+ </button>`
45
46
  ).join('')}</div>`;
46
47
 
47
48
  const tabs = [
@@ -63,23 +64,23 @@ export function renderEngagementGrid(user, engagements, options = {}) {
63
64
  const yearOpts = years.map(y => `<option value="${esc(y)}">${esc(y)}</option>`).join('');
64
65
 
65
66
  const rows = engagements.map(e => engRow(e)).join('') ||
66
- `<tr><td colspan="11" style="text-align:center;padding:48px;color:var(--color-text-muted)">No engagements found</td></tr>`;
67
+ emptyRow(11, 'No engagements found');
67
68
 
68
- const emailReceiveDialog = `<div id="email-receive-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
69
+ const emailReceiveDialog = `<div id="email-receive-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="email-receive-dialog-title" data-dialog-close="email-receive-dialog">
69
70
  <div class="dialog-panel" style="max-width:540px">
70
- <div class="dialog-header"><span class="dialog-title">Receive Email</span><button class="dialog-close" aria-label="Close dialog" data-dialog-close="email-receive-dialog">&times;</button></div>
71
+ <div class="dialog-header"><span class="dialog-title" id="email-receive-dialog-title">Receive Email</span><button class="dialog-close" aria-label="Close dialog" data-dialog-close="email-receive-dialog">&times;</button></div>
71
72
  <div class="dialog-body">
72
73
  <div class="modal-form-group"><label>Email Content</label><textarea id="email-receive-content" class="form-input" rows="8" placeholder="Paste raw email content here..."></textarea></div>
73
74
  <div id="email-receive-result" style="display:none;margin-top:8px"></div>
74
75
  </div>
75
76
  <div class="dialog-footer">
76
- <button class="btn btn-ghost btn-sm" data-dialog-close="email-receive-dialog">Cancel</button>
77
- <button class="btn btn-primary btn-sm" data-action="submitEmailReceive">Process Email</button>
77
+ <button type="button" class="btn-ghost-clean" data-dialog-close="email-receive-dialog">Cancel</button>
78
+ <button type="button" class="btn-primary-clean" data-action="submitEmailReceive">Process Email</button>
78
79
  </div>
79
80
  </div>
80
81
  </div>`;
81
82
 
82
- const emailReceiveScript = `window.submitEmailReceive=async function(){var content=document.getElementById('email-receive-content').value.trim();var res=document.getElementById('email-receive-result');if(!content){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Email content required.</div>';return}res.style.display='block';res.innerHTML='<div style="font-size:13px">Processing...</div>';try{var r=await fetch('/api/email/receive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})});var d=await r.json();if(r.ok&&d.success){res.innerHTML='<div style="color:var(--color-success);font-size:13px">Email processed successfully.'+(d.engagement_id?' Matched engagement: '+d.engagement_id:'')+(d.message?' '+d.message:'')+'</div>'}else{res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||d.message||'Failed to process email')+'</div>'}}catch(e){res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Error: '+e.message+'</div>'}};`;
83
+ const emailReceiveScript = `window.submitEmailReceive=async function(){var content=document.getElementById('email-receive-content').value.trim();var res=document.getElementById('email-receive-result');if(!content){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px;margin-top:${SPACING.sm}">Email content required.</div>';return}res.style.display='block';res.innerHTML='<div style="font-size:13px;margin-top:${SPACING.sm}">Processing...</div>';try{var r=await fetch('/api/email/receive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})});var d=await r.json();if(r.ok&&d.success){res.innerHTML='<div style="color:var(--color-success);font-size:13px;margin-top:${SPACING.sm}">Email processed successfully.'+(d.engagement_id?' Matched engagement: '+d.engagement_id:'')+(d.message?' '+d.message:'')+'</div>'}else{res.innerHTML='<div style="color:var(--color-danger);font-size:13px;margin-top:${SPACING.sm}">'+(d.error||d.message||'Failed to process email')+'</div>'}}catch(e){res.innerHTML='<div style="color:var(--color-danger);font-size:13px;margin-top:${SPACING.sm}">Error: '+e.message+'</div>'}};`;
83
84
 
84
85
  const content = `<div class="page-header">
85
86
  <div><h1 class="page-title">Engagements</h1><p class="page-subtitle">${engagements.length} total engagements</p></div>
@@ -89,18 +90,18 @@ export function renderEngagementGrid(user, engagements, options = {}) {
89
90
  <div class="table-wrap eng-table-mobile-scroll">
90
91
  <div class="table-toolbar">
91
92
  <div class="table-search"><input id="search-input" type="text" placeholder="Search engagements..."></div>
92
- ${stageOpts ? `<div class="table-filter"><select data-filter="stage-raw" id="filter-stage"><option value="">All Stages</option>${stageOpts}</select></div>` : ''}
93
- ${teamOpts ? `<div class="table-filter"><select data-filter="team" id="filter-team"><option value="">All Teams</option>${teamOpts}</select></div>` : ''}
94
- ${yearOpts ? `<div class="table-filter"><select data-filter="year" id="filter-year"><option value="">All Years</option>${yearOpts}</select></div>` : ''}
93
+ ${stageOpts ? `<div class="table-filter"><select data-filter="stage-raw" id="filter-stage" aria-label="Filter by stage"><option value="">All Stages</option>${stageOpts}</select></div>` : ''}
94
+ ${teamOpts ? `<div class="table-filter"><select data-filter="team" id="filter-team" aria-label="Filter by team"><option value="">All Teams</option>${teamOpts}</select></div>` : ''}
95
+ ${yearOpts ? `<div class="table-filter"><select data-filter="year" id="filter-year" aria-label="Filter by year"><option value="">All Years</option>${yearOpts}</select></div>` : ''}
95
96
  <button class="btn btn-ghost btn-sm" data-action="openDialog" data-args='["email-receive-dialog"]'>Receive Email</button>
96
97
  <span class="table-count" id="row-count">${engagements.length} items</span>
97
98
  </div>
98
- <table class="data-table">
99
+ <table class="data-table" role="grid">
99
100
  <thead><tr>
100
- <th data-sort="name">Name</th><th data-sort="client">Client</th><th data-sort="type" class="eng-col-type">Type</th>
101
- <th data-sort="year" class="eng-col-year">Year</th><th data-sort="team" class="eng-col-team">Team</th><th data-sort="stage-raw">Stage</th>
102
- <th style="display:none"></th><th data-sort="status">Status</th><th data-sort="deadline">Deadline</th>
103
- <th data-sort="rfi" class="eng-col-rfi" style="text-align:center">RFI</th><th class="eng-col-progress">Progress</th>
101
+ <th data-sort="name" aria-label="Sort by Name">Name</th><th data-sort="client" aria-label="Sort by Client">Client</th><th data-sort="type" class="eng-col-type" aria-label="Sort by Type">Type</th>
102
+ <th data-sort="year" class="eng-col-year" aria-label="Sort by Year">Year</th><th data-sort="team" class="eng-col-team" aria-label="Sort by Team">Team</th><th data-sort="stage-raw" aria-label="Sort by Stage">Stage</th>
103
+ <th style="display:none"></th><th data-sort="status" aria-label="Sort by Status">Status</th><th data-sort="deadline" aria-label="Sort by Deadline">Deadline</th>
104
+ <th data-sort="rfi" class="eng-col-rfi" style="text-align:center" aria-label="Sort by RFI">RFI</th><th class="eng-col-progress" aria-label="Progress">Progress</th>
104
105
  </tr></thead>
105
106
  <tbody>${rows}</tbody>
106
107
  </table>
@@ -108,5 +109,5 @@ export function renderEngagementGrid(user, engagements, options = {}) {
108
109
 
109
110
  const stageFilterScript = `var _activeStage='';function filterByStage(stage){_activeStage=_activeStage===stage?'':stage;document.querySelectorAll('[id^="stage-card-"]').forEach(c=>{const s=c.id.replace('stage-card-','');c.style.outline=_activeStage===s?'2px solid var(--color-primary)':'none'});window.filterTable();}var _origFilter=window.filterTable||function(){};window.filterTable=function(){_origFilter();if(!_activeStage)return;const stageLabels=${JSON.stringify(Object.fromEntries(STAGE_CONFIG.map(s=>[s.key,s.label])))};document.querySelectorAll('tbody tr[data-row]').forEach(row=>{const stageCell=row.querySelector('[data-col="stage"]');if(stageCell&&!stageCell.textContent.toLowerCase().includes((stageLabels[_activeStage]||_activeStage||'').toLowerCase())){row.style.display='none';}})};function switchTab(tab){document.querySelectorAll('[id^="tab-"]').forEach(b=>b.classList.toggle('active',b.id==='tab-'+tab))}`;
110
111
 
111
- return page(user, 'Engagements | Moonlanding', null, content, [TABLE_SCRIPT, stageFilterScript, emailReceiveScript]);
112
+ return page(user, 'Engagements | Thatcher', null, content, [TABLE_SCRIPT, stageFilterScript, emailReceiveScript]);
112
113
  }
@@ -1,6 +1,6 @@
1
1
  import { h } from '@/ui/webjsx.js'
2
2
  import { page, confirmDialog, dataTable } from '@/ui/layout.js'
3
- import { fmtVal, TOAST_SCRIPT } from '@/ui/render-helpers.js'
3
+ import { fmtVal, TOAST_SCRIPT, esc } from '@/ui/render-helpers.js'
4
4
  import { canCreate, canEdit, canDelete } from '@/ui/permissions-ui.js'
5
5
 
6
6
  export function renderEntityList(entityName, items, spec, user, options = {}) {
@@ -10,16 +10,20 @@ export function renderEntityList(entityName, items, spec, user, options = {}) {
10
10
  let listFields = Object.entries(fields).filter(([k, f]) => f.list).slice(0, 5)
11
11
  if (!listFields.length) listFields = Object.entries(fields).filter(([k]) => !['created_by', 'updated_by'].includes(k)).slice(0, 6)
12
12
  if (!listFields.length && items.length > 0) listFields = Object.keys(items[0]).filter(k => !['created_by', 'updated_by'].includes(k)).slice(0, 5).map(k => [k, { label: k }])
13
- const headers = listFields.map(([k, f]) => `<th>${f?.label || k}</th>`).join('') + '<th>Actions</th>'
13
+ const headers = listFields.map(([k, f]) => `<th>${esc(f?.label || k)}</th>`).join('') + '<th>Actions</th>'
14
14
  const userCanEdit = canEdit(user, entityName)
15
15
  const userCanDelete = canDelete(user, entityName)
16
16
  const userCanCreate = canCreate(user, entityName)
17
17
 
18
18
  const buildRow = item => {
19
+ const id = esc(String(item.id))
20
+ const idArg = esc(JSON.stringify([String(item.id)]))
19
21
  const cells = listFields.map(([k]) => `<td>${fmtVal(item[k], k, item)}</td>`).join('')
20
- const editBtn = userCanEdit ? `<a href="/${entityName}/${item.id}/edit" class="btn btn-xs btn-outline">Edit</a>` : `<span class="btn btn-xs btn-outline btn-disabled tooltip" data-tip="No permission">Edit</span>`
21
- const delBtn = userCanDelete ? `<button data-stop-propagation="true" data-action="confirmDelete" data-args='["${item.id}"]' class="btn btn-xs btn-error btn-outline">Delete</button>` : `<span class="btn btn-xs btn-error btn-outline btn-disabled tooltip" data-tip="No permission">Delete</span>`
22
- return `<tr class="hover cursor-pointer" data-searchable tabindex="0" role="link" data-navigate="/${entityName}/${item.id}" onkeydown="if(event.key==='Enter'){window.location='/${entityName}/${item.id}'}">${cells}<td class="flex gap-1"><a href="/${entityName}/${item.id}" class="btn btn-xs btn-ghost">View</a>${editBtn}${delBtn}</td></tr>`
22
+ const editBtn = userCanEdit ? `<a href="/${entityName}/${id}/edit" class="btn btn-xs btn-outline">Edit</a>` : `<span class="btn btn-xs btn-outline btn-disabled tooltip" data-tip="No permission">Edit</span>`
23
+ const delBtn = userCanDelete ? `<button data-stop-propagation="true" data-action="confirmDelete" data-args='${idArg}' class="btn btn-xs btn-error btn-outline">Delete</button>` : `<span class="btn btn-xs btn-error btn-outline btn-disabled tooltip" data-tip="No permission">Delete</span>`
24
+ // Keyboard activation is handled centrally by event-delegation's data-navigate
25
+ // handler (Enter/Space on role=link); no inline onkeydown to avoid a competing path.
26
+ return `<tr class="hover cursor-pointer" data-searchable tabindex="0" role="link" data-navigate="/${entityName}/${id}">${cells}<td class="flex gap-1"><a href="/${entityName}/${id}" class="btn btn-xs btn-ghost">View</a>${editBtn}${delBtn}</td></tr>`
23
27
  }
24
28
 
25
29
  let tableContent
@@ -27,25 +31,29 @@ export function renderEntityList(entityName, items, spec, user, options = {}) {
27
31
  const groups = {}
28
32
  items.forEach(item => { const key = String(item[groupBy] || '(No ' + groupBy + ')'); if (!groups[key]) groups[key] = []; groups[key].push(item) })
29
33
  const sortedKeys = Object.keys(groups).sort()
30
- const groupRows = sortedKeys.map(gkey => {
34
+ const groupRows = sortedKeys.map((gkey, gi) => {
31
35
  const groupItems = groups[gkey]
32
- const groupId = `group-${gkey.replace(/\s+/g, '-').toLowerCase()}`
33
- const togglerId = `toggle-${gkey.replace(/\s+/g, '-').toLowerCase()}`
36
+ // Index-based ids: slugs collide when two group values differ only by
37
+ // punctuation/case (e.g. 'A/B' vs 'a-b'), which breaks the collapse toggle.
38
+ const groupId = `group-${gi}`
39
+ const togglerId = `toggle-${gi}`
34
40
  const itemRows = groupItems.map(buildRow).join('')
35
- return `<tbody class="group-section" data-group="${gkey}"><tr class="group-header hover cursor-pointer" tabindex="0" data-toggle="${togglerId}"><td colspan="100" style="padding:12px"><div class="flex items-center gap-2"><input type="checkbox" id="${togglerId}" class="group-toggle" style="cursor:pointer" checked/><span class="font-semibold text-base">${gkey}</span><span class="badge badge-sm">${groupItems.length}</span></div></td></tr><tr class="group-content" id="${groupId}" style="display:contents"><td colspan="100"><table class="data-table" style="background:transparent"><tbody>${itemRows}</tbody></table></td></tr></tbody>`
41
+ const gkeySafe = esc(gkey)
42
+ return `<tbody class="group-section" data-group="${gkeySafe}"><tr class="group-header hover cursor-pointer" tabindex="0" data-toggle="${togglerId}"><td colspan="100" style="padding:12px"><div class="flex items-center gap-2"><input type="checkbox" id="${togglerId}" class="group-toggle" style="cursor:pointer" checked/><span class="font-semibold text-base">${gkeySafe}</span><span class="badge badge-sm">${groupItems.length}</span></div></td></tr><tr class="group-content" id="${groupId}" style="display:contents"><td colspan="100"><table class="data-table" style="background:transparent"><tbody>${itemRows}</tbody></table></td></tr></tbody>`
36
43
  }).join('')
37
44
  tableContent = `<div class="card-clean"><div class="table-wrap"><table class="data-table"><thead><tr>${headers}</tr></thead>${groupRows}</table></div></div>`
38
45
  } else {
39
46
  const rows = items.map(buildRow).join('')
40
- tableContent = dataTable(headers, rows, items.length === 0 ? (userCanCreate ? `No items found. <a href="/${entityName}/new" class="text-primary hover:underline">Create your first ${label.toLowerCase()}</a>` : 'No items found.') : '')
47
+ tableContent = dataTable(headers, rows, items.length === 0 ? (userCanCreate ? `No items found. <a href="/${entityName}/new" class="text-primary hover:underline">Create your first ${esc(label.toLowerCase())}</a>` : 'No items found.') : '')
41
48
  }
42
49
 
43
50
  const createBtn = userCanCreate ? `<a href="/${entityName}/new" class="btn btn-primary btn-sm">Create New</a>` : ''
44
- const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">${label}</h1><div class="flex gap-2"><label for="search-input" class="sr-only">Search</label><input type="text" id="search-input" placeholder="Search..." class="input input-solid input-sm" style="width:200px" aria-label="Search items"/>${createBtn}</div></div>${tableContent}${userCanDelete ? confirmDialog(entityName) : ''}`
51
+ const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">${esc(label)}</h1><div class="flex gap-2"><label for="search-input" class="sr-only">Search</label><input type="text" id="search-input" placeholder="Search..." class="input input-solid input-sm" style="width:200px" aria-label="Search items"/>${createBtn}</div></div>${tableContent}`
45
52
 
46
- const deleteScript = `let pendingDeleteId=null;window.confirmDelete=(id)=>{pendingDeleteId=id;document.getElementById('confirm-dialog').style.display='flex'};window.cancelDelete=()=>{pendingDeleteId=null;document.getElementById('confirm-dialog').style.display='none'};window.executeDelete=async()=>{if(!pendingDeleteId)return;const btn=document.getElementById('confirm-delete-btn');btn.classList.add('btn-loading');btn.textContent='Deleting...';try{const res=await fetch('/api/${entityName}/'+pendingDeleteId,{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>window.location.reload(),500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error');cancelDelete();btn.classList.remove('btn-loading');btn.textContent='Delete'}}catch(err){showToast('Error: '+err.message,'error');cancelDelete();btn.classList.remove('btn-loading');btn.textContent='Delete'}}`
53
+ // Canonical gmConfirm (session-13): replaces the bespoke confirm-dialog markup + confirmDelete/cancelDelete/executeDelete show-hide.
54
+ const deleteScript = `window.confirmDelete=async(id)=>{if(!id)return;const ok=await window.gmConfirm({title:'Delete ${entityName}',message:'Delete this ${entityName}? This cannot be undone.',confirmLabel:'Delete',danger:true});if(!ok)return;try{const res=await fetch('/api/${entityName}/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>window.location.reload(),500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error')}}catch(err){showToast('Error: '+err.message,'error')}}`
47
55
  const searchScript = `function initSearch(){const si=document.getElementById('search-input');const tb=document.querySelectorAll('[data-searchable]');const groups=document.querySelectorAll('[data-group]');let visibleGroupCount=0;si.addEventListener('input',(e)=>{const query=e.target.value.toLowerCase().trim();const terms=query.length>0?query.split(/\\s+/):[],hasResults=terms.length>0;visibleGroupCount=0;groups.forEach(g=>{const rows=g.querySelectorAll('[data-searchable]');let visibleRows=0;rows.forEach(r=>{const text=r.textContent.toLowerCase();const matches=terms.every(t=>text.includes(t));r.style.display=matches?'':'none';if(matches)visibleRows++});const groupVisible=visibleRows>0;g.style.display=groupVisible?'':'none';if(groupVisible)visibleGroupCount++;const badge=g.querySelector('.badge');if(badge)badge.textContent=visibleRows});tb.forEach(r=>{if(!r.closest('[data-group]')){const text=r.textContent.toLowerCase();const matches=query.length===0||terms.every(t=>text.includes(t));r.style.display=matches?'':'none'}});const totalVisible=document.querySelectorAll('[data-searchable]:not([style*="display: none"])').length,noResults=hasResults&&totalVisible===0;let msg=document.getElementById('search-no-results');if(noResults&&!msg){msg=document.createElement('tr');msg.id='search-no-results';msg.innerHTML='<td colspan="100" class="text-center py-8 text-base-content/50">No results found for "'+query+'"</td>';const tbody=document.getElementById('table-body')||document.querySelector('tbody');if(tbody)tbody.appendChild(msg)}else if(!noResults&&msg)msg.remove()});const toggles=document.querySelectorAll('.group-toggle');toggles.forEach(tog=>{tog.addEventListener('change',(e)=>{e.stopPropagation();const row=tog.closest('tr');const tbody=row.parentElement;const content=tbody.querySelector('.group-content');content.style.display=tog.checked?'contents':'none'})});if(si.value)si.dispatchEvent(new Event('input'))}document.addEventListener('DOMContentLoaded',initSearch)`
48
- return page(user, `${label} | Moonlanding`, [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label }], content, [TOAST_SCRIPT, deleteScript, searchScript])
56
+ return page(user, `${label} | Thatcher`, [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label }], content, [TOAST_SCRIPT, deleteScript, searchScript])
49
57
  }
50
58
 
51
59
  const HIDDEN_FIELDS = new Set(['password_hash', 'password', 'session_token', 'photo_url'])
@@ -60,10 +68,10 @@ function formatFieldValue(k, v, entityName) {
60
68
  if (entityName === 'user' && k === 'role') return `<span class="pill pill-neutral">${roleLabel(v)}</span>`
61
69
  if (entityName === 'user' && k === 'status') {
62
70
  const cls = v === 'active' ? 'pill-success' : v === 'deleted' ? 'pill-danger' : 'pill-neutral'
63
- return `<span class="pill ${cls}">${v ? v.charAt(0).toUpperCase() + v.slice(1) : '-'}</span>`
71
+ return `<span class="pill ${cls}">${v ? esc(v.charAt(0).toUpperCase() + v.slice(1)) : '-'}</span>`
64
72
  }
65
- if (entityName === 'user' && k === 'email' && v) return `<a href="mailto:${v}" class="text-primary hover:underline">${v}</a>`
66
- if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${v}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
73
+ if (entityName === 'user' && k === 'email' && v) { const e = esc(v); return `<a href="mailto:${e}" class="text-primary hover:underline">${e}</a>` }
74
+ if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${esc(v)}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
67
75
  return fmtVal(v, k)
68
76
  }
69
77
 
@@ -77,30 +85,31 @@ export function renderEntityDetail(entityName, item, spec, user) {
77
85
 
78
86
  const fieldRows = visibleFields.map(([k, f]) =>
79
87
  `<div class="detail-row">
80
- <span class="detail-row-label">${f.label || k}</span>
88
+ <span class="detail-row-label">${esc(f.label || k)}</span>
81
89
  <span class="detail-row-value">${formatFieldValue(k, item[k], entityName)}</span>
82
90
  </div>`
83
91
  ).join('')
84
92
 
85
93
  const displayName = item.name || item.title || label
86
- const initials = (displayName || '?').charAt(0).toUpperCase()
94
+ const displayNameSafe = esc(displayName)
95
+ const initials = esc((displayName || '?').charAt(0).toUpperCase())
87
96
  const statusCls = item.status === 'active' ? 'pill-success' : item.status === 'deleted' ? 'pill-danger' : 'pill-neutral'
88
97
  const statusLabel2 = item.status ? item.status.charAt(0).toUpperCase() + item.status.slice(1) : '-'
89
98
 
90
99
  const headerExtra = entityName === 'user' ? `
91
100
  <div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem">
92
- <div style="width:3.5rem;height:3.5rem;border-radius:50%;background:#e0f2fe;display:flex;align-items:center;justify-content:center;font-size:1.25rem;font-weight:700;color:#0369a1;flex-shrink:0">
93
- ${item.photo_url ? `<img src="${item.photo_url}" style="width:3.5rem;height:3.5rem;border-radius:50%;object-fit:cover" alt="${displayName}" onerror="this.style.display='none'"/>` : initials}
101
+ <div style="width:3.5rem;height:3.5rem;border-radius:50%;background:var(--color-avatar-bg,#e0f2fe);display:flex;align-items:center;justify-content:center;font-size:1.25rem;font-weight:700;color:var(--color-avatar-fg,#0369a1);flex-shrink:0">
102
+ ${item.photo_url ? `<img src="${esc(item.photo_url)}" style="width:3.5rem;height:3.5rem;border-radius:50%;object-fit:cover" alt="${displayNameSafe}" onerror="this.style.display='none'"/>` : initials}
94
103
  </div>
95
104
  <div>
96
- <h1 style="font-size:1.5rem;font-weight:700;margin:0">${displayName}</h1>
105
+ <h1 style="font-size:1.5rem;font-weight:700;margin:0">${displayNameSafe}</h1>
97
106
  <div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.375rem">
98
- <span class="pill pill-neutral">${roleLabel(item.role)}</span>
99
- <span class="pill ${statusCls}">${statusLabel2}</span>
100
- ${item.user_type ? `<span style="font-size:0.75rem;color:var(--color-text-muted)">${item.user_type}</span>` : ''}
107
+ <span class="pill pill-neutral">${esc(roleLabel(item.role))}</span>
108
+ <span class="pill ${statusCls}">${esc(statusLabel2)}</span>
109
+ ${item.user_type ? `<span style="font-size:0.75rem;color:var(--color-text-muted)">${esc(item.user_type)}</span>` : ''}
101
110
  </div>
102
111
  </div>
103
- </div>` : `<div style="margin-bottom:1.5rem"><h1 style="font-size:1.5rem;font-weight:700">${displayName}</h1></div>`
112
+ </div>` : `<div style="margin-bottom:1.5rem"><h1 style="font-size:1.5rem;font-weight:700">${displayNameSafe}</h1></div>`
104
113
 
105
114
  const editBtn = userCanEdit ? `<a href="/${entityName}/${item.id}/edit" class="btn btn-outline btn-sm">Edit</a>` : ''
106
115
  const delBtn = userCanDelete ? `<button data-action="showDeleteConfirm" class="btn btn-error btn-outline btn-sm">Delete</button>` : ''
@@ -113,9 +122,10 @@ export function renderEntityDetail(entityName, item, spec, user) {
113
122
  <div class="card-clean">
114
123
  <div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
115
124
  </div>
116
- ${userCanDelete ? confirmDialog(entityName) : ''}`
125
+ `
117
126
 
118
- const script = `${TOAST_SCRIPT}window.showDeleteConfirm=()=>{document.getElementById('confirm-dialog').style.display='flex'};window.hideDeleteConfirm=()=>{document.getElementById('confirm-dialog').style.display='none'};window.executeDelete=async()=>{const btn=document.getElementById('confirm-delete-btn');btn.classList.add('btn-loading');btn.textContent='Deleting...';try{const res=await fetch('/api/${entityName}/${item.id}',{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>{window.location='/${entityName}'},500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error');hideDeleteConfirm();btn.classList.remove('btn-loading');btn.textContent='Delete'}}catch(err){showToast('Error: '+err.message,'error');hideDeleteConfirm();btn.classList.remove('btn-loading');btn.textContent='Delete'}}`
127
+ // Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
128
+ const script = `${TOAST_SCRIPT}window.showDeleteConfirm=async()=>{const ok=await window.gmConfirm({title:'Delete ${entityName}',message:'Delete this ${entityName}? This cannot be undone.',confirmLabel:'Delete',danger:true});if(!ok)return;try{const res=await fetch('/api/${entityName}/${item.id}',{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>{window.location='/${entityName}'},500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error')}}catch(err){showToast('Error: '+err.message,'error')}}`
119
129
  const bc = [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: item.name || item.title || `#${item.id}` }]
120
130
  return page(user, `${label} Detail`, bc, content, [script])
121
131
  }
@@ -123,14 +133,14 @@ export function renderEntityDetail(entityName, item, spec, user) {
123
133
  export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}) {
124
134
  const label = spec?.label || entityName
125
135
  const fields = spec?.fields || {}
126
- const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${f.label||k}${req ? '<span class="req">*</span>' : ''}</label>`
136
+ const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
127
137
  const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readOnly && !f.auto_generate && k !== 'password_hash').map(([k, f]) => {
128
138
  let val = item?.[k] ?? f.default ?? ''
129
139
  const req = f.required ? 'required' : ''
130
140
  const type = f.type === 'number' || f.type === 'int' || f.type === 'decimal' ? 'number' : f.type === 'email' ? 'email' : f.type === 'timestamp' || f.type === 'date' ? 'date' : f.type === 'bool' ? 'checkbox' : 'text'
131
141
  // <input type=date> only populates from a strict YYYY-MM-DD; coerce a stored timestamp/ISO value so the existing date pre-fills (else it renders blank and saves empty).
132
142
  if (type === 'date' && val) { const d = new Date(val); if (!isNaN(d)) val = d.toISOString().slice(0, 10) }
133
- const placeholder = `placeholder="Enter ${(f.label||k).toLowerCase()}"`
143
+ const placeholder = `placeholder="Enter ${esc((f.label||k).toLowerCase())}"`
134
144
  if (entityName === 'user' && k === 'role') {
135
145
  const opts = ['partner','manager','clerk','client_admin','client_user'].map(o => `<option value="${o}" ${val===o?'selected':''}>${o.charAt(0).toUpperCase()+o.slice(1).replace('_',' ')}</option>`).join('')
136
146
  return `<div class="form-field">${lbl(k,{label:'Role'},true)}<select id="field-role" name="role" class="form-input" required>${opts}</select></div>`
@@ -140,24 +150,24 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
140
150
  return `<div class="form-field">${lbl(k,{label:'Status'},false)}<select id="field-status" name="status" class="form-input">${opts}</select></div>`
141
151
  }
142
152
  if (f.type === 'ref' && refOptions[k]) {
143
- const opts = refOptions[k].map(o => `<option value="${o.value}" ${val===o.value?'selected':''}>${o.label}</option>`).join('')
144
- return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${f.label||k}...</option>${opts}</select></div>`
153
+ const opts = refOptions[k].map(o => `<option value="${esc(o.value)}" ${val===o.value?'selected':''}>${esc(o.label)}</option>`).join('')
154
+ return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select></div>`
145
155
  }
146
- if (f.type === 'textarea') return `<div class="form-field full">${lbl(k,f,f.required)}<textarea id="field-${k}" name="${k}" class="form-input" style="min-height:100px;resize:vertical" ${req} placeholder="Enter ${(f.label||k).toLowerCase()}">${val}</textarea></div>`
147
- if (f.type === 'bool') return `<div class="form-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="field-${k}" name="${k}" class="checkbox checkbox-primary" ${val?'checked':''}/><span class="form-label" style="margin:0">${f.label||k}</span></label></div>`
156
+ if (f.type === 'textarea') return `<div class="form-field full">${lbl(k,f,f.required)}<textarea id="field-${k}" name="${k}" class="form-input" style="min-height:100px;resize:vertical" ${req} placeholder="Enter ${esc((f.label||k).toLowerCase())}">${esc(val)}</textarea></div>`
157
+ if (f.type === 'bool') return `<div class="form-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="field-${k}" name="${k}" class="checkbox checkbox-primary" ${val?'checked':''}/><span class="form-label" style="margin:0">${esc(f.label||k)}</span></label></div>`
148
158
  if (f.type === 'enum' && f.options) {
149
- const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<option value="${ov}" ${val===ov?'selected':''}>${ol}</option>` }).join('')
150
- return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${f.label||k}...</option>${opts}</select></div>`
159
+ const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<option value="${esc(ov)}" ${val===ov?'selected':''}>${esc(ol)}</option>` }).join('')
160
+ return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select></div>`
151
161
  }
152
- return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${val}" class="form-input" ${req} ${placeholder}/></div>`
162
+ return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${esc(val)}" class="form-input" ${req} ${placeholder}/></div>`
153
163
  }).join('\n')
154
164
 
155
165
  const pwField = entityName === 'user' ? `<div class="form-field"><label class="form-label" for="field-new-password">New Password <small style="font-weight:400;color:var(--color-text-muted)">(leave blank to keep unchanged)</small></label><input type="password" id="field-new-password" name="new_password" class="form-input" placeholder="Enter new password" autocomplete="new-password"/></div>` : ''
156
166
  const bc = isNew
157
167
  ? [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: 'Create' }]
158
168
  : [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { href: `/${entityName}/${item?.id}`, label: item?.name || item?.title || `#${item?.id}` }, { label: 'Edit' }]
159
- const content = `<div class="form-shell"><div style="margin-bottom:24px"><h1 style="font-size:24px;font-weight:700">${isNew ? 'Create' : 'Edit'} ${label}</h1></div>
160
- <div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${label}">${formFields}${pwField}
169
+ const content = `<div class="form-shell"><div style="margin-bottom:24px"><h1 style="font-size:24px;font-weight:700">${isNew ? 'Create' : 'Edit'} ${esc(label)}</h1></div>
170
+ <div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${formFields}${pwField}
161
171
  <div class="form-actions" style="grid-column:1/-1"><button type="submit" id="submit-btn" class="btn-primary-clean"><span class="btn-text">Save</span><span class="btn-loading-text" style="display:none">Saving...</span></button>
162
172
  <a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
163
173
  const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;const fd=new FormData(form);const data={};for(const[k,v]of fd.entries())data[k]=v;form.querySelectorAll('input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('input[type=number]').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
@@ -167,12 +177,16 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
167
177
  export function renderSettings(user, config = {}) {
168
178
  const t = config.thresholds || {}
169
179
  const sections = [
170
- { title: 'System Information', items: [['Database Type', config.database?.type || 'SQLite'], ['Server Port', config.server?.port || 3004], ['Session TTL', (t.cache?.session_ttl_seconds || 3600) + 's'], ['Page Size (Default)', t.system?.default_page_size || 50], ['Page Size (Max)', t.system?.max_page_size || 500]] },
180
+ { title: 'System Information', items: [['Database Type', config.database?.type || 'SQLite'], ['Server Port', config.server?.port || 3000], ['Session TTL', (t.cache?.session_ttl_seconds || 3600) + 's'], ['Page Size (Default)', t.system?.default_page_size || 50], ['Page Size (Max)', t.system?.max_page_size || 500]] },
171
181
  { title: 'RFI Configuration', items: [['Max Days Outstanding', (t.rfi?.max_days_outstanding || 90) + ' days'], ['Escalation Delay', (t.rfi?.escalation_delay_hours || 24) + ' hours'], ['Notification Days', (t.rfi?.notification_days || [7,3,1,0]).join(', ')]] },
172
182
  { title: 'Email Configuration', items: [['Batch Size', t.email?.send_batch_size || 10], ['Max Retries', t.email?.send_max_retries || 3], ['Rate Limit Delay', (t.email?.rate_limit_delay_ms || 6000) + 'ms']] },
173
183
  { title: 'Workflow Configuration', items: [['Stage Transition Lockout', (t.workflow?.stage_transition_lockout_minutes || 5) + ' minutes'], ['Collaborator Default Expiry', (t.collaborator?.default_expiry_days || 7) + ' days'], ['Collaborator Max Expiry', (t.collaborator?.max_expiry_days || 30) + ' days']] },
174
184
  ]
175
- const cards = sections.map(s => `<div class="card-clean"><div class="card-clean-body"><h2 style="font-size:1rem;font-weight:600">${s.title}</h2><div class="space-y-4 mt-4">${s.items.map(([l, v]) => `<div class="flex justify-between py-2 border-b border-base-200"><span class="text-base-content/50 text-sm">${l}</span><span class="font-medium text-sm">${v}</span></div>`).join('')}</div></div></div>`).join('')
176
- return page(user, 'System Settings | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }],
177
- `<h1 class="text-2xl font-bold mb-6">System Settings</h1><div class="grid grid-cols-1 lg:grid-cols-2 gap-6">${cards}</div>`)
185
+ const cards = sections.map(s => `<div class="card-clean"><div class="card-clean-body"><h2 style="font-size:1rem;font-weight:600">${esc(s.title)}</h2><div class="space-y-4 mt-4">${s.items.map(([l, v]) => `<div class="flex justify-between py-2 border-b border-base-200"><span class="text-base-content/50 text-sm">${esc(l)}</span><span class="font-medium text-sm">${esc(v)}</span></div>`).join('')}</div></div></div>`).join('')
186
+ // These values are config-driven (read-only here). State the source so an admin
187
+ // who lands expecting to edit knows where to change them, rather than hitting a
188
+ // silent dead-end page with no affordance.
189
+ const notice = `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body" style="display:flex;gap:0.75rem;align-items:flex-start"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted,#64748b)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;margin-top:1px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg><p style="margin:0;font-size:0.875rem;color:var(--color-text-muted,#64748b)">These settings are read-only. They are defined in <code>thatcher.config.yml</code> and applied on server start; edit that file and restart to change them.</p></div></div>`
190
+ return page(user, 'System Settings | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }],
191
+ `<h1 class="text-2xl font-bold mb-6">System Settings</h1>${notice}<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">${cards}</div>`)
178
192
  }
@@ -1,26 +1,31 @@
1
1
  import { statusLabel } from '@/ui/renderer.js';
2
2
 
3
+ // Inline HTML-escape for client-script innerHTML sinks (file names are DB-sourced).
4
+ const FD_ESC = `function fdEsc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}`;
5
+
3
6
  export function zipFileCreationDialog(engagementId) {
4
- return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close="zip-create-dialog" role="dialog" aria-modal="true" aria-labelledby="zip-create-dialog-title" aria-hidden="true">
7
+ return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close="zip-create-dialog" role="dialog" aria-modal="true" aria-labelledby="zip-create-dialog-title">
5
8
  <div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="zip-create-dialog-title">Create Zip Archive</span><button class="dialog-close" data-dialog-close="zip-create-dialog" aria-label="Close dialog">&times;</button></div>
6
9
  <div class="dialog-body"><div id="zcd-files" class="flex flex-col gap-2" style="max-height:300px;overflow:auto"></div><div class="flex items-center gap-2 mt-3"><label class="flex items-center gap-2 text-sm"><input type="checkbox" id="zcd-all" class="checkbox checkbox-sm" checked/><span>Select All</span></label></div></div>
7
10
  <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="zip-create-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="zcdCreate">Create Zip</button></div>
8
11
  </div></div>
9
12
  <script>
10
- window.openZipCreation=function(){document.getElementById('zip-create-dialog').style.display='flex';fetch('/api/file?engagement_id=${engagementId}').then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];var el=document.getElementById('zcd-files');el.innerHTML=files.map(function(f){return'<label class="flex items-center gap-2"><input type="checkbox" class="checkbox checkbox-sm zcd-cb" value="'+f.id+'" checked/><span class="text-sm">'+(f.name||f.id)+'</span><span class="text-xs text-gray-400">'+(f.size?Math.round(f.size/1024)+'KB':'')+'</span></label>'}).join('')||'<div class="text-gray-500 text-sm">No files available</div>'}).catch(function(){})};
13
+ ${FD_ESC}
14
+ window.openZipCreation=function(){document.getElementById('zip-create-dialog').style.display='flex';fetch('/api/file?engagement_id=${engagementId}').then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];var el=document.getElementById('zcd-files');el.innerHTML=files.map(function(f){return'<label class="flex items-center gap-2"><input type="checkbox" class="checkbox checkbox-sm zcd-cb" value="'+fdEsc(f.id)+'" checked/><span class="text-sm">'+fdEsc(f.name||f.id)+'</span><span class="text-xs text-gray-400">'+(f.size?Math.round(f.size/1024)+'KB':'')+'</span></label>'}).join('')||'<div class="text-gray-500 text-sm">No files available</div>'}).catch(function(err){if(window.showToast)showToast(err.message||'Request failed','error');console.error(err)})};
11
15
  document.getElementById('zcd-all').addEventListener('change',function(){document.querySelectorAll('.zcd-cb').forEach(function(c){c.checked=this.checked}.bind(this))});
12
16
  window.zcdCreate=async function(){var ids=[].slice.call(document.querySelectorAll('.zcd-cb:checked')).map(function(c){return c.value});if(!ids.length){showToast('Select files','error');return}showToast('Creating zip...','info');try{var r=await fetch('/api/file/zip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_ids:ids,engagement_id:'${engagementId}'})});if(r.ok){var blob=await r.blob();var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='files.zip';a.click();showToast('Zip downloaded','success')}else showToast('Failed','error')}catch(e){showToast('Error','error')}document.getElementById('zip-create-dialog').style.display='none'};
13
17
  </script>`;
14
18
  }
15
19
  export function crossEngagementFilePicker(currentEngId) {
16
- return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close="cross-file-picker" role="dialog" aria-modal="true" aria-labelledby="cross-file-picker-title" aria-hidden="true">
20
+ return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close="cross-file-picker" role="dialog" aria-modal="true" aria-labelledby="cross-file-picker-title">
17
21
  <div class="dialog-panel" style="max-width:640px"><div class="dialog-header"><span class="dialog-title" id="cross-file-picker-title">Pick File from Another Engagement</span><button class="dialog-close" data-dialog-close="cross-file-picker" aria-label="Close dialog">&times;</button></div>
18
22
  <div class="dialog-body"><div class="modal-form-group"><label for="cfp-eng">Engagement</label><select id="cfp-eng" class="select select-bordered w-full" onchange="cfpLoadFiles()"></select></div><div id="cfp-files" class="flex flex-col gap-2 mt-3" style="max-height:300px;overflow:auto"></div></div>
19
23
  <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="cross-file-picker">Close</button></div>
20
24
  </div></div>
21
25
  <script>
22
- window.openCrossFilePicker=function(){document.getElementById('cross-file-picker').style.display='flex';fetch('/api/engagement').then(function(r){return r.json()}).then(function(d){var engs=d.data||d||[];var sel=document.getElementById('cfp-eng');while(sel.options.length>0)sel.remove(0);engs.filter(function(e){return e.id!=='${currentEngId}'}).forEach(function(e){var o=document.createElement('option');o.value=e.id;o.textContent=e.name||e.id;sel.appendChild(o)});if(sel.options.length)cfpLoadFiles()}).catch(function(){})};
23
- window.cfpLoadFiles=function(){var eid=document.getElementById('cfp-eng').value;if(!eid)return;fetch('/api/file?engagement_id='+eid).then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];document.getElementById('cfp-files').innerHTML=files.map(function(f){return'<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded"><span class="text-sm">'+(f.name||f.id)+'</span><button class="btn btn-xs btn-primary" data-action="cfpLink" data-args='["'+f.id+'"]'>Link</button></div>'}).join('')||'<div class="text-gray-500 text-sm">No files</div>'}).catch(function(){})};
26
+ ${FD_ESC}
27
+ window.openCrossFilePicker=function(){document.getElementById('cross-file-picker').style.display='flex';fetch('/api/engagement').then(function(r){return r.json()}).then(function(d){var engs=d.data||d||[];var sel=document.getElementById('cfp-eng');while(sel.options.length>0)sel.remove(0);engs.filter(function(e){return e.id!=='${currentEngId}'}).forEach(function(e){var o=document.createElement('option');o.value=e.id;o.textContent=e.name||e.id;sel.appendChild(o)});if(sel.options.length)cfpLoadFiles()}).catch(function(err){if(window.showToast)showToast(err.message||'Request failed','error');console.error(err)})};
28
+ window.cfpLoadFiles=function(){var eid=document.getElementById('cfp-eng').value;if(!eid)return;fetch('/api/file?engagement_id='+eid).then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];document.getElementById('cfp-files').innerHTML=files.map(function(f){return'<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded"><span class="text-sm">'+fdEsc(f.name||f.id)+'</span><button class="btn btn-xs btn-primary" data-action="cfpLink" data-args='["'+fdEsc(f.id)+'"]'>Link</button></div>'}).join('')||'<div class="text-gray-500 text-sm">No files</div>'}).catch(function(err){if(window.showToast)showToast(err.message||'Request failed','error');console.error(err)})};
24
29
  window.cfpLink=async function(fid){try{var r=await fetch('/api/file/link',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fid,engagement_id:'${currentEngId}'})});if(r.ok){showToast('File linked','success');document.getElementById('cross-file-picker').style.display='none'}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
25
30
  </script>`;
26
31
  }
@@ -37,7 +42,7 @@ export function userCvUpload(userId) {
37
42
  </script>`;
38
43
  }
39
44
  export function quickViewAttachment() {
40
- return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close="quick-view" role="dialog" aria-modal="true" aria-labelledby="quick-view-title" aria-hidden="true">
45
+ return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close="quick-view" role="dialog" aria-modal="true" aria-labelledby="quick-view-title">
41
46
  <div class="dialog-panel" style="max-width:900px;max-height:90vh"><div class="dialog-header"><span class="dialog-title" id="quick-view-title">Preview</span><button class="dialog-close" data-dialog-close="quick-view" aria-label="Close dialog">&times;</button></div>
42
47
  <div class="dialog-body" style="overflow:auto;max-height:75vh"><div id="qv-content" class="text-center"><div class="text-gray-500">Loading...</div></div></div>
43
48
  <div class="dialog-footer"><a id="qv-download" href="#" download class="btn btn-primary btn-sm">Download</a><button class="btn btn-ghost btn-sm" data-dialog-close="quick-view">Close</button></div>
@@ -48,7 +53,18 @@ export function quickViewAttachment() {
48
53
  }
49
54
  export function fetchCachedPdf(fileId) {
50
55
  return `<script>
51
- window.fetchCachedPdf=function(id){var cacheKey='pdf_cache_'+id;var cached=sessionStorage.getItem(cacheKey);if(cached){return Promise.resolve(cached)}return fetch('/api/file/'+(id||'${fileId}')+'/download').then(function(r){return r.blob()}).then(function(b){var url=URL.createObjectURL(b);sessionStorage.setItem(cacheKey,url);return url})};
56
+ window.fetchCachedPdf = function(id) {
57
+ var cacheKey = 'pdf_cache_' + id;
58
+ var cached = sessionStorage.getItem(cacheKey);
59
+ if (cached) { return Promise.resolve(cached); }
60
+ return fetch('/api/file/' + (id || '${fileId}') + '/download')
61
+ .then(function(r) { return r.blob(); })
62
+ .then(function(b) {
63
+ var url = URL.createObjectURL(b);
64
+ sessionStorage.setItem(cacheKey, url);
65
+ return url;
66
+ });
67
+ };
52
68
  </script>`;
53
69
  }
54
70
  export function fileAttachmentBar(files = []) {
@@ -62,14 +78,14 @@ export function fileLinksBar(links = []) {
62
78
  return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
63
79
  }
64
80
  export function reviewAttachmentChoiceDialog(reviewId) {
65
- return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close="rev-attach-choice" role="dialog" aria-modal="true" aria-labelledby="rev-attach-choice-title" aria-hidden="true">
81
+ return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close="rev-attach-choice" role="dialog" aria-modal="true" aria-labelledby="rev-attach-choice-title">
66
82
  <div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rev-attach-choice-title">Add Attachment</span><button class="dialog-close" data-dialog-close="rev-attach-choice" aria-label="Close dialog">&times;</button></div>
67
83
  <div class="dialog-body"><div class="flex flex-col gap-3"><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="triggerUpload"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>Upload New File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="openCrossFilePicker"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>Link Existing File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="racUrl"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>Add URL</button></div><input type="file" id="rac-upload" style="display:none" onchange="racUploadFile()"/></div>
68
84
  <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rev-attach-choice">Cancel</button></div>
69
85
  </div></div>
70
86
  <script>
71
87
  window.openRevAttachChoice=function(){document.getElementById('rev-attach-choice').style.display='flex'};
72
- window.racUploadFile=async function(){var file=document.getElementById('rac-upload').files[0];if(!file)return;var fd=new FormData();fd.append('file',file);fd.append('review_id','${reviewId}');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)showToast('Attached','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}};
88
+ window.racUploadFile=async function(){if(window.__racUploading)return;var inp=document.getElementById('rac-upload');var file=inp.files[0];if(!file)return;window.__racUploading=true;inp.disabled=true;showToast('Uploading...','info');var fd=new FormData();fd.append('file',file);fd.append('review_id','${reviewId}');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){showToast('Attached','success');setTimeout(function(){location.reload()},500)}else showToast('Upload failed','error')}catch(e){showToast('Upload error','error')}finally{window.__racUploading=false;inp.disabled=false;inp.value=''}};
73
89
  window.racUrl=async function(){var url=await window.gmPrompt({title:'Add Link','label':'Enter URL','placeholder':'https://...','type':'url',confirmLabel:'Add Link',validate:function(v){return /^https?:\\/\\//i.test(v)?null:'Enter a valid http(s) URL.'}});if(!url)return;fetch('/api/file',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:'${reviewId}',url:url,name:url,type:'link'})}).then(function(r){if(r.ok)showToast('Link added','success');else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
74
90
  </script>`;
75
91
  }
@@ -31,7 +31,7 @@ function progressRing(value, label, color) {
31
31
  stroke-dasharray="${circ}" stroke-dashoffset="${offset}" stroke-linecap="round"/>
32
32
  </svg>
33
33
  <div style="font-size:20px;font-weight:700;color:#111;margin-top:-54px;position:relative;height:24px">${value}%</div>
34
- <div style="font-size:12px;font-weight:500;color:#64748b;margin-top:4px">${esc(label)}</div>
34
+ <div style="font-size:12px;font-weight:500;color:#475569;margin-top:4px">${esc(label)}</div>
35
35
  </div>`;
36
36
  }
37
37
 
@@ -63,27 +63,27 @@ export function renderFlexupReport(user, engagement, client, rfis, reviews, high
63
63
  const overdue = r.due_date && Number(r.due_date) < Math.floor(Date.now() / 1000) && (r.status || 'open') !== 'closed';
64
64
  return `<tr>
65
65
  <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px">${esc(r.title || r.name || '-')}</td>
66
- <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#64748b">${esc(r.status || 'open')}</td>
67
- <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:${overdue ? '#dc2626' : '#64748b'}">${r.due_date ? fmtDate(r.due_date) : '-'}</td>
66
+ <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#475569">${esc(r.status || 'open')}</td>
67
+ <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:${overdue ? '#dc2626' : '#475569'}">${r.due_date ? fmtDate(r.due_date) : '-'}</td>
68
68
  </tr>`;
69
- }).join('') : `<tr><td colspan="3" style="padding:20px;text-align:center;color:#9ca3af;font-size:13px">No RFIs</td></tr>`;
69
+ }).join('') : `<tr><td colspan="3" style="padding:20px;text-align:center;color:#6b7280;font-size:13px">No RFIs</td></tr>`;
70
70
 
71
71
  const reviewRows = reviews.length ? reviews.map((r) => `<tr>
72
72
  <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px">${esc(r.name || '-')}</td>
73
- <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#64748b">${esc(r.status || '-')}</td>
74
- <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#64748b">${r.total_highlights || 0}</td>
75
- <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#64748b">${r.resolved_highlights || 0}</td>
76
- </tr>`).join('') : `<tr><td colspan="4" style="padding:20px;text-align:center;color:#9ca3af;font-size:13px">No reviews linked</td></tr>`;
73
+ <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#475569">${esc(r.status || '-')}</td>
74
+ <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#475569">${r.total_highlights || 0}</td>
75
+ <td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#475569">${r.resolved_highlights || 0}</td>
76
+ </tr>`).join('') : `<tr><td colspan="4" style="padding:20px;text-align:center;color:#6b7280;font-size:13px">No reviews linked</td></tr>`;
77
77
 
78
78
  const activityRows = activity.slice(0, 10).map((a) => {
79
79
  const ts = a.timestamp || a.created_at;
80
80
  const when = ts ? (typeof ts === 'number' && ts > 1e9 ? new Date(ts * 1000).toLocaleString() : new Date(ts).toLocaleString()) : '-';
81
81
  return `<tr>
82
- <td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#64748b">${when}</td>
82
+ <td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#475569">${when}</td>
83
83
  <td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:13px">${esc(a.action || a.operation || a.type || '-')}</td>
84
- <td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#64748b">${esc(a.user_id || 'System')}</td>
84
+ <td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#475569">${esc(a.user_id || 'System')}</td>
85
85
  </tr>`;
86
- }).join('') || `<tr><td colspan="3" style="padding:12px;text-align:center;color:#9ca3af;font-size:12px">No recent activity</td></tr>`;
86
+ }).join('') || `<tr><td colspan="3" style="padding:12px;text-align:center;color:#6b7280;font-size:12px">No recent activity</td></tr>`;
87
87
 
88
88
  const html = `<!DOCTYPE html>
89
89
  <html lang="en"><head>
@@ -94,20 +94,23 @@ export function renderFlexupReport(user, engagement, client, rfis, reviews, high
94
94
  .wrap { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
95
95
  .accent { height: 6px; background: ${esc(themeColor)}; border-radius: 3px; margin-bottom: 24px; }
96
96
  .h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px 0; }
97
- .muted { color: #64748b; font-size: 13px; }
97
+ .muted { color: #475569; font-size: 13px; }
98
98
  .card { background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
99
99
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 32px; font-size: 14px; }
100
- .grid .k { color: #64748b; }
100
+ .grid .k { color: #475569; }
101
101
  .rings { display: flex; gap: 24px; justify-content: center; padding: 12px 0; }
102
102
  .kstat { display: flex; flex-direction: column; align-items: center; }
103
103
  .kstat .val { font-size: 28px; font-weight: 700; color: #111; }
104
- .kstat .lbl { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }
104
+ .kstat .lbl { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: 0.5px; }
105
105
  .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; text-align: center; }
106
106
  table { width: 100%; border-collapse: collapse; }
107
107
  th { background: #f3f4f6; color: #374151; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 10px; text-align: left; }
108
108
  .no-print { display: block; }
109
- .foot { text-align: center; font-size: 11px; color: #9ca3af; margin-top: 32px; padding-top: 16px; border-top: 1px solid #e5e7eb; }
109
+ .foot { text-align: center; font-size: 11px; color: #6b7280; margin-top: 32px; padding-top: 16px; border-top: 1px solid #e5e7eb; }
110
110
  .pbtn { background: ${esc(themeColor)}; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; }
111
+ @media (max-width: 640px) {
112
+ .grid { grid-template-columns: 1fr; }
113
+ }
111
114
  @media print {
112
115
  body { background: white; }
113
116
  .no-print { display: none !important; }
@@ -118,13 +121,13 @@ export function renderFlexupReport(user, engagement, client, rfis, reviews, high
118
121
  </head><body>
119
122
  <div class="wrap">
120
123
  <div class="no-print" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
121
- <a href="/engagement/${esc(e.id)}" style="color:#64748b;text-decoration:none;font-size:13px">← Back</a>
124
+ <a href="/engagement/${esc(e.id)}" style="color:#475569;text-decoration:none;font-size:13px">&lt;- Back</a>
122
125
  <button class="pbtn" onclick="window.print()">Print / Save as PDF</button>
123
126
  </div>
124
127
  <div class="accent"></div>
125
128
  <div class="card">
126
129
  <h1 class="h1">${esc(e.name || e.title || 'Engagement')}</h1>
127
- <div class="muted">${esc(client?.name || e.client_name || '-')} &middot; ${esc(cfg?.label || e.stage || '-')} &middot; FY ${esc(e.year || '-')}</div>
130
+ <div class="muted">${esc(client?.name || e.client_name || '-')} - ${esc(cfg?.label || e.stage || '-')} - FY ${esc(e.year || '-')}</div>
128
131
  </div>
129
132
  <div class="card">
130
133
  <h2 style="font-size:14px;margin:0 0 16px 0;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#374151">Progress</h2>
@@ -165,7 +168,7 @@ export function renderFlexupReport(user, engagement, client, rfis, reviews, high
165
168
  <table><thead><tr><th>When</th><th>Action</th><th>By</th></tr></thead><tbody>${activityRows}</tbody></table>
166
169
  </div>
167
170
  <div class="foot">
168
- Generated ${new Date().toLocaleString('en-ZA')} by ${esc(user?.email || user?.name || 'system')} &middot; Moonlanding
171
+ Generated ${new Date().toLocaleString('en-ZA')} by ${esc(user?.email || user?.name || 'system')} - Thatcher
169
172
  </div>
170
173
  </div>
171
174
  </body></html>`;