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,202 @@
1
+ import { canCreate } from '@/ui/permissions-ui.js';
2
+ import { page } from '@/ui/layout.js';
3
+ import { reviewCreateDialog, reviewTemplateChoiceDialog, reviewContextMenu, reviewFlagsDialog, reviewTagsDialog, reviewValueDialog, reviewDeadlineDialog, reviewNotificationDialog } from '@/ui/review-dialogs.js';
4
+ import { SPACING, renderButton, renderEmptyState, renderStatsRow, renderPageHeader } from '@/ui/spacing-system.js';
5
+
6
+ export { reviewCreateDialog, reviewTemplateChoiceDialog, reviewContextMenu, reviewFlagsDialog, reviewTagsDialog, reviewValueDialog, reviewDeadlineDialog, reviewNotificationDialog };
7
+ export { renderMwrHome, renderSectionReport } from '@/ui/review-mwr-renderer.js';
8
+
9
+ const TOAST_SCRIPT = `window.showToast=(m,t='info')=>{let c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';c.setAttribute('role','status');c.setAttribute('aria-live','polite');c.setAttribute('aria-atomic','true');document.body.appendChild(c)}const d=document.createElement('div');d.className='toast toast-'+t;d.textContent=m;c.appendChild(d);setTimeout(()=>{d.style.opacity='0';setTimeout(()=>d.remove(),300)},3000)};`;
10
+
11
+ const TAB_DEFS = [
12
+ { key: 'all', label: 'All', filter: () => true },
13
+ { key: 'active', label: 'Active', filter: r => r.status === 'active' || r.status === 'open' || r.status === 'in_progress' },
14
+ { key: 'priority', label: 'Priority', filter: r => r.is_priority || r.flagged || r.flags_count > 0 },
15
+ { key: 'history', label: 'History', filter: r => r.status === 'completed' || r.status === 'closed' },
16
+ { key: 'archive', label: 'Archive', filter: r => r.status === 'archived' || r.archived === 1 },
17
+ ];
18
+
19
+ function fmtDate(ts) {
20
+ if (!ts) return '-';
21
+ const n = Number(ts);
22
+ if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
23
+ return String(ts);
24
+ }
25
+
26
+ function statusBadge(status) {
27
+ const s = status || 'open';
28
+ const map = { active: 'pill pill-info', open: 'pill pill-info', in_progress: 'pill pill-info', completed: 'pill pill-success', closed: 'pill pill-success', archived: 'pill pill-neutral' };
29
+ const cls = map[s] || 'pill pill-warning';
30
+ return `<span class="${cls}">${s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</span>`;
31
+ }
32
+
33
+ function reviewRow(r) {
34
+ const isPri = (r.is_priority || r.flagged || (r.flags_count > 0)) ? 1 : 0;
35
+ return `<tr class="review-row" data-status="${r.status || ''}" data-flags="${r.flags_count || 0}" data-highlights="${r.highlights_count || 0}" data-archived="${r.archived || 0}" data-priority="${isPri}" data-stage="${r.stage || r.status || ''}" data-navigate="/review/${r.id}" oncontextmenu="showCtxMenu(event,'${r.id}')" style="cursor:pointer">
36
+ <td style="width:32px"><button class="review-priority-star${isPri?' is-priority':''}" data-action="togglePriority" data-args='["${r.id}"]' aria-label="Toggle priority" onclick="event.stopPropagation()">${isPri?'★':'☆'}</button></td>
37
+ <td class="review-row-name">${r.name || r.title || 'Untitled'}</td>
38
+ <td style="font-size:13px;color:var(--color-text-muted)">${r.engagement_name || r.engagement_id_display || '-'}</td>
39
+ <td>${statusBadge(r.status)}</td>
40
+ <td style="text-align:center;font-size:13px">${r.highlights_count !== undefined ? r.highlights_count : '-'}</td>
41
+ <td style="font-size:13px">${fmtDate(r.deadline)}</td>
42
+ <td style="font-size:13px;color:var(--color-text-light)">${fmtDate(r.created_at)}</td>
43
+ </tr>`;
44
+ }
45
+
46
+ export function renderReviewListTabbed(user, reviews) {
47
+ const counts = {};
48
+ TAB_DEFS.forEach(t => { counts[t.key] = reviews.filter(t.filter).length; });
49
+
50
+ const tabBar = `<nav class="tab-bar" style="margin-bottom:${SPACING.lg}">
51
+ ${TAB_DEFS.map(t => `<button class="tab-btn${t.key === 'all' ? ' active' : ''}" data-tab="${t.key}" data-action="switchTab" data-args='["${t.key}"]'>${t.label}<span class="tab-count">${counts[t.key]}</span></button>`).join('')}
52
+ </nav>`;
53
+
54
+ const createBtn = canCreate(user, 'review')
55
+ ? renderButton('New Review', { variant: 'primary', size: 'sm', action: 'openDialog', args: ['review-create-dialog'] })
56
+ : '';
57
+
58
+ const pageHeader = renderPageHeader(
59
+ 'Reviews',
60
+ `${reviews.length} total`,
61
+ `<label for="review-search" class="sr-only">Search reviews</label>
62
+ <input type="text" id="review-search" placeholder="Search reviews..." class="form-input review-search-input" aria-label="Search reviews"/>
63
+ ${createBtn}`
64
+ );
65
+
66
+ const reviewRows = reviews.length ? reviews.map(reviewRow).join('') : '';
67
+ const emptyState = reviews.length === 0 ? renderEmptyState(
68
+ `<div style="font-size:32px;margin-bottom:12px;opacity:0.3">&#128196;</div>
69
+ <svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
70
+ <div class="empty-state-title">No reviews yet</div>
71
+ <div class="empty-state-desc">Create a new review to start tracking work.</div>`
72
+ ) : '';
73
+
74
+ const tableWrap = `<div class="table-wrap">
75
+ <div class="table-toolbar">
76
+ <div class="table-search"></div>
77
+ <span class="table-count" id="row-count">${reviews.length} items</span>
78
+ </div>
79
+ <table class="data-table">
80
+ <thead>
81
+ <tr>
82
+ <th style="width:32px" aria-label="Priority"></th>
83
+ <th>Name</th>
84
+ <th>Engagement</th>
85
+ <th>Status</th>
86
+ <th style="text-align:center">Highlights</th>
87
+ <th>Deadline</th>
88
+ <th>Created</th>
89
+ </tr>
90
+ </thead>
91
+ <tbody id="review-tbody">${reviewRows || `<tr><td colspan="7">${emptyState}</td></tr>`}</tbody>
92
+ </table>
93
+ </div>`;
94
+
95
+ const content = `${pageHeader}${tabBar}${tableWrap}${reviewContextMenu()}`;
96
+ const bc = [{ href: '/', label: 'Dashboard' }, { href: '/review', label: 'Reviews' }];
97
+
98
+ const tabScript = `window.switchTab=function(key){
99
+ document.querySelectorAll('.tab-btn[data-tab]').forEach(function(b){b.classList.toggle('active',b.dataset.tab===key)});
100
+ var rows=document.querySelectorAll('#review-tbody tr.review-row');var count=0;
101
+ rows.forEach(function(r){var show=true;
102
+ if(key==='active')show=r.dataset.status==='active'||r.dataset.status==='open'||r.dataset.status==='in_progress';
103
+ else if(key==='priority')show=r.dataset.priority==='1';
104
+ else if(key==='history')show=r.dataset.status==='completed'||r.dataset.status==='closed';
105
+ else if(key==='archive')show=r.dataset.status==='archived'||r.dataset.archived==='1';
106
+ r.style.display=show?'':'none';if(show)count++;
107
+ });
108
+ var el=document.getElementById('row-count');if(el)el.textContent=count+' items';
109
+ }`;
110
+
111
+ const ctxScript = `var ctxEl=document.getElementById('review-ctx-menu');
112
+ window.showCtxMenu=function(e,id){e.preventDefault();if(!ctxEl)return;ctxEl.dataset.reviewId=id;ctxEl.style.display='block';ctxEl.style.left=e.pageX+'px';ctxEl.style.top=e.pageY+'px'};
113
+ document.addEventListener('click',function(){if(ctxEl)ctxEl.style.display='none'});
114
+ window.ctxAction=function(action){var id=ctxEl&&ctxEl.dataset.reviewId;if(!id)return;
115
+ if(action==='open')window.location='/review/'+id;
116
+ else if(action==='pdf')window.location='/review/'+id+'/pdf';
117
+ else if(action==='edit')window.location='/review/'+id+'/edit';
118
+ else if(action==='highlights')window.location='/review/'+id+'/highlights';
119
+ }`;
120
+
121
+ const priorityScript = `window.togglePriority=async function(id){try{var btn=document.querySelector('button[data-args=\\'["'+id+'"]\\']');var on=btn&&btn.classList.contains('is-priority');var res=await fetch('/api/review/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_priority:on?0:1})});if(res.ok){btn.classList.toggle('is-priority');btn.textContent=btn.classList.contains('is-priority')?'★':'☆';showToast('Priority updated','success')}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}};`;
122
+ const searchScript = `(function(){var input=document.getElementById('review-search');if(!input)return;
123
+ input.addEventListener('input',function(){var q=input.value.toLowerCase();
124
+ document.querySelectorAll('#review-tbody tr.review-row').forEach(function(r){
125
+ var text=r.textContent.toLowerCase();r.style.display=text.indexOf(q)>=0?'':'none';
126
+ });
127
+ });
128
+ })()`;
129
+
130
+ return page(user, 'Reviews | Moonlanding', bc, content, [TOAST_SCRIPT, tabScript, ctxScript, searchScript, priorityScript]);
131
+ }
132
+
133
+ export function reviewSearchField() {
134
+ return `<label for="review-search" class="sr-only">Search reviews</label><input type="text" id="review-search" placeholder="Search reviews..." class="form-input review-search-input" aria-label="Search reviews"/>`;
135
+ }
136
+
137
+ export function hideEmptyReviewsToggle() {
138
+ return `<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--color-text-muted);cursor:pointer" title="Hide reviews with 0 highlights"><input type="checkbox" id="hide-empty-toggle"/>Hide empty</label>`;
139
+ }
140
+
141
+ export function reviewGroupedList(reviews, groupBy) {
142
+ const groups = {};
143
+ reviews.forEach(r => {
144
+ const key = r[groupBy] || r[`${groupBy}_name`] || r[`${groupBy}_id`] || 'Ungrouped';
145
+ if (!groups[key]) groups[key] = [];
146
+ groups[key].push(r);
147
+ });
148
+ return Object.entries(groups).map(([name, items]) =>
149
+ `<div class="card-clean" style="margin-bottom:${SPACING.md}">
150
+ <div class="card-clean-body" style="padding:${SPACING.md}">
151
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:${SPACING.sm};cursor:pointer" data-action="toggleReviewGroup" data-self>
152
+ <span style="font-weight:600;font-size:14px">${name}</span>
153
+ <span class="pill pill-neutral">${items.length}</span>
154
+ </div>
155
+ <div class="review-group-items">
156
+ ${items.map(r => `<div class="list-item-row" style="padding:8px 0" data-navigate="/review/${r.id}">
157
+ <span class="list-item-name">${r.name || r.title || 'Untitled'}</span>
158
+ ${r.status ? statusBadge(r.status) : ''}
159
+ </div>`).join('')}
160
+ </div>
161
+ </div>
162
+ </div>`
163
+ ).join('');
164
+ }
165
+
166
+ export function renderReviewSections(user, review, sections) {
167
+ const totalHighlights = sections.reduce((s, sec) => s + (sec.highlights_count || 0), 0);
168
+ const resolvedHighlights = sections.reduce((s, sec) => s + (sec.resolved_count || 0), 0);
169
+ const pct = totalHighlights > 0 ? Math.round(resolvedHighlights / totalHighlights * 100) : 0;
170
+
171
+ const statsHtml = renderStatsRow([
172
+ { value: sections.length, label: 'Sections' },
173
+ { value: totalHighlights, label: 'Total Highlights' },
174
+ { value: resolvedHighlights, label: 'Resolved', color: 'var(--color-success)' },
175
+ { value: `${pct}%`, label: 'Progress' },
176
+ ]);
177
+
178
+ const sectionRows = sections.map((sec, i) => {
179
+ const secPct = sec.highlights_count > 0 ? Math.round((sec.resolved_count || 0) / sec.highlights_count * 100) : 0;
180
+ return `<div class="card-clean" style="margin-bottom:${SPACING.sm}">
181
+ <div class="card-clean-body" style="padding:${SPACING.md}">
182
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:${SPACING.sm}">
183
+ <span style="font-weight:600;font-size:14px">${sec.name || `Section ${i + 1}`}</span>
184
+ <span style="font-size:13px;color:var(--color-text-muted)">${sec.highlights_count || 0} highlights</span>
185
+ </div>
186
+ <div class="resolution-bar" style="margin-bottom:${SPACING.xs}">
187
+ <div class="resolution-bar-segment resolution-bar-resolved" style="width:${secPct}%"></div>
188
+ </div>
189
+ <div style="display:flex;justify-content:space-between;font-size:12px;color:var(--color-text-muted)">
190
+ <span>Resolved: ${sec.resolved_count || 0}</span>
191
+ <span>Flagged: ${sec.flagged_count || 0}</span>
192
+ <span>${secPct}%</span>
193
+ </div>
194
+ </div>
195
+ </div>`;
196
+ }).join('');
197
+
198
+ const content = `${renderPageHeader(`Sections: ${review.name || 'Review'}`, '')}${statsHtml}${sectionRows || renderEmptyState('No sections found')}`;
199
+ const bc = [{ href: '/', label: 'Dashboard' }, { href: '/review', label: 'Reviews' }, { href: `/review/${review.id}`, label: review.name || 'Review' }, { label: 'Sections' }];
200
+ return page(user, `Sections - ${review.name || 'Review'}`, bc, content);
201
+ }
202
+
@@ -0,0 +1,88 @@
1
+ import { h } from '@/ui/webjsx.js'
2
+ import { statusLabel, TOAST_SCRIPT } from '@/ui/render-helpers.js'
3
+ import { teamAvatarGroup } from '@/ui/widgets.js'
4
+
5
+ export function mobileReviewCard(review) {
6
+ const r = review || {}
7
+ const sts = r.status ? statusLabel(r.status) : ''
8
+ const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at}</span>` : ''
9
+ return `<div class="mobile-card mobile-review-card card-clean">
10
+ <div class="card-clean-body">
11
+ <div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${r.name || r.title || 'Untitled Review'}</h3>${sts}</div>
12
+ <div class="flex items-center gap-2 text-xs text-gray-500 mb-2">${r.engagement_name ? `<span>${r.engagement_name}</span>` : ''}${date}</div>
13
+ ${r.highlights_count !== undefined ? `<div class="text-xs text-gray-500">${r.highlights_count} highlight${r.highlights_count !== 1 ? 's' : ''}</div>` : ''}
14
+ <div class="flex gap-1 mt-2"><a href="/review/${r.id}" class="btn btn-xs btn-primary">View</a><a href="/review/${r.id}/edit" class="btn btn-xs btn-outline">Edit</a></div>
15
+ </div></div>`
16
+ }
17
+
18
+ export function sidebarReviewDetails(review) {
19
+ const r = review || {}
20
+ const sts = r.status ? statusLabel(r.status) : ''
21
+ const meta = [
22
+ r.created_at ? ['Created', typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at] : null,
23
+ r.updated_at ? ['Updated', typeof r.updated_at === 'number' ? new Date(r.updated_at * 1000).toLocaleDateString() : r.updated_at] : null,
24
+ r.creator_name ? ['Creator', r.creator_name] : null,
25
+ r.manager_name ? ['Manager', r.manager_name] : null,
26
+ ].filter(Boolean).map(([l, v]) => `<div class="sidebar-meta-row"><span class="text-gray-500 text-xs">${l}</span><span class="text-sm font-medium">${v}</span></div>`).join('')
27
+ const highlights = r.highlights_count !== undefined ? `<div class="sidebar-section"><span class="text-xs text-gray-500">Highlights</span><span class="font-medium">${r.highlights_count}</span></div>` : ''
28
+ const collaborators = r.collaborators?.length ? `<div class="sidebar-section"><span class="text-xs text-gray-500 mb-1">Collaborators</span>${teamAvatarGroup(r.collaborators, 5)}</div>` : ''
29
+ const checklists = r.checklists_total !== undefined ? `<div class="sidebar-section"><span class="text-xs text-gray-500">Checklists</span><span class="font-medium">${r.checklists_completed || 0}/${r.checklists_total}</span></div>` : ''
30
+ return `<aside class="sidebar-review-panel" id="sidebar-review-panel">
31
+ <div class="sidebar-header"><h3 class="font-medium">Review Details</h3><button class="btn btn-ghost btn-xs sidebar-toggle" data-action="toggleSidebar" aria-label="Toggle sidebar">&times;</button></div>
32
+ <div class="sidebar-body">
33
+ <div class="sidebar-section"><h4 class="font-medium text-sm mb-1">${r.name || r.title || 'Untitled Review'}</h4>${sts}</div>
34
+ ${meta ? `<div class="sidebar-section">${meta}</div>` : ''}
35
+ ${highlights}${collaborators}${checklists}
36
+ </div></aside>`
37
+ }
38
+
39
+ export function archiveReviewDialog() {
40
+ return `<div id="archive-review-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="archive-review-dialog-title" aria-hidden="true">
41
+ <div class="dialog-panel">
42
+ <div class="dialog-header"><span class="dialog-title" id="archive-review-dialog-title">Archive Review</span><button class="dialog-close" data-dialog-close="archive-review-dialog" aria-label="Close dialog">&times;</button></div>
43
+ <div class="dialog-body">
44
+ <div class="archive-ctx">This action will archive the review. Archived reviews are hidden from active lists but can be restored.</div>
45
+ <div class="modal-form-group"><label class="form-label" for="ard-confirm">Type ARCHIVE to confirm</label><input type="text" id="ard-confirm" class="archive-type-input" placeholder="Type ARCHIVE"/></div>
46
+ </div>
47
+ <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="archive-review-dialog">Cancel</button><button class="btn btn-error btn-sm" id="ard-btn" data-action="ardConfirm">Archive</button></div>
48
+ </div></div>
49
+ <script>${TOAST_SCRIPT}
50
+ window._ardId=null;
51
+ window.showArchiveDialog=function(reviewId){window._ardId=reviewId;document.getElementById('archive-review-dialog').style.display='flex';document.getElementById('ard-confirm').value=''};
52
+ window.ardConfirm=async function(){if(document.getElementById('ard-confirm').value!=='ARCHIVE'){showToast('Type ARCHIVE to confirm','error');return}try{var res=await fetch('/api/review/'+window._ardId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:'archived'})});if(res.ok){showToast('Review archived','success');document.getElementById('archive-review-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{showToast('Archive failed','error')}}catch(e){showToast('Error','error')}};</script>`
53
+ }
54
+
55
+ export function reviewOpenCloseToggle(reviewId, isOpen) {
56
+ const on = isOpen ? 'rvw-on rvw-green' : ''
57
+ return `<div class="rvw-toggle">
58
+ <div class="rvw-toggle-track ${on}" id="rvw-oc-track" role="switch" tabindex="0" aria-checked="${isOpen}" aria-label="Review open/closed status" data-action="rvwToggleOC" data-args='["${reviewId}"]' onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();rvwToggleOC('${reviewId}')}"><div class="rvw-toggle-knob"></div></div>
59
+ <span class="rvw-toggle-label">${isOpen ? 'Open' : 'Closed'}</span>
60
+ </div>
61
+ <script>${TOAST_SCRIPT}
62
+ window.rvwToggleOC=async function(id){var t=document.getElementById('rvw-oc-track');var isOn=t.classList.contains('rvw-on');var newStatus=isOn?'closed':'open';try{var res=await fetch('/api/review/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:newStatus})});if(res.ok){t.classList.toggle('rvw-on');t.classList.toggle('rvw-green');t.setAttribute('aria-checked',!isOn);t.nextElementSibling.textContent=isOn?'Closed':'Open';showToast('Review '+(isOn?'closed':'opened'),'success')}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};</script>`
63
+ }
64
+
65
+ export function reviewPrivateToggle(reviewId, isPrivate) {
66
+ const on = isPrivate ? 'rvw-on rvw-purple' : ''
67
+ return `<div class="rvw-toggle">
68
+ <div class="rvw-toggle-track ${on}" id="rvw-priv-track" role="switch" tabindex="0" aria-checked="${isPrivate}" aria-label="Review private/public toggle" data-action="rvwTogglePriv" data-args='["${reviewId}"]' onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();rvwTogglePriv('${reviewId}')}"><div class="rvw-toggle-knob"></div></div>
69
+ <span class="rvw-toggle-label"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" style="vertical-align:middle;margin-right:4px"><path d="${isPrivate ? 'M8 1a4 4 0 014 4v2h1a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1V8a1 1 0 011-1h1V5a4 4 0 014-4zm2 6V5a2 2 0 10-4 0v2h4z' : 'M12 7h1a1 1 0 011 1v6a1 1 0 01-1 1H3a1 1 0 01-1-1V8a1 1 0 011-1h7V5a2 2 0 10-4 0v1H4V5a4 4 0 018 0v2z'}"/></svg>${isPrivate ? 'Private' : 'Public'}</span>
70
+ </div>
71
+ <script>${TOAST_SCRIPT}
72
+ window.rvwTogglePriv=async function(id){var t=document.getElementById('rvw-priv-track');var isOn=t.classList.contains('rvw-on');try{var res=await fetch('/api/review/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_private:isOn?0:1})});if(res.ok){t.classList.toggle('rvw-on');t.classList.toggle('rvw-purple');showToast(isOn?'Made public':'Made private','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};</script>`
73
+ }
74
+
75
+ export function markAllHighlightsResolved(reviewId, unresolvedCount = 0) {
76
+ return `<div id="bulk-resolve-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="bulk-resolve-dialog-title" aria-hidden="true">
77
+ <div class="dialog-panel" style="max-width:380px">
78
+ <div class="dialog-header"><span class="dialog-title" id="bulk-resolve-dialog-title">Resolve All Highlights</span><button class="dialog-close" data-dialog-close="bulk-resolve-dialog" aria-label="Close dialog">&times;</button></div>
79
+ <div class="dialog-body">
80
+ <div class="bulk-resolve-count" id="brc-count">${unresolvedCount}</div>
81
+ <div class="bulk-resolve-label">unresolved highlights will be marked as resolved</div>
82
+ </div>
83
+ <div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="bulk-resolve-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="bulkResolve" data-args='["${reviewId}"]'>Resolve All</button></div>
84
+ </div></div>
85
+ <script>${TOAST_SCRIPT}
86
+ window.showBulkResolve=function(reviewId,count){document.getElementById('brc-count').textContent=count;document.getElementById('bulk-resolve-dialog').style.display='flex'};
87
+ window.bulkResolve=async function(id){try{var res=await fetch('/api/review/'+id+'/resolve-all',{method:'POST',headers:{'Content-Type':'application/json'}});if(res.ok){showToast('All highlights resolved','success');document.getElementById('bulk-resolve-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{var alt=await fetch('/api/highlight?review_id='+id);var hl=await alt.json();var highlights=(hl.data||hl||[]).filter(function(h){return !h.resolved&&h.status!=='resolved'});var resolved=0;for(var h of highlights){var r2=await fetch('/api/highlight/'+h.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolved:1,status:'resolved'})});if(r2.ok)resolved++}showToast(resolved+' highlights resolved','success');document.getElementById('bulk-resolve-dialog').style.display='none';setTimeout(function(){location.reload()},500)}}catch(e){showToast('Error: '+e.message,'error')}};</script>`
88
+ }
@@ -0,0 +1,12 @@
1
+ export function reviewZoneNav(reviewId, active) {
2
+ const links = [
3
+ { href: `/review/${reviewId}`, label: 'Overview', key: 'overview' },
4
+ { href: `/review/${reviewId}/pdf`, label: 'PDF', key: 'pdf' },
5
+ { href: `/review/${reviewId}/highlights`, label: 'Highlights', key: 'highlights' },
6
+ { href: `/review/${reviewId}/sections`, label: 'Sections', key: 'sections' },
7
+ { href: `/review/${reviewId}/resolution`, label: 'Resolution', key: 'resolution' },
8
+ ];
9
+ return `<nav class="tab-bar" style="margin-bottom:1.25rem;overflow-x:auto;scrollbar-width:none">
10
+ ${links.map(l => `<a href="${l.href}" class="tab-btn${l.key===active?' active':''}">${l.label}</a>`).join('')}
11
+ </nav>`;
12
+ }
@@ -0,0 +1,191 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { canEdit, isPartner, isManager, isClientUser } from '@/ui/permissions-ui.js';
3
+ import { esc, statusBadge, TOAST_SCRIPT } from '@/ui/render-helpers.js';
4
+ import { SPACING } from '@/ui/spacing-system.js';
5
+
6
+ const TOAST = TOAST_SCRIPT;
7
+
8
+ function questionRow(q, i, sections = [], clientId = '', showRespond = false, showView = false) {
9
+ const section = sections.find(s => s.id === q.section_id || s.id === q.rfi_section_id);
10
+ const sLabel = section ? `<span class="badge badge-flat-primary text-xs">${esc(section.name)}</span>` : '';
11
+ const hasResp = q.responses > 0 || q.response_count > 0;
12
+ const respBadge = hasResp ? `<span class="badge badge-success badge-flat-success text-xs">Responded</span>` : '';
13
+ const assignedLabel = q.assigned_to ? `<span class="badge badge-flat-secondary text-xs">Assigned</span>` : '';
14
+ const assignBtn = clientId ? `<button data-action="openAssignQuestion" data-args='["${esc(q.id)}"]' class="btn btn-ghost btn-xs">Assign</button>` : '';
15
+ const respondBtn = showRespond ? `<button data-action="openRespondQuestion" data-args='["${esc(q.id)}","${esc(q.question_text||q.question||q.title||'')}"]' class="btn btn-primary btn-xs">Respond</button>` : '';
16
+ const viewBtn = showView && hasResp ? `<button data-action="viewResponses" data-args='["${esc(q.id)}","${esc(q.question_text||q.question||q.title||'')}"]' class="btn btn-ghost btn-xs">Responses</button>` : '';
17
+ const editDelBtns = !showRespond ? `<button data-action="openEditQuestion" data-args='["${esc(q.id)}"]' class="btn btn-ghost btn-xs">Edit</button>${assignBtn}<button data-action="deleteQuestion" data-args='["${esc(q.id)}"]' class="btn btn-error btn-xs">Del</button>` : '';
18
+ return `<tr class="hover">
19
+ <td class="text-center text-sm text-base-content/40 w-8">${i+1}</td>
20
+ <td class="text-sm max-w-sm">${esc(q.question_text||q.question||q.title||'Question')}</td>
21
+ <td>${sLabel}</td>
22
+ <td class="text-sm text-base-content/50">${q.deadline?new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline).toLocaleDateString():'-'}</td>
23
+ <td>${respBadge}${assignedLabel}</td>
24
+ <td><div class="flex gap-2">${respondBtn}${viewBtn}${editDelBtns}</div></td>
25
+ </tr>`;
26
+ }
27
+
28
+ function editQuestionDialog(rfiId, sections) {
29
+ const sectionOpts = sections.map(s => `<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');
30
+ return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" data-dialog-close-overlay="true"><div class="modal-overlay" data-dialog-close="q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-4" id="q-dialog-title">Edit Question</h3><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Question Text</span></label><textarea id="q-text" rows="4" class="textarea textarea-solid max-w-full"></textarea></div><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Section</span></label><select id="q-section" class="select select-solid max-w-full"><option value="">No section</option>${sectionOpts}</select></div><div class="grid grid-cols-2 gap-3 mb-4"><div class="form-group"><label class="label"><span class="label-text font-medium">Deadline</span></label><input type="date" id="q-deadline" class="input input-solid max-w-full"/></div><div class="form-group"><label class="label"><span class="label-text font-medium">Category</span></label><input type="text" id="q-category" placeholder="Category" class="input input-solid max-w-full"/></div></div><input type="hidden" id="q-edit-id"/><div class="modal-action"><button data-action="saveQuestion" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Save</button><button data-dialog-close="q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
31
+ }
32
+
33
+ function assignQuestionDialog(rfiId) {
34
+ return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" data-dialog-close-overlay="true"><div class="modal-overlay" data-dialog-close="assign-q-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><h3 class="text-lg font-semibold mb-4">Assign to Client User</h3><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Client User</span></label><select id="assign-q-user" class="select select-solid max-w-full"><option value="">Loading...</option></select></div><input type="hidden" id="assign-q-id"/><div class="modal-action"><button onclick="saveAssignQuestion()" class="btn btn-primary">Assign</button><button data-dialog-close="assign-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
35
+ }
36
+
37
+ function addSectionDialog(rfiId) {
38
+ return `<div id="sec-dialog" class="modal" style="display:none" data-dialog-close-overlay="true"><div class="modal-overlay" data-dialog-close="sec-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><h3 class="text-lg font-semibold mb-4">Add Section</h3><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Section Name</span></label><input type="text" id="sec-name" class="input input-solid max-w-full"/></div><div class="modal-action"><button data-action="saveSection" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Add</button><button data-dialog-close="sec-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
39
+ }
40
+
41
+ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], engagement = null) {
42
+ const canEditRfi = canEdit(user, 'rfi');
43
+ const isClient = isClientUser(user);
44
+ const canViewResponses = isPartner(user) || isManager(user);
45
+ const clientId = engagement?.client_id || '';
46
+ const qRows = questions.map((q, i) => questionRow(q, i, sections, canEditRfi ? clientId : '', isClient, canViewResponses)).join('') ||
47
+ `<tr><td colspan="6" class="text-center py-8 text-base-content/40 text-sm">No questions yet. Add one using the button above.</td></tr>`;
48
+
49
+ const sectionTabs = sections.length ? `<div class="tabs tabs-boxed bg-base-200 flex-wrap gap-1">
50
+ <button data-action="filterBySection" data-args='[""]' id="stab-all" class="tab-btn active">All</button>
51
+ ${sections.map(s => `<button data-action="filterBySection" data-args='["${esc(s.id)}"]' id="stab-${esc(s.id)}" class="tab-btn">${esc(s.name)}</button>`).join('')}
52
+ </div>` : '';
53
+
54
+ const engLink = engagement
55
+ ? `<a href="/engagement/${esc(engagement.id)}" class="text-primary text-sm">&#8592; ${esc(engagement.name||'Engagement')}</a>`
56
+ : `<a href="/rfi" class="text-primary text-sm">&#8592; RFI List</a>`;
57
+
58
+ const reportBtn = `<a href="/rfi/${esc(rfi.id)}/report" target="_blank" class="btn btn-ghost btn-sm">Report</a>`;
59
+ const headerBtns = `<div class="flex gap-2 flex-wrap">${reportBtn}${canEditRfi ? `
60
+ <button data-action="openAddQuestion" class="btn btn-primary btn-sm">+ Question</button>
61
+ <button data-action="openDialog" data-params='{"dialogId":"sec-dialog"}' class="btn btn-ghost btn-sm">+ Section</button>
62
+ <button data-action="sendReminder" data-args='["${esc(rfi.id)}"]' class="btn btn-ghost btn-sm">Send Reminder</button>
63
+ <a href="/rfi/${esc(rfi.id)}/edit" class="btn btn-outline-primary btn-sm">Edit RFI</a>
64
+ ` : ''}</div>`;
65
+
66
+ const infoItems = [
67
+ ['Engagement', engagement?.name ? esc(engagement.name) : '<span class="text-base-content/30">—</span>'],
68
+ ['Status', statusBadge(rfi.status)],
69
+ ['Deadline', rfi.deadline?new Date(typeof rfi.deadline==='number'?rfi.deadline*1000:rfi.deadline).toLocaleDateString():'-'],
70
+ ['Questions', questions.length],
71
+ ['Sections', sections.length],
72
+ ['Mandatory', rfi.mandatory!==false?'Yes':'No'],
73
+ ];
74
+
75
+ const infoGrid = `<div class="rfi-info-grid">` +
76
+ infoItems.map(([l,v]) => `<div><div style="font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:var(--color-text-muted);margin-bottom:${SPACING.xs}">${l}</div><div style="font-size:0.875rem;color:var(--color-text)">${v}</div></div>`).join('') +
77
+ `</div>`;
78
+
79
+ const content = `
80
+ <nav class="breadcrumb-clean">${engLink}</nav>
81
+ <div class="page-header">
82
+ <div>
83
+ <h1 class="page-title">${esc(rfi.display_name||rfi.name||rfi.title||(engagement?'RFI \u2013 '+(engagement.name||''):'RFI'))}</h1>
84
+ ${rfi.description ? `<p style="margin-top:${SPACING.xs};font-size:0.875rem;color:var(--color-text-muted)">${esc(rfi.description)}</p>` : ''}
85
+ </div>
86
+ ${headerBtns}
87
+ </div>
88
+ <div class="card-clean mb-4">
89
+ <div class="card-clean-body">${infoGrid}</div>
90
+ </div>
91
+ <div class="card-clean">
92
+ <div class="card-clean-body">
93
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:${SPACING.md};flex-wrap:wrap;gap:${SPACING.sm}">
94
+ <h2 style="font-size:0.875rem;font-weight:600;color:var(--color-text)">Questions (${questions.length})</h2>
95
+ <div style="display:flex;gap:${SPACING.sm};align-items:center;flex-wrap:wrap">
96
+ ${sectionTabs}
97
+ <input type="text" placeholder="Search..." oninput="filterQuestions(this.value)" class="input input-solid" style="max-width:160px;font-size:0.875rem"/>
98
+ </div>
99
+ </div>
100
+ <div class="table-wrap">
101
+ <table class="data-table" id="q-table">
102
+ <thead><tr><th>#</th><th>Question</th><th>Section</th><th>Deadline</th><th>Status</th><th>Actions</th></tr></thead>
103
+ <tbody id="q-tbody">${qRows}</tbody>
104
+ </table>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ ${editQuestionDialog(rfi.id, sections)}
109
+ ${addSectionDialog(rfi.id)}
110
+ ${clientId ? assignQuestionDialog(rfi.id) : ''}
111
+ ${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none" data-dialog-close-overlay="true"><div class="modal-overlay" data-dialog-close="view-resp-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-1">Responses</h3><p id="view-resp-qtext" class="text-sm text-base-content/60 mb-3"></p><div id="view-resp-list" style="max-height:240px;overflow-y:auto"></div><div class="modal-action"><button data-dialog-close="view-resp-dialog" class="btn btn-ghost">Close</button></div></div></div>` : ''}
112
+ ${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none" data-dialog-close-overlay="true"><div class="modal-overlay" data-dialog-close="respond-q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-1">Respond to Question</h3><p id="respond-q-text" class="text-sm text-base-content/60 mb-4"></p><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Your Response</span></label><textarea id="respond-q-answer" rows="5" class="textarea textarea-solid max-w-full" placeholder="Enter your response..."></textarea></div><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Attach File (optional)</span></label><input type="file" id="respond-q-file" class="input input-solid max-w-full"/></div><input type="hidden" id="respond-q-id"/><div class="modal-action"><button onclick="submitResponse()" class="btn btn-primary">Submit</button><button data-dialog-close="respond-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>` : ''}
113
+ `;
114
+
115
+ const script = `${TOAST}
116
+ var activeSection='';
117
+ function filterBySection(sid){
118
+ activeSection=sid;
119
+ document.querySelectorAll('[id^="stab-"]').forEach(b=>b.classList.toggle('active',b.id==='stab-'+(sid||'all')));
120
+ filterRows()
121
+ }
122
+ function filterRows(){var q=(document.querySelector('#q-table input')?.value||'').toLowerCase();document.querySelectorAll('#q-tbody tr').forEach(function(r){var text=(r.textContent||'').toLowerCase();var secSpan=r.querySelector('td:nth-child(3) span');var rowSec=secSpan?secSpan.textContent:'';var showSec=!activeSection||rowSec===document.getElementById('stab-'+activeSection)?.textContent;r.style.display=(!q||text.includes(q))&&showSec?'':'none'})}
123
+ function filterQuestions(q){filterRows()}
124
+ function openAddQuestion(){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Add Question';document.getElementById('q-edit-id').value='';document.getElementById('q-text').value='';document.getElementById('q-section').value='';document.getElementById('q-deadline').value='';document.getElementById('q-category').value=''}
125
+ async function openEditQuestion(id){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Edit Question';document.getElementById('q-edit-id').value=id;try{var rfiId=document.getElementById('q-dialog').dataset.rfiId;var r=await fetch('/api/rfi/'+rfiId+'/questions/'+id);var d=await r.json();var q=d.data||d;document.getElementById('q-text').value=q.question_text||q.question||q.title||'';document.getElementById('q-section').value=q.section_id||q.rfi_section_id||'';document.getElementById('q-deadline').value=q.deadline?(new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline)).toISOString().split('T')[0]:'';document.getElementById('q-category').value=q.category||''}catch(e){showToast('Error loading','error')}}
126
+ async function saveQuestion(rfiId){var id=document.getElementById('q-edit-id').value;var questionText=document.getElementById('q-text').value.trim();if(!questionText){showToast('Question text required','error');return}var data={question:questionText,category:document.getElementById('q-category').value.trim()||null,section_id:document.getElementById('q-section').value||null};var dl=document.getElementById('q-deadline').value;if(dl)data.due_date=Math.floor(new Date(dl).getTime()/1000);var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Saving...'}try{var url=id?'/api/rfi/'+rfiId+'/questions/'+id:'/api/rfi/'+rfiId+'/questions';var r=await fetch(url,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});var result=await r.json();if(r.ok){showToast(id?'Question updated':'Question added','success');document.getElementById('q-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{showToast(result.message||result.error||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}finally{if(btn){btn.disabled=false;btn.textContent='Save'}}}
127
+ async function deleteQuestion(id){if(!confirm('Delete this question?'))return;var rfiId=document.getElementById('q-dialog').dataset.rfiId;try{var r=await fetch('/api/rfi/'+rfiId+'/questions/'+id,{method:'DELETE'});if(r.ok){showToast('Question deleted','success');setTimeout(function(){location.reload()},500)}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
128
+ async function saveSection(rfiId){var name=document.getElementById('sec-name').value.trim();if(!name){showToast('Name required','error');return}try{var r=await fetch('/api/rfi_section',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,rfi_id:rfiId})});if(r.ok){showToast('Section added','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}document.getElementById('sec-dialog').style.display='none'}
129
+ async function sendReminder(rfiId){try{var r=await fetch('/api/friday/rfi/reminder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rfi_id:rfiId})});if(r.ok)showToast('Reminder sent','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}}`;
130
+
131
+ const assignScript = clientId ? `
132
+ var _assignClientId=${JSON.stringify(clientId)};
133
+ window.openAssignQuestion=async function(qId){
134
+ var dlg=document.getElementById('assign-q-dialog');
135
+ dlg.style.display='flex';
136
+ document.getElementById('assign-q-id').value=qId;
137
+ var sel=document.getElementById('assign-q-user');
138
+ sel.innerHTML='<option value="">Loading...</option>';
139
+ try{
140
+ var r=await fetch('/api/users?client_id='+_assignClientId+'&pageSize=100',{credentials:'include'});
141
+ var d=await r.json();
142
+ var users=(d.data||d||[]).filter(function(u){return !u.deleted_at});
143
+ sel.innerHTML='<option value="">Select client user...</option>'+users.map(function(u){return'<option value="'+u.id+'">'+(u.name||u.email)+'</option>'}).join('');
144
+ }catch(e){sel.innerHTML='<option value="">Error loading users</option>'}
145
+ };
146
+ window.saveAssignQuestion=async function(){
147
+ var qId=document.getElementById('assign-q-id').value;
148
+ var userId=document.getElementById('assign-q-user').value;
149
+ if(!userId){showToast('Select a user','error');return}
150
+ var rfiId=document.getElementById('assign-q-dialog').dataset.rfiId;
151
+ try{
152
+ var r=await fetch('/api/rfi/'+rfiId+'/questions/'+qId,{method:'PUT',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({assigned_to:userId})});
153
+ if(r.ok){showToast('Question assigned','success');document.getElementById('assign-q-dialog').style.display='none';setTimeout(function(){location.reload()},500)}
154
+ else showToast('Failed to assign','error')
155
+ }catch(e){showToast('Error: '+e.message,'error')}
156
+ };` : '';
157
+
158
+ const viewRespScript = canViewResponses ? `
159
+ var _rfiIdForView=${JSON.stringify(rfi.id)};
160
+ window.viewResponses=async function(qId,qText){document.getElementById('view-resp-qtext').textContent=qText||'';document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-base-content/40">Loading...</div>';document.getElementById('view-resp-dialog').style.display='flex';try{var r=await fetch('/api/rfi/'+_rfiIdForView+'/questions/'+qId+'/responses',{credentials:'include'});var d=await r.json();var items=d.data||d||[];document.getElementById('view-resp-list').innerHTML=items.length?items.map(function(resp){return'<div style="border:1px solid var(--color-border);border-radius:6px;padding:8px;margin-bottom:6px"><div class="text-sm">'+resp.response+'</div><div class="text-xs text-base-content/40 mt-1">'+new Date(typeof resp.created_at==="number"?resp.created_at*1000:resp.created_at).toLocaleString()+'</div></div>'}).join(''):'<div class="text-sm text-base-content/40">No responses yet</div>'}catch(e){document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-error">Error loading</div>'}};` : '';
161
+
162
+ const respondScript = isClient ? `
163
+ var _respondRfiId=${JSON.stringify(rfi.id)};
164
+ window.openRespondQuestion=function(qId,qText){
165
+ document.getElementById('respond-q-id').value=qId;
166
+ document.getElementById('respond-q-text').textContent=qText||'';
167
+ document.getElementById('respond-q-answer').value='';
168
+ document.getElementById('respond-q-file').value='';
169
+ document.getElementById('respond-q-dialog').style.display='flex';
170
+ };
171
+ window.submitResponse=async function(){
172
+ var qId=document.getElementById('respond-q-id').value;
173
+ var answer=document.getElementById('respond-q-answer').value.trim();
174
+ if(!answer){showToast('Response required','error');return}
175
+ var fileInput=document.getElementById('respond-q-file');
176
+ var file=fileInput.files&&fileInput.files[0];
177
+ var btn=event&&event.target;
178
+ if(btn){btn.disabled=true;btn.textContent='Submitting...'}
179
+ try{
180
+ var body;var headers={};
181
+ if(file){body=new FormData();body.append('response_text',answer);body.append('file',file)}
182
+ else{body=JSON.stringify({response_text:answer});headers['Content-Type']='application/json'}
183
+ var r=await fetch('/api/rfi/'+_respondRfiId+'/questions/'+qId+'/responses',{method:'POST',headers,body,credentials:'include'});
184
+ if(r.ok){showToast('Response submitted','success');document.getElementById('respond-q-dialog').style.display='none';setTimeout(function(){location.reload()},500)}
185
+ else{var d=await r.json();showToast(d.error||'Failed','error')}
186
+ }catch(e){showToast('Error: '+e.message,'error')}
187
+ finally{if(btn){btn.disabled=false;btn.textContent='Submit'}}
188
+ };` : '';
189
+
190
+ return page(user, `${esc(rfi.name||'RFI')} | Moonlanding`, null, content, [script, assignScript, viewRespScript, respondScript]);
191
+ }