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,18 +1,19 @@
1
1
  import { statusLabel } from '@/ui/renderer.js';
2
2
  import { page } from '@/ui/layout.js';
3
+ import { emptyRow, esc } from './render-helpers.js';
3
4
 
4
5
  function reviewSummaryPanel(review, highlights, side) {
5
6
  const total = highlights.length;
6
7
  const resolved = highlights.filter(h => h.status === 'resolved').length;
7
8
  const sts = review.status ? statusLabel(review.status) : '';
8
- return `<div class="p-4 bg-gray-50 border-b border-gray-200"><div class="flex items-center justify-between mb-2"><span class="font-medium text-sm">${review.name || 'Untitled'}</span>${sts}</div><div class="grid grid-cols-3 gap-2 text-xs"><div class="text-center"><div class="font-bold text-lg">${total}</div><div class="text-gray-500">Highlights</div></div><div class="text-center"><div class="font-bold text-lg text-green-600">${resolved}</div><div class="text-gray-500">Resolved</div></div><div class="text-center"><div class="font-bold text-lg text-red-600">${total - resolved}</div><div class="text-gray-500">Open</div></div></div></div>`;
9
+ return `<div class="p-4 bg-gray-50 border-b border-gray-200"><div class="flex items-center justify-between mb-2"><span class="font-medium text-sm">${esc(review.name || 'Untitled')}</span>${sts}</div><div class="grid grid-cols-3 gap-2 text-xs"><div class="text-center"><div class="font-bold text-lg">${total}</div><div class="text-gray-500">Highlights</div></div><div class="text-center"><div class="font-bold text-lg text-green-600">${resolved}</div><div class="text-gray-500">Resolved</div></div><div class="text-center"><div class="font-bold text-lg text-red-600">${total - resolved}</div><div class="text-gray-500">Open</div></div></div></div>`;
9
10
  }
10
11
 
11
12
  function highlightDiffRow(leftH, rightH, idx) {
12
13
  const renderSide = (h) => {
13
14
  if (!h) return '<td class="p-2 bg-gray-50 text-center text-xs text-gray-400">-</td>';
14
- const colors = { resolved: '#22c55e', partial_resolved: '#f59e0b' };
15
- const color = colors[h.status] || '#ef4444';
15
+ const colors = { resolved: 'var(--color-success)', partial_resolved: 'var(--color-warning)' };
16
+ const color = colors[h.status] || 'var(--color-danger)';
16
17
  return `<td class="p-2 border-b border-gray-50"><div class="flex items-center gap-1"><span style="width:6px;height:6px;border-radius:50%;background:${color};display:inline-block"></span><span class="text-xs">${h.text || h.content || 'Area'}</span></div><div class="text-xs text-gray-400">p.${h.page_number || '?'}</div></td>`;
17
18
  };
18
19
  const match = leftH && rightH && leftH.text === rightH?.text ? 'bg-green-50' : leftH && rightH ? 'bg-yellow-50' : 'bg-red-50';
@@ -37,11 +38,11 @@ export function renderReviewComparison(user, leftReview, rightReview, leftHighli
37
38
 
38
39
  const diffSummary = `<div class="grid grid-cols-3 gap-4 mb-6"><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-green-600">${stats.common}</div><div class="text-xs text-gray-500">Matching</div></div></div><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-blue-600">${stats.onlyLeft}</div><div class="text-xs text-gray-500">Only in Left</div></div></div><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-purple-600">${stats.onlyRight}</div><div class="text-xs text-gray-500">Only in Right</div></div></div></div>`;
39
40
 
40
- const comparisonTable = `<div class="card-clean" style="overflow-x:auto"><div class="card-clean-body" style="padding:0rem"><div class="grid grid-cols-2 gap-0"><div class="border-r border-gray-200">${reviewSummaryPanel(leftReview, leftHighlights, 'left')}</div><div>${reviewSummaryPanel(rightReview, rightHighlights, 'right')}</div></div><table class="data-table"><thead class="bg-gray-50"><tr><th class="text-center w-12">#</th><th>${leftReview.name || 'Left Review'}</th><th>${rightReview.name || 'Right Review'}</th><th class="text-center w-20">Status</th></tr></thead><tbody>${rows || '<tr><td colspan="4" class="text-center py-8 text-gray-400">No highlights to compare</td></tr>'}</tbody></table></div></div>`;
41
+ const comparisonTable = `<div class="card-clean" style="overflow-x:auto"><div class="card-clean-body" style="padding:0rem"><div class="grid grid-cols-2 gap-0"><div class="border-r border-gray-200">${reviewSummaryPanel(leftReview, leftHighlights, 'left')}</div><div>${reviewSummaryPanel(rightReview, rightHighlights, 'right')}</div></div><table class="data-table"><thead class="bg-gray-50"><tr><th class="text-center w-12">#</th><th>${esc(leftReview.name || 'Left Review')}</th><th>${esc(rightReview.name || 'Right Review')}</th><th class="text-center w-20">Status</th></tr></thead><tbody>${rows || emptyRow(4, 'No highlights to compare')}</tbody></table></div></div>`;
41
42
 
42
43
  const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Review Comparison</h1><a href="/reviews" class="btn btn-ghost btn-sm">Back to Reviews</a></div>${diffSummary}${comparisonTable}`;
43
44
 
44
- return page(user, 'Review Comparison | Moonlanding', [
45
+ return page(user, 'Review Comparison | Thatcher', [
45
46
  { href: '/', label: 'Dashboard' },
46
47
  { href: '/reviews', label: 'Reviews' },
47
48
  { label: 'Comparison' }
@@ -49,10 +50,10 @@ export function renderReviewComparison(user, leftReview, rightReview, leftHighli
49
50
  }
50
51
 
51
52
  export function renderComparisonPicker(user, reviews) {
52
- const reviewOptions = reviews.map(r => `<option value="${r.id}">${r.name || r.title || 'Untitled'} ${r.status ? '(' + r.status + ')' : ''}</option>`).join('');
53
+ const reviewOptions = reviews.map(r => `<option value="${esc(String(r.id))}">${esc(r.name || r.title || 'Untitled')} ${r.status ? '(' + esc(r.status) + ')' : ''}</option>`).join('');
53
54
  const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Compare Reviews</h1><a href="/reviews" class="btn btn-ghost btn-sm">Back to Reviews</a></div><div class="card-clean" style="margin:autorem"><div class="card-clean-body"><p class="text-sm text-gray-500 mb-4">Select two reviews to compare their highlights side by side.</p><div class="form-group mb-4"><label class="text-sm font-medium block mb-1" for="left-review">Left Review</label><select id="left-review" class="select select-bordered w-full">${reviewOptions}</select></div><div class="form-group mb-4"><label class="text-sm font-medium block mb-1" for="right-review">Right Review</label><select id="right-review" class="select select-bordered w-full">${reviewOptions}</select></div><button class="btn btn-primary w-full" data-action="startComparison">Compare</button></div></div>`;
54
55
 
55
56
  const script = `window.startComparison=function(){const l=document.getElementById('left-review')?.value;const r=document.getElementById('right-review')?.value;if(!l||!r)return showToast('Select both reviews','error');if(l===r)return showToast('Select different reviews','error');window.location='/reviews/compare?left='+l+'&right='+r}`;
56
57
 
57
- return page(user, 'Compare Reviews | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/reviews', label: 'Reviews' }, { label: 'Compare' }], content, [script]);
58
+ return page(user, 'Compare Reviews | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/reviews', label: 'Reviews' }, { label: 'Compare' }], content, [script]);
58
59
  }
@@ -1,4 +1,4 @@
1
- import { esc } from '@/ui/render-helpers.js';
1
+ import { esc, AVATAR_COLORS } from '@/ui/render-helpers.js';
2
2
 
3
3
  export function highlightRow(h) {
4
4
  const resolved = h.resolved || h.status === 'resolved';
@@ -14,13 +14,13 @@ export function highlightRow(h) {
14
14
  try { tags = JSON.parse(h.tags || '[]') || []; } catch {}
15
15
  const isFlagged = flags.includes('flagged');
16
16
 
17
- const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? '#f59e0b' : 'var(--color-text-muted)'}">${isFlagged ? '⚑' : '⚐'}</button>`;
18
- const tagPills = tags.map(t => `<span style="display:inline-flex;align-items:center;gap:3px;background:#eff6ff;color:#1d4ed8;font-size:11px;padding:1px 6px;border-radius:9999px;font-weight:500">${esc(t)}<button data-action="removeTag" data-args='["${esc(h.id)}","${esc(t)}"]' style="background:none;border:none;cursor:pointer;padding:0;margin-left:2px;font-size:11px;color:#6b7280;line-height:1">&times;</button></span>`).join(' ');
17
+ const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? 'var(--color-warning)' : 'var(--color-text-muted)'}">${isFlagged ? '⚑' : '⚐'}</button>`;
18
+ const tagPills = tags.map(t => `<span style="display:inline-flex;align-items:center;gap:3px;background:var(--color-info-bg,#eff6ff);color:var(--color-info,#1e40af);font-size:11px;padding:2px 8px;border-radius:9999px;font-weight:500">${esc(t)}<button data-action="removeTag" data-args='["${esc(h.id)}","${esc(t)}"]' style="background:none;border:none;cursor:pointer;padding:0;margin-left:2px;font-size:11px;color:var(--color-text-muted);line-height:1">&times;</button></span>`).join(' ');
19
19
  const addTagBtn = `<button data-action="openAddTag" data-args='["${esc(h.id)}"]' style="background:none;border:none;cursor:pointer;font-size:11px;color:var(--color-text-muted);padding:1px 5px;border:1px dashed var(--color-border,#e5e7eb);border-radius:9999px">+tag</button>`;
20
20
  const tagsCell = `<div style="display:flex;flex-wrap:wrap;gap:3px;align-items:center;min-width:80px">${tagPills}${addTagBtn}</div>`;
21
21
 
22
22
  return `<tr style="${rowStyle}" data-highlight-id="${esc(h.id)}" data-flagged="${isFlagged}" data-section-id="${esc(h.section_id||'')}">
23
- <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;${textStyle}">${flagBtn}${esc(h.text||h.content||h.comment||'Highlight')}</td>
23
+ <td title="${esc(h.text||h.content||h.comment||'Highlight')}" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;${textStyle}">${flagBtn}${esc(h.text||h.content||h.comment||'Highlight')}</td>
24
24
  <td style="font-size:13px;color:var(--color-text-muted);white-space:nowrap">p.${h.page||h.page_number||'-'}</td>
25
25
  <td>${badge}</td>
26
26
  <td>${tagsCell}</td>
@@ -31,8 +31,7 @@ export function highlightRow(h) {
31
31
 
32
32
  export function collaboratorRow(c) {
33
33
  const initials = (c.user_name || c.email || '?').charAt(0).toUpperCase();
34
- const colors = ['#1565c0','#2e7d32','#6a1b9a','#c62828','#e65100','#00695c'];
35
- const bg = colors[(c.user_name || c.email || '').charCodeAt(0) % colors.length] || colors[0];
34
+ const bg = AVATAR_COLORS[(c.user_name || c.email || '').charCodeAt(0) % AVATAR_COLORS.length] || AVATAR_COLORS[0];
36
35
  return `<tr>
37
36
  <td>
38
37
  <div style="display:flex;align-items:center;gap:8px">
@@ -41,15 +40,15 @@ export function collaboratorRow(c) {
41
40
  </div>
42
41
  </td>
43
42
  <td><span class="pill pill-info" style="text-transform:capitalize">${esc(c.role||'viewer')}</span></td>
44
- <td style="font-size:13px;color:var(--color-text-muted)">${c.expires_at?new Date(typeof c.expires_at==='number'?c.expires_at*1000:c.expires_at).toLocaleDateString():'-'}</td>
43
+ <td style="font-size:13px;color:var(--color-text-muted)">${c.expires_at?new Date(typeof c.expires_at==='number'?c.expires_at*1000:c.expires_at).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'}</td>
45
44
  <td style="text-align:right"><button data-action="removeCollaborator" data-args='["${esc(c.id)}"]' class="btn-danger-clean" style="font-size:12px;padding:4px 10px;min-height:28px">Remove</button></td>
46
45
  </tr>`;
47
46
  }
48
47
 
49
48
  export function addCollaboratorDialog(reviewId) {
50
- return `<div id="collab-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true">
49
+ return `<div id="collab-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="collab-dialog-title" data-dialog-close="collab-dialog">
51
50
  <div class="dialog-panel" style="max-width:400px;width:100%">
52
- <h3 style="font-size:16px;font-weight:600;color:var(--color-text);margin:0 0 16px">Add Collaborator</h3>
51
+ <h3 id="collab-dialog-title" style="font-size:16px;font-weight:600;color:var(--color-text);margin:0 0 16px">Add Collaborator</h3>
53
52
  <div class="form-field" style="margin-bottom:12px">
54
53
  <label class="form-label">Email</label>
55
54
  <input type="email" id="collab-email" class="form-input" placeholder="collaborator@example.com"/>
@@ -13,8 +13,8 @@ import { SPACING, renderCard, renderTable, renderButton, renderInfoGrid } from '
13
13
  function fmtDate(ts) {
14
14
  if (!ts) return '-';
15
15
  const n = Number(ts);
16
- if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
17
- try { return new Date(ts).toLocaleDateString(); } catch { return String(ts); }
16
+ if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
17
+ try { return new Date(ts).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}); } catch { return String(ts); }
18
18
  }
19
19
 
20
20
  export function renderReviewDetail(user, review, highlights = [], collaborators = [], checklists = [], sections = [], tenders = []) {
@@ -166,5 +166,5 @@ window.sendCollabNotify=async function(reviewId){var msg=document.getElementById
166
166
  const content = `${pageHeader}${reviewZoneNav(r.id, 'overview')}${tabBar}
167
167
  <div>${overviewPanel}${highlightsPanel}${collabPanel}${checklistPanel}${sectionsPanel}${tenderPanelHtml(r.id, tenders, canEditReview, tablePanel)}${linksPanelHtml(r.id, parsedLinks, canEditReview, tablePanel)}${historyPanel}${addCollaboratorDialog(r.id)}${checklistPickerDialog}${tenderDialog(r.id)}${linkDialog(r.id)}${compareDialogHtml(r.id)}${notifyDialog}${notifyScript}${retryHighlightDialog(esc(r.id))}${reviewTemplateChoiceDialog()}</div>`;
168
168
 
169
- return page(user, `${esc(r.name || 'Review')} | Moonlanding`, bc, content, [reviewDetailScript(r.id)]);
169
+ return page(user, `${esc(r.name || 'Review')} | Thatcher`, bc, content, [reviewDetailScript(r.id)]);
170
170
  }
@@ -5,6 +5,7 @@ import { reviewHighlightActionsScript } from '@/ui/review-highlight-actions-scri
5
5
  export function reviewDetailScript(reviewId) {
6
6
  const id = esc(reviewId || '');
7
7
  return `${TOAST_SCRIPT}
8
+ function __esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
8
9
  function switchTab(tab){
9
10
  document.querySelectorAll('.rv-panel').forEach(function(p){p.style.display='none'});
10
11
  var panel=document.getElementById('rvpanel-'+tab);if(panel)panel.style.display='';
@@ -12,20 +13,20 @@ function switchTab(tab){
12
13
  var btn=document.getElementById('rvtab-'+tab);if(btn)btn.classList.add('active');
13
14
  if(tab==='history')loadHistory('${id}');
14
15
  }
15
- async function resolveHighlight(id){if(!confirm('Mark this highlight as resolved?'))return;try{var r=await fetch('/api/mwr/review/${id}/highlights/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolved:true,status:'resolved'})});if(r.ok){showToast('Resolved','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
16
- async function bulkResolve(reviewId){if(!confirm('Resolve all highlights?'))return;try{var r=await fetch('/api/mwr/review/'+reviewId+'/highlights/bulk-resolve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolve_all:true})});if(r.ok){showToast('All resolved','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
16
+ async function resolveHighlight(id){if(!(await window.gmConfirm({title:'Resolve Highlight',message:'Mark this highlight as resolved?',confirmLabel:'Resolve',danger:true})))return;try{var r=await fetch('/api/mwr/review/${id}/highlights/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolved:true,status:'resolved'})});if(r.ok){showToast('Resolved','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
17
+ async function bulkResolve(reviewId){if(!(await window.gmConfirm({title:'Resolve All',message:'Resolve all highlights? This marks every highlight in this review as resolved.',confirmLabel:'Resolve all',danger:true})))return;try{var r=await fetch('/api/mwr/review/'+reviewId+'/highlights/bulk-resolve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolve_all:true})});if(r.ok){showToast('All resolved','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
17
18
  async function exportPdf(reviewId){showToast('Generating PDF...','info');try{var r=await fetch('/api/mwr/review/'+reviewId+'/export-pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});if(r.ok){var b=await r.blob();var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='review-'+reviewId+'.pdf';a.click();showToast('PDF downloaded','success')}else showToast('Export failed','error')}catch(e){showToast('Error','error')}}
18
19
  async function addCollaborator(reviewId){var email=document.getElementById('collab-email').value.trim();var role=document.getElementById('collab-role').value;if(!email){showToast('Email required','error');return}try{var r=await fetch('/api/mwr/review/'+reviewId+'/collaborators',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,role:role})});if(r.ok){showToast('Collaborator added','success');document.getElementById('collab-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
19
- async function removeCollaborator(collabId){if(!confirm('Remove this collaborator?'))return;try{var r=await fetch('/api/mwr/review/${id}/collaborators/'+collabId,{method:'DELETE'});if(r.ok){showToast('Removed','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
20
+ async function removeCollaborator(collabId){if(!(await window.gmConfirm({title:'Remove Collaborator',message:'Remove this collaborator? They will lose access to this review.',confirmLabel:'Remove',danger:true})))return;try{var r=await fetch('/api/mwr/review/${id}/collaborators/'+collabId,{method:'DELETE'});if(r.ok){showToast('Removed','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
20
21
  async function openChecklistPicker(reviewId){
21
22
  var dlg=document.getElementById('checklist-picker-dialog');if(!dlg)return;
22
23
  dlg.style.display='flex';
23
- var sel=document.getElementById('checklist-picker-template');sel.innerHTML='<option>Loading...</option>';
24
+ var sel=document.getElementById('checklist-picker-template');sel.setAttribute('aria-busy','true');sel.innerHTML='<option>Loading...</option>';
24
25
  try{
25
26
  var r=await fetch('/api/mwr/checklist-template',{credentials:'include'});var data=await r.json();
26
- if(!data.success||!data.templates||!data.templates.length){sel.innerHTML='<option value="">No templates available</option>';return}
27
- sel.innerHTML=data.templates.map(function(t){return '<option value="'+t.id+'">'+t.name.replace(/</g,'&lt;')+' ('+t.item_count+' items)</option>'}).join('');
28
- }catch(e){sel.innerHTML='<option value="">Failed to load templates</option>'}
27
+ if(!data.success||!data.templates||!data.templates.length){sel.removeAttribute('aria-busy');sel.innerHTML='<option value="">No templates available</option>';return}
28
+ sel.removeAttribute('aria-busy');sel.innerHTML=data.templates.map(function(t){return '<option value="'+__esc(t.id)+'">'+__esc(t.name)+' ('+__esc(t.item_count)+' items)</option>'}).join('');
29
+ }catch(e){sel.removeAttribute('aria-busy');sel.innerHTML='<option value="">Failed to load templates</option>'}
29
30
  }
30
31
  async function saveChecklistFromTemplate(reviewId){
31
32
  var sel=document.getElementById('checklist-picker-template');var nameInput=document.getElementById('checklist-picker-name');
@@ -75,21 +76,21 @@ async function saveTender(reviewId){
75
76
  }catch(e){showToast('Network error','error')}
76
77
  finally{if(btn)btn.disabled=false}
77
78
  }
78
- async function deleteTender(tenderId){if(!confirm('Delete this tender?'))return;try{var r=await fetch('/api/mwr/review/${id}/tender/'+tenderId,{method:'DELETE',credentials:'include'});var data=await r.json();if(r.ok&&data.success){showToast('Tender deleted','success');setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Error','error')}}
79
+ async function deleteTender(tenderId){if(!(await window.gmConfirm({title:'Delete Tender',message:'Delete this tender? This cannot be undone.',confirmLabel:'Delete',danger:true})))return;try{var r=await fetch('/api/mwr/review/${id}/tender/'+tenderId,{method:'DELETE',credentials:'include'});var data=await r.json();if(r.ok&&data.success){showToast('Tender deleted','success');setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Error','error')}}
79
80
  function openAddLink(){document.getElementById('link-dialog').style.display='flex';document.getElementById('link-label').value='';document.getElementById('link-url').value='';}
80
81
  async function saveLink(reviewId){var url=document.getElementById('link-url').value.trim();var label=document.getElementById('link-label').value.trim();if(!url){showToast('URL required','error');return}try{var r=await fetch('/api/mwr/review/'+reviewId+'/links',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({url:url,label:label||url,type:'url'})});var data=await r.json();if(r.ok&&data.success){showToast('Link added','success');document.getElementById('link-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Network error','error')}}
81
- async function removeLink(reviewId,idx){if(!confirm('Remove this link?'))return;try{var r=await fetch('/api/mwr/review/'+reviewId+'/links/'+idx,{method:'DELETE',credentials:'include'});var data=await r.json();if(r.ok&&data.success){showToast('Link removed','success');setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Error','error')}}
82
+ async function removeLink(reviewId,idx){if(!(await window.gmConfirm({title:'Remove Link',message:'Remove this link?',confirmLabel:'Remove',danger:true})))return;try{var r=await fetch('/api/mwr/review/'+reviewId+'/links/'+idx,{method:'DELETE',credentials:'include'});var data=await r.json();if(r.ok&&data.success){showToast('Link removed','success');setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Error','error')}}
82
83
  async function loadHistory(reviewId){
83
84
  var list=document.getElementById('history-list');
84
85
  if(!list)return;
85
- list.innerHTML='<div style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">Loading...</div>';
86
+ list.innerHTML='<div aria-busy="true" style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">Loading...</div>';
86
87
  try{
87
88
  var r=await fetch('/api/audit/logs?entity_type=review&entity_id='+reviewId,{credentials:'include'});
88
89
  var data=await r.json();
89
90
  var logs=(data.logs||data.data||[]);
90
91
  if(!logs.length){list.innerHTML='<div style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">No history found</div>';return}
91
- list.innerHTML=logs.map(function(log){var ts=log.timestamp?new Date(log.timestamp).toLocaleString():'-';var op=log.operation||log.action||'-';var uid=log.user_id?'User '+log.user_id:'System';var details='';if(log.details&&typeof log.details==='object'){var keys=Object.keys(log.details).slice(0,3);details=keys.map(function(k){return k+': '+String(log.details[k]).slice(0,40)}).join(', ');}return '<div style="display:flex;gap:12px;align-items:flex-start;padding:10px 0;border-bottom:1px solid var(--color-border,#e5e7eb)"><div style="width:8px;height:8px;border-radius:50%;background:var(--color-primary,#2563eb);margin-top:5px;flex-shrink:0"></div><div style="flex:1;min-width:0"><div style="display:flex;justify-content:space-between;gap:8px"><span style="font-size:13px;font-weight:500;color:var(--color-text)">'+op.replace(/_/g,' ')+'</span><span style="font-size:12px;color:var(--color-text-muted);white-space:nowrap">'+ts+'</span></div><div style="font-size:12px;color:var(--color-text-muted);margin-top:2px">'+uid+(details?' &mdash; '+details:'')+'</div></div></div>';}).join('');
92
- }catch(e){list.innerHTML='<div style="font-size:13px;color:var(--color-danger,#dc2626);padding:24px 0;text-align:center">Failed to load history</div>'}
92
+ list.innerHTML=logs.map(function(log){var ts=log.timestamp?window.fmtDateTime(log.timestamp):'-';var op=log.operation||log.action||'-';var uid=log.user_id?'User '+log.user_id:'System';var details='';var detailsFull='';if(log.details&&typeof log.details==='object'){var keys=Object.keys(log.details).slice(0,3);details=keys.map(function(k){return k+': '+String(log.details[k]).slice(0,40)}).join(', ');detailsFull=keys.map(function(k){return k+': '+String(log.details[k])}).join(', ');}return '<div style="display:flex;gap:12px;align-items:flex-start;max-width:100%;padding:10px 0;border-bottom:1px solid var(--color-border,#e5e7eb)"><div style="width:8px;height:8px;border-radius:50%;background:var(--color-primary,#2563eb);margin-top:5px;flex-shrink:0"></div><div style="flex:1;min-width:0;max-width:100%;word-break:break-word"><div style="display:flex;justify-content:space-between;gap:8px"><span style="font-size:13px;font-weight:500;color:var(--color-text)">'+__esc(op.replace(/_/g,' '))+'</span><span style="font-size:12px;color:var(--color-text-muted);white-space:nowrap">'+__esc(ts)+'</span></div><div'+(detailsFull?' title="'+__esc(detailsFull)+'"':'')+' style="font-size:12px;color:var(--color-text-muted);margin-top:2px">'+__esc(uid)+(details?' - '+__esc(details):'')+'</div></div></div>';}).join('');
93
+ }catch(e){list.innerHTML='<div role="alert" style="font-size:13px;color:var(--color-danger,#dc2626);padding:24px 0;text-align:center">Failed to load history</div>'}
93
94
  }
94
95
  ${reviewHighlightActionsScript(reviewId)}`;
95
96
  }
@@ -4,13 +4,14 @@ import { SPACING, renderCard, renderButton, renderProgress, renderEmptyState, re
4
4
  import { icon, esc } from '@/ui/format-helpers.js';
5
5
  import { TOAST_SCRIPT } from '@/ui/render-helpers.js';
6
6
 
7
- // Star icon with fill toggle for priority on/off state.
8
- const starIcon = (on) => `<svg width="16" height="16" viewBox="0 0 24 24" fill="${on ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
7
+ // Star icon for priority. Filled vs outline state is conveyed by the button's
8
+ // .is-priority class (CSS) and aria-pressed; the glyph itself is the shared icon().
9
+ const starIcon = () => icon('star', 16);
9
10
 
10
11
  function fmtDate(ts) {
11
12
  if (!ts) return '-';
12
13
  const n = Number(ts);
13
- if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
14
+ if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
14
15
  return String(ts);
15
16
  }
16
17
 
@@ -48,7 +49,7 @@ export function renderMwrHome(user, stats) {
48
49
  const archiveLabel = r.status === 'archived' ? 'Unarchive' : 'Archive';
49
50
  const archiveAct = r.status === 'archived' ? 'unarchive' : 'archive';
50
51
  return `<div class="list-item-row" data-review-id="${r.id}">
51
- <button type="button" class="row-action row-action-star${isPri ? ' is-priority' : ''}" data-action="toggleReviewStar" data-args='["${esc(r.id)}"]' aria-label="Toggle priority" aria-pressed="${isPri}" title="Star/Priority">${starIcon(isPri)}</button>
52
+ <button type="button" class="row-action row-action-star${isPri ? ' is-priority' : ''}" data-action="toggleReviewStar" data-args='["${esc(r.id)}"]' aria-label="Toggle priority" aria-pressed="${isPri}" title="Star/Priority">${starIcon()}</button>
52
53
  <span class="list-item-name" data-action="openReview" data-args='["${esc(r.id)}"]' style="cursor:pointer;flex:1">${esc(r.name || 'Untitled')}</span>
53
54
  <div class="list-item-meta">${statusBadge(r.status)}<span style="font-size:12px;color:var(--color-text-light)">${fmtDate(r.updated_at || r.created_at)}</span></div>
54
55
  <button class="row-action row-action-flag" data-action="toggleReviewFlag" data-args='["${r.id}"]' aria-label="Flag" title="Flag">${icon('flag', 16)}</button>
@@ -68,7 +69,7 @@ export function renderMwrHome(user, stats) {
68
69
  const content = `${renderPageHeader('MWR Home', `Welcome back, ${user?.name || 'User'}`)}${statsHtml}${tabBar}${renderCard(panels, { padding: 0 })}`;
69
70
  const script = `${TOAST_SCRIPT}window.switchHomeTab=(key)=>{document.querySelectorAll('.tab-btn[data-tab]').forEach(t=>t.classList.toggle('active',t.dataset.tab===key));document.querySelectorAll('[id^="home-panel-"]').forEach(p=>p.style.display='none');const el=document.getElementById('home-panel-'+key);if(el)el.style.display='block'};window.openReview=(id)=>{location.href='/review/'+id};window.toggleReviewStar=async(id)=>{try{const r=await fetch('/api/mwr/review/star',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Star failed','error')}catch(e){window.showToast('Star failed','error')}};window.toggleReviewFlag=async(id)=>{try{const r=await fetch('/api/mwr/review/flag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Flag failed','error')}catch(e){window.showToast('Flag failed','error')}};window.openReviewTag=async(id)=>{const t=await window.gmPrompt({title:'Tag Review',label:'Tag',placeholder:'Enter a tag',confirmLabel:'Add Tag'});if(t)fetch('/api/mwr/review/tag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id,tag:t})}).then(r=>{if(r.ok)window.showToast('Tagged');else window.showToast('Tag failed','error')})};window.toggleReviewArchive=async(id,act)=>{try{const r=await fetch('/api/mwr/review/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_ids:[id],action:act})});if(r.ok)location.reload();else window.showToast('Archive failed','error')}catch(e){window.showToast('Archive failed','error')}};`;
70
71
  const bc = [{ href: '/', label: 'Dashboard' }, { label: 'MWR Home' }];
71
- return page(user, 'MWR Home | Moonlanding', bc, content, [script]);
72
+ return page(user, 'MWR Home | Thatcher', bc, content, [script]);
72
73
  }
73
74
 
74
75
  export function renderSectionReport(user, review, sections) {
@@ -1,5 +1,6 @@
1
1
  import { canCreate } from '@/ui/permissions-ui.js';
2
2
  import { page } from '@/ui/layout.js';
3
+ import { esc } from '@/ui/render-helpers.js';
3
4
  import { reviewCreateDialog, reviewTemplateChoiceDialog, reviewContextMenu, reviewFlagsDialog, reviewTagsDialog, reviewValueDialog, reviewDeadlineDialog, reviewNotificationDialog } from '@/ui/review-dialogs.js';
4
5
  import { SPACING, renderButton, renderEmptyState, renderStatsRow, renderPageHeader } from '@/ui/spacing-system.js';
5
6
 
@@ -19,7 +20,7 @@ const TAB_DEFS = [
19
20
  function fmtDate(ts) {
20
21
  if (!ts) return '-';
21
22
  const n = Number(ts);
22
- if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
23
+ if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
23
24
  return String(ts);
24
25
  }
25
26
 
@@ -33,13 +34,13 @@ function statusBadge(status) {
33
34
  function reviewRow(r) {
34
35
  const isPri = (r.is_priority || r.flagged || (r.flags_count > 0)) ? 1 : 0;
35
36
  return `<tr class="review-row" data-status="${r.status || ''}" data-flags="${r.flags_count || 0}" data-highlights="${r.highlights_count || 0}" data-archived="${r.archived || 0}" data-priority="${isPri}" data-stage="${r.stage || r.status || ''}" data-navigate="/review/${r.id}" oncontextmenu="showCtxMenu(event,'${r.id}')" style="cursor:pointer">
36
- <td style="width:32px"><button class="review-priority-star${isPri?' is-priority':''}" data-action="togglePriority" data-args='["${r.id}"]' aria-label="Toggle priority" onclick="event.stopPropagation()">${isPri?'★':'☆'}</button></td>
37
+ <td style="width:32px"><button class="review-priority-star${isPri?' is-priority':''}" data-action="togglePriority" data-args='["${r.id}"]' aria-label="Toggle priority" aria-pressed="${isPri?'true':'false'}" data-stop-propagation><svg width="16" height="16" viewBox="0 0 24 24" fill="${isPri?'currentColor':'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button></td>
37
38
  <td class="review-row-name">${r.name || r.title || 'Untitled'}</td>
38
- <td style="font-size:13px;color:var(--color-text-muted)">${r.engagement_name || r.engagement_id_display || '-'}</td>
39
+ <td style="font-size:13px;color:var(--color-text)">${r.engagement_name || r.engagement_id_display || '-'}</td>
39
40
  <td>${statusBadge(r.status)}</td>
40
41
  <td style="text-align:center;font-size:13px">${r.highlights_count !== undefined ? r.highlights_count : '-'}</td>
41
42
  <td style="font-size:13px">${fmtDate(r.deadline)}</td>
42
- <td style="font-size:13px;color:var(--color-text-light)">${fmtDate(r.created_at)}</td>
43
+ <td style="font-size:13px;color:var(--color-text-muted)">${fmtDate(r.created_at)}</td>
43
44
  </tr>`;
44
45
  }
45
46
 
@@ -127,7 +128,7 @@ export function renderReviewListTabbed(user, reviews) {
127
128
  });
128
129
  })()`;
129
130
 
130
- return page(user, 'Reviews | Moonlanding', bc, content, [TOAST_SCRIPT, tabScript, ctxScript, searchScript, priorityScript]);
131
+ return page(user, 'Reviews | Thatcher', bc, content, [TOAST_SCRIPT, tabScript, ctxScript, searchScript, priorityScript]);
131
132
  }
132
133
 
133
134
  export function reviewSearchField() {
@@ -149,7 +150,7 @@ export function reviewGroupedList(reviews, groupBy) {
149
150
  `<div class="card-clean" style="margin-bottom:${SPACING.md}">
150
151
  <div class="card-clean-body" style="padding:${SPACING.md}">
151
152
  <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:${SPACING.sm};cursor:pointer" data-action="toggleReviewGroup" data-self>
152
- <span style="font-weight:600;font-size:14px">${name}</span>
153
+ <span style="font-weight:600;font-size:14px">${esc(name)}</span>
153
154
  <span class="pill pill-neutral">${items.length}</span>
154
155
  </div>
155
156
  <div class="review-group-items">
@@ -5,7 +5,7 @@ import { teamAvatarGroup } from '@/ui/widgets.js'
5
5
  export function mobileReviewCard(review) {
6
6
  const r = review || {}
7
7
  const sts = r.status ? statusLabel(r.status) : ''
8
- const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at}</span>` : ''
8
+ const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.created_at}</span>` : ''
9
9
  return `<div class="mobile-card mobile-review-card card-clean">
10
10
  <div class="card-clean-body">
11
11
  <div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${esc(r.name || r.title || 'Untitled Review')}</h3>${sts}</div>
@@ -19,8 +19,8 @@ export function sidebarReviewDetails(review) {
19
19
  const r = review || {}
20
20
  const sts = r.status ? statusLabel(r.status) : ''
21
21
  const meta = [
22
- r.created_at ? ['Created', typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at] : null,
23
- r.updated_at ? ['Updated', typeof r.updated_at === 'number' ? new Date(r.updated_at * 1000).toLocaleDateString() : r.updated_at] : null,
22
+ r.created_at ? ['Created', typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.created_at] : null,
23
+ r.updated_at ? ['Updated', typeof r.updated_at === 'number' ? new Date(r.updated_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.updated_at] : null,
24
24
  r.creator_name ? ['Creator', r.creator_name] : null,
25
25
  r.manager_name ? ['Manager', r.manager_name] : null,
26
26
  ].filter(Boolean).map(([l, v]) => `<div class="sidebar-meta-row"><span class="text-gray-500 text-xs">${l}</span><span class="text-sm font-medium">${v}</span></div>`).join('')
@@ -1,13 +1,13 @@
1
1
  import { page } from '@/ui/layout.js';
2
2
  import { canEdit, isPartner, isManager, isClientUser } from '@/ui/permissions-ui.js';
3
- import { esc, statusBadge, TOAST_SCRIPT } from '@/ui/render-helpers.js';
3
+ import { esc, statusBadge, TOAST_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
4
4
  import { SPACING } from '@/ui/spacing-system.js';
5
5
 
6
6
  const TOAST = TOAST_SCRIPT;
7
7
 
8
8
  function questionRow(q, i, sections = [], clientId = '', showRespond = false, showView = false) {
9
9
  const section = sections.find(s => s.id === q.section_id || s.id === q.rfi_section_id);
10
- const sLabel = section ? `<span class="badge badge-flat-primary text-xs">${esc(section.name)}</span>` : '';
10
+ const sLabel = section ? `<span class="badge badge-primary text-xs">${esc(section.name)}</span>` : '';
11
11
  const hasResp = q.responses > 0 || q.response_count > 0;
12
12
  const respBadge = hasResp ? `<span class="badge badge-success badge-flat-success text-xs">Responded</span>` : '';
13
13
  const assignedLabel = q.assigned_to ? `<span class="badge badge-flat-secondary text-xs">Assigned</span>` : '';
@@ -16,10 +16,10 @@ function questionRow(q, i, sections = [], clientId = '', showRespond = false, sh
16
16
  const viewBtn = showView && hasResp ? `<button data-action="viewResponses" data-args='["${esc(q.id)}","${esc(q.question_text||q.question||q.title||'')}"]' class="btn btn-ghost btn-xs">Responses</button>` : '';
17
17
  const editDelBtns = !showRespond ? `<button data-action="openEditQuestion" data-args='["${esc(q.id)}"]' class="btn btn-ghost btn-xs">Edit</button>${assignBtn}<button data-action="deleteQuestion" data-args='["${esc(q.id)}"]' class="btn btn-error btn-xs">Del</button>` : '';
18
18
  return `<tr class="hover">
19
- <td class="text-center text-sm text-base-content/40 w-8">${i+1}</td>
19
+ <td class="text-center text-sm text-base-content w-8">${i+1}</td>
20
20
  <td class="text-sm max-w-sm">${esc(q.question_text||q.question||q.title||'Question')}</td>
21
21
  <td>${sLabel}</td>
22
- <td class="text-sm text-base-content/50">${q.deadline?new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline).toLocaleDateString():'-'}</td>
22
+ <td class="text-sm text-base-content">${q.deadline?new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'}</td>
23
23
  <td>${respBadge}${assignedLabel}</td>
24
24
  <td><div class="flex gap-2">${respondBtn}${viewBtn}${editDelBtns}</div></td>
25
25
  </tr>`;
@@ -27,15 +27,15 @@ function questionRow(q, i, sections = [], clientId = '', showRespond = false, sh
27
27
 
28
28
  function editQuestionDialog(rfiId, sections) {
29
29
  const sectionOpts = sections.map(s => `<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');
30
- return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-4" id="q-dialog-title">Edit Question</h3><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Question Text</span></label><textarea id="q-text" rows="4" class="textarea textarea-solid max-w-full"></textarea></div><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Section</span></label><select id="q-section" class="select select-solid max-w-full"><option value="">No section</option>${sectionOpts}</select></div><div class="grid grid-cols-2 gap-3 mb-4"><div class="form-group"><label class="label"><span class="label-text font-medium">Deadline</span></label><input type="date" id="q-deadline" class="input input-solid max-w-full"/></div><div class="form-group"><label class="label"><span class="label-text font-medium">Category</span></label><input type="text" id="q-category" placeholder="Category" class="input input-solid max-w-full"/></div></div><input type="hidden" id="q-edit-id"/><div class="modal-action"><button data-action="saveQuestion" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Save</button><button data-dialog-close="q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
30
+ return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="q-dialog-title"><h2 class="text-lg font-semibold mb-4" id="q-dialog-title">Edit Question</h2><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Question Text</span></label><textarea id="q-text" rows="4" class="textarea textarea-solid max-w-full"></textarea></div><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Section</span></label><select id="q-section" class="select select-solid max-w-full"><option value="">No section</option>${sectionOpts}</select></div><div class="grid grid-cols-2 gap-3 mb-4"><div class="form-group"><label class="label"><span class="label-text font-medium">Deadline</span></label><input type="date" id="q-deadline" class="input input-solid max-w-full"/></div><div class="form-group"><label class="label"><span class="label-text font-medium">Category</span></label><input type="text" id="q-category" placeholder="Category" class="input input-solid max-w-full"/></div></div><input type="hidden" id="q-edit-id"/><div class="modal-action"><button data-action="saveQuestion" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Save</button><button data-dialog-close="q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
31
31
  }
32
32
 
33
33
  function assignQuestionDialog(rfiId) {
34
- return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="assign-q-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><h3 class="text-lg font-semibold mb-4">Assign to Client User</h3><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Client User</span></label><select id="assign-q-user" class="select select-solid max-w-full"><option value="">Loading...</option></select></div><input type="hidden" id="assign-q-id"/><div class="modal-action"><button data-action="saveAssignQuestion" class="btn btn-primary">Assign</button><button data-dialog-close="assign-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
34
+ return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="assign-q-dialog"></div><div class="modal-content rounded-box max-w-sm p-6" aria-labelledby="assign-q-dialog-title"><h2 class="text-lg font-semibold mb-4" id="assign-q-dialog-title">Assign to Client User</h2><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Client User</span></label><select id="assign-q-user" class="select select-solid max-w-full"><option value="">Loading...</option></select></div><input type="hidden" id="assign-q-id"/><div class="modal-action"><button data-action="saveAssignQuestion" class="btn btn-primary">Assign</button><button data-dialog-close="assign-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
35
35
  }
36
36
 
37
37
  function addSectionDialog(rfiId) {
38
- return `<div id="sec-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="sec-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><h3 class="text-lg font-semibold mb-4">Add Section</h3><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Section Name</span></label><input type="text" id="sec-name" class="input input-solid max-w-full"/></div><div class="modal-action"><button data-action="saveSection" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Add</button><button data-dialog-close="sec-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
38
+ return `<div id="sec-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="sec-dialog"></div><div class="modal-content rounded-box max-w-sm p-6" aria-labelledby="sec-dialog-title"><h2 class="text-lg font-semibold mb-4" id="sec-dialog-title">Add Section</h2><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Section Name</span></label><input type="text" id="sec-name" class="input input-solid max-w-full"/></div><div class="modal-action"><button data-action="saveSection" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Add</button><button data-dialog-close="sec-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
39
39
  }
40
40
 
41
41
  export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], engagement = null) {
@@ -44,7 +44,11 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
44
44
  const canViewResponses = isPartner(user) || isManager(user);
45
45
  const clientId = engagement?.client_id || '';
46
46
  const qRows = questions.map((q, i) => questionRow(q, i, sections, canEditRfi ? clientId : '', isClient, canViewResponses)).join('') ||
47
- `<tr><td colspan="6" class="text-center py-8 text-base-content/40 text-sm">No questions yet. Add one using the button above.</td></tr>`;
47
+ `<tr><td colspan="6"><div class="empty-state">
48
+ <svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/></svg>
49
+ <div class="empty-state-title">No questions yet</div>
50
+ <div class="empty-state-desc">Add one using the +&nbsp;Question button above.</div>
51
+ </div></td></tr>`;
48
52
 
49
53
  const sectionTabs = sections.length ? `<div class="tabs tabs-boxed bg-base-200 flex-wrap gap-1">
50
54
  <button data-action="filterBySection" data-args='[""]' id="stab-all" class="tab-btn active">All</button>
@@ -64,16 +68,16 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
64
68
  ` : ''}</div>`;
65
69
 
66
70
  const infoItems = [
67
- ['Engagement', engagement?.name ? esc(engagement.name) : '<span class="text-base-content/30">—</span>'],
71
+ ['Engagement', engagement?.name ? esc(engagement.name) : '<span class="text-base-content">—</span>'],
68
72
  ['Status', statusBadge(rfi.status)],
69
- ['Deadline', rfi.deadline?new Date(typeof rfi.deadline==='number'?rfi.deadline*1000:rfi.deadline).toLocaleDateString():'-'],
73
+ ['Deadline', rfi.deadline?new Date(typeof rfi.deadline==='number'?rfi.deadline*1000:rfi.deadline).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'],
70
74
  ['Questions', questions.length],
71
75
  ['Sections', sections.length],
72
76
  ['Mandatory', rfi.mandatory!==false?'Yes':'No'],
73
77
  ];
74
78
 
75
79
  const infoGrid = `<div class="rfi-info-grid">` +
76
- infoItems.map(([l,v]) => `<div><div style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:var(--color-text-muted);margin-bottom:${SPACING.xs}">${l}</div><div style="font-size:0.875rem;color:var(--color-text)">${v}</div></div>`).join('') +
80
+ infoItems.map(([l,v]) => `<div><div class="info-label-key">${l}</div><div class="info-label-value">${v}</div></div>`).join('') +
77
81
  `</div>`;
78
82
 
79
83
  const content = `
@@ -91,16 +95,16 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
91
95
  <div class="card-clean">
92
96
  <div class="card-clean-body">
93
97
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:${SPACING.md};flex-wrap:wrap;gap:${SPACING.sm}">
94
- <h2 style="font-size:0.875rem;font-weight:600;color:var(--color-text)">Questions (${questions.length})</h2>
98
+ <h3 style="font-size:1rem;font-weight:600;color:var(--color-text)">Questions (${questions.length})</h3>
95
99
  <div style="display:flex;gap:${SPACING.sm};align-items:center;flex-wrap:wrap">
96
100
  ${sectionTabs}
97
101
  <input type="text" placeholder="Search..." oninput="filterQuestions(this.value)" class="input input-solid" style="max-width:160px;font-size:0.875rem"/>
98
102
  </div>
99
103
  </div>
100
104
  <div class="table-wrap">
101
- <table class="data-table" id="q-table">
102
- <thead><tr><th>#</th><th>Question</th><th>Section</th><th>Deadline</th><th>Status</th><th>Actions</th></tr></thead>
103
- <tbody id="q-tbody">${qRows}</tbody>
105
+ <table class="data-table" id="q-table" aria-label="Questions table">
106
+ <thead><tr><th scope="col">#</th><th scope="col">Question</th><th scope="col">Section</th><th scope="col">Deadline</th><th scope="col">Status</th><th scope="col">Actions</th></tr></thead>
107
+ <tbody id="q-tbody">${qRows || `<tr><td colspan="6" role="status" aria-live="polite" style="padding:${SPACING.md}"><span class="sr-only">Loading questions…</span><div class="skeleton" style="height:40px"></div></td></tr>`}</tbody>
104
108
  </table>
105
109
  </div>
106
110
  </div>
@@ -108,11 +112,12 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
108
112
  ${editQuestionDialog(rfi.id, sections)}
109
113
  ${addSectionDialog(rfi.id)}
110
114
  ${clientId ? assignQuestionDialog(rfi.id) : ''}
111
- ${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="view-resp-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-1">Responses</h3><p id="view-resp-qtext" class="text-sm text-base-content/60 mb-3"></p><div id="view-resp-list" style="max-height:240px;overflow-y:auto"></div><div class="modal-action"><button data-dialog-close="view-resp-dialog" class="btn btn-ghost">Close</button></div></div></div>` : ''}
112
- ${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="respond-q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-1">Respond to Question</h3><p id="respond-q-text" class="text-sm text-base-content/60 mb-4"></p><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Your Response</span></label><textarea id="respond-q-answer" rows="5" class="textarea textarea-solid max-w-full" placeholder="Enter your response..."></textarea></div><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Attach File (optional)</span></label><input type="file" id="respond-q-file" class="input input-solid max-w-full"/></div><input type="hidden" id="respond-q-id"/><div class="modal-action"><button data-action="submitResponse" class="btn btn-primary">Submit</button><button data-dialog-close="respond-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>` : ''}
115
+ ${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="view-resp-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="view-resp-dialog-title"><h2 class="text-lg font-semibold mb-1" id="view-resp-dialog-title">Responses</h2><p id="view-resp-qtext" class="text-sm text-base-content mb-3"></p><div id="view-resp-list" style="max-height:240px;overflow-y:auto"></div><div class="modal-action"><button data-dialog-close="view-resp-dialog" class="btn btn-ghost">Close</button></div></div></div>` : ''}
116
+ ${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="respond-q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="respond-q-dialog-title"><h2 class="text-lg font-semibold mb-1" id="respond-q-dialog-title">Respond to Question</h2><p id="respond-q-text" class="text-sm text-base-content mb-4"></p><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Your Response</span></label><textarea id="respond-q-answer" rows="5" class="textarea textarea-solid max-w-full" placeholder="Enter your response..."></textarea></div><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Attach File (optional)</span></label><input type="file" id="respond-q-file" class="input input-solid max-w-full"/></div><input type="hidden" id="respond-q-id"/><div class="modal-action"><button data-action="submitResponse" class="btn btn-primary">Submit</button><button data-dialog-close="respond-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>` : ''}
113
117
  `;
114
118
 
115
119
  const script = `${TOAST}
120
+ (function(){if(!document.getElementById('rfi-detail-styles')){var style=document.createElement('style');style.id='rfi-detail-styles';style.textContent='.info-label-key{font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:var(--color-text-muted);margin-bottom:${SPACING.xs}} .info-label-value{font-size:0.875rem;color:var(--color-text)} .tab-btn:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}';document.head.appendChild(style)}})();
116
121
  var activeSection='';
117
122
  function filterBySection(sid){
118
123
  activeSection=sid;
@@ -121,6 +126,17 @@ function filterBySection(sid){
121
126
  }
122
127
  function filterRows(){var q=(document.querySelector('#q-table input')?.value||'').toLowerCase();document.querySelectorAll('#q-tbody tr').forEach(function(r){var text=(r.textContent||'').toLowerCase();var secSpan=r.querySelector('td:nth-child(3) span');var rowSec=secSpan?secSpan.textContent:'';var showSec=!activeSection||rowSec===document.getElementById('stab-'+activeSection)?.textContent;r.style.display=(!q||text.includes(q))&&showSec?'':'none'})}
123
128
  function filterQuestions(q){filterRows()}
129
+ (function(){
130
+ var tabBtns=document.querySelectorAll('[id^="stab-"]');
131
+ tabBtns.forEach(function(btn,idx){
132
+ btn.setAttribute('role','tab');
133
+ btn.setAttribute('tabindex',btn.classList.contains('active')?'0':'-1');
134
+ btn.setAttribute('aria-selected',btn.classList.contains('active')?'true':'false');
135
+ btn.addEventListener('click',function(){tabBtns.forEach(b=>b.setAttribute('tabindex','-1'));btn.setAttribute('tabindex','0');btn.focus()});
136
+ });
137
+ var searchInput=document.querySelector('#q-table input');
138
+ if(searchInput){searchInput.setAttribute('tabindex','0')}
139
+ })();
124
140
  function openAddQuestion(){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Add Question';document.getElementById('q-edit-id').value='';document.getElementById('q-text').value='';document.getElementById('q-section').value='';document.getElementById('q-deadline').value='';document.getElementById('q-category').value=''}
125
141
  async function openEditQuestion(id){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Edit Question';document.getElementById('q-edit-id').value=id;try{var rfiId=document.getElementById('q-dialog').dataset.rfiId;var r=await fetch('/api/rfi/'+rfiId+'/questions/'+id);var d=await r.json();var q=d.data||d;document.getElementById('q-text').value=q.question_text||q.question||q.title||'';document.getElementById('q-section').value=q.section_id||q.rfi_section_id||'';document.getElementById('q-deadline').value=q.deadline?(new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline)).toISOString().split('T')[0]:'';document.getElementById('q-category').value=q.category||''}catch(e){showToast('Error loading','error')}}
126
142
  async function saveQuestion(rfiId){var id=document.getElementById('q-edit-id').value;var questionText=document.getElementById('q-text').value.trim();if(!questionText){showToast('Question text required','error');return}var data={question:questionText,category:document.getElementById('q-category').value.trim()||null,section_id:document.getElementById('q-section').value||null};var dl=document.getElementById('q-deadline').value;if(dl)data.due_date=Math.floor(new Date(dl).getTime()/1000);var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Saving...'}try{var url=id?'/api/rfi/'+rfiId+'/questions/'+id:'/api/rfi/'+rfiId+'/questions';var r=await fetch(url,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});var result=await r.json();if(r.ok){showToast(id?'Question updated':'Question added','success');document.getElementById('q-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{showToast(result.message||result.error||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}finally{if(btn){btn.disabled=false;btn.textContent='Save'}}}
@@ -129,6 +145,7 @@ async function saveSection(rfiId){var name=document.getElementById('sec-name').v
129
145
  async function sendReminder(rfiId){try{var r=await fetch('/api/friday/rfi/reminder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rfi_id:rfiId})});if(r.ok)showToast('Reminder sent','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}}`;
130
146
 
131
147
  const assignScript = clientId ? `
148
+ function rdEsc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
132
149
  var _assignClientId=${JSON.stringify(clientId)};
133
150
  window.openAssignQuestion=async function(qId){
134
151
  var dlg=document.getElementById('assign-q-dialog');
@@ -140,7 +157,7 @@ window.openAssignQuestion=async function(qId){
140
157
  var r=await fetch('/api/users?client_id='+_assignClientId+'&pageSize=100',{credentials:'include'});
141
158
  var d=await r.json();
142
159
  var users=(d.data||d||[]).filter(function(u){return !u.deleted_at});
143
- sel.innerHTML='<option value="">Select client user...</option>'+users.map(function(u){return'<option value="'+u.id+'">'+(u.name||u.email)+'</option>'}).join('');
160
+ sel.innerHTML='<option value="">Select client user...</option>'+users.map(function(u){return'<option value="'+rdEsc(u.id)+'">'+rdEsc(u.name||u.email)+'</option>'}).join('');
144
161
  }catch(e){sel.innerHTML='<option value="">Error loading users</option>'}
145
162
  };
146
163
  window.saveAssignQuestion=async function(){
@@ -157,7 +174,7 @@ window.saveAssignQuestion=async function(){
157
174
 
158
175
  const viewRespScript = canViewResponses ? `
159
176
  var _rfiIdForView=${JSON.stringify(rfi.id)};
160
- window.viewResponses=async function(qId,qText){document.getElementById('view-resp-qtext').textContent=qText||'';document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-base-content/40">Loading...</div>';document.getElementById('view-resp-dialog').style.display='flex';try{var r=await fetch('/api/rfi/'+_rfiIdForView+'/questions/'+qId+'/responses',{credentials:'include'});var d=await r.json();var items=d.data||d||[];document.getElementById('view-resp-list').innerHTML=items.length?items.map(function(resp){return'<div style="border:1px solid var(--color-border);border-radius:6px;padding:8px;margin-bottom:6px"><div class="text-sm">'+resp.response+'</div><div class="text-xs text-base-content/40 mt-1">'+new Date(typeof resp.created_at==="number"?resp.created_at*1000:resp.created_at).toLocaleString()+'</div></div>'}).join(''):'<div class="text-sm text-base-content/40">No responses yet</div>'}catch(e){document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-error">Error loading</div>'}};` : '';
177
+ window.viewResponses=async function(qId,qText){document.getElementById('view-resp-qtext').textContent=qText||'';document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-base-content/40">Loading...</div>';document.getElementById('view-resp-dialog').style.display='flex';try{var r=await fetch('/api/rfi/'+_rfiIdForView+'/questions/'+qId+'/responses',{credentials:'include'});var d=await r.json();var items=d.data||d||[];document.getElementById('view-resp-list').innerHTML=items.length?items.map(function(resp){return'<div style="border:1px solid var(--color-border);border-radius:6px;padding:8px;margin-bottom:6px"><div class="text-sm">'+resp.response+'</div><div class="text-xs text-base-content/40 mt-1">'+window.fmtDateTime(resp.created_at)+'</div></div>'}).join(''):'<div class="text-sm text-base-content/40">No responses yet</div>'}catch(e){document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-error">Error loading</div>'}};` : '';
161
178
 
162
179
  const respondScript = isClient ? `
163
180
  var _respondRfiId=${JSON.stringify(rfi.id)};
@@ -187,5 +204,5 @@ window.submitResponse=async function(){
187
204
  finally{if(btn){btn.disabled=false;btn.textContent='Submit'}}
188
205
  };` : '';
189
206
 
190
- return page(user, `${esc(rfi.name||'RFI')} | Moonlanding`, null, content, [script, assignScript, viewRespScript, respondScript]);
207
+ return page(user, `${esc(rfi.name||'RFI')} | Thatcher`, null, content, [script, assignScript, viewRespScript, respondScript]);
191
208
  }