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,8 +1,7 @@
1
1
  import { getUser, setCurrentRequest } from '@/engine.server.js';
2
2
  import { hasGoogleAuth } from '@/config/env.js';
3
3
  import { getSpec } from '@/config/spec-helpers.js';
4
- import { list, get } from '@/lib/query-engine.js';
5
- import { getDatabase } from '@/lib/database-core.js';
4
+ import { list, get } from '@/lib/busybase-store.js';
6
5
  import { renderLogin, renderDashboard, renderEntityList, renderEntityDetail, renderEntityForm, renderAccessDenied, renderPasswordReset, renderPasswordResetConfirm, REDIRECT } from '@/ui/renderer.js';
7
6
  import { renderClientDashboard, renderClientList } from '@/ui/client-renderer.js';
8
7
  import { canList, canView, canCreate, canEdit, isPartner, isClerk, isClientUser, canClientAccessEntity } from '@/ui/permissions-ui.js';
@@ -25,25 +24,32 @@ function reqUrl(req) {
25
24
 
26
25
  async function handleEngagementDetail(user, engId) {
27
26
  if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
28
- const db = getDatabase();
29
- const engagement = get('engagement', engId) || db.prepare('SELECT * FROM engagement WHERE id=?').get(engId);
27
+ const engagement = await get('engagement', engId);
30
28
  if (!engagement) return null;
31
- let client = null; try { client = engagement.client_id ? get('client', engagement.client_id) : null; } catch {}
32
- let rfis = []; try { rfis = db.prepare('SELECT * FROM rfis WHERE engagement_id=?').all(engId); } catch { try { rfis = list('rfi', {}).filter(r => r.engagement_id === engId); } catch {} }
33
- let sections = []; try { sections = db.prepare('SELECT * FROM rfi_section WHERE engagement_id=? ORDER BY sort_order ASC').all(engId); } catch {}
34
- let team = null; try { team = engagement.team_id ? get('team', engagement.team_id) : null; } catch {}
29
+ let client = null; try { client = engagement.client_id ? await get('client', engagement.client_id) : null; } catch {}
30
+ let rfis = []; try { rfis = await list('rfi', { engagement_id: engId }); } catch {}
31
+ let sections = [];
32
+ try {
33
+ sections = await list('rfi_section', { engagement_id: engId }, { sort: { field: 'sort_order', dir: 'ASC' } });
34
+ } catch {}
35
+ let team = null; try { team = engagement.team_id ? await get('team', engagement.team_id) : null; } catch {}
35
36
  let assignedUsers = [];
36
- try { const ids = JSON.parse(engagement.users || '[]'); const um = Object.fromEntries(db.prepare('SELECT id, name, email FROM users').all().map(u => [u.id, u.name || u.email])); assignedUsers = ids.map(id => ({ id, name: um[id] || null })).filter(u => u.name); } catch {}
37
+ try {
38
+ const ids = JSON.parse(engagement.users || '[]');
39
+ const users = await list('user', {});
40
+ const um = Object.fromEntries(users.map(u => [u.id, u.name || u.email]));
41
+ assignedUsers = ids.map(id => ({ id, name: um[id] || null })).filter(u => u.name);
42
+ } catch {}
37
43
  const { renderEngagementDetail } = await lazyRenderer('engagement-detail-renderer.js');
38
44
  return renderEngagementDetail(user, { ...engagement, team_name: team?.name || engagement.team_name, client_name: client?.name || engagement.client_name, assigned_users_resolved: assignedUsers }, client, rfis, sections);
39
45
  }
40
46
  async function handleEngagementList(user, req) {
41
47
  if (!canList(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'list');
42
- let engagements = list('engagement', {});
43
- const clientMap = Object.fromEntries(list('client', {}).map(c => [c.id, c.name]));
48
+ let engagements = await list('engagement', {});
49
+ const clientMap = Object.fromEntries((await list('client', {})).map(c => [c.id, c.name]));
44
50
  engagements = engagements.map(e => ({ ...e, client_name: clientMap[e.client_id] || e.client_name || '-' }));
45
51
  const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
46
- let teams = []; try { teams = list('team', {}); } catch {}
52
+ let teams = []; try { teams = await list('team', {}); } catch {}
47
53
  const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name]));
48
54
  engagements = engagements.map(e => ({ ...e, team_name: teamMap[e.team_id] || e.team_name || '-' }));
49
55
  const years = [...new Set(engagements.map(e => { if (!e.year) return null; const m = String(e.year).match(/\b(20\d{2}|19\d{2})\b/); return m ? m[1] : null; }).filter(Boolean))].sort().reverse();
@@ -53,10 +59,10 @@ async function handleEngagementList(user, req) {
53
59
  async function handleSearch(user, req) {
54
60
  const url = reqUrl(req);
55
61
  const q = url.searchParams.get('q') || '', entityFilter = url.searchParams.get('entity') || '', statusFilter = url.searchParams.get('status') || '';
56
- let teams = []; try { teams = list('team', {}); } catch {}
62
+ let teams = []; try { teams = await list('team', {}); } catch {}
57
63
  const results = {};
58
64
  for (const eName of (entityFilter ? [entityFilter] : ['engagement', 'client', 'rfi', 'review'])) {
59
- try { let items = list(eName, {}); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
65
+ try { let items = await list(eName, {}); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
60
66
  }
61
67
  return renderAdvancedSearch(user, results, { teams, stages: ['info_gathering', 'commencement', 'team_execution', 'partner_review', 'finalization', 'closeout'] });
62
68
  }
@@ -67,10 +73,10 @@ async function handleGenericEntityView(user, entityName, id) {
67
73
  if (!canCreate(user, entityName)) return renderAccessDenied(user, entityName, 'create');
68
74
  const resolvedSpec = resolveEnumOptions(spec);
69
75
  const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
70
- return lazyEntityForm(entityName, null, resolvedSpec, user, true, getRefOptions(resolvedSpec));
76
+ return lazyEntityForm(entityName, null, resolvedSpec, user, true, await getRefOptions(resolvedSpec));
71
77
  }
72
78
  if (!canView(user, entityName)) return renderAccessDenied(user, entityName, 'view');
73
- const item = get(entityName, id); if (!item) return null;
79
+ const item = await get(entityName, id); if (!item) return null;
74
80
  if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
75
81
  if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
76
82
  const [resolvedItem] = resolveRefFields([item], spec);
@@ -79,14 +85,14 @@ async function handleGenericEntityView(user, entityName, id) {
79
85
  }
80
86
  async function handleClientSubRoute(user, clientId, subRoute) {
81
87
  if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
82
- const client = get('client', clientId); if (!client) return null;
88
+ const client = await get('client', clientId); if (!client) return null;
83
89
  if (isClientUser(user) && user.client_id && user.client_id !== clientId) return renderAccessDenied(user, 'client', 'view');
84
- if (subRoute === 'dashboard' || subRoute === 'users') return renderClientDashboard(user, client, getClientDashboardStats(clientId));
90
+ if (subRoute === 'dashboard' || subRoute === 'users') return renderClientDashboard(user, client, await getClientDashboardStats(clientId));
85
91
  if (subRoute === 'progress') {
86
- let engagements = []; try { engagements = list('engagement', {}).filter(e => e.client_id === clientId); } catch {}
92
+ let engagements = []; try { engagements = (await list('engagement', {})).filter(e => e.client_id === clientId); } catch {}
87
93
  const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
88
94
  let rfiStats = { total: 0, responded: 0, overdue: 0 };
89
- try { const allRfis = list('rfi', {}).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
95
+ try { const allRfis = (await list('rfi', {})).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
90
96
  return renderClientProgress(user, client, engagements, rfiStats);
91
97
  }
92
98
  return null;
@@ -94,11 +100,11 @@ async function handleClientSubRoute(user, clientId, subRoute) {
94
100
  async function handleGenericEntityEdit(user, entityName, id) {
95
101
  const spec = getSpec(entityName); if (!spec) return null;
96
102
  if (!canEdit(user, entityName)) return renderAccessDenied(user, entityName, 'edit');
97
- const item = get(entityName, id); if (!item) return null;
103
+ const item = await get(entityName, id); if (!item) return null;
98
104
  if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'edit');
99
105
  const resolvedSpec = resolveEnumOptions(spec);
100
106
  const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
101
- return lazyEntityForm(entityName, item, resolvedSpec, user, false, getRefOptions(resolvedSpec));
107
+ return lazyEntityForm(entityName, item, resolvedSpec, user, false, await getRefOptions(resolvedSpec));
102
108
  }
103
109
  export async function handlePage(pathname, req, res) {
104
110
  setCurrentRequest(req);
@@ -119,18 +125,31 @@ export async function handlePage(pathname, req, res) {
119
125
  const user = await getUser();
120
126
  if (!user) { res.writeHead(302, { Location: '/login' }); res.end(); return REDIRECT; }
121
127
  if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
122
- if (normalized === '/notifications') { let notifs=[]; try{notifs=getDatabase().prepare('SELECT * FROM notification WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(user.id)}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
128
+ if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
123
129
  if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
124
130
  if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user);
125
131
  if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
126
132
  if (isClerk(user) && segments.length >= 1 && ['user', 'team'].includes(segments[0])) return renderAccessDenied(user, segments[0], 'list');
127
133
  if (normalized === '/mwr' || normalized === '/mwr/home') {
128
134
  if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
129
- const db = getDatabase();
130
135
  let myReviews = [], sharedReviews = [], recentActivity = [];
131
- try { myReviews = db.prepare('SELECT * FROM review WHERE created_by=? OR assigned_to=? ORDER BY updated_at DESC LIMIT 100').all(user.id, user.id); } catch { try { myReviews = list('review', {}).filter(r => r.created_by === user.id || r.assigned_to === user.id); } catch {} }
132
- try { sharedReviews = db.prepare('SELECT r.* FROM review r JOIN collaborator c ON c.review_id=r.id WHERE c.user_id=? ORDER BY r.updated_at DESC LIMIT 100').all(user.id); } catch {}
133
- try { recentActivity = db.prepare("SELECT * FROM audit_logs WHERE entity_type='review' ORDER BY timestamp DESC LIMIT 50").all(); } catch {}
136
+ // OR across two columns: busybase eq() is single-column, so fetch + filter in JS.
137
+ try {
138
+ const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } });
139
+ myReviews = reviews.filter(r => r.created_by === user.id || r.assigned_to === user.id).slice(0, 100);
140
+ } catch {}
141
+ // Old JOIN collaborator -> two-step: collaborator rows for this user, then their reviews.
142
+ try {
143
+ const collabs = await list('collaborator', { user_id: user.id });
144
+ const ids = new Set(collabs.map(c => c.review_id));
145
+ if (ids.size) {
146
+ const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } });
147
+ sharedReviews = reviews.filter(r => ids.has(r.id)).slice(0, 100);
148
+ }
149
+ } catch {}
150
+ try {
151
+ recentActivity = (await list('audit_logs', { entity_type: 'review' }, { sort: { field: 'created_at', dir: 'DESC' } })).slice(0, 50);
152
+ } catch {}
134
153
  const all = [...myReviews, ...sharedReviews];
135
154
  const stats = { myReviews, sharedReviews, recentActivity, totalReviews: all.length, activeReviews: all.filter(r => (r.status||'open') !== 'archived' && (r.status||'open') !== 'completed' && (r.status||'open') !== 'closed').length, flaggedReviews: all.filter(r => r.flagged).length, overdueReviews: 0 };
136
155
  const { renderMwrHome } = await lazyRenderer('review-mwr-renderer.js');
@@ -151,18 +170,16 @@ export async function handlePage(pathname, req, res) {
151
170
  return renderFlexUpView(user);
152
171
  }
153
172
  if (normalized === '/ml-console') {
154
- const db = getDatabase();
155
173
  let candidates = []; let reviewMap = {};
156
174
  try {
157
- candidates = db.prepare(`SELECT * FROM highlight
158
- WHERE (comment IS NOT NULL AND comment != '')
159
- AND (comment LIKE '%?' OR length(comment) > 40)
160
- ORDER BY created_at DESC LIMIT 50`).all();
175
+ // Old: highlights with a non-trivial comment (LIKE '%?' OR length>40), newest 50.
176
+ const all = await list('highlight', {}, { sort: { field: 'created_at', dir: 'DESC' } });
177
+ candidates = all.filter(c => c.comment && (c.comment.includes('?') || c.comment.length > 40)).slice(0, 50);
161
178
  const revIds = [...new Set(candidates.map((c) => c.review_id))];
162
179
  if (revIds.length) {
163
- const ph = revIds.map(() => '?').join(',');
164
- const rows = db.prepare(`SELECT id, name FROM review WHERE id IN (${ph})`).all(...revIds);
165
- reviewMap = Object.fromEntries(rows.map((r) => [r.id, r.name || '-']));
180
+ const reviews = await list('review', {});
181
+ const byId = new Map(reviews.map(r => [r.id, r.name || '-']));
182
+ reviewMap = Object.fromEntries(revIds.map(id => [id, byId.get(id) || '-']));
166
183
  }
167
184
  } catch {}
168
185
  const { renderMlConsole } = await lazyRenderer('ml-console-renderer.js');
@@ -171,19 +188,19 @@ export async function handlePage(pathname, req, res) {
171
188
  if (segments.length === 2 && (segments[0] === 'engagements' || segments[0] === 'engagement') && segments[1] !== 'new') return handleEngagementDetail(user, segments[1]);
172
189
  if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'letter') {
173
190
  if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
174
- const engagement = get('engagement', segments[1]); if (!engagement) return null;
191
+ const engagement = await get('engagement', segments[1]); if (!engagement) return null;
175
192
  return renderLetterWorkflow(user, engagement);
176
193
  }
177
194
  if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'report') {
178
195
  if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
179
- const engId = segments[1]; const engagement = get('engagement', engId); if (!engagement) return null;
180
- const db = getDatabase(); let client=null,team=null,rfis=[],reviews=[],highlights=[],activity=[];
181
- try{client=engagement.client_id?get('client',engagement.client_id):null}catch{}
182
- try{team=engagement.team_id?get('team',engagement.team_id):null}catch{}
183
- try{rfis=db.prepare('SELECT * FROM rfi WHERE engagement_id=? ORDER BY created_at DESC').all(engId)}catch{}
184
- try{reviews=db.prepare('SELECT * FROM review WHERE engagement_id=? ORDER BY created_at DESC').all(engId)}catch{}
185
- try{const rids=reviews.map(r=>r.id);if(rids.length){const ph=rids.map(()=>'?').join(',');highlights=db.prepare(`SELECT * FROM highlight WHERE review_id IN (${ph})`).all(...rids)}}catch{}
186
- try{activity=db.prepare("SELECT * FROM audit_logs WHERE entity_type='engagement' AND entity_id=? ORDER BY timestamp DESC LIMIT 20").all(engId)}catch{}
196
+ const engId = segments[1]; const engagement = await get('engagement', engId); if (!engagement) return null;
197
+ let client=null,team=null,rfis=[],reviews=[],highlights=[],activity=[];
198
+ try{client=engagement.client_id?await get('client',engagement.client_id):null}catch{}
199
+ try{team=engagement.team_id?await get('team',engagement.team_id):null}catch{}
200
+ try{rfis=await list('rfi',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'}})}catch{}
201
+ try{reviews=await list('review',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'}})}catch{}
202
+ try{const rids=new Set(reviews.map(r=>r.id));if(rids.size){const allHl=await list('highlight',{});highlights=allHl.filter(h=>rids.has(h.review_id))}}catch{}
203
+ try{activity=(await list('audit_logs',{entity_type:'engagement',entity_id:engId},{sort:{field:'created_at',dir:'DESC'}})).slice(0,20)}catch{}
187
204
  const{renderFlexupReport}=await lazyRenderer('flexup-report-renderer.js');
188
205
  return renderFlexupReport(user,engagement,client,rfis,reviews,highlights,activity,team);
189
206
  }
@@ -192,7 +209,7 @@ export async function handlePage(pathname, req, res) {
192
209
  if (segments.length === 1 && segments[0] === 'rfi') return handleRfiList(user);
193
210
  if (segments.length === 1 && segments[0] === 'client') {
194
211
  if (!canList(user, 'client')) return renderAccessDenied(user, 'client', 'list');
195
- let clients = list('client', {});
212
+ let clients = await list('client', {});
196
213
  if (isClientUser(user) && user.client_id) clients = clients.filter(c => c.id === user.client_id);
197
214
  return renderClientList(user, clients);
198
215
  }
@@ -201,7 +218,7 @@ export async function handlePage(pathname, req, res) {
201
218
  const spec = getSpec(entityName); if (!spec) return null;
202
219
  if (isClientUser(user) && !canClientAccessEntity(user, entityName)) return renderAccessDenied(user, entityName, 'list');
203
220
  if (!canList(user, entityName)) return renderAccessDenied(user, entityName, 'list');
204
- let items = list(entityName, {});
221
+ let items = await list(entityName, {});
205
222
  if (isClientUser(user) && user.client_id) items = items.filter(item => { if (item.client_id) return item.client_id === user.client_id; if (item.assigned_to) return item.assigned_to === user.id; return true; });
206
223
  const { renderEntityList: lazyEntityList } = await lazyRenderer('entity-renderer.js');
207
224
  return lazyEntityList(entityName, resolveRefFields(items, spec), spec, user);
@@ -209,8 +226,8 @@ export async function handlePage(pathname, req, res) {
209
226
  if (segments.length === 2 && segments[0] === 'client' && segments[1] !== 'new') {
210
227
  if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
211
228
  if (isClientUser(user) && user.client_id && user.client_id !== segments[1]) return renderAccessDenied(user, 'client', 'view');
212
- const client = get('client', segments[1]); if (!client) return null;
213
- return renderClientDashboard(user, client, getClientDashboardStats(segments[1]));
229
+ const client = await get('client', segments[1]); if (!client) return null;
230
+ return renderClientDashboard(user, client, await getClientDashboardStats(segments[1]));
214
231
  }
215
232
  if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1]);
216
233
  if (segments.length === 3 && segments[2] === 'edit') return handleGenericEntityEdit(user, segments[0], segments[1]);
@@ -3,22 +3,27 @@ import { STAGE_COLORS, TOAST_SCRIPT } from '@/ui/render-helpers.js'
3
3
 
4
4
  const DIALOG_COLORS = ['#B0B0B0','#44BBA4','#FF4141','#7F7EFF','#3b82f6','#f59e0b','#ec4899','#8b5cf6','#ef4444','#22c55e','#06b6d4','#f97316','#84cc16','#e11d48','#14b8a6','#6366f1']
5
5
 
6
+ // Inline client-side HTML-escape for innerHTML sinks (user name/email/team name are user-sourced).
7
+ const PD_ESC = `function pdEsc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}`;
8
+
6
9
  export function colorPickerDialog(id = 'cpd', selected = '#B0B0B0', onSelect = '') {
7
10
  const swatches = DIALOG_COLORS.map(c =>
8
- `<div class="cpd-swatch${c === selected ? ' cpd-selected' : ''}" style="background:${c}" data-color="${c}" role="option" tabindex="0" aria-label="Color ${c}" aria-selected="${c === selected}" data-action="cpdSelect" data-args='["${id}","${c}",${onSelect ? "true" : "false"}]' onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();cpdSelect('${id}','${c}',${onSelect ? 'true' : 'false'})}"></div>`
11
+ `<div class="cpd-swatch${c === selected ? ' cpd-selected' : ''}" style="background:${c}" data-color="${c}" role="option" tabindex="0" aria-label="Color ${c}" aria-selected="${c === selected}"${c === selected ? ' aria-current="true"' : ''} data-action="cpdSelect" data-args='["${id}","${c}",${onSelect ? "true" : "false"}]'></div>`
9
12
  ).join('')
10
- return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
11
- <div class="dialog-panel" style="max-width:360px">
13
+ return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
14
+ <div class="dialog-panel" style="max-width:min(360px,90vw)">
12
15
  <div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Choose Color</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">&times;</button></div>
13
16
  <div class="dialog-body"><div class="color-picker-grid" role="listbox" aria-label="Color options">${swatches}</div>
14
- <div style="margin-top:0.75rem;display:flex;align-items:center;gap:0.75rem"><label class="text-sm text-gray-500" for="${id}-custom">Custom:</label><input type="color" id="${id}-custom" value="${selected}" onchange="cpdSelect('${id}',this.value,true)" aria-label="Custom color picker" style="width:40px;height:32px;border:none;cursor:pointer"/><span id="${id}-val" class="text-sm font-medium" aria-live="polite">${selected}</span></div>
17
+ <div style="margin-top:0.75rem;display:flex;align-items:center;gap:0.75rem"><label class="text-sm text-gray-500" for="${id}-custom">Custom:</label><input type="color" id="${id}-custom" value="${selected}" onchange="cpdSelect('${id}',this.value,true)" aria-label="Custom color picker" class="focus-visible-ring" style="width:44px;height:44px;min-width:44px;min-height:44px;border:none;cursor:pointer"/><span id="${id}-val" class="text-sm font-medium" aria-live="polite">${selected}</span></div>
15
18
  </div>
16
19
  <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="${id}-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="cpdConfirm" data-args='["${id}"]'>Select</button></div>
17
20
  </div></div>
18
21
  <script>window._cpd=window._cpd||{};window._cpd['${id}']='${selected}';
19
- window.cpdSelect=function(id,c){window._cpd[id]=c;document.getElementById(id+'-val').textContent=c;document.getElementById(id+'-custom').value=c;document.querySelectorAll('#'+id+'-dialog .cpd-swatch').forEach(function(el){el.classList.toggle('cpd-selected',el.dataset.color===c)})};
22
+ window.cpdSelect=function(id,c){window._cpd[id]=c;document.getElementById(id+'-val').textContent=c;document.getElementById(id+'-custom').value=c;document.querySelectorAll('#'+id+'-dialog .cpd-swatch').forEach(function(el){var sel=el.dataset.color===c;el.classList.toggle('cpd-selected',sel);el.setAttribute('aria-selected',sel?'true':'false');if(sel)el.setAttribute('aria-current','true');else el.removeAttribute('aria-current')})};
20
23
  window.cpdConfirm=function(id){var c=window._cpd[id];document.getElementById(id+'-dialog').style.display='none';if(window._cpdCallback)window._cpdCallback(c)};
21
- window.showColorPicker=function(id,current,cb){window._cpdCallback=cb;if(current)cpdSelect(id,current);document.getElementById(id+'-dialog').style.display='flex'};</script>`
24
+ window.showColorPicker=function(id,current,cb){window._cpdCallback=cb;if(current)cpdSelect(id,current);document.getElementById(id+'-dialog').style.display='flex'};
25
+ window.cpdKeydown=function(id,e){var sw=Array.prototype.slice.call(document.querySelectorAll('#'+id+'-dialog .cpd-swatch'));if(!sw.length)return;var cur=sw.indexOf(document.activeElement);if(cur<0)cur=0;var next=cur;var k=e.key;if(k==='ArrowRight'||k==='ArrowDown')next=Math.min(sw.length-1,cur+1);else if(k==='ArrowLeft'||k==='ArrowUp')next=Math.max(0,cur-1);else if(k==='Home')next=0;else if(k==='End')next=sw.length-1;else return;e.preventDefault();sw[next].focus()};
26
+ document.querySelectorAll('#'+'${id}'+'-dialog .cpd-swatch').forEach(function(el){el.addEventListener('keydown',function(e){window.cpdKeydown('${id}',e)})});</script>`
22
27
  }
23
28
 
24
29
  export function dateChoiceDialog(id = 'dcd') {
@@ -28,8 +33,8 @@ export function dateChoiceDialog(id = 'dcd') {
28
33
  { label: '+3 Months', days: 90 }, { label: 'End of Month', days: 'eom' }, { label: 'End of Quarter', days: 'eoq' },
29
34
  ]
30
35
  const presetBtns = presets.map(p => `<button class="date-preset-btn" data-action="dcdPreset" data-args='["${id}",${typeof p.days === 'number' ? p.days : '"' + p.days + '"'}]'>${p.label}</button>`).join('')
31
- return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
32
- <div class="dialog-panel" style="max-width:380px">
36
+ return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
37
+ <div class="dialog-panel" style="max-width:min(380px,90vw)">
33
38
  <div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Choose Date</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">&times;</button></div>
34
39
  <div class="dialog-body">
35
40
  <div class="modal-form-group"><label for="${id}-input">Date</label><input type="date" id="${id}-input" class="input input-bordered w-full"/></div>
@@ -45,7 +50,7 @@ export function dateChoiceDialog(id = 'dcd') {
45
50
 
46
51
  export function stageTransitionDialog(stages = null) {
47
52
  const stageKeys = stages || Object.keys(STAGE_COLORS)
48
- return `<div id="stage-trans-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="stage-trans-dialog-title" aria-hidden="true">
53
+ return `<div id="stage-trans-dialog" class="dialog-overlay" style="display:none" data-dialog-close="stage-trans-dialog" role="dialog" aria-modal="true" aria-labelledby="stage-trans-dialog-title" aria-hidden="true">
49
54
  <div class="dialog-panel">
50
55
  <div class="dialog-header"><span class="dialog-title" id="stage-trans-dialog-title">Transition Stage</span><button class="dialog-close" data-dialog-close="stage-trans-dialog" aria-label="Close dialog">&times;</button></div>
51
56
  <div class="dialog-body">
@@ -63,19 +68,20 @@ export function stageTransitionDialog(stages = null) {
63
68
  }
64
69
 
65
70
  export function teamAssignmentDialog(id = 'tad') {
66
- return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
71
+ return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
67
72
  <div class="dialog-panel">
68
73
  <div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Assign Team Members</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">&times;</button></div>
69
74
  <div class="dialog-body">
70
75
  <input type="text" id="${id}-search" class="tad-search" placeholder="Search by name or email..." aria-label="Search team members" oninput="tadFilter('${id}')"/>
71
- <div id="${id}-list" class="tad-list"></div>
76
+ <div id="${id}-list" class="tad-list" role="region" aria-live="polite" aria-label="Team member results"></div>
72
77
  </div>
73
78
  <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="${id}-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="tadConfirm" data-args='["${id}"]'>Assign</button></div>
74
79
  </div></div>
75
80
  <script>${TOAST_SCRIPT}
81
+ ${PD_ESC}
76
82
  window._tad=window._tad||{};
77
83
  window.showTeamAssignment=function(id,users,selected,cb){window._tad[id]={users:users,selected:new Set(selected||[]),cb:cb};document.getElementById(id+'-dialog').style.display='flex';tadRender(id)};
78
- function tadRender(id){var d=window._tad[id];var c=document.getElementById(id+'-list');c.innerHTML='';d.users.forEach(function(u){var row=document.createElement('div');row.className='tad-row';row.dataset.name=(u.name||'').toLowerCase();row.dataset.email=(u.email||'').toLowerCase();var checked=d.selected.has(u.id)?'checked':'';row.innerHTML='<input type="checkbox" '+checked+' onchange="tadToggle(\\''+id+'\\',\\''+u.id+'\\') " aria-label="Select '+(u.name||'Unknown').replace(/"/g,'&quot;')+'">'+'<div><div class="tad-name">'+(u.name||'Unknown')+'</div><div class="tad-email">'+(u.email||'')+'</div></div>';c.appendChild(row)})}
84
+ function tadRender(id){var d=window._tad[id];var c=document.getElementById(id+'-list');c.innerHTML='';d.users.forEach(function(u){var row=document.createElement('div');row.className='tad-row';row.dataset.name=(u.name||'').toLowerCase();row.dataset.email=(u.email||'').toLowerCase();var checked=d.selected.has(u.id)?'checked':'';row.innerHTML='<input type="checkbox" '+checked+' onchange="tadToggle(\\''+id+'\\',\\''+u.id+'\\') " aria-label="Select '+(u.name||'Unknown').replace(/"/g,'&quot;')+'">'+'<div><div class="tad-name">'+pdEsc(u.name||'Unknown')+'</div><div class="tad-email">'+pdEsc(u.email||'')+'</div></div>';c.appendChild(row)})}
79
85
  window.tadFilter=function(id){var q=document.getElementById(id+'-search').value.toLowerCase();document.querySelectorAll('#'+id+'-list .tad-row').forEach(function(r){r.style.display=(r.dataset.name.includes(q)||r.dataset.email.includes(q))?'':'none'})};
80
86
  window.tadToggle=function(id,uid){var d=window._tad[id];if(d.selected.has(uid))d.selected.delete(uid);else d.selected.add(uid)};
81
87
  window.tadConfirm=function(id){var d=window._tad[id];document.getElementById(id+'-dialog').style.display='none';if(d.cb)d.cb(Array.from(d.selected))};</script>`
@@ -83,18 +89,19 @@ export function teamAssignmentDialog(id = 'tad') {
83
89
 
84
90
  export function teamSelector(id = 'ts', teams = []) {
85
91
  const items = teams.map(t =>
86
- `<div class="tad-row" data-name="${(t.name || '').toLowerCase()}" data-action="tsSelect" data-args='["${id}","${t.id}","${(t.name || '').replace(/"/g, '&quot;')}"]'><div class="tad-name">${t.name || 'Unknown'}</div><div class="tad-email">${t.member_count || 0} members</div></div>`
92
+ `<div class="tad-row" role="option" aria-selected="false" tabindex="-1" data-name="${(t.name || '').toLowerCase()}" data-action="tsSelect" data-args='["${id}","${t.id}","${(t.name || '').replace(/"/g, '&quot;')}"]'><div class="tad-name">${t.name || 'Unknown'}</div><div class="tad-email">${t.member_count || 0} members</div></div>`
87
93
  ).join('')
88
94
  return `<div class="ts-wrap" id="${id}-wrap">
89
95
  <div id="${id}-badges" class="ts-badges"></div>
90
- <input type="text" id="${id}-search" class="tad-search" placeholder="Search teams..." aria-label="Search teams" oninput="tsFilter('${id}')" onfocus="document.getElementById('${id}-dropdown').classList.add('ts-open')" />
91
- <div id="${id}-dropdown" class="ts-dropdown">${items}</div>
96
+ <input type="text" id="${id}-search" class="tad-search" placeholder="Search teams..." aria-label="Search teams" role="combobox" aria-expanded="false" aria-controls="${id}-dropdown" aria-autocomplete="list" oninput="tsFilter('${id}')" onfocus="document.getElementById('${id}-dropdown').classList.add('ts-open');this.setAttribute('aria-expanded','true')" />
97
+ <div id="${id}-dropdown" class="ts-dropdown" role="listbox" aria-label="Teams">${items}</div>
92
98
  <input type="hidden" id="${id}-value" name="team_id" value=""/>
93
99
  </div>
94
- <script>window._ts=window._ts||{};window._ts['${id}']=[];
100
+ <script>${PD_ESC}
101
+ window._ts=window._ts||{};window._ts['${id}']=[];
95
102
  window.tsFilter=function(id){var q=document.getElementById(id+'-search').value.toLowerCase();document.querySelectorAll('#'+id+'-dropdown .tad-row').forEach(function(r){r.style.display=r.dataset.name.includes(q)?'':'none'})};
96
- window.tsSelect=function(id,tid,name){window._ts[id].push({id:tid,name:name});tsRender(id);document.getElementById(id+'-dropdown').classList.remove('ts-open');document.getElementById(id+'-search').value=''};
97
- function tsRender(id){var b=document.getElementById(id+'-badges');b.innerHTML='';window._ts[id].forEach(function(t,i){b.innerHTML+='<span class="ts-badge">'+t.name+' <span class="ts-badge-x" data-action="tsRemove" data-args=\\'["'+id+'",'+i+']\\' >&times;</span></span>'});document.getElementById(id+'-value').value=window._ts[id].map(function(t){return t.id}).join(',')}
103
+ window.tsSelect=function(id,tid,name){window._ts[id].push({id:tid,name:name});tsRender(id);document.getElementById(id+'-dropdown').classList.remove('ts-open');document.getElementById(id+'-search').setAttribute('aria-expanded','false');document.getElementById(id+'-search').value=''};
104
+ function tsRender(id){var b=document.getElementById(id+'-badges');b.innerHTML='';window._ts[id].forEach(function(t,i){b.innerHTML+='<span class="ts-badge">'+pdEsc(t.name)+' <span class="ts-badge-x" data-action="tsRemove" data-args=\\'["'+id+'",'+i+']\\' >&times;</span></span>'});document.getElementById(id+'-value').value=window._ts[id].map(function(t){return t.id}).join(',')}
98
105
  window.tsRemove=function(id,idx){window._ts[id].splice(idx,1);tsRender(id)};
99
- document.addEventListener('click',function(e){if(!document.getElementById('${id}-wrap').contains(e.target))document.getElementById('${id}-dropdown').classList.remove('ts-open')});</script>`
106
+ document.addEventListener('click',function(e){if(!document.getElementById('${id}-wrap').contains(e.target)){document.getElementById('${id}-dropdown').classList.remove('ts-open');document.getElementById('${id}-search').setAttribute('aria-expanded','false')}});</script>`
100
107
  }
@@ -1,36 +1,37 @@
1
1
  import { h } from '@/ui/webjsx.js'
2
+ import { SPACING } from './spacing-system.js'
2
3
 
3
4
  export const STAGE_COLORS = {
4
- info_gathering: { bg: '#dbeafe', text: '#1e40af', label: 'Info Gathering', bgDark: '#1e3a8a', textDark: '#60a5fa' },
5
- commencement: { bg: '#dbeafe', text: '#1e40af', label: 'Commencement', bgDark: '#1e3a8a', textDark: '#60a5fa' },
6
- team_execution: { bg: '#fef3c7', text: '#92400e', label: 'Team Execution', bgDark: '#78350f', textDark: '#fbbf24' },
7
- partner_review: { bg: '#fef3c7', text: '#92400e', label: 'Partner Review', bgDark: '#78350f', textDark: '#fbbf24' },
8
- finalization: { bg: '#d1fae5', text: '#065f46', label: 'Finalization', bgDark: '#064e3b', textDark: '#34d399' },
9
- closeout: { bg: '#d1fae5', text: '#065f46', label: 'Close Out', bgDark: '#064e3b', textDark: '#34d399' },
5
+ info_gathering: { label: 'Info Gathering' },
6
+ commencement: { label: 'Commencement' },
7
+ team_execution: { label: 'Team Execution' },
8
+ partner_review: { label: 'Partner Review' },
9
+ finalization: { label: 'Finalization' },
10
+ closeout: { label: 'Close Out' },
10
11
  }
11
12
 
12
13
  export const STATUS_COLORS = {
13
- pending: { bg: '#fef3c7', text: '#92400e' },
14
- active: { bg: '#dbeafe', text: '#1e40af' },
15
- completed: { bg: '#d1fae5', text: '#065f46' },
16
- archived: { bg: '#f3f4f6', text: '#4b5563' },
17
- open: { bg: '#fef3c7', text: '#92400e' },
18
- closed: { bg: '#d1fae5', text: '#065f46' },
19
- draft: { bg: '#f3f4f6', text: '#6b7280' },
20
- in_progress: { bg: '#dbeafe', text: '#1e40af' },
21
- review: { bg: '#ede9fe', text: '#5b21b6' },
22
- approved: { bg: '#d1fae5', text: '#065f46' },
23
- rejected: { bg: '#fee2e2', text: '#991b1b' },
24
- overdue: { bg: '#fee2e2', text: '#991b1b' },
25
- cancelled: { bg: '#f3f4f6', text: '#6b7280' },
26
- on_hold: { bg: '#fef3c7', text: '#92400e' },
27
- resolved: { bg: '#d1fae5', text: '#065f46' },
28
- unresolved: { bg: '#fee2e2', text: '#991b1b' },
29
- flagged: { bg: '#fce7f3', text: '#9d174d' },
30
- responded: { bg: '#dbeafe', text: '#1e40af' },
31
- expired: { bg: '#f3f4f6', text: '#6b7280' },
32
- private: { bg: '#ede9fe', text: '#5b21b6' },
33
- public: { bg: '#dbeafe', text: '#1e40af' },
14
+ pending: {},
15
+ active: {},
16
+ completed: {},
17
+ archived: {},
18
+ open: {},
19
+ closed: {},
20
+ draft: {},
21
+ in_progress: {},
22
+ review: {},
23
+ approved: {},
24
+ rejected: {},
25
+ overdue: {},
26
+ cancelled: {},
27
+ on_hold: {},
28
+ resolved: {},
29
+ unresolved: {},
30
+ flagged: {},
31
+ responded: {},
32
+ expired: {},
33
+ private: {},
34
+ public: {},
34
35
  }
35
36
 
36
37
  export const STAGE_CONFIG = [
@@ -47,7 +48,7 @@ export const AVATAR_SIZES = { sm: 24, md: 32, lg: 40, xl: 48 }
47
48
 
48
49
  export const TOAST_SCRIPT = `window.showToast=(m,t='info')=>{let c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';c.setAttribute('role','status');c.setAttribute('aria-live','polite');c.setAttribute('aria-atomic','true');document.body.appendChild(c)}const d=document.createElement('div');d.className='toast toast-'+t;d.textContent=m;c.appendChild(d);setTimeout(()=>{d.style.opacity='0';setTimeout(()=>d.remove(),300)},3000)};`
49
50
 
50
- export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col){th.classList.add(sortDir===1?'sort-asc':'sort-desc');th.setAttribute('aria-sort',sortDir===1?'ascending':'descending')}else{th.removeAttribute('aria-sort')}});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>{th.setAttribute('tabindex','0');th.setAttribute('role','button');if(!th.getAttribute('aria-label'))th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim());th.addEventListener('click',()=>sortTable(th.dataset.sort));th.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '||e.key==='Spacebar'){e.preventDefault();sortTable(th.dataset.sort)}})})})})();`
51
+ export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col){th.classList.add(sortDir===1?'sort-asc':'sort-desc');th.setAttribute('aria-sort',sortDir===1?'ascending':'descending');th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim()+', '+(sortDir===1?'ascending':'descending'))}else{th.removeAttribute('aria-sort')}});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;const style=document.createElement('style');style.textContent='th[data-sort]:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}';document.head.appendChild(style);document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>{th.setAttribute('tabindex','0');th.setAttribute('role','button');if(!th.getAttribute('aria-label'))th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim());th.addEventListener('click',e=>{e.preventDefault();sortTable(th.dataset.sort)});th.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '||e.key==='Spacebar'){e.preventDefault();sortTable(th.dataset.sort)}});th.addEventListener('keypress',e=>{if(e.key==='Enter'){e.preventDefault();sortTable(th.dataset.sort)}})})})})();`
51
52
 
52
53
  export function esc(s) {
53
54
  return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')
@@ -62,11 +63,8 @@ export function emptyRow(colspan, message = 'No items found') {
62
63
  export function stagePill(stage) {
63
64
  const cfg = STAGE_CONFIG.find(s => s.key === stage)
64
65
  const lbl = cfg ? cfg.label : (stage || '-')
65
- const stageColor = STAGE_COLORS[stage]
66
- if (stageColor) {
67
- return `<span class="stage-pill stage-${esc(stage||'')}" data-stage="${stage}" style="background:${stageColor.bg};color:${stageColor.text}">${lbl}</span>`
68
- }
69
- return `<span class="stage-pill stage-${esc(stage||'')}">${lbl}</span>`
66
+ // Use CSS classes (stage-pill stage-${stage}) for dark-mode support via styles2.css
67
+ return `<span class="stage-pill stage-${esc(stage||'')}" data-stage="${stage}">${lbl}</span>`
70
68
  }
71
69
 
72
70
  export function statusPill(status) {
@@ -84,7 +82,8 @@ export function statusBadge(status) {
84
82
  export function progressBar(pct) {
85
83
  if (typeof pct !== 'number') return '-'
86
84
  const p = Math.min(100, Math.max(0, Math.round(pct)))
87
- return `<div style="display:flex;align-items:center;gap:8px;min-width:100px"><div style="flex:1;height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden"><div style="height:100%;width:${p}%;background:var(--color-primary);border-radius:3px"></div></div><span style="font-size:12px;color:var(--color-text-muted);min-width:28px">${p}%</span></div>`
85
+ // Use CSS variable for border color (defined in both light and dark mode), responsive min-width, 44px tap-target height, contrast-compliant text color
86
+ return `<div style="display:flex;align-items:center;gap:${SPACING.sm};min-width:min(60px,50vw);max-width:100%;min-height:44px"><div style="flex:1;height:6px;background:var(--color-border);border-radius:3px;overflow:hidden"><div style="height:100%;width:${p}%;background:var(--color-primary);border-radius:3px"></div></div><span style="font-size:13px;font-weight:600;color:var(--color-text);min-width:28px;line-height:1.4">${p}%</span></div>`
88
87
  }
89
88
 
90
89
  export function fmtVal(value, fieldKey, item = {}) {
@@ -95,23 +94,32 @@ export function fmtVal(value, fieldKey, item = {}) {
95
94
  }
96
95
  if (fieldKey === 'year') { const n = Number(value); if (!isNaN(n)) return String(Math.floor(n)) }
97
96
  if (fieldKey === 'stage' && STAGE_COLORS[value]) {
98
- const s = STAGE_COLORS[value]
99
- return h('span', { className: 'badge-stage', style: `background:${s.bg};color:${s.text}` }, s.label)
97
+ const lbl = STAGE_COLORS[value].label || value.charAt(0).toUpperCase() + value.slice(1)
98
+ // Use CSS classes (stage-${value}) for dark-mode support via styles2.css
99
+ return h('span', { className: `badge-stage stage-${esc(value)}` }, lbl)
100
100
  }
101
101
  if (fieldKey === 'status' && STATUS_COLORS[value]) {
102
- const s = STATUS_COLORS[value]
103
- return h('span', { className: 'badge-status', style: `background:${s.bg};color:${s.text}` }, value.charAt(0).toUpperCase() + value.slice(1))
102
+ // Map status to pill class following the pattern in statusBadge
103
+ const statusClassMap = { active:'pill pill-success', inactive:'pill pill-danger', pending:'pill pill-warning', open:'pill pill-info', in_progress:'pill pill-info', completed:'pill pill-success', closed:'pill pill-neutral', archived:'pill pill-neutral', responded:'pill pill-info', overdue:'pill pill-danger', rejected:'pill pill-danger', unresolved:'pill pill-danger', approved:'pill pill-success', review:'pill pill-primary', flagged:'pill pill-danger', expired:'pill pill-neutral', private:'pill pill-primary', public:'pill pill-info' }
104
+ const cls = statusClassMap[value] || 'pill pill-neutral'
105
+ return h('span', { className: cls }, value.charAt(0).toUpperCase() + value.slice(1))
104
106
  }
105
- if (item[`${fieldKey}_display`]) return item[`${fieldKey}_display`]
106
- return String(value)
107
+ // *_display is the resolved label of a referenced record (set in
108
+ // page-handler-helpers from refCaches), i.e. DB-sourced plain text — escape it.
109
+ if (item[`${fieldKey}_display`]) return esc(String(item[`${fieldKey}_display`]))
110
+ // Default path returns raw DB/user data into table cells and detail values —
111
+ // escape it so a stored value cannot inject markup.
112
+ return esc(String(value))
107
113
  }
108
114
 
109
115
  export function statusLabel(status) {
110
116
  if (!status) return ''
111
117
  const key = status.toLowerCase().replace(/\s+/g, '_')
112
- const s = STATUS_COLORS[key] || { bg: '#f3f4f6', text: '#6b7280' }
118
+ // Map to pill class following the pattern in statusBadge for consistent dark-mode styling
119
+ const statusClassMap = { active:'pill pill-success', inactive:'pill pill-danger', pending:'pill pill-warning', open:'pill pill-info', in_progress:'pill pill-info', completed:'pill pill-success', closed:'pill pill-neutral', archived:'pill pill-neutral', responded:'pill pill-info', overdue:'pill pill-danger', rejected:'pill pill-danger', unresolved:'pill pill-danger', approved:'pill pill-success', review:'pill pill-primary', flagged:'pill pill-danger', expired:'pill pill-neutral', private:'pill pill-primary', public:'pill pill-info' }
120
+ const cls = statusClassMap[key] || 'pill pill-neutral'
113
121
  const label = status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ')
114
- return h('span', { className: 'status-label', style: `background:${s.bg};color:${s.text}` }, label)
122
+ return h('span', { className: `status-label ${cls}` }, label)
115
123
  }
116
124
 
117
125
  export function nameHash(name) {
@@ -123,9 +131,39 @@ export function nameHash(name) {
123
131
  export { getInitials } from '@/lib/utils.js'
124
132
  export { icon } from '@/ui/format-helpers.js'
125
133
 
126
- export function fmtDate(ts) {
127
- if (!ts) return '-';
134
+ // Canonical date helpers — fixed en-ZA locale + format so the same data renders
135
+ // identically for every user (browser-locale toLocaleDateString gives MM/DD to a
136
+ // US user and DD/MM to a ZA user for the same timestamp; that is the unpredictability
137
+ // these replace). Scale-detect seconds vs milliseconds vs ISO string internally.
138
+ const DATE_LOCALE = 'en-ZA';
139
+ function toDate(ts) {
140
+ if (ts == null || ts === '') return null;
141
+ if (ts instanceof Date) return isNaN(ts) ? null : ts;
128
142
  const n = Number(ts);
129
- if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
130
- return String(ts);
143
+ // 10-digit (or smaller) numeric => unix seconds; 13-digit => ms; non-numeric => ISO/parseable
144
+ const d = !isNaN(n) && String(ts).trim() !== '' && /^\d+$/.test(String(ts).trim())
145
+ ? new Date(n < 1e12 ? n * 1000 : n)
146
+ : new Date(ts);
147
+ return isNaN(d) ? null : d;
148
+ }
149
+ export function fmtDate(ts) {
150
+ const d = toDate(ts);
151
+ return d ? d.toLocaleDateString(DATE_LOCALE, { day: '2-digit', month: 'short', year: 'numeric' }) : '-';
152
+ }
153
+ export function fmtDateTime(ts) {
154
+ const d = toDate(ts);
155
+ return d ? d.toLocaleDateString(DATE_LOCALE, { day: '2-digit', month: 'short', year: 'numeric' })
156
+ + ', ' + d.toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' }) : '-';
157
+ }
158
+ // YYYY-MM-DD for <input type=date> values
159
+ export function fmtDateInput(ts) {
160
+ const d = toDate(ts);
161
+ if (!d) return '';
162
+ const p = n => String(n).padStart(2, '0');
163
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
164
+ }
165
+ // parse any date-ish value to unix seconds (the storage scale)
166
+ export function dateToUnixTimestamp(v) {
167
+ const d = toDate(v);
168
+ return d ? Math.floor(d.getTime() / 1000) : null;
131
169
  }