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,24 +1,17 @@
1
- /**
2
- * Notification Engine - In-app notifications, email triggers
3
- */
4
-
5
1
  import { getTransporter, sendEmail } from './email-sender.js';
6
2
  import { getConfigEngineSync } from '../lib/config-generator-engine.js';
7
3
  import { executeHook } from '../lib/hook-engine.js';
4
+ import { createLogger } from '../lib/logger.js';
5
+
6
+ const log = createLogger('[Notification]');
8
7
 
9
- /**
10
- * Get notification template from config
11
- */
12
8
  export function getNotificationTemplate(name) {
13
9
  const engine = getConfigEngineSync();
14
10
  return engine.generateNotificationHandler(name);
15
11
  }
16
12
 
17
- /**
18
- * Create notification record
19
- */
20
13
  export async function createNotification(notification) {
21
- const { create } = await import('../lib/query-engine-write.js');
14
+ const { create } = await import('../lib/busybase-store.js');
22
15
  return create('notification', {
23
16
  ...notification,
24
17
  created_at: Math.floor(Date.now() / 1000),
@@ -26,20 +19,16 @@ export async function createNotification(notification) {
26
19
  }, { id: notification.created_by || 'system', role: 'system' });
27
20
  }
28
21
 
29
- /**
30
- * Send notification to user (in-app + optional email)
31
- */
32
22
  export async function sendNotification(type, userId, context = {}, options = {}) {
33
23
  const template = getNotificationTemplate(type);
34
24
  if (!template) {
35
- console.warn(`[Notification] Template not found: ${type}`);
25
+ log.warn(`template not found: ${type}`);
36
26
  return null;
37
27
  }
38
28
 
39
29
  const title = interpolate(template.title, context);
40
30
  const message = interpolate(template.message, context);
41
31
 
42
- // Create notification record
43
32
  const notification = await createNotification({
44
33
  type,
45
34
  user_id: userId,
@@ -51,7 +40,6 @@ export async function sendNotification(type, userId, context = {}, options = {})
51
40
  created_by: context.userId || 'system',
52
41
  });
53
42
 
54
- // Send email if enabled
55
43
  if (options.sendEmail !== false) {
56
44
  try {
57
45
  const { getUser } = await import('../engine.server.js');
@@ -64,7 +52,7 @@ export async function sendNotification(type, userId, context = {}, options = {})
64
52
  });
65
53
  }
66
54
  } catch (err) {
67
- console.error('[Notification] Email failed:', err.message);
55
+ log.error('email failed:', { message: err.message });
68
56
  }
69
57
  }
70
58
 
@@ -72,35 +60,26 @@ export async function sendNotification(type, userId, context = {}, options = {})
72
60
  notification,
73
61
  user: { id: userId },
74
62
  context,
75
- }).catch(console.error);
63
+ }).catch(err => log.error('hook error:', { message: err?.message || String(err) }));
76
64
 
77
65
  return notification;
78
66
  }
79
67
 
80
- /**
81
- * Mark notification as read
82
- */
83
68
  export async function markNotificationRead(notificationId, userId) {
84
- const { update } = await import('../lib/query-engine-write.js');
69
+ const { update } = await import('../lib/busybase-store.js');
85
70
  return update('notification', notificationId, {
86
71
  read_at: Math.floor(Date.now() / 1000),
87
- }, { id: userId, role: 'user' });
72
+ });
88
73
  }
89
74
 
90
- /**
91
- * Get unread count for user
92
- */
93
75
  export async function getUnreadCount(userId) {
94
- const { list } = await import('../lib/query-engine.js');
95
- return list('notification', {
76
+ const { list } = await import('../lib/busybase-store.js');
77
+ return (await list('notification', {
96
78
  user_id: userId,
97
79
  read_at: null,
98
- }).length;
80
+ })).length;
99
81
  }
100
82
 
101
- /**
102
- * Interpolate {{key}} in template strings
103
- */
104
83
  function interpolate(template, context) {
105
84
  if (!template) return '';
106
85
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
@@ -1,46 +1,22 @@
1
- /**
2
- * Permission Service - Authorization logic
3
- * Adapted from moonlanding/src/services/permission.service.js
4
- */
1
+ // Adapted from moonlanding/src/services/permission.service.js
5
2
 
6
3
  import { getCollaboratorRole, checkCollaboratorAccess } from '../services/collaborator-role.service.js';
7
4
  import { PermissionError } from '../lib/error-handler.js';
8
5
  import { getConfigEngineSync } from '../lib/config-generator-engine.js';
9
6
 
10
7
  class PermissionService {
11
- /**
12
- * Check if user can perform action on entity spec
13
- * @param {object} user
14
- * @param {object} spec
15
- * @param {string} action
16
- * @returns {boolean}
17
- */
18
8
  can(user, spec, action) {
19
9
  if (!user) return false;
20
10
  if (!spec?.access?.[action]) return true; // No restriction defined = allow
21
11
  return spec.access[action].includes(user.role);
22
12
  }
23
13
 
24
- /**
25
- * Require permission (throw if denied)
26
- * @param {object} user
27
- * @param {object} spec
28
- * @param {string} action
29
- */
30
14
  require(user, spec, action) {
31
15
  if (!this.can(user, spec, action)) {
32
16
  throw new PermissionError(`Cannot ${action} ${spec?.name || 'unknown'}`);
33
17
  }
34
18
  }
35
19
 
36
- /**
37
- * Check field-level access
38
- * @param {object} user
39
- * @param {object} spec
40
- * @param {string} fieldName
41
- * @param {string} action - 'view' or 'edit'
42
- * @returns {boolean}
43
- */
44
20
  checkFieldAccess(user, spec, fieldName, action) {
45
21
  if (!user) return false;
46
22
  const perm = spec.fieldPermissions?.[fieldName];
@@ -50,13 +26,6 @@ class PermissionService {
50
26
  return Array.isArray(allowed) && allowed.includes(user.role);
51
27
  }
52
28
 
53
- /**
54
- * Check row-level access
55
- * @param {object} user
56
- * @param {object} spec
57
- * @param {object} record
58
- * @returns {boolean}
59
- */
60
29
  checkRowAccess(user, spec, record) {
61
30
  if (!user) return false;
62
31
  const rowAccess = spec.rowAccess || spec.row_access;
@@ -68,17 +37,22 @@ class PermissionService {
68
37
  const clientUserRole = Object.keys(roles).find(r => r === 'client_user');
69
38
 
70
39
  const scope = rowAccess.scope || rowAccess;
40
+ // The owner field defaults to 'assigned_to' but is configurable, so an app
41
+ // whose ownership column is named differently (e.g. casey's case.assignee) can
42
+ // use scope=assigned without renaming its schema: row_access: { scope: assigned,
43
+ // field: assignee }.
44
+ const ownerField = (typeof rowAccess === 'object' && rowAccess.field) || 'assigned_to';
71
45
 
72
46
  if (scope === 'team' && record.team_id && user.team_id && record.team_id !== user.team_id) {
73
47
  return false;
74
48
  }
75
49
 
76
- if (scope === 'assigned' && record.assigned_to && record.assigned_to !== user.id && user.role !== partnerRole) {
50
+ if (scope === 'assigned' && record[ownerField] && record[ownerField] !== user.id && user.role !== partnerRole) {
77
51
  return false;
78
52
  }
79
53
 
80
54
  if (scope === 'assigned_or_team' && user.role !== partnerRole) {
81
- const assignedMatch = record.assigned_to && record.assigned_to === user.id;
55
+ const assignedMatch = record[ownerField] && record[ownerField] === user.id;
82
56
  const teamMatch = record.team_id && user.team_id && record.team_id === user.team_id;
83
57
  if (!assignedMatch && !teamMatch) return false;
84
58
  }
@@ -97,25 +71,11 @@ class PermissionService {
97
71
  return true;
98
72
  }
99
73
 
100
- /**
101
- * Filter records by row access
102
- * @param {object} user
103
- * @param {object} spec
104
- * @param {Array} records
105
- * @returns {Array}
106
- */
107
74
  filterRecords(user, spec, records) {
108
75
  if (!user || !Array.isArray(records)) return records;
109
76
  return records.filter(r => this.checkRowAccess(user, spec, r));
110
77
  }
111
78
 
112
- /**
113
- * Filter fields by field-level permissions
114
- * @param {object} user
115
- * @param {object} spec
116
- * @param {object} record
117
- * @returns {object}
118
- */
119
79
  filterFields(user, spec, record) {
120
80
  if (!user) return record;
121
81
  const filtered = {};
@@ -128,12 +88,6 @@ class PermissionService {
128
88
  return filtered;
129
89
  }
130
90
 
131
- /**
132
- * Enforce edit permissions on data
133
- * @param {object} user
134
- * @param {object} spec
135
- * @param {object} data
136
- */
137
91
  enforceEditPermissions(user, spec, data) {
138
92
  if (!user) throw new PermissionError(`Cannot edit ${spec.name}`);
139
93
  if (!this.can(user, spec, 'edit')) throw new PermissionError(`Cannot edit ${spec.name}`);
@@ -145,35 +99,18 @@ class PermissionService {
145
99
  }
146
100
  }
147
101
 
148
- /**
149
- * Check if user owns record
150
- * @param {object} user
151
- * @param {object} spec
152
- * @param {object} record
153
- * @returns {boolean}
154
- */
155
102
  checkOwnership(user, spec, record) {
156
103
  if (!record) return false;
157
104
  return record.created_by === user.id || record.user_id === user.id;
158
105
  }
159
106
 
160
- /**
161
- * Check assignment for client access
162
- * @param {object} user
163
- * @param {object} spec
164
- * @param {object} record
165
- * @returns {boolean}
166
- */
167
107
  checkAssignment(user, spec, record) {
168
108
  // Simplified - check if user is assigned to record
169
109
  return record.assigned_to === user.id || record.team_id === user.team_id;
170
110
  }
171
111
 
172
- /**
173
- * Check collaborator-style permission
174
- */
175
- hasCollaboratorPermission(collaboratorId, permission) {
176
- return getCollaboratorRole(collaboratorId)?.permissions?.includes(permission);
112
+ async hasCollaboratorPermission(collaboratorId, permission) {
113
+ return (await getCollaboratorRole(collaboratorId))?.permissions?.includes(permission);
177
114
  }
178
115
  }
179
116
 
@@ -1,6 +1,7 @@
1
1
  import { statusLabel } from '@/ui/renderer.js';
2
2
  import { page } from '@/ui/layout.js';
3
3
  import { fmtDate, esc } from '@/ui/render-helpers.js';
4
+ import { emptyState } from '@/ui/format-helpers.js';
4
5
 
5
6
  function resultCard(item, entityType) {
6
7
  const sts = item.status ? statusLabel(item.status) : '';
@@ -32,11 +33,11 @@ export function renderAdvancedSearch(user, results = {}, options = {}) {
32
33
 
33
34
  const resultCards = allResults.length > 0
34
35
  ? allResults.map(r => resultCard(r, r._type)).join('')
35
- : '<div class="text-center py-12 text-gray-400">Enter a search query to find engagements, clients, RFIs, and reviews</div>';
36
+ : emptyState('Enter a search query to find engagements, clients, RFIs, and reviews', 'search');
36
37
 
37
38
  const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Advanced Search</h1></div>${filterPanel(teams, stages)}<div class="flex items-center gap-2 mb-4"><span class="text-sm text-gray-500">${totalCount} result${totalCount !== 1 ? 's' : ''}</span>${countBadges}</div><div id="search-results">${resultCards}</div>`;
38
39
 
39
40
  const searchScript = `window.doSearch=async function(){const q=document.getElementById('search-query')?.value||'';const entity=document.getElementById('filter-entity')?.value||'';const status=document.getElementById('filter-status')?.value||'';const stage=document.getElementById('filter-stage')?.value||'';const team=document.getElementById('filter-team')?.value||'';const from=document.getElementById('filter-from')?.value||'';const to=document.getElementById('filter-to')?.value||'';const params=new URLSearchParams();if(q)params.set('q',q);if(entity)params.set('entity',entity);if(status)params.set('status',status);if(stage)params.set('stage',stage);if(team)params.set('team',team);if(from)params.set('from',from);if(to)params.set('to',to);window.location='/search?'+params.toString()};document.getElementById('search-query')?.addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})`;
40
41
 
41
- return page(user, 'Search | Moonlanding', [{ href: '/', label: 'Dashboard' }, { label: 'Search' }], content, [searchScript]);
42
+ return page(user, 'Search | Thatcher', [{ href: '/', label: 'Dashboard' }, { label: 'Search' }], content, [searchScript]);
42
43
  }
@@ -43,5 +43,5 @@ export function collapsibleSidebar(sections, currentPath) {
43
43
  return `<div class="sidebar-section"><div class="sidebar-section-header" data-action="sidebarToggleSection" data-self>${section.title}<span class="sidebar-section-arrow"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></span></div><div class="sidebar-section-body">${links}</div></div>`
44
44
  }).join('')
45
45
  const script = `(function(){var sb=document.getElementById('${sidebarId}');var saved=localStorage.getItem('sidebar-width');if(saved)sb.style.width=saved+'px';var collapsed=localStorage.getItem('sidebar-collapsed')==='true';if(collapsed)sb.classList.add('sidebar-collapsed');window.sidebarToggleSection=function(el){el.parentElement.classList.toggle('sidebar-section-closed')};window.sidebarToggleCollapse=function(){sb.classList.toggle('sidebar-collapsed');localStorage.setItem('sidebar-collapsed',sb.classList.contains('sidebar-collapsed'))};var handle=sb.querySelector('.sidebar-resize-handle');var startX,startW;handle.addEventListener('mousedown',function(e){startX=e.clientX;startW=sb.offsetWidth;function onMove(ev){var w=startW+(ev.clientX-startX);if(w>48&&w<400){sb.style.width=w+'px';localStorage.setItem('sidebar-width',w)}}function onUp(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp)}document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp)})})();`
46
- return `<aside id="${sidebarId}" class="sidebar-collapsible"><div class="sidebar-inner"><button class="sidebar-collapse-btn" data-action="sidebarToggleCollapse" title="Toggle sidebar"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></button>${sectionHtml}</div><div class="sidebar-resize-handle"></div></aside><script>${script}</script>`
46
+ return `<aside id="${sidebarId}" class="sidebar-collapsible"><div class="sidebar-inner"><button class="sidebar-collapse-btn" data-action="sidebarToggleCollapse" title="Toggle sidebar" aria-label="Toggle sidebar"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></button>${sectionHtml}</div><div class="sidebar-resize-handle"></div></aside><script>${script}</script>`
47
47
  }
@@ -1,10 +1,11 @@
1
1
  import { generateHtml } from '@/ui/layout.js'
2
2
  import { nav as navFn } from '@/ui/layout.js'
3
3
  import { AUTH_CSS, GOOGLE_SVG, WARN_SVG, LOGO_SVG, authLogoHtml } from '@/ui/auth-styles.js'
4
+ import { esc } from '@/ui/render-helpers.js'
4
5
 
5
6
  export function renderLogin(error = null, hasGoogleAuth = false) {
6
7
  const errHtml = error
7
- ? `<div class="auth-err" role="alert">${WARN_SVG}${String(error).replace(/</g, '&lt;')}</div>`
8
+ ? `<div class="auth-err" role="alert">${WARN_SVG}${esc(error)}</div>`
8
9
  : ''
9
10
  const googleBtn = hasGoogleAuth
10
11
  ? `<a href="/api/auth/google" class="auth-google-btn" aria-label="Continue with Google">${GOOGLE_SVG}Continue with Google</a>
@@ -37,7 +38,7 @@ form.addEventListener('submit',async function(e){
37
38
  else{var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=data.error||'Login failed. Please check your credentials.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Sign In';btn.disabled=false;}
38
39
  }catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Sign In';btn.disabled=false;}
39
40
  });`
40
- return generateHtml('Sign In | Moonlanding', body, [script])
41
+ return generateHtml('Sign In | Thatcher', body, [script])
41
42
  }
42
43
 
43
44
  export function renderPasswordReset() {
@@ -65,7 +66,7 @@ form.addEventListener('submit',async function(e){
65
66
  else{var d=await res.json().catch(function(){return{};});var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=d.error||'Request failed. Please try again.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Send Reset Link';btn.disabled=false;}
66
67
  }catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Send Reset Link';btn.disabled=false;}
67
68
  });`
68
- return generateHtml('Reset Password | Moonlanding', body, [script])
69
+ return generateHtml('Reset Password | Thatcher', body, [script])
69
70
  }
70
71
 
71
72
  export function renderPasswordResetConfirm(token) {
@@ -73,9 +74,9 @@ export function renderPasswordResetConfirm(token) {
73
74
  <div class="auth-shell"><div class="auth-wrap">
74
75
  ${authLogoHtml('Set a new password')}
75
76
  <div class="auth-card">
76
- <div id="confirm-success" class="auth-success" style="display:none" role="alert">Password updated. <a href="/login" style="color:#15803d;text-decoration:underline;font-weight:600">Sign in now</a></div>
77
+ <div id="confirm-success" class="auth-success" style="display:none" role="alert">Password updated. <a href="/login" style="color:var(--color-success,#15803d);text-decoration:underline;font-weight:600">Sign in now</a></div>
77
78
  <form id="confirm-form" aria-label="Set new password" novalidate>
78
- <input type="hidden" name="token" value="${token}">
79
+ <input type="hidden" name="token" value="${esc(token)}">
79
80
  <div class="auth-field"><label class="auth-label" for="password">New password</label>
80
81
  <input type="password" name="password" id="password" class="auth-input" placeholder="At least 8 characters" minlength="8" required autocomplete="new-password" autofocus></div>
81
82
  <div class="auth-field"><label class="auth-label" for="confirm_password">Confirm new password</label>
@@ -99,16 +100,16 @@ form.addEventListener('submit',async function(e){
99
100
  else{var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=data.error||'Reset failed. Please try again.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Update Password';btn.disabled=false;}
100
101
  }catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Update Password';btn.disabled=false;}
101
102
  });`
102
- return generateHtml('Set New Password | Moonlanding', body, [script])
103
+ return generateHtml('Set New Password | Thatcher', body, [script])
103
104
  }
104
105
 
105
106
  export function renderAccessDenied(user, entityName, action) {
106
107
  const txt = { list: 'view this list', view: 'view this item', create: 'create items here', edit: 'edit this item', delete: 'delete this item' }
107
108
  const body = `<div class="min-h-screen">${navFn(user)}<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;text-align:center;padding:40px 20px">
108
- <div style="font-size:48px;margin-bottom:16px" aria-hidden="true">&#128274;</div>
109
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted,#64748b)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
109
110
  <h1 style="font-size:24px;font-weight:700;color:var(--color-text,#0f172a);margin:0 0 8px">Access Denied</h1>
110
- <p style="color:var(--color-text-muted,#64748b);margin:0 0 24px;max-width:400px">You do not have permission to ${txt[action] || action} in ${entityName}.</p>
111
+ <p style="color:var(--color-text-muted,#64748b);margin:0 0 24px;max-width:400px">You do not have permission to ${esc(txt[action] || action)} in ${esc(entityName)}.</p>
111
112
  <a href="/" class="btn-primary-clean">Return to Dashboard</a>
112
113
  </div></div>`
113
- return generateHtml('Access Denied | Moonlanding', body)
114
+ return generateHtml('Access Denied | Thatcher', body)
114
115
  }
@@ -48,6 +48,6 @@ export const LOGO_SVG = `<svg width="26" height="26" viewBox="0 0 24 24" fill="n
48
48
  export const authLogoHtml = (subtitle) => `
49
49
  <div class="auth-logo">
50
50
  <div class="auth-logo-mark">${LOGO_SVG}</div>
51
- <span class="auth-logo-name">MOONLANDING</span>
51
+ <span class="auth-logo-name">Thatcher</span>
52
52
  <div class="auth-logo-sub">${subtitle}</div>
53
53
  </div>`;
package/src/ui/client.js CHANGED
@@ -68,7 +68,7 @@ export async function submitForm(formId, url, method = 'POST') {
68
68
  }
69
69
 
70
70
  export async function confirmDelete(url, redirectUrl) {
71
- if (!confirm('Are you sure you want to delete this?')) return false;
71
+ if (!(await window.gmConfirm({ title: 'Please confirm', message: 'Are you sure you want to delete this?', confirmLabel: 'Delete', danger: true }))) return false;
72
72
  try {
73
73
  const res = await fetch(url, { method: 'DELETE' });
74
74
  if (res.ok) {
@@ -34,7 +34,7 @@ export function renderComponent(componentId, props = {}) {
34
34
  if (Array.isArray(value)) {
35
35
  html = html.replace(regex, value.map(v => renderItem(v, key)).join(''));
36
36
  } else {
37
- html = html.replace(regex, JSON.stringify(value));
37
+ html = html.replace(regex, esc(JSON.stringify(value)));
38
38
  }
39
39
  } else {
40
40
  html = html.replace(regex, esc(value));
@@ -42,7 +42,7 @@ export function renderDashboard(user, stats = {}) {
42
42
  </div></div>` : '';
43
43
 
44
44
  const recentRows = (stats.recentEngagements || []).map(e => {
45
- const updated = e.updated_at ? new Date(typeof e.updated_at === 'number' ? e.updated_at * 1000 : e.updated_at).toLocaleDateString() : '-';
45
+ const updated = e.updated_at ? new Date(typeof e.updated_at === 'number' ? e.updated_at * 1000 : e.updated_at).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-';
46
46
  return `<tr data-row data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer">
47
47
  <td data-col="name"><strong>${esc(e.name || e.client_name || 'Untitled')}</strong></td>
48
48
  <td data-col="client">${esc(e.client_id_display || e.client_name || '-')}</td>
@@ -82,14 +82,14 @@ export function renderDashboard(user, stats = {}) {
82
82
  </div>
83
83
  ${statsHtml}${overdueAlert}${actionsHtml}${emptyEngHtml}${recentHtml}`;
84
84
 
85
- return page(user, 'Dashboard | Moonlanding', [], content, [TABLE_SCRIPT]);
85
+ return page(user, 'Dashboard | Thatcher', [], content, [TABLE_SCRIPT]);
86
86
  }
87
87
 
88
88
  export function renderAuditDashboard(user, auditData = {}) {
89
89
  const { summary = {}, recentActivity = [] } = auditData;
90
90
  const actRows = recentActivity.slice(0, 20).map(a =>
91
91
  `<tr data-row>
92
- <td data-col="time">${new Date((a.timestamp||a.created_at)*1000).toLocaleString()}</td>
92
+ <td data-col="time">${new Date((a.timestamp||a.created_at)*1000).toLocaleString('en-ZA')}</td>
93
93
  <td data-col="action"><span class="pill pill-info">${a.action||'-'}</span></td>
94
94
  <td data-col="entity">${a.entity_type||'-'}</td>
95
95
  <td data-col="id" style="font-size:12px">${a.entity_id||'-'}</td>
@@ -118,7 +118,7 @@ export function renderAuditDashboard(user, auditData = {}) {
118
118
  </table>
119
119
  </div>`;
120
120
 
121
- return page(user, 'Audit Dashboard | MOONLANDING', [{ href: '/', label: 'Dashboard' }, { href: '/admin/audit', label: 'Audit' }], content, [TABLE_SCRIPT]);
121
+ return page(user, 'Audit Dashboard | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/admin/audit', label: 'Audit' }], content, [TABLE_SCRIPT]);
122
122
  }
123
123
 
124
124
  export function renderSystemHealth(user, healthData = {}) {
@@ -146,5 +146,5 @@ export function renderSystemHealth(user, healthData = {}) {
146
146
  </table>
147
147
  </div>`;
148
148
 
149
- return page(user, 'System Health | MOONLANDING', [{ href: '/', label: 'Dashboard' }, { href: '/admin/health', label: 'Health' }], content, [TABLE_SCRIPT]);
149
+ return page(user, 'System Health | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/admin/health', label: 'Health' }], content, [TABLE_SCRIPT]);
150
150
  }
@@ -1,5 +1,5 @@
1
1
  import { getConfigEngineSync } from '@/lib/config-generator-engine';
2
- import { aria, role, focusTrap, keyboard } from '@/lib/accessibility';
2
+ import { aria, role } from '@/lib/accessibility';
3
3
 
4
4
  export function renderDialog(dialogId, context = {}) {
5
5
  const config = getConfigEngineSync().getConfig();
@@ -29,7 +29,7 @@ function generateDialogHtml(dialogId, title, fields, actions, width, context, on
29
29
  }).join('');
30
30
 
31
31
  return `
32
- <div id="${dialogId}" class="dialog-overlay" style="display:none" ${role.dialog} aria-modal="true" ${aria.labelledBy(titleId)} ${aria.hidden(true)} data-overlay-close="true" data-action="closeDialog" data-args='["${dialogId}"]' onkeydown="${keyboard.escape(`closeDialog('${dialogId}')`)}">
32
+ <div id="${dialogId}" class="dialog-overlay" style="display:none" ${role.dialog} aria-modal="true" ${aria.labelledBy(titleId)} ${aria.hidden(true)} data-dialog-close="${dialogId}">
33
33
  <div id="${dialogId}-panel" class="dialog-panel" style="max-width:${width}">
34
34
  <div class="dialog-header">
35
35
  <h2 id="${titleId}" class="dialog-title">${escapeHtml(title)}</h2>
@@ -48,11 +48,8 @@ function generateDialogHtml(dialogId, title, fields, actions, width, context, on
48
48
  const dlg = document.getElementById(id);
49
49
  dlg.style.display = 'none';
50
50
  dlg.setAttribute('aria-hidden', 'true');
51
- ${focusTrap.deactivate('${dialogId}-panel')}
52
- if (dlg._previousFocus) {
53
- dlg._previousFocus.focus();
54
- delete dlg._previousFocus;
55
- }
51
+ // Escape + focus-restore + tab-trap are owned centrally by dialogFocus
52
+ // (event-delegation.js, session-12) — no local focusTrap/_previousFocus here.
56
53
  };
57
54
  window.submitDialog = window.submitDialog || function(id) {
58
55
  const form = document.getElementById(id + '-form');
@@ -60,10 +57,10 @@ function generateDialogHtml(dialogId, title, fields, actions, width, context, on
60
57
  };
61
58
  window.openDialog = window.openDialog || function(id) {
62
59
  const dlg = document.getElementById(id);
63
- dlg._previousFocus = document.activeElement;
64
60
  dlg.style.display = 'flex';
65
61
  dlg.setAttribute('aria-hidden', 'false');
66
- ${focusTrap.activate('${dialogId}-panel')}
62
+ // focus-move into the panel + Tab-trap are owned by dialogFocus
63
+ // (event-delegation.js MutationObserver watches the display flip).
67
64
  };
68
65
  </script>
69
66
  `;
@@ -3,7 +3,7 @@ import { esc } from '@/ui/render-helpers.js';
3
3
  export function createDialog(id, title, body, footer, opts = {}) {
4
4
  const maxWidth = opts.maxWidth || '640px';
5
5
  const extraClass = opts.class || '';
6
- return `<div id="${esc(id)}" class="dialog-overlay ${extraClass}" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="${esc(id)}-title" aria-hidden="true">
6
+ return `<div id="${esc(id)}" class="dialog-overlay ${extraClass}" style="display:none" data-dialog-close="${esc(id)}" role="dialog" aria-modal="true" aria-labelledby="${esc(id)}-title" aria-hidden="true">
7
7
  <div class="dialog-panel" style="max-width:${maxWidth}">
8
8
  <div class="dialog-header">
9
9
  <span class="dialog-title" id="${esc(id)}-title">${title}</span>
@@ -19,7 +19,7 @@ export function modalDialog(id, title, body, footer, opts = {}) {
19
19
  const maxWidth = opts.maxWidth || 'md';
20
20
  const sizeMap = { sm: '28rem', md: '36rem', lg: '48rem', xl: '64rem' };
21
21
  const width = sizeMap[maxWidth] || maxWidth;
22
- return `<div id="${esc(id)}" class="modal" style="display:none" data-dialog-close-overlay="true">
22
+ return `<div id="${esc(id)}" class="modal" style="display:none">
23
23
  <div class="modal-overlay" data-dialog-close="${esc(id)}"></div>
24
24
  <div class="modal-content rounded-box" style="max-width:${width};padding:1.5rem">
25
25
  <h3 class="text-lg font-semibold mb-4">${title}</h3>
@@ -1,5 +1,5 @@
1
1
  import { h } from '@/ui/webjsx.js'
2
- import { STAGE_COLORS, statusLabel, esc } from '@/ui/render-helpers.js'
2
+ import { STAGE_COLORS, statusLabel, esc, icon } from '@/ui/render-helpers.js'
3
3
  import { teamAvatarGroup } from '@/ui/widgets.js'
4
4
  import { canEdit } from '@/ui/permissions-ui.js'
5
5
 
@@ -10,7 +10,7 @@ export function engagementCard(engagement, user) {
10
10
  const sts = e.status ? statusLabel(e.status) : ''
11
11
  const progress = typeof e.progress === 'number' ? `<div class="engagement-card-progress"><div class="engagement-card-progress-bar" style="width:${Math.min(100, Math.max(0, e.progress))}%"></div></div>` : ''
12
12
  const team = e.team?.length ? `<div class="engagement-card-team">${teamAvatarGroup(e.team, 3)}</div>` : ''
13
- const dueDate = e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString() : e.due_date}</span>` : ''
13
+ const dueDate = e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : e.due_date}</span>` : ''
14
14
  const clientName = e.client_name || e.client?.name || ''
15
15
  return `<div class="engagement-card card-clean" data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer">
16
16
  <div class="card-clean-body">
@@ -31,7 +31,7 @@ export function mobileEngagementCard(engagement) {
31
31
  <div class="card-clean-body">
32
32
  <div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${esc(e.name || e.title || 'Untitled')}</h3>${sts}</div>
33
33
  ${e.client_name ? `<div class="text-xs text-gray-500 mb-1">${esc(e.client_name)}</div>` : ''}
34
- <div class="flex items-center gap-2 mb-2">${stageBadge}${e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString() : e.due_date}</span>` : ''}</div>
34
+ <div class="flex items-center gap-2 mb-2">${stageBadge}${e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : e.due_date}</span>` : ''}</div>
35
35
  <div class="flex gap-1 mt-2"><a href="/engagement/${e.id}" class="btn btn-xs btn-primary">View</a><a href="/engagement/${e.id}/edit" class="btn btn-xs btn-outline">Edit</a></div>
36
36
  </div></div>`
37
37
  }
@@ -51,13 +51,13 @@ export function stagePipeline(currentStage, stages = null, clickable = false, on
51
51
  export function activityTimeline(activities = [], showAll = false) {
52
52
  const maxItems = showAll ? activities.length : 10
53
53
  const items = activities.slice(0, maxItems)
54
- const iconMap = { created: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`, updated: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>`, status_changed: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>`, commented: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`, assigned: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`, uploaded: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>` }
54
+ const iconMap = { created: 'document', updated: 'clipboard', status_changed: 'flag', commented: 'chat', assigned: 'user', uploaded: 'inbox' }
55
55
  const timelineItems = items.map(a => {
56
56
  const action = a.action || 'updated'
57
- const icon = iconMap[action] || `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>`
57
+ const actIcon = icon(iconMap[action] || 'list', 13)
58
58
  const ts = a.timestamp || a.created_at
59
- const timeStr = ts ? (typeof ts === 'number' && ts > 1e9 ? new Date(ts * 1000).toLocaleString() : new Date(ts).toLocaleString()) : ''
60
- return `<div class="act-tl-item"><div class="act-tl-icon act-tl-${action}">${icon}</div><div class="act-tl-content"><div class="act-tl-header"><span class="act-tl-desc">${esc(a.description || a.action || '')}</span></div><div class="act-tl-time">${timeStr}${a.user_name ? ' by ' + esc(a.user_name) : ''}</div></div></div>`
59
+ const timeStr = ts ? (typeof ts === 'number' && ts > 1e9 ? new Date(ts * 1000).toLocaleString('en-ZA') : new Date(ts).toLocaleString('en-ZA')) : ''
60
+ return `<div class="act-tl-item"><div class="act-tl-icon act-tl-${action}">${actIcon}</div><div class="act-tl-content"><div class="act-tl-header"><span class="act-tl-desc">${esc(a.description || a.action || '')}</span></div><div class="act-tl-time">${timeStr}${a.user_name ? ' by ' + esc(a.user_name) : ''}</div></div></div>`
61
61
  }).join('')
62
62
  const moreBtn = !showAll && activities.length > maxItems ? `<div class="act-tl-more"><button class="btn btn-ghost btn-sm" data-action="showAllActivity" aria-label="Show ${activities.length - maxItems} more activities">Show ${activities.length - maxItems} more</button></div>` : ''
63
63
  return `<div class="act-timeline">${timelineItems}</div>${moreBtn}`
@@ -8,8 +8,7 @@ export function engagementFileSearchDialog(engagementId) {
8
8
  return createDialog('file-search-dialog', 'Search Engagement Files', body, footer) + `
9
9
  <script>
10
10
  var efsTimer=null;
11
- window.efsSearch=function(){clearTimeout(efsTimer);efsTimer=setTimeout(function(){var q=document.getElementById('efs-query').value.trim();if(!q){document.getElementById('efs-results').innerHTML='';return}fetch('/api/file?engagement_id=${engagementId}&search='+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];var el=document.getElementById('efs-results');if(!files.length){el.innerHTML='<div class="text-gray-500 text-sm text-center py-4">No files found</div>';return}el.innerHTML=files.map(function(f){return'<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded cursor-pointer" data-action="previewAttachment" data-args=\\'["/api/file/'+f.id+'/download","'+((f.name||'').replace(/"/g,'&quot;'))+'","'+((f.mime_type||'').replace(/"/g,'&quot;'))+'"]\\'>
12
- <div><div class="text-sm font-medium">'+(f.name||'Unknown')+'</div><div class="text-xs text-gray-500">'+(f.size?Math.round(f.size/1024)+'KB':'')+'</div></div><span class="badge-status text-xs">'+(f.type||'file')+'</span></div>'}).join('')}).catch(function(){document.getElementById('efs-results').innerHTML='<div class="text-red-500 text-sm">Search error</div>'})},300)};
11
+ window.efsSearch=function(){clearTimeout(efsTimer);efsTimer=setTimeout(function(){var q=document.getElementById('efs-query').value.trim();var el=document.getElementById('efs-results');if(!q){el.textContent='';return}fetch('/api/file?engagement_id=${engagementId}&search='+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];el.textContent='';if(!files.length){var none=document.createElement('div');none.className='text-gray-500 text-sm text-center py-4';none.textContent='No files found';el.appendChild(none);return}files.forEach(function(f){var row=document.createElement('div');row.className='flex items-center justify-between p-2 hover:bg-gray-50 rounded cursor-pointer';row.setAttribute('data-action','previewAttachment');row.setAttribute('data-args',JSON.stringify(['/api/file/'+f.id+'/download',f.name||'',f.mime_type||'']));var left=document.createElement('div');var nm=document.createElement('div');nm.className='text-sm font-medium';nm.textContent=f.name||'Unknown';var sz=document.createElement('div');sz.className='text-xs text-gray-500';sz.textContent=f.size?Math.round(f.size/1024)+'KB':'';left.appendChild(nm);left.appendChild(sz);var badge=document.createElement('span');badge.className='badge-status text-xs';badge.textContent=f.type||'file';row.appendChild(left);row.appendChild(badge);el.appendChild(row)})}).catch(function(){el.textContent='';var err=document.createElement('div');err.className='text-red-500 text-sm';err.textContent='Search error';el.appendChild(err)})},300)};
13
12
  </script>`;
14
13
  }
15
14
 
@@ -29,7 +28,7 @@ export function postRfiJournalDialog(engagementId) {
29
28
  export function postRfiFileUpload(engagementId) {
30
29
  return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body"><h3 style="font-size:0.875rem;font-weight:600">Upload Post-RFI Files</h3><div class="mt-3"><input type="file" id="prfi-files" class="file-input file-input-bordered w-full" multiple/><div class="flex justify-end mt-2"><button class="btn btn-primary btn-sm" data-action="prfiUpload">Upload</button></div><div id="prfi-progress" class="mt-2"></div></div></div></div>
31
30
  <script>
32
- window.prfiUpload=async function(){var files=document.getElementById('prfi-files').files;if(!files.length){showToast('Select files','error');return}var prog=document.getElementById('prfi-progress');prog.innerHTML='Uploading '+files.length+' file(s)...';var ok=0;for(var i=0;i<files.length;i++){var fd=new FormData();fd.append('file',files[i]);fd.append('engagement_id','${engagementId}');fd.append('type','post_rfi');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)ok++;prog.innerHTML='Uploaded '+(i+1)+' of '+files.length}catch(e){}}prog.innerHTML=ok+' of '+files.length+' uploaded successfully';if(ok>0)showToast(ok+' files uploaded','success')};
31
+ window.prfiUpload=async function(){var files=document.getElementById('prfi-files').files;if(!files.length){showToast('Select files','error');return}var prog=document.getElementById('prfi-progress');prog.textContent='Uploading '+files.length+' file(s)...';var ok=0;for(var i=0;i<files.length;i++){var fd=new FormData();fd.append('file',files[i]);fd.append('engagement_id','${engagementId}');fd.append('type','post_rfi');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)ok++;prog.textContent='Uploaded '+(i+1)+' of '+files.length}catch(e){}}prog.textContent=ok+' of '+files.length+' uploaded successfully';if(ok>0)showToast(ok+' files uploaded','success')};
33
32
  </script>`;
34
33
  }
35
34