thatcher 1.0.4

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 (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +398 -0
  3. package/package.json +73 -0
  4. package/src/adapters/google-auth.js +148 -0
  5. package/src/adapters/google-drive.js +209 -0
  6. package/src/app/api/[entity]/[[...path]]/route.js +33 -0
  7. package/src/app/api/audit/dashboard/route.js +77 -0
  8. package/src/app/api/audit/logs/route.js +46 -0
  9. package/src/app/api/audit/permissions/[id]/route.js +37 -0
  10. package/src/app/api/audit/permissions/route.js +93 -0
  11. package/src/app/api/audit/permissions/stats/route.js +29 -0
  12. package/src/app/api/audit/route.js +79 -0
  13. package/src/app/api/audit/stats/route.js +18 -0
  14. package/src/app/api/auth/google/callback/route.js +94 -0
  15. package/src/app/api/auth/google/route.js +58 -0
  16. package/src/app/api/auth/login/route.js +121 -0
  17. package/src/app/api/auth/logout/route.js +52 -0
  18. package/src/app/api/auth/me/route.js +16 -0
  19. package/src/app/api/auth/mwr-bridge/route.js +77 -0
  20. package/src/app/api/auth/password-reset/route.js +65 -0
  21. package/src/app/api/cron/trigger/route.js +58 -0
  22. package/src/app/api/csrf-token/route.js +8 -0
  23. package/src/app/api/debug/config/route.js +22 -0
  24. package/src/app/api/debug/hooks/route.js +16 -0
  25. package/src/app/api/debug/plugins/route.js +20 -0
  26. package/src/app/api/debug/sqlite/route.js +21 -0
  27. package/src/app/api/debug/sync/route.js +29 -0
  28. package/src/app/api/debug/workflow/route.js +20 -0
  29. package/src/app/api/domains/[domain]/route.js +26 -0
  30. package/src/app/api/domains/route.js +21 -0
  31. package/src/app/api/email/allocate/batch/route.js +106 -0
  32. package/src/app/api/email/allocate/route.js +141 -0
  33. package/src/app/api/email/receive/route.js +158 -0
  34. package/src/app/api/email/route.js +3 -0
  35. package/src/app/api/email/send/route.js +77 -0
  36. package/src/app/api/email/unallocated/route.js +47 -0
  37. package/src/app/api/files/[id]/route.js +38 -0
  38. package/src/app/api/health/route.js +95 -0
  39. package/src/app/api/metrics/route.js +73 -0
  40. package/src/app/api/monitoring/dashboard/route.js +18 -0
  41. package/src/cli.js +243 -0
  42. package/src/config/config-loader.js +112 -0
  43. package/src/config/constants.js +127 -0
  44. package/src/config/env.js +164 -0
  45. package/src/config/spec-helpers.js +232 -0
  46. package/src/engine.server.js +212 -0
  47. package/src/index.js +368 -0
  48. package/src/lib/accessibility.js +162 -0
  49. package/src/lib/action-factory.js +34 -0
  50. package/src/lib/action-utils.js +21 -0
  51. package/src/lib/alert-manager.js +189 -0
  52. package/src/lib/api-error-wrapper.js +125 -0
  53. package/src/lib/api-helpers.js +53 -0
  54. package/src/lib/api.js +82 -0
  55. package/src/lib/audit-logger-enhanced.js +117 -0
  56. package/src/lib/audit-logger.js +193 -0
  57. package/src/lib/auth-middleware.js +102 -0
  58. package/src/lib/auth-route-helpers.js +83 -0
  59. package/src/lib/business-rules-engine.js +86 -0
  60. package/src/lib/compression.js +44 -0
  61. package/src/lib/config-field-helpers.js +91 -0
  62. package/src/lib/config-generator-engine.js +445 -0
  63. package/src/lib/config-helpers.js +120 -0
  64. package/src/lib/connection-guard.js +79 -0
  65. package/src/lib/crud-action-helpers.js +34 -0
  66. package/src/lib/crud-factory.js +83 -0
  67. package/src/lib/crud-handlers.js +244 -0
  68. package/src/lib/csrf-protection.js +63 -0
  69. package/src/lib/database-core.js +258 -0
  70. package/src/lib/database-migrations.js +96 -0
  71. package/src/lib/date-utils.js +159 -0
  72. package/src/lib/db-backup.js +97 -0
  73. package/src/lib/db-monitor.js +127 -0
  74. package/src/lib/domain-loader.js +82 -0
  75. package/src/lib/email-sender.js +100 -0
  76. package/src/lib/error-boundary.js +134 -0
  77. package/src/lib/error-handler.js +84 -0
  78. package/src/lib/error-recovery.js +190 -0
  79. package/src/lib/error-resilience.js +130 -0
  80. package/src/lib/errors.js +69 -0
  81. package/src/lib/events-engine.js +182 -0
  82. package/src/lib/field-iterator.js +50 -0
  83. package/src/lib/field-registry.js +68 -0
  84. package/src/lib/field-types.js +154 -0
  85. package/src/lib/generic-crud-handler.js +32 -0
  86. package/src/lib/health-monitor.js +134 -0
  87. package/src/lib/hook-engine.js +169 -0
  88. package/src/lib/hot-reload/cache-invalidator.js +115 -0
  89. package/src/lib/hot-reload/checkpoint.js +95 -0
  90. package/src/lib/hot-reload/debug-exposure.js +67 -0
  91. package/src/lib/hot-reload/directory-watcher.js +96 -0
  92. package/src/lib/hot-reload/index.js +50 -0
  93. package/src/lib/hot-reload/mutex.js +75 -0
  94. package/src/lib/hot-reload/promise-container.js +66 -0
  95. package/src/lib/hot-reload/route-wrapper.js +46 -0
  96. package/src/lib/hot-reload/safe-error.js +51 -0
  97. package/src/lib/hot-reload/supervisor.js +161 -0
  98. package/src/lib/hot-reload/timeout-wrapper.js +52 -0
  99. package/src/lib/http-methods-factory.js +25 -0
  100. package/src/lib/index-optimizer.js +96 -0
  101. package/src/lib/index.js +35 -0
  102. package/src/lib/list-data-transform.js +39 -0
  103. package/src/lib/log-aggregator.js +116 -0
  104. package/src/lib/logger.js +55 -0
  105. package/src/lib/metrics-collector.js +102 -0
  106. package/src/lib/minifier.js +19 -0
  107. package/src/lib/monitoring-init.js +67 -0
  108. package/src/lib/next-compat.js +80 -0
  109. package/src/lib/next-polyfills.js +135 -0
  110. package/src/lib/perf-monitor.js +91 -0
  111. package/src/lib/progress-components.js +181 -0
  112. package/src/lib/query-cache.js +126 -0
  113. package/src/lib/query-engine-write.js +221 -0
  114. package/src/lib/query-engine.js +399 -0
  115. package/src/lib/query-perf.js +117 -0
  116. package/src/lib/query-string-adapter.js +75 -0
  117. package/src/lib/realtime-server.js +67 -0
  118. package/src/lib/render-cache.js +61 -0
  119. package/src/lib/request-tracker.js +43 -0
  120. package/src/lib/resource-hints.js +29 -0
  121. package/src/lib/resource-monitor.js +117 -0
  122. package/src/lib/response-formatter.js +80 -0
  123. package/src/lib/route-helpers.js +33 -0
  124. package/src/lib/route-resolver.js +142 -0
  125. package/src/lib/safe-json.js +8 -0
  126. package/src/lib/server-bootstrap.js +71 -0
  127. package/src/lib/stage-pipeline.js +153 -0
  128. package/src/lib/state-protocol.js +171 -0
  129. package/src/lib/state-transport-client.js +169 -0
  130. package/src/lib/state-transport-reconnect.js +121 -0
  131. package/src/lib/state-transport-server.js +181 -0
  132. package/src/lib/static-server.js +97 -0
  133. package/src/lib/status-helpers.js +98 -0
  134. package/src/lib/universal-handler.js +7 -0
  135. package/src/lib/utils.js +93 -0
  136. package/src/lib/validate.js +197 -0
  137. package/src/lib/validation/business-validators.js +61 -0
  138. package/src/lib/validation/csrf.js +51 -0
  139. package/src/lib/validation/file-validators.js +34 -0
  140. package/src/lib/validation/format-validators.js +106 -0
  141. package/src/lib/validation/index.js +19 -0
  142. package/src/lib/validation/rate-limit.js +31 -0
  143. package/src/lib/validation/security-validators.js +78 -0
  144. package/src/lib/validation-middleware.js +133 -0
  145. package/src/lib/validators.js +105 -0
  146. package/src/lib/with-audit-logging.js +63 -0
  147. package/src/lib/with-error-handler.js +31 -0
  148. package/src/lib/workflow-engine.js +250 -0
  149. package/src/server/server.js +305 -0
  150. package/src/services/collaborator-role.service.js +205 -0
  151. package/src/services/email-sender.js +105 -0
  152. package/src/services/notification-engine.js +110 -0
  153. package/src/services/permission.service.js +181 -0
  154. package/src/ui/advanced-search-renderer.js +42 -0
  155. package/src/ui/advanced-widgets.js +47 -0
  156. package/src/ui/auth-pages.js +114 -0
  157. package/src/ui/auth-styles.js +53 -0
  158. package/src/ui/client.js +99 -0
  159. package/src/ui/collaboration-dialogs.js +31 -0
  160. package/src/ui/common-handlers.js +156 -0
  161. package/src/ui/component-engine.js +103 -0
  162. package/src/ui/dashboard-renderer.js +150 -0
  163. package/src/ui/dialog-engine.js +147 -0
  164. package/src/ui/dialog-factory.js +38 -0
  165. package/src/ui/engagement-cards.js +76 -0
  166. package/src/ui/engagement-dialogs.js +100 -0
  167. package/src/ui/engagement-grid-renderer.js +109 -0
  168. package/src/ui/entity-renderer.js +176 -0
  169. package/src/ui/event-delegation.js +108 -0
  170. package/src/ui/fetch-json.js +24 -0
  171. package/src/ui/file-dialogs.js +81 -0
  172. package/src/ui/flexup-report-renderer.js +173 -0
  173. package/src/ui/format-helpers.js +102 -0
  174. package/src/ui/global-tags.js +144 -0
  175. package/src/ui/highlight-threading-renderer.js +176 -0
  176. package/src/ui/idle-logout.js +156 -0
  177. package/src/ui/job-management-renderer.js +61 -0
  178. package/src/ui/layout.js +219 -0
  179. package/src/ui/letter-dialogs.js +37 -0
  180. package/src/ui/ml-console-renderer.js +108 -0
  181. package/src/ui/monitoring-dashboard-client.js +136 -0
  182. package/src/ui/monitoring-dashboard.js +134 -0
  183. package/src/ui/notifications-renderer.js +51 -0
  184. package/src/ui/page-handler-admin.js +121 -0
  185. package/src/ui/page-handler-helpers.js +111 -0
  186. package/src/ui/page-handler-reviews.js +165 -0
  187. package/src/ui/page-handler-rfi.js +42 -0
  188. package/src/ui/page-handler.js +210 -0
  189. package/src/ui/password-reset-page.js +146 -0
  190. package/src/ui/perf-helpers.js +96 -0
  191. package/src/ui/perf-renderer.js +71 -0
  192. package/src/ui/permissions-ui.js +163 -0
  193. package/src/ui/picker-dialogs.js +100 -0
  194. package/src/ui/render-helpers.js +124 -0
  195. package/src/ui/renderer.js +35 -0
  196. package/src/ui/review-comparison-renderer.js +58 -0
  197. package/src/ui/review-detail-panels.js +71 -0
  198. package/src/ui/review-detail-renderer.js +170 -0
  199. package/src/ui/review-detail-script.js +95 -0
  200. package/src/ui/review-mwr-renderer.js +113 -0
  201. package/src/ui/review-renderer.js +202 -0
  202. package/src/ui/review-widgets.js +88 -0
  203. package/src/ui/review-zone-nav.js +12 -0
  204. package/src/ui/rfi-detail-renderer.js +191 -0
  205. package/src/ui/rfi-renderer.js +194 -0
  206. package/src/ui/rfi-report-renderer.js +56 -0
  207. package/src/ui/rippleui.css +1 -0
  208. package/src/ui/settings-renderer-advanced.js +195 -0
  209. package/src/ui/settings-renderer-advanced2.js +158 -0
  210. package/src/ui/settings-renderer-teams.js +112 -0
  211. package/src/ui/settings-renderer.js +166 -0
  212. package/src/ui/spacing-system.js +155 -0
  213. package/src/ui/standalone-login.js +109 -0
  214. package/src/ui/styles.css +2530 -0
  215. package/src/ui/styles2.css +1602 -0
  216. package/src/ui/test-page.js +23 -0
  217. package/src/ui/validation-rules.js +73 -0
  218. package/src/ui/validation-ui.js +147 -0
  219. package/src/ui/virtual-scroll.js +107 -0
  220. package/src/ui/webjsx.js +61 -0
  221. package/src/ui/widgets.js +152 -0
@@ -0,0 +1,195 @@
1
+ import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
2
+ import { esc } from '@/ui/render-helpers.js';
3
+
4
+ const hdr = (title, addHref, addLabel) => `${settingsBack()}<div class="flex justify-between items-center mb-6">
5
+ <h1 class="text-2xl font-bold">${title}</h1>
6
+ <a href="${addHref}" class="btn btn-primary btn-sm">${addLabel}</a>
7
+ </div>`;
8
+ const bc = (label) => [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label }];
9
+ const editBtn = (href) => `<a href="${href}" data-stop-propagation="true" class="btn btn-ghost btn-xs">Edit</a>`;
10
+ const trClick = (url) => `class="hover cursor-pointer" data-navigate="${url}"`;
11
+
12
+ export function renderSettingsTemplates(user, templates = []) {
13
+ const rows = templates.map(t => `<tr ${trClick('/review_template/'+t.id)}>
14
+ <td class="text-sm font-medium">${esc(t.name||'-')}</td>
15
+ <td><span class="badge badge-flat-primary text-xs">${esc(t.type||'standard')}</span></td>
16
+ <td>${t.is_active ? '<span class="badge badge-success badge-flat-success text-xs">Active</span>' : '<span class="badge badge-flat-secondary text-xs">Inactive</span>'}</td>
17
+ <td>${editBtn('/review_template/'+t.id+'/edit')}</td>
18
+ </tr>`).join('');
19
+ return settingsPage(user, 'Templates - Settings', bc('Templates'), hdr('Templates', '/review_template/new', '+ Add Template') + inlineTable(['Name', 'Type', 'Status', 'Actions'], rows, 'No templates found'));
20
+ }
21
+
22
+ export function renderSettingsChecklists(user, checklists = []) {
23
+ const rows = checklists.map(c => `<tr ${trClick('/checklist/'+c.id)}>
24
+ <td class="text-sm font-medium">${esc(c.name||'-')}</td>
25
+ <td class="text-sm">${esc(c.type||'-')}</td>
26
+ <td class="text-sm text-base-content/50">${esc(c.review_id||'-')}</td>
27
+ <td>${editBtn('/checklist/'+c.id+'/edit')}</td>
28
+ </tr>`).join('');
29
+ return settingsPage(user, 'Checklists - Settings', bc('Checklists'), hdr('Checklists', '/checklist/new', '+ Add Checklist') + inlineTable(['Name', 'Type', 'Review', 'Actions'], rows, 'No checklists found'));
30
+ }
31
+
32
+ export function renderSettingsRecreation(user, _logs = [], _users = []) {
33
+ const filters = `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body">
34
+ <div class="flex flex-wrap gap-4 items-end">
35
+ <div class="form-group"><label class="label"><span class="label-text font-semibold">Level</span></label><select id="filter-level" class="select select-solid"><option value="">All Levels</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></div>
36
+ <div class="form-group"><label class="label"><span class="label-text font-semibold">Entity Type</span></label><select id="filter-entity" class="select select-solid"><option value="">All Entities</option><option value="engagement">Engagement</option><option value="review">Review</option><option value="rfi">RFI</option><option value="user">User</option><option value="client">Client</option></select></div>
37
+ <div class="form-group"><label class="label"><span class="label-text font-semibold">Start Date</span></label><input type="date" id="filter-start" class="input input-solid"/></div>
38
+ <div class="form-group"><label class="label"><span class="label-text font-semibold">End Date</span></label><input type="date" id="filter-end" class="input input-solid"/></div>
39
+ <button data-action="applyAuditFilters" class="btn btn-primary btn-sm">Filter</button>
40
+ </div>
41
+ </div></div>`;
42
+ const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Audit Logs</h1>${filters}
43
+ <div class="card-clean"><div class="card-clean-body" style="padding:0">
44
+ <div class="table-wrap"><table class="data-table">
45
+ <thead><tr><th>Timestamp</th><th>Operation</th><th>Entity</th><th>Entity ID</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
46
+ <tbody id="audit-tbody"><tr><td colspan="7" class="text-center py-8 text-base-content/40 text-sm">Loading...</td></tr></tbody>
47
+ </table></div>
48
+ </div></div>`;
49
+ const script = `${TOAST_SCRIPT}
50
+ async function loadAuditLogs(params){const tbody=document.getElementById('audit-tbody');try{const q=params?'?'+new URLSearchParams(params):'';const r=await fetch('/api/audit/logs'+q);const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(l){const ts=l.timestamp;const date=ts?new Date(ts).toLocaleString():'-';const details=l.details?JSON.stringify(l.details).substring(0,60)+'...':'-';return'<tr><td class="text-xs text-base-content/50">'+date+'</td><td><span class="badge badge-flat-primary text-xs">'+(l.operation||'-')+'</span></td><td class="text-sm">'+(l.entity_type||'-')+'</td><td class="text-xs text-base-content/50">'+(l.entity_id||'-')+'</td><td class="text-sm">'+(l.user_id||'-')+'</td><td class="text-sm">'+(l.action||'-')+'</td><td class="text-xs text-base-content/40">'+details+'</td></tr>'}).join(''):'<tr><td colspan="7" class="text-center py-8 text-base-content/40 text-sm">No audit logs found</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="7" class="text-center py-4 text-error text-sm">Failed to load: '+e.message+'</td></tr>'}}
51
+ function applyAuditFilters(){const level=document.getElementById('filter-level').value;const entity=document.getElementById('filter-entity').value;const start=document.getElementById('filter-start').value;const end=document.getElementById('filter-end').value;const p={};if(level)p.level=level;if(entity)p.entityType=entity;if(start)p.from=new Date(start).toISOString();if(end)p.to=new Date(end+'T23:59:59').toISOString();loadAuditLogs(p)}
52
+ loadAuditLogs();`;
53
+ return settingsPage(user, 'Audit Logs - Settings', bc('Audit Logs'), content, [script]);
54
+ }
55
+
56
+ const INTEGRATIONS = [
57
+ { id: 'google_drive', icon: '&#128194;', name: 'Google Drive', desc: 'Document storage and collaboration' },
58
+ { id: 'gmail', icon: '&#9993;', name: 'Gmail', desc: 'Email integration for notifications' },
59
+ { id: 'firebase', icon: '&#128293;', name: 'Firebase (Legacy)', desc: 'Legacy data source for migration' },
60
+ ];
61
+
62
+ export function renderSettingsIntegrations(user, integrations = {}) {
63
+ const { private_key = '', mwr_api_key = '' } = integrations;
64
+ const cards = INTEGRATIONS.map(integ => {
65
+ const state = integrations[integ.id] || {};
66
+ const connected = state.connected || false;
67
+ return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body">
68
+ <div class="flex items-center gap-4">
69
+ <div class="text-2xl">${integ.icon}</div>
70
+ <div class="flex-1"><div class="font-semibold">${integ.name}</div><div class="text-xs text-base-content/50">${integ.desc}</div></div>
71
+ <div class="flex flex-col items-end gap-2">
72
+ <span class="badge ${connected ? 'badge-success badge-flat-success' : 'badge-flat-secondary'} text-xs">${connected ? 'Connected' : 'Disconnected'}</span>
73
+ <button data-action="toggleConfig" data-args='["${integ.id}"]' class="btn btn-ghost btn-xs">Configure</button>
74
+ </div>
75
+ </div>
76
+ <div id="config-${integ.id}" class="hidden mt-4 pt-4 border-t border-base-200">
77
+ <div class="flex gap-3 items-end flex-wrap">
78
+ <div class="form-group flex-1 min-w-48"><label class="label"><span class="label-text font-semibold">API Key / Credentials</span></label><input type="password" id="key-${integ.id}" class="input input-solid max-w-full" placeholder="Enter credentials"/></div>
79
+ <div class="flex gap-2"><button data-action="saveIntegration" data-args='["${integ.id}"]' class="btn btn-primary btn-sm">Save</button><button data-action="testIntegration" data-args='["${integ.id}"]' class="btn btn-ghost btn-sm">Test</button></div>
80
+ </div>
81
+ </div>
82
+ </div></div>`;
83
+ }).join('');
84
+ const apiKeysCard = `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body">
85
+ <h2 class="card-title text-base mb-4">Cross-App API Keys</h2>
86
+ <div class="mb-5">
87
+ <label class="label"><span class="label-text font-semibold">My Review Private Key</span></label>
88
+ <div class="flex gap-2 items-center">
89
+ <input type="text" id="mwr-private-key" class="input input-solid flex-1" readonly value="${esc(private_key)}" placeholder="No key generated"/>
90
+ <button class="btn btn-ghost btn-sm" onclick="copyPrivateKey()">Copy</button>
91
+ <button class="btn btn-primary btn-sm" onclick="generatePrivateKey()">Generate New Key</button>
92
+ </div>
93
+ <div class="text-xs text-base-content/50 mt-1">This key links Friday and My Review. Keep it confidential — anyone with access can import data.</div>
94
+ </div>
95
+ <div class="mb-4">
96
+ <label class="label"><span class="label-text font-semibold">My Review API Key (from MWR app)</span></label>
97
+ <input type="text" id="mwr-api-key" class="input input-solid w-full" value="${esc(mwr_api_key)}" placeholder="Paste the key from My Review Integrations settings"/>
98
+ <div class="text-xs text-base-content/50 mt-1">Enter the private key generated in the My Review settings to enable cross-app access.</div>
99
+ </div>
100
+ <button class="btn btn-primary btn-sm" onclick="saveApiKeys()">Save API Keys</button>
101
+ </div></div>`;
102
+ const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Integrations</h1>${apiKeysCard}${cards}`;
103
+ const script = `${TOAST_SCRIPT}
104
+ function toggleConfig(id){const el=document.getElementById('config-'+id);el.classList.toggle('hidden')}
105
+ function saveIntegration(id){const key=document.getElementById('key-'+id).value;if(!key){showToast('Enter credentials','error');return}showToast('Integration saved','success');toggleConfig(id)}
106
+ function testIntegration(id){showToast('Testing connection...','info');setTimeout(()=>showToast('Test complete','success'),1000)}
107
+ function generatePrivateKey(){const arr=new Uint8Array(32);crypto.getRandomValues(arr);const key=Array.from(arr,b=>b.toString(16).padStart(2,'0')).join('');document.getElementById('mwr-private-key').value=key}
108
+ function copyPrivateKey(){const v=document.getElementById('mwr-private-key').value;if(!v){showToast('No key to copy','error');return}navigator.clipboard.writeText(v).then(()=>showToast('Copied to clipboard','success')).catch(()=>showToast('Copy failed','error'))}
109
+ async function saveApiKeys(){const private_key=document.getElementById('mwr-private-key').value;const mwr_api_key=document.getElementById('mwr-api-key').value;try{const r=await fetch('/api/admin/settings/integrations',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({private_key,mwr_api_key})});if(r.ok){showToast('API keys saved','success')}else{showToast('Save failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
110
+ return settingsPage(user, 'Integrations - Settings', bc('Integrations'), content, [script]);
111
+ }
112
+
113
+ export function renderSettingsNotifications(user, config = {}) {
114
+ const t = config.thresholds || {};
115
+ const rfi = t.rfi || {};
116
+ const notif = t.notification || {};
117
+ const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/50">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
118
+ const toggles = [
119
+ { id: 'rfi_reminders', label: 'RFI Reminders', desc: 'Send reminders for outstanding RFIs', checked: true },
120
+ { id: 'deadline_alerts', label: 'Deadline Alerts', desc: 'Alert when deadlines are approaching', checked: true },
121
+ { id: 'stage_transitions', label: 'Stage Transitions', desc: 'Notify on engagement stage changes', checked: true },
122
+ { id: 'new_messages', label: 'New Messages', desc: 'Notify when new messages are received', checked: true },
123
+ { id: 'weekly_reports', label: 'Weekly Reports', desc: 'Send weekly summary reports', checked: true },
124
+ ];
125
+ const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none" data-dialog-close-overlay="true">
126
+ <div class="modal-overlay" data-dialog-close="notif-trigger-dialog"></div>
127
+ <div class="modal-content rounded-box max-w-md p-6">
128
+ <h3 class="text-lg font-semibold mb-4">Add Notification Trigger</h3>
129
+ <div class="form-group mb-3"><label class="form-label">Trigger Type</label><select id="ntd-type" class="form-input"><option value="before">Before</option><option value="after">After</option></select></div>
130
+ <div class="form-group mb-3"><label class="form-label">Days</label><input id="ntd-days" type="number" min="0" max="365" value="7" class="form-input"/></div>
131
+ <div class="form-group mb-3"><label class="form-label">Reference Date</label><select id="ntd-ref" class="form-input"><option value="commencement_date">Commencement Date</option><option value="deadline_date">Deadline Date</option></select></div>
132
+ <div class="form-group mb-3"><label class="form-label">Recipient</label><select id="ntd-recipient" class="form-input"><option value="client_admin">Client Admin</option><option value="client_user">Client User</option><option value="team_manager">Team Manager</option><option value="team_clerk">Team Clerk</option><option value="partner">Partner</option></select></div>
133
+ <div class="form-group mb-4"><label class="flex items-center gap-2"><input id="ntd-active" type="checkbox" class="checkbox" checked/><span class="text-sm">Active</span></label></div>
134
+ <div class="modal-action"><button data-action="saveNotifTrigger" class="btn btn-primary btn-sm">Save Trigger</button><button data-dialog-close="notif-trigger-dialog" class="btn btn-ghost btn-sm">Cancel</button></div>
135
+ </div>
136
+ </div>`;
137
+ const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Notifications</h1>
138
+ <form id="notif-form"><div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
139
+ <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-2">Notification Toggles</h2>${toggles.map(togRow).join('')}</div></div>
140
+ <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-4">Configuration</h2>
141
+ <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">RFI Notification Days</span></label><input type="text" name="notification_days" class="input input-solid max-w-full" value="${(rfi.notification_days || [7,3,1,0]).join(', ')}"/></div>
142
+ <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Escalation Delay (hours)</span></label><input type="number" name="escalation_delay_hours" class="input input-solid max-w-full" value="${rfi.escalation_delay_hours || 24}"/></div>
143
+ <div class="form-group"><label class="label"><span class="label-text font-semibold">Batch Size</span></label><input type="number" name="batch_size" class="input input-solid max-w-full" value="${notif.batch_size || 50}"/></div>
144
+ </div></div>
145
+ </div><button type="submit" class="btn btn-primary">Save Settings</button></form>
146
+ <div class="flex justify-between items-center mt-8 mb-4">
147
+ <h2 class="text-lg font-semibold">Engagement Notification Triggers</h2>
148
+ <button data-action="openNotifTriggerDialog" class="btn btn-primary btn-sm">+ Add Trigger</button>
149
+ </div>
150
+ <div class="card-clean"><div class="card-clean-body" style="padding:0">
151
+ <div class="table-wrap"><table class="data-table" id="triggers-table">
152
+ <thead><tr><th>Type</th><th>Days</th><th>Reference</th><th>Recipient</th><th>Status</th><th></th></tr></thead>
153
+ <tbody id="triggers-tbody"><tr><td colspan="6" class="text-center py-8 text-base-content/40 text-sm">Loading...</td></tr></tbody>
154
+ </table></div>
155
+ </div></div>
156
+ ${triggerDialog}`;
157
+ const script = `${TOAST_SCRIPT}
158
+ document.getElementById('notif-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#notif-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});try{const res=await fetch('/api/admin/settings/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}});
159
+ async function loadTriggers(){const tbody=document.getElementById('triggers-tbody');try{const r=await fetch('/api/friday/engagement/notifications');const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(t){return'<tr><td class="text-sm">'+t.trigger_type+'</td><td class="text-sm">'+t.trigger_days+' days</td><td class="text-sm">'+t.trigger_reference+'</td><td class="text-sm">'+t.recipient_type+'</td><td>'+(t.active?'<span class="badge badge-flat-success text-xs">Active</span>':'<span class="badge badge-flat-secondary text-xs">Inactive</span>')+'</td><td><button class="btn btn-ghost btn-xs" data-action="deleteNotifTrigger" data-args=\\'["\\'+t.id+\\'"]\\'>Delete</button></td></tr>'}).join(''):'<tr><td colspan="6" class="text-center py-8 text-base-content/40 text-sm">No triggers configured</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="6" class="text-center py-4 text-error text-sm">Failed to load</td></tr>'}}
160
+ function openNotifTriggerDialog(){document.getElementById('notif-trigger-dialog').style.display='flex'}
161
+ async function saveNotifTrigger(){const type=document.getElementById('ntd-type').value;const days=Number(document.getElementById('ntd-days').value);const ref=document.getElementById('ntd-ref').value;const recipient=document.getElementById('ntd-recipient').value;const active=document.getElementById('ntd-active').checked;if(!days&&days!==0){showToast('Enter number of days','error');return}try{const r=await fetch('/api/friday/engagement/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trigger_type:type,trigger_days:days,trigger_reference:ref,recipient_type:recipient,active})});if(r.ok){showToast('Trigger added','success');document.getElementById('notif-trigger-dialog').style.display='none';loadTriggers()}else{const d=await r.json();showToast(d.message||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
162
+ async function deleteNotifTrigger(id){if(!confirm('Delete this trigger?'))return;try{const r=await fetch('/api/friday/engagement/notifications',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});if(r.ok){showToast('Deleted','success');loadTriggers()}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
163
+ loadTriggers();`;
164
+ return settingsPage(user, 'Notifications - Settings', bc('Notifications'), content, [script]);
165
+ }
166
+
167
+ function renderTypeList(user, items, entityKey, title) {
168
+ const rows = items.map(t => `<tr class="hover">
169
+ <td class="font-medium text-sm">${t.name || '-'}</td>
170
+ <td class="text-xs text-base-content/50">${t.created_at ? new Date(t.created_at).toLocaleDateString() : '-'}</td>
171
+ <td><div class="flex gap-2">
172
+ <button class="btn btn-ghost btn-xs type-edit-btn" data-id="${t.id}" data-name="${(t.name||'').replace(/"/g,'&quot;')}">Edit</button>
173
+ <button class="btn btn-error btn-xs type-del-btn" data-id="${t.id}">Delete</button>
174
+ </div></td>
175
+ </tr>`).join('');
176
+ const script = `${TOAST_SCRIPT}
177
+ var _eid='';
178
+ function openAdd(){_eid='';document.getElementById('type-name').value='';document.getElementById('type-form').style.display='block'}
179
+ function openEdit(id,name){_eid=id;document.getElementById('type-name').value=name;document.getElementById('type-form').style.display='block'}
180
+ function cancelForm(){document.getElementById('type-form').style.display='none'}
181
+ async function saveType(){const name=document.getElementById('type-name').value.trim();if(!name){showToast('Name required','error');return}const url=_eid?'/api/${entityKey}/'+_eid:'/api/${entityKey}';const method=_eid?'PUT':'POST';try{const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});if(r.ok){showToast(_eid?'Updated':'Created','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
182
+ async function delType(id){if(!confirm('Delete?'))return;try{const r=await fetch('/api/${entityKey}/'+id,{method:'DELETE'});if(r.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
183
+ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.type-edit-btn').forEach(b=>b.addEventListener('click',function(){openEdit(this.dataset.id,this.dataset.name)}));document.querySelectorAll('.type-del-btn').forEach(b=>b.addEventListener('click',function(){delType(this.dataset.id)}));document.querySelectorAll('.type-add-btn').forEach(b=>b.addEventListener('click',openAdd))});`;
184
+ const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex gap-2 items-end"><div class="form-group"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid" style="max-width:240px" placeholder="Name"/></div><button data-action="saveType" class="btn btn-primary btn-sm" onclick="saveType()">Save</button><button class="btn btn-ghost btn-sm" onclick="cancelForm()">Cancel</button></div></div></div>`;
185
+ const content = `${settingsBack()}<div class="flex justify-between items-center mb-4"><h1 class="text-2xl font-bold">${title}</h1><button class="btn btn-primary btn-sm type-add-btn">Add</button></div>${formHtml}<div class="card-clean"><div class="card-clean-body" style="padding:0">${inlineTable(['Name','Created','Actions'],rows,'No items found.')}</div></div>`;
186
+ return settingsPage(user, `${title} - Settings`, bc(title), content, [script]);
187
+ }
188
+
189
+ export function renderSettingsEntityTypes(user, items = []) {
190
+ return renderTypeList(user, items, 'entity_type', 'Entity Types');
191
+ }
192
+
193
+ export function renderSettingsEngagementTypes(user, items = []) {
194
+ return renderTypeList(user, items, 'engagement_type', 'Engagement Types');
195
+ }
@@ -0,0 +1,158 @@
1
+ import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
2
+ import { esc } from '@/ui/render-helpers.js';
3
+
4
+ const bc = (label) => [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label }];
5
+ const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/50">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
6
+
7
+ export function renderSettingsReviewSettings(user, config = {}) {
8
+ const review = config.review || {};
9
+ const toggles = [
10
+ { id: 'auto_save', label: 'Auto-save', desc: 'Automatically save review changes', checked: review.auto_save !== false },
11
+ { id: 'highlight_notifications', label: 'Highlight Notifications', desc: 'Notify on new highlights', checked: review.highlight_notifications !== false },
12
+ { id: 'require_resolution', label: 'Require Resolution', desc: 'All highlights resolved before closing', checked: !!review.require_resolution },
13
+ { id: 'allow_private', label: 'Allow Private Reviews', desc: 'Enable private review visibility', checked: review.allow_private !== false },
14
+ { id: 'enable_sections', label: 'Enable Sections', desc: 'Allow reviews organized into sections', checked: review.enable_sections !== false },
15
+ { id: 'enable_wip_value', label: 'Enable WIP Value', desc: 'Track work-in-progress value', checked: !!review.enable_wip_value },
16
+ ];
17
+ const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Review Settings</h1>
18
+ <form id="review-settings-form"><div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
19
+ <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-2">Review Options</h2>${toggles.map(togRow).join('')}</div></div>
20
+ <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-4">Defaults</h2>
21
+ <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Default Status</span></label><select name="default_status" class="select select-solid max-w-full"><option value="active" ${review.default_status==='active'?'selected':''}>Active</option><option value="draft" ${review.default_status==='draft'?'selected':''}>Draft</option></select></div>
22
+ <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Max Highlights Per Review</span></label><input type="number" name="max_highlights" class="input input-solid max-w-full" value="${review.max_highlights || 500}" min="1"/></div>
23
+ <div class="form-group"><label class="label"><span class="label-text font-semibold">Default Currency</span></label><select name="default_currency" class="select select-solid max-w-full"><option value="ZAR" ${!review.default_currency||review.default_currency==='ZAR'?'selected':''}>ZAR</option><option value="USD" ${review.default_currency==='USD'?'selected':''}>USD</option><option value="EUR" ${review.default_currency==='EUR'?'selected':''}>EUR</option><option value="GBP" ${review.default_currency==='GBP'?'selected':''}>GBP</option></select></div>
24
+ </div></div>
25
+ </div><button type="submit" class="btn btn-primary">Save Review Settings</button></form>`;
26
+ const script = `${TOAST_SCRIPT}document.getElementById('review-settings-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#review-settings-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});document.querySelectorAll('#review-settings-form input[type=number]').forEach(n=>{if(data[n.name])data[n.name]=Number(data[n.name])});try{const res=await fetch('/api/admin/settings/review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(e){showToast('Error: '+e.message,'error')}})`;
27
+ return settingsPage(user, 'Review Settings', bc('Review Settings'), content, [script]);
28
+ }
29
+
30
+ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
31
+ const fr = config.fileReview || {};
32
+ const reviewFlags = frSettings.review_flags || [];
33
+ const tenderFlags = frSettings.tender_flags || [];
34
+ const flagManagers = frSettings.flags_managers || [];
35
+ const tempAccess = frSettings.temp_review_access_period || 0;
36
+ const toggles = [
37
+ { id: 'auto_pdf_cache', label: 'Auto-cache PDFs', desc: 'Cache PDF files for faster loading', checked: fr.auto_pdf_cache !== false },
38
+ { id: 'allow_annotations', label: 'Allow Annotations', desc: 'Enable PDF annotation tools', checked: fr.allow_annotations !== false },
39
+ { id: 'mobile_resize', label: 'Mobile Resize', desc: 'Enable mobile-friendly resizable highlights', checked: fr.mobile_resize !== false },
40
+ { id: 'coordinate_snap', label: 'Coordinate Snap', desc: 'Snap highlight coordinates to text boundaries', checked: !!fr.coordinate_snap },
41
+ ];
42
+ const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button" onclick="removeFlag('${type}','${esc(f.replace(/'/g,"\\'"))}')" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>`).join('');
43
+ const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">File Review Settings</h1>
44
+ <div id="fr-review-flags" data-flags='${JSON.stringify(reviewFlags)}'></div>
45
+ <div id="fr-tender-flags" data-flags='${JSON.stringify(tenderFlags)}'></div>
46
+ <form id="file-review-settings-form">
47
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
48
+ <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-2">File Review Options</h2>${toggles.map(togRow).join('')}</div></div>
49
+ <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-4">Access &amp; Limits</h2>
50
+ <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Temp Access Period (days)</span></label><input type="number" name="temp_review_access_period" class="input input-solid max-w-full" value="${tempAccess}" min="0"/></div>
51
+ <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Max File Size (MB)</span></label><input type="number" name="max_file_size_mb" class="input input-solid max-w-full" value="${fr.max_file_size_mb || 50}" min="1"/></div>
52
+ <div class="form-group"><label class="label"><span class="label-text font-semibold">Allowed File Types</span></label><input type="text" name="allowed_types" class="input input-solid max-w-full" value="${fr.allowed_types || 'pdf,doc,docx,xls,xlsx,png,jpg'}"/></div>
53
+ </div></div>
54
+ </div>
55
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
56
+ <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Review Flags</h2>
57
+ <div id="review-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(reviewFlags, 'review')}</div>
58
+ <div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" onclick="addFlag('review')" class="btn btn-primary btn-sm">Add</button></div>
59
+ </div></div>
60
+ <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Tender Flags</h2>
61
+ <div id="tender-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(tenderFlags, 'tender')}</div>
62
+ <div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" onclick="addFlag('tender')" class="btn btn-primary btn-sm">Add</button></div>
63
+ </div></div>
64
+ </div>
65
+ <button type="submit" class="btn btn-primary">Save File Review Settings</button></form>`;
66
+ const script = `${TOAST_SCRIPT}
67
+ var _rvFlags=${JSON.stringify(reviewFlags)};
68
+ var _tdFlags=${JSON.stringify(tenderFlags)};
69
+ function renderFlags(type){const list=type==='review'?_rvFlags:_tdFlags;const el=document.getElementById(type+'-flags-list');if(!el)return;el.innerHTML=list.map(f=>'<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">'+f+'<button type="button" onclick="removeFlag(\\''+type+'\\',\\''+f.replace(/'/g,"\\'")+'\\')" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>').join('')}
70
+ window.addFlag=function(type){const inp=document.getElementById('new-'+type+'-flag');const val=(inp?.value||'').trim();if(!val)return;if(type==='review'){_rvFlags=[..._rvFlags,val]}else{_tdFlags=[..._tdFlags,val]};inp.value='';renderFlags(type)};
71
+ window.removeFlag=function(type,flag){if(type==='review'){_rvFlags=_rvFlags.filter(f=>f!==flag)}else{_tdFlags=_tdFlags.filter(f=>f!==flag)};renderFlags(type)};
72
+ document.getElementById('file-review-settings-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#file-review-settings-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});document.querySelectorAll('#file-review-settings-form input[type=number]').forEach(n=>{if(data[n.name])data[n.name]=Number(data[n.name])});data.review_flags=_rvFlags;data.tender_flags=_tdFlags;try{const res=await fetch('/api/admin/settings/file-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}})`;
73
+ return settingsPage(user, 'File Review Settings', bc('File Review Settings'), content, [script]);
74
+ }
75
+
76
+ export function renderSettingsMwrPermissions(user, permissions = []) {
77
+ const rows = permissions.map(p => `<tr>
78
+ <td class="text-sm">${esc(p.entity_type||'-')}</td>
79
+ <td class="text-sm">${esc(p.entity_id||'-')}</td>
80
+ <td class="text-sm">${esc(p.user_id||'-')}</td>
81
+ <td class="text-sm">${esc(p.permission_type||'-')}</td>
82
+ <td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString() : '-'}</td>
83
+ <td><button class="btn btn-error btn-xs btn-outline" onclick="revokePermission('${esc(p.id)}')">Revoke</button></td>
84
+ </tr>`).join('');
85
+ const content = `${settingsBack()}<div class="flex justify-between items-center mb-6">
86
+ <h1 class="text-2xl font-bold">MWR Permissions</h1>
87
+ <button class="btn btn-primary btn-sm" onclick="document.getElementById('grant-perm-dialog').style.display='flex'">+ Grant Permission</button>
88
+ </div>
89
+ ${inlineTable(['Entity Type','Entity ID','User ID','Permission','Granted','Actions'], rows, 'No permissions configured')}
90
+ <div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
91
+ <div class="dialog-panel" style="max-width:460px">
92
+ <div class="dialog-header"><span class="dialog-title">Grant Permission</span><button class="dialog-close" onclick="document.getElementById('grant-perm-dialog').style.display='none'">&times;</button></div>
93
+ <div class="dialog-body">
94
+ <div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="review"/></div>
95
+ <div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="entity id"/></div>
96
+ <div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="user id"/></div>
97
+ <div class="modal-form-group"><label>Permission Type</label><select id="gp-perm-type" class="form-input"><option value="view">view</option><option value="edit">edit</option><option value="admin">admin</option></select></div>
98
+ <div id="gp-result" style="display:none;margin-top:8px"></div>
99
+ </div>
100
+ <div class="dialog-footer">
101
+ <button class="btn btn-ghost btn-sm" onclick="document.getElementById('grant-perm-dialog').style.display='none'">Cancel</button>
102
+ <button class="btn btn-primary btn-sm" onclick="grantPermission()">Grant</button>
103
+ </div>
104
+ </div>
105
+ </div>`;
106
+ const script = `${TOAST_SCRIPT}async function grantPermission(){var body={entity_type:document.getElementById('gp-entity-type').value.trim(),entity_id:document.getElementById('gp-entity-id').value.trim(),user_id:document.getElementById('gp-user-id').value.trim(),permission_type:document.getElementById('gp-perm-type').value};var res=document.getElementById('gp-result');if(!body.entity_type||!body.entity_id||!body.user_id){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">All fields required.</div>';return}try{var r=await fetch('/api/mwr/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var d=await r.json();if(r.ok&&d.success){showToast('Permission granted','success');document.getElementById('grant-perm-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||'Failed')+'</div>'}}catch(e){showToast('Error: '+e.message,'error')}}async function revokePermission(id){if(!confirm('Revoke this permission?'))return;try{var r=await fetch('/api/permission/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
107
+ return settingsPage(user, 'MWR Permissions', bc('MWR Permissions'), content, [script]);
108
+ }
109
+
110
+ export function renderSettingsTemplateManage(user, template = {}, sections = []) {
111
+ const sectionRows = sections.map((s, i) => `<tr data-id="${esc(s.id)}">
112
+ <td><span class="inline-block w-5 h-5 rounded" style="background:${s.color || '#B0B0B0'}"></span></td>
113
+ <td class="text-sm font-medium">${esc(s.name || '-')}</td>
114
+ <td class="text-sm text-base-content/50">${s.order ?? i}</td>
115
+ <td><div class="flex gap-1">
116
+ <button data-action="editTplSection" data-args='["${esc(s.id)}","${esc((s.name||'').replace(/"/g,'&quot;'))}","${esc(s.color||'#B0B0B0')}"]' class="btn btn-ghost btn-xs">Edit</button>
117
+ <button data-action="deleteTplSection" data-args='["${esc(s.id)}"]' class="btn btn-error btn-xs btn-outline">Delete</button>
118
+ </div></td>
119
+ </tr>`).join('');
120
+ const tplBc = [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { href: '/admin/settings/templates', label: 'Templates' }, { label: template.name || 'Template' }];
121
+ const content = `${settingsBack()}<div class="mb-6">
122
+ <h1 class="text-2xl font-bold">${esc(template.name || 'Template')}</h1>
123
+ <p class="text-sm text-base-content/50 mt-1">Manage template sections and configuration</p>
124
+ </div>
125
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
126
+ <div class="card-clean"><div class="card-clean-body">
127
+ <h2 class="card-title text-base mb-4">Template Info</h2>
128
+ <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Name</span></label><input type="text" id="tpl-name" class="input input-solid max-w-full" value="${esc(template.name || '')}"/></div>
129
+ <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Type</span></label><select id="tpl-type" class="select select-solid max-w-full"><option value="standard" ${template.type==='standard'?'selected':''}>Standard</option><option value="checklist" ${template.type==='checklist'?'selected':''}>Checklist</option><option value="audit" ${template.type==='audit'?'selected':''}>Audit</option></select></div>
130
+ <div class="form-group mb-4"><label class="label cursor-pointer justify-start gap-3"><input type="checkbox" id="tpl-active" class="checkbox checkbox-primary" ${template.is_active?'checked':''}/><span class="label-text">Active</span></label></div>
131
+ <button data-action="saveTplInfo" class="btn btn-primary btn-sm">Save Template Info</button>
132
+ </div></div>
133
+ <div class="card-clean"><div class="card-clean-body">
134
+ <div class="flex justify-between items-center mb-4">
135
+ <h2 class="card-title text-base">Sections</h2>
136
+ <button data-action="addTplSection" class="btn btn-primary btn-sm">+ Add Section</button>
137
+ </div>
138
+ ${inlineTable(['Color', 'Name', 'Order', 'Actions'], sectionRows, 'No sections defined')}
139
+ </div></div>
140
+ </div>
141
+ <div id="tpl-section-form" class="hidden card-clean" style="margin-top:1rem"><div class="card-clean-body">
142
+ <div class="flex flex-wrap gap-4 items-end">
143
+ <div class="form-group flex-1 min-w-40"><label class="label"><span class="label-text font-semibold">Name</span></label><input type="text" id="tpl-sec-name" class="input input-solid max-w-full" placeholder="Section name"/></div>
144
+ <div class="form-group"><label class="label"><span class="label-text font-semibold">Color</span></label><input type="color" id="tpl-sec-color" value="#B0B0B0" class="input input-solid" style="height:42px;width:60px;padding:4px"/></div>
145
+ <div class="flex gap-2"><button data-action="saveTplSection" class="btn btn-primary btn-sm">Save</button><button data-action="cancelTplSection" class="btn btn-ghost btn-sm">Cancel</button></div>
146
+ </div>
147
+ <input type="hidden" id="tpl-sec-id" value=""/>
148
+ </div></div>`;
149
+ const script = `${TOAST_SCRIPT}
150
+ var tplId='${esc(template.id || '')}';
151
+ window.saveTplInfo=async function(){var body={name:document.getElementById('tpl-name').value,type:document.getElementById('tpl-type').value,is_active:document.getElementById('tpl-active').checked?1:0};try{var res=await fetch('/api/review_template/'+tplId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast('Template updated','success')}else{showToast('Update failed','error')}}catch(e){showToast('Error','error')}};
152
+ window.addTplSection=function(){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value='';document.getElementById('tpl-sec-name').value=''};
153
+ window.editTplSection=function(id,name,color){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value=id;document.getElementById('tpl-sec-name').value=name;document.getElementById('tpl-sec-color').value=color};
154
+ window.cancelTplSection=function(){document.getElementById('tpl-section-form').classList.add('hidden')};
155
+ window.saveTplSection=async function(){var id=document.getElementById('tpl-sec-id').value;var body={name:document.getElementById('tpl-sec-name').value,color:document.getElementById('tpl-sec-color').value,review_template_id:tplId};if(!body.name){showToast('Name required','error');return}var url=id?'/api/review_template_section/'+id:'/api/review_template_section';var method=id?'PUT':'POST';try{var res=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};
156
+ window.deleteTplSection=async function(id){if(!confirm('Delete this section?'))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
157
+ return settingsPage(user, `Manage Template - ${esc(template.name || 'Template')}`, tplBc, content, [script]);
158
+ }
@@ -0,0 +1,112 @@
1
+ import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
2
+
3
+ const RFI_PALETTE = ['#B0B0B0', '#44BBA4', '#FF4141', '#7F7EFF', '#3b82f6', '#f59e0b', '#ec4899', '#8b5cf6'];
4
+
5
+ function parseMembers(t) {
6
+ try {
7
+ const u = t.users;
8
+ if (!u) return [];
9
+ const arr = Array.isArray(u) ? u : JSON.parse(u);
10
+ if (!Array.isArray(arr)) return [];
11
+ return arr.map(m => typeof m === 'object' ? (m.email || m.name || m.cloud_id || '') : String(m));
12
+ } catch { return []; }
13
+ }
14
+
15
+ export function renderSettingsTeams(user, teams = [], allUsers = []) {
16
+ const editDialog = `<div id="team-dialog" class="modal" style="display:none" data-overlay-close="true" data-action="closeTeamDialog">
17
+ <div class="modal-overlay" data-action="closeTeamDialog"></div>
18
+ <div class="modal-content rounded-box max-w-md p-6">
19
+ <h3 id="team-dialog-title" class="text-lg font-semibold mb-4">Edit Team</h3>
20
+ <input type="hidden" id="team-edit-id"/>
21
+ <div class="form-group mb-4">
22
+ <label class="label"><span class="label-text font-medium">Team Name</span></label>
23
+ <input id="team-name-input" type="text" class="input input-solid max-w-full" placeholder="Team name"/>
24
+ </div>
25
+ <div class="modal-action">
26
+ <button data-action="saveTeam" class="btn btn-primary">Save</button>
27
+ <button data-action="closeTeamDialog" class="btn btn-ghost">Cancel</button>
28
+ </div>
29
+ </div>
30
+ </div>`;
31
+
32
+ const rows = teams.map(t => {
33
+ const members = parseMembers(t);
34
+ const memberCount = members.length;
35
+ const memberNames = members.slice(0, 3).map(m => String(m).split('@')[0]).join(', ');
36
+ const moreCount = memberCount > 3 ? ` +${memberCount - 3} more` : '';
37
+ const membersDisplay = memberCount > 0
38
+ ? `<span class="text-sm text-base-content/60">${memberNames}${moreCount}</span>`
39
+ : '<span class="text-base-content/30 text-sm">No members</span>';
40
+ return `<tr class="hover">
41
+ <td class="font-medium text-sm">${t.name || '-'}</td>
42
+ <td><span class="badge badge-flat-primary text-xs mr-2">${memberCount} member${memberCount !== 1 ? 's' : ''}</span>${membersDisplay}</td>
43
+ <td><div class="flex gap-2">
44
+ <button class="btn btn-ghost btn-xs team-edit-btn" data-id="${t.id}" data-name="${(t.name||'').replace(/"/g,'&quot;')}">Edit</button>
45
+ <button class="btn btn-error btn-xs team-delete-btn" data-id="${t.id}">Delete</button>
46
+ </div></td>
47
+ </tr>`;
48
+ }).join('');
49
+
50
+ const script = `${TOAST_SCRIPT}
51
+ function openEditTeam(id,name){document.getElementById('team-edit-id').value=id;document.getElementById('team-name-input').value=name;document.getElementById('team-dialog-title').textContent='Edit Team';document.getElementById('team-dialog').style.display='flex'}
52
+ function openAddTeam(){document.getElementById('team-edit-id').value='';document.getElementById('team-name-input').value='';document.getElementById('team-dialog-title').textContent='Add Team';document.getElementById('team-dialog').style.display='flex'}
53
+ function closeTeamDialog(){document.getElementById('team-dialog').style.display='none'}
54
+ async function saveTeam(){const id=document.getElementById('team-edit-id').value;const name=document.getElementById('team-name-input').value.trim();if(!name){showToast('Name is required','error');return}const url=id?'/api/team/'+id:'/api/team';const method=id?'PUT':'POST';try{const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});if(r.ok){showToast(id?'Team updated':'Team created','success');setTimeout(()=>location.reload(),500)}else{const d=await r.json();showToast(d.error||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
55
+ async function deleteTeam(id){if(!confirm('Delete this team?'))return;try{const r=await fetch('/api/team/'+id,{method:'DELETE'});if(r.ok){showToast('Team deleted','success');setTimeout(()=>location.reload(),500)}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
56
+ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.team-edit-btn').forEach(b=>b.addEventListener('click',function(){const id=this.dataset.id;const name=this.dataset.name;openEditTeam(id,name)}));document.querySelectorAll('.team-delete-btn').forEach(b=>b.addEventListener('click',function(){const id=this.dataset.id;deleteTeam(id)}));document.querySelectorAll('.team-add-btn').forEach(b=>b.addEventListener('click',openAddTeam))});`;
57
+
58
+ const content = `${settingsBack()}${editDialog}
59
+ <div class="flex justify-between items-center mb-4">
60
+ <h1 class="text-2xl font-bold">Teams</h1>
61
+ <button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
62
+ </div>
63
+ <div class="card-clean"><div class="card-clean-body" style="padding:0rem">${inlineTable(['Name', 'Members', 'Actions'], rows, 'No teams found')}</div></div>`;
64
+ return settingsPage(user, 'Teams - Settings', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Teams' }], content, [script]);
65
+ }
66
+
67
+ export function renderSettingsRfiSections(user, sections = []) {
68
+ const rows = sections.map((s, i) => `<tr class="hover rfi-section-row" data-id="${s.id}">
69
+ <td><span class="inline-block w-5 h-5 rounded" style="background:${s.color || '#B0B0B0'}"></span></td>
70
+ <td class="text-sm">${s.name || '-'}</td>
71
+ <td class="text-sm text-base-content/50">${s.order ?? i}</td>
72
+ <td><div class="flex gap-1">
73
+ <button class="btn btn-ghost btn-xs rfi-move-btn" data-id="${s.id}" data-direction="up" ${i === 0 ? 'disabled' : ''}>&#9650;</button>
74
+ <button class="btn btn-ghost btn-xs rfi-move-btn" data-id="${s.id}" data-direction="down" ${i === sections.length - 1 ? 'disabled' : ''}>&#9660;</button>
75
+ <button class="btn btn-ghost btn-xs rfi-edit-btn" data-id="${s.id}" data-name="${(s.name || '').replace(/"/g,'&quot;')}" data-color="${s.color || '#B0B0B0'}">Edit</button>
76
+ <button class="btn btn-error btn-xs rfi-delete-btn" data-id="${s.id}">Delete</button>
77
+ </div></td>
78
+ </tr>`).join('');
79
+
80
+ const palette = RFI_PALETTE.map(c => `<div data-color="${c}" class="rounded cursor-pointer" style="background:${c};width:24px;height:24px;border:2px solid transparent;border-radius:4px" data-action="selectColor" data-args='["${c}"]' title="${c}"></div>`).join('');
81
+ const formHtml = `<div id="section-form" class="card-clean" style="margin-bottom:1rem;display:none">
82
+ <div class="card-clean-body">
83
+ <div style="display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end">
84
+ <div class="form-group"><label class="label"><span class="label-text font-medium">Name</span></label><input type="text" id="section-name" class="input input-solid" style="max-width:200px" placeholder="Section name"/></div>
85
+ <div><label class="label"><span class="label-text font-medium">Color</span></label><div id="color-palette" style="display:flex;gap:8px;flex-wrap:wrap">${palette}</div><input type="hidden" id="section-color" value="#B0B0B0"/></div>
86
+ <div class="flex gap-2"><button data-action="saveSection" class="btn btn-primary btn-sm">Save</button><button data-action="cancelForm" class="btn btn-ghost btn-sm">Cancel</button></div>
87
+ </div>
88
+ <input type="hidden" id="edit-id" value=""/>
89
+ </div>
90
+ </div>`;
91
+
92
+ const content = `${settingsBack()}
93
+ <div class="flex justify-between items-center mb-4">
94
+ <h1 class="text-2xl font-bold">RFI Sections</h1>
95
+ <button class="btn btn-primary btn-sm rfi-add-btn">Add Section</button>
96
+ </div>
97
+ ${formHtml}
98
+ <div class="card-clean"><div class="card-clean-body" style="padding:0rem">${inlineTable(['Color', 'Name', 'Order', 'Actions'], rows, 'No RFI sections. Add one below.')}</div></div>`;
99
+
100
+ const script = `${TOAST_SCRIPT}
101
+ function toggleAddForm(){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value='';document.getElementById('section-name').value='';selectColor('#B0B0B0')}
102
+ function cancelForm(){document.getElementById('section-form').style.display='none'}
103
+ function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'#1a1a1a':'transparent'})}
104
+ function editSection(id,name,color){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value=id;document.getElementById('section-name').value=name;selectColor(color)}
105
+ async function saveSection(){const name=document.getElementById('section-name').value.trim();const color=document.getElementById('section-color').value;const id=document.getElementById('edit-id').value;if(!name){showToast('Name is required','error');return}const url=id?'/api/rfi_section/'+id:'/api/rfi_section';const method=id?'PUT':'POST';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name,color})});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(()=>location.reload(),500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
106
+ async function deleteSection(id){if(!confirm('Delete this section?'))return;try{const res=await fetch('/api/rfi_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}}
107
+ async function moveSection(id,dir){try{const res=await fetch('/api/rfi_section/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({reorder:dir})});if(res.ok){location.reload()}else{showToast('Reorder failed','error')}}catch(e){showToast('Error','error')}}
108
+ selectColor('#B0B0B0');
109
+ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.rfi-add-btn').forEach(b=>b.addEventListener('click',function(e){e.preventDefault();toggleAddForm()}));document.querySelectorAll('.rfi-edit-btn').forEach(b=>b.addEventListener('click',function(e){e.preventDefault();editSection(this.dataset.id,this.dataset.name,this.dataset.color)}));document.querySelectorAll('.rfi-delete-btn').forEach(b=>b.addEventListener('click',function(e){e.preventDefault();deleteSection(this.dataset.id)}));document.querySelectorAll('.rfi-move-btn').forEach(b=>b.addEventListener('click',function(e){e.preventDefault();moveSection(this.dataset.id,this.dataset.direction)}))});`;
110
+
111
+ return settingsPage(user, 'RFI Sections - Settings', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'RFI Sections' }], content, [script]);
112
+ }