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.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { statusLabel } from '@/ui/renderer.js';
|
|
2
|
+
|
|
3
|
+
export function rfiSectionCrudPanel(engagementId, sections = []) {
|
|
4
|
+
const rows = sections.map((s, i) => `<div class="rfi-section-row" draggable="true" data-id="${s.id}" data-idx="${i}" ondragstart="rfiDragStart(event)" ondragover="rfiDragOver(event)" ondrop="rfiDrop(event)"><span class="rfi-section-handle">☰</span><span class="rfi-section-name">${s.name || 'Untitled'}</span><span class="badge-status">${s.question_count || 0} Q</span><div class="flex gap-1"><button class="btn btn-xs btn-ghost" data-action="rfiEditSection" data-args='["${s.id}","${(s.name || '').replace(/"/g, '"')}"]'>Edit</button><button class="btn btn-xs btn-ghost btn-error" data-action="rfiDeleteSection" data-args='["${s.id}"]'>Del</button></div></div>`).join('');
|
|
5
|
+
return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body"><div class="flex justify-between items-center mb-3"><h3 style="font-size:0.875rem;font-weight:600">RFI Sections</h3><button class="btn btn-primary btn-sm" data-action="rfiAddSection">Add Section</button></div><div id="rfi-sections-list">${rows || '<div class="text-gray-500 text-sm text-center py-3">No sections yet</div>'}</div></div></div>
|
|
6
|
+
<script>
|
|
7
|
+
var rfiEngId='${engagementId}';var dragSrc=null;
|
|
8
|
+
window.rfiDragStart=function(e){dragSrc=e.target.closest('.rfi-section-row');e.dataTransfer.effectAllowed='move'};
|
|
9
|
+
window.rfiDragOver=function(e){e.preventDefault();e.dataTransfer.dropEffect='move'};
|
|
10
|
+
window.rfiDrop=function(e){e.preventDefault();var tgt=e.target.closest('.rfi-section-row');if(!tgt||!dragSrc||tgt===dragSrc)return;var list=tgt.parentNode;var all=[].slice.call(list.children);var srcIdx=all.indexOf(dragSrc);var tgtIdx=all.indexOf(tgt);if(srcIdx<tgtIdx)list.insertBefore(dragSrc,tgt.nextSibling);else list.insertBefore(dragSrc,tgt);var order=[].slice.call(list.children).map(function(el,i){return{id:el.dataset.id,sort_order:i}});fetch('/api/rfi_section/reorder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({engagement_id:rfiEngId,order:order})}).then(function(){showToast('Reordered','success')}).catch(function(){showToast('Reorder failed','error')})};
|
|
11
|
+
window.rfiAddSection=function(){var name=prompt('Section name:');if(!name)return;fetch('/api/rfi_section',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,engagement_id:rfiEngId,sort_order:99})}).then(function(r){if(r.ok){showToast('Section added','success');location.reload()}else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
|
|
12
|
+
window.rfiEditSection=function(id,name){var n=prompt('Section name:',name);if(!n||n===name)return;fetch('/api/rfi_section/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:n})}).then(function(r){if(r.ok){showToast('Updated','success');location.reload()}else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
|
|
13
|
+
window.rfiDeleteSection=function(id){if(!confirm('Delete this section?'))return;fetch('/api/rfi_section/'+id,{method:'DELETE'}).then(function(r){if(r.ok){showToast('Deleted','success');location.reload()}else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
|
|
14
|
+
</script>`;
|
|
15
|
+
}
|
|
16
|
+
export function rfiQuestionEditDialog(questionId) {
|
|
17
|
+
return `<div id="rfi-q-edit" 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="rfi-q-edit-title" aria-hidden="true">
|
|
18
|
+
<div class="dialog-panel" style="max-width:640px">
|
|
19
|
+
<div class="dialog-header"><span class="dialog-title" id="rfi-q-edit-title">Edit Question</span><button class="dialog-close" data-dialog-close="rfi-q-edit" aria-label="Close dialog">×</button></div>
|
|
20
|
+
<div class="dialog-body">
|
|
21
|
+
<div class="modal-form-group"><label for="rqe-text">Question Text</label><textarea id="rqe-text" class="textarea textarea-bordered w-full" rows="4"></textarea></div>
|
|
22
|
+
<div class="grid grid-cols-2 gap-4">
|
|
23
|
+
<div class="modal-form-group"><label for="rqe-cat">Category</label><input id="rqe-cat" class="input input-bordered w-full" placeholder="Category"/></div>
|
|
24
|
+
<div class="modal-form-group"><label for="rqe-deadline">Deadline</label><input type="date" id="rqe-deadline" class="input input-bordered w-full"/></div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="modal-form-group"><label for="rqe-assign">Assigned To</label><select id="rqe-assign" class="select select-bordered w-full"><option value="">Unassigned</option></select></div>
|
|
27
|
+
<div class="modal-form-group"><label class="flex items-center gap-2"><input type="checkbox" id="rqe-required" class="checkbox" checked/><span class="text-sm">Required</span></label></div>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-q-edit">Cancel</button><button class="btn btn-primary btn-sm" data-action="rqeSave">Save</button></div>
|
|
30
|
+
</div></div>
|
|
31
|
+
<script>
|
|
32
|
+
var rqeId=null;
|
|
33
|
+
window.openQuestionEdit=function(id){rqeId=id;document.getElementById('rfi-q-edit').style.display='flex';fetch('/api/rfi_question/'+id).then(function(r){return r.json()}).then(function(d){var q=d.data||d;document.getElementById('rqe-text').value=q.question_text||q.question||'';document.getElementById('rqe-cat').value=q.category||'';document.getElementById('rqe-deadline').value=q.deadline?new Date(q.deadline*1000).toISOString().split('T')[0]:'';document.getElementById('rqe-required').checked=q.required!==false}).catch(function(){})};
|
|
34
|
+
window.rqeSave=async function(){if(!rqeId)return;var d={question_text:document.getElementById('rqe-text').value,category:document.getElementById('rqe-cat').value,required:document.getElementById('rqe-required').checked};var dl=document.getElementById('rqe-deadline').value;if(dl)d.deadline=Math.floor(new Date(dl).getTime()/1000);var assign=document.getElementById('rqe-assign').value;if(assign)d.assigned_to=assign;try{var r=await fetch('/api/rfi_question/'+rqeId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(d)});if(r.ok){showToast('Question updated','success');document.getElementById('rfi-q-edit').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
35
|
+
</script>`;
|
|
36
|
+
}
|
|
37
|
+
export function rfiClientUserAssignment(rfiId) {
|
|
38
|
+
return `<div id="rfi-assign-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="rfi-assign-dialog-title" aria-hidden="true">
|
|
39
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rfi-assign-dialog-title">Assign Client User</span><button class="dialog-close" data-dialog-close="rfi-assign-dialog" aria-label="Close dialog">×</button></div>
|
|
40
|
+
<div class="dialog-body"><div class="modal-form-group"><label for="rca-question">Question</label><select id="rca-question" class="select select-bordered w-full"></select></div><div class="modal-form-group"><label for="rca-user">Client User</label><select id="rca-user" class="select select-bordered w-full"></select></div></div>
|
|
41
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-assign-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="rcaAssign">Assign</button></div>
|
|
42
|
+
</div></div>
|
|
43
|
+
<script>
|
|
44
|
+
window.openRfiAssign=function(){document.getElementById('rfi-assign-dialog').style.display='flex';Promise.all([fetch('/api/rfi_question?rfi_id=${rfiId}').then(function(r){return r.json()}),fetch('/api/user?role=client_user').then(function(r){return r.json()})]).then(function(r){var qs=(r[0].data||r[0]||[]);var us=(r[1].data||r[1]||[]);var qsel=document.getElementById('rca-question');while(qsel.options.length>0)qsel.remove(0);qs.forEach(function(q){var o=document.createElement('option');o.value=q.id;o.textContent=q.question_text||q.question||q.id;qsel.appendChild(o)});var usel=document.getElementById('rca-user');while(usel.options.length>0)usel.remove(0);us.forEach(function(u){var o=document.createElement('option');o.value=u.id;o.textContent=u.name||u.email||u.id;usel.appendChild(o)})}).catch(function(){})};
|
|
45
|
+
window.rcaAssign=async function(){var qid=document.getElementById('rca-question').value;var uid=document.getElementById('rca-user').value;if(!qid||!uid){showToast('Select both','error');return}try{var r=await fetch('/api/rfi_question/'+qid,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({assigned_to:uid})});if(r.ok){showToast('Assigned','success');document.getElementById('rfi-assign-dialog').style.display='none'}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
46
|
+
</script>`;
|
|
47
|
+
}
|
|
48
|
+
export function rfiBulkAssignDialog(rfiId) {
|
|
49
|
+
return `<div id="rfi-bulk-assign" 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="rfi-bulk-assign-title" aria-hidden="true">
|
|
50
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rfi-bulk-assign-title">Bulk Assign Client Users</span><button class="dialog-close" data-dialog-close="rfi-bulk-assign" aria-label="Close dialog">×</button></div>
|
|
51
|
+
<div class="dialog-body"><div class="modal-form-group"><label for="rba-user">Client User</label><select id="rba-user" class="select select-bordered w-full"></select></div><div class="modal-form-group"><label class="flex items-center gap-2"><input type="checkbox" id="rba-unassigned" class="checkbox" checked/><span class="text-sm">Unassigned questions only</span></label></div><div id="rba-preview" class="text-sm text-gray-500 mt-2"></div></div>
|
|
52
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-bulk-assign">Cancel</button><button class="btn btn-primary btn-sm" data-action="rbaBulk">Assign All</button></div>
|
|
53
|
+
</div></div>
|
|
54
|
+
<script>
|
|
55
|
+
window.openBulkAssign=function(){document.getElementById('rfi-bulk-assign').style.display='flex';fetch('/api/user?role=client_user').then(function(r){return r.json()}).then(function(d){var us=d.data||d||[];var sel=document.getElementById('rba-user');while(sel.options.length>0)sel.remove(0);us.forEach(function(u){var o=document.createElement('option');o.value=u.id;o.textContent=u.name||u.email;sel.appendChild(o)})}).catch(function(){});fetch('/api/rfi_question?rfi_id=${rfiId}').then(function(r){return r.json()}).then(function(d){var qs=d.data||d||[];var unassigned=qs.filter(function(q){return!q.assigned_to}).length;document.getElementById('rba-preview').textContent=unassigned+' unassigned of '+qs.length+' total questions'}).catch(function(){})};
|
|
56
|
+
window.rbaBulk=async function(){var uid=document.getElementById('rba-user').value;if(!uid){showToast('Select a user','error');return}var onlyUnassigned=document.getElementById('rba-unassigned').checked;try{var r=await fetch('/api/rfi_question/bulk-assign',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rfi_id:'${rfiId}',assigned_to:uid,unassigned_only:onlyUnassigned})});if(r.ok){var d=await r.json();showToast((d.count||'All')+' questions assigned','success');document.getElementById('rfi-bulk-assign').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
57
|
+
</script>`;
|
|
58
|
+
}
|
|
59
|
+
export function rfiBulkDeadlineDialog(rfiId) {
|
|
60
|
+
return `<div id="rfi-bulk-deadline" 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="rfi-bulk-deadline-title" aria-hidden="true">
|
|
61
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rfi-bulk-deadline-title">Set Bulk Deadline</span><button class="dialog-close" data-dialog-close="rfi-bulk-deadline" aria-label="Close dialog">×</button></div>
|
|
62
|
+
<div class="dialog-body"><div class="modal-form-group"><label for="rbd-date">Deadline Date</label><input type="date" id="rbd-date" class="input input-bordered w-full"/></div><div class="modal-form-group"><label class="flex items-center gap-2"><input type="checkbox" id="rbd-all" class="checkbox" checked/><span class="text-sm">Apply to all questions (including those with existing deadlines)</span></label></div></div>
|
|
63
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-bulk-deadline">Cancel</button><button class="btn btn-primary btn-sm" data-action="rbdSet">Set Deadline</button></div>
|
|
64
|
+
</div></div>
|
|
65
|
+
<script>
|
|
66
|
+
window.openBulkDeadline=function(){document.getElementById('rfi-bulk-deadline').style.display='flex'};
|
|
67
|
+
window.rbdSet=async function(){var date=document.getElementById('rbd-date').value;if(!date){showToast('Select a date','error');return}var ts=Math.floor(new Date(date).getTime()/1000);try{var r=await fetch('/api/rfi_question/bulk-deadline',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rfi_id:'${rfiId}',deadline:ts,override_existing:document.getElementById('rbd-all').checked})});if(r.ok){showToast('Deadlines set','success');document.getElementById('rfi-bulk-deadline').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
68
|
+
</script>`;
|
|
69
|
+
}
|
|
70
|
+
export function rfiAttachmentPreview() {
|
|
71
|
+
return `<div id="rfi-attach-preview" 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="rfi-attach-preview-title" aria-hidden="true">
|
|
72
|
+
<div class="dialog-panel" style="max-width:800px;max-height:90vh">
|
|
73
|
+
<div class="dialog-header"><span class="dialog-title" id="rfi-attach-preview-title">Attachment</span><button class="dialog-close" data-dialog-close="rfi-attach-preview" aria-label="Close dialog">×</button></div>
|
|
74
|
+
<div class="dialog-body" style="overflow:auto;max-height:70vh"><div id="rap-content" class="text-center"><div class="text-gray-500">Loading...</div></div></div>
|
|
75
|
+
<div class="dialog-footer"><a id="rap-download" href="#" download class="btn btn-primary btn-sm">Download</a><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-attach-preview">Close</button></div>
|
|
76
|
+
</div></div>
|
|
77
|
+
<script>
|
|
78
|
+
window.previewAttachment=function(url,name,type){document.getElementById('rfi-attach-preview').style.display='flex';document.getElementById('rfi-attach-preview-title').textContent=name||'Attachment';document.getElementById('rap-download').href=url;var c=document.getElementById('rap-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" style="max-width:100%;max-height:60vh" alt="'+name+'"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:60vh;border:none"></iframe>'}else{c.innerHTML='<div class="text-gray-500 py-8"><div style="font-size:3rem">📄</div><div class="mt-2">'+name+'</div><div class="text-xs mt-1">Preview not available for this file type</div></div>'}};
|
|
79
|
+
window.lazyLoadAttachments=function(){var items=document.querySelectorAll('[data-lazy-src]');if(!items.length)return;var obs=new IntersectionObserver(function(entries){entries.forEach(function(e){if(e.isIntersecting){var el=e.target;var src=el.dataset.lazySrc;if(el.tagName==='IMG'){el.src=src}else{el.style.backgroundImage='url('+src+')'}el.removeAttribute('data-lazy-src');obs.unobserve(el)}})},{rootMargin:'200px'});items.forEach(function(el){obs.observe(el)})};
|
|
80
|
+
document.addEventListener('DOMContentLoaded',function(){lazyLoadAttachments()});
|
|
81
|
+
</script>`;
|
|
82
|
+
}
|
|
83
|
+
export function rfiReportGeneration(rfiId, rfiName) {
|
|
84
|
+
return `<div id="rfi-report-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="rfi-report-dialog-title" aria-hidden="true">
|
|
85
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rfi-report-dialog-title">Generate RFI Report</span><button class="dialog-close" data-dialog-close="rfi-report-dialog" aria-label="Close dialog">×</button></div>
|
|
86
|
+
<div class="dialog-body">
|
|
87
|
+
<div class="modal-form-group"><label for="rrg-type">Report Type</label><select id="rrg-type" class="select select-bordered w-full"><option value="summary">Summary Report</option><option value="detailed">Detailed Report (with responses)</option><option value="status">Status Report</option><option value="overdue">Overdue Items Report</option></select></div>
|
|
88
|
+
<div class="modal-form-group"><label for="rrg-format">Format</label><select id="rrg-format" class="select select-bordered w-full"><option value="pdf">PDF</option><option value="xlsx">Excel</option><option value="csv">CSV</option></select></div>
|
|
89
|
+
<div class="modal-form-group"><label class="flex items-center gap-2"><input type="checkbox" id="rrg-attachments" class="checkbox"/><span class="text-sm">Include attachments</span></label></div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-report-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="rrgGenerate">Generate</button></div>
|
|
92
|
+
</div></div>
|
|
93
|
+
<script>
|
|
94
|
+
window.openRfiReport=function(){document.getElementById('rfi-report-dialog').style.display='flex'};
|
|
95
|
+
window.rrgGenerate=async function(){var type=document.getElementById('rrg-type').value;var format=document.getElementById('rrg-format').value;var attach=document.getElementById('rrg-attachments').checked;showToast('Generating report...','info');try{var r=await fetch('/api/rfi/${rfiId}/report',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:type,format:format,include_attachments:attach})});if(r.ok){var blob=await r.blob();var url=URL.createObjectURL(blob);var a=document.createElement('a');a.href=url;a.download='${(rfiName || 'rfi').replace(/'/g, '')}-'+type+'.'+format;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);showToast('Report downloaded','success')}else{showToast('Report generation failed','error')}}catch(e){showToast('Error generating report','error')}document.getElementById('rfi-report-dialog').style.display='none'};
|
|
96
|
+
</script>`;
|
|
97
|
+
}
|
|
98
|
+
export function rfiEmailRemindersDialog(rfiId) {
|
|
99
|
+
return `<div id="rfi-reminders-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="rfi-reminders-dialog-title" aria-hidden="true">
|
|
100
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rfi-reminders-dialog-title">Email Reminders</span><button class="dialog-close" data-dialog-close="rfi-reminders-dialog" aria-label="Close dialog">×</button></div>
|
|
101
|
+
<div class="dialog-body">
|
|
102
|
+
<div class="modal-form-group"><label class="flex items-center gap-2"><input type="checkbox" id="rre-auto" class="checkbox" checked/><span class="text-sm">Auto-send reminders for overdue items</span></label></div>
|
|
103
|
+
<div class="modal-form-group"><label for="rre-freq">Reminder Frequency</label><select id="rre-freq" class="select select-bordered w-full"><option value="daily">Daily</option><option value="weekly" selected>Weekly</option><option value="biweekly">Bi-weekly</option></select></div>
|
|
104
|
+
<div class="modal-form-group"><label>Include in reminder</label><div class="flex flex-col gap-2"><label class="flex items-center gap-2"><input type="checkbox" class="checkbox rre-inc" value="overdue" checked/><span class="text-sm">Overdue questions</span></label><label class="flex items-center gap-2"><input type="checkbox" class="checkbox rre-inc" value="upcoming" checked/><span class="text-sm">Upcoming deadlines (3 days)</span></label><label class="flex items-center gap-2"><input type="checkbox" class="checkbox rre-inc" value="unanswered"/><span class="text-sm">All unanswered questions</span></label></div></div>
|
|
105
|
+
<button class="btn btn-outline btn-sm mt-2" data-action="rreSendNow">Send Reminder Now</button>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-reminders-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="rreSave">Save Settings</button></div>
|
|
108
|
+
</div></div>
|
|
109
|
+
<script>
|
|
110
|
+
window.openRfiReminders=function(){document.getElementById('rfi-reminders-dialog').style.display='flex'};
|
|
111
|
+
window.rreSave=async function(){var inc=[].slice.call(document.querySelectorAll('.rre-inc:checked')).map(function(c){return c.value});try{var r=await fetch('/api/rfi/${rfiId}',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({reminder_auto:document.getElementById('rre-auto').checked,reminder_frequency:document.getElementById('rre-freq').value,reminder_includes:JSON.stringify(inc)})});if(r.ok){showToast('Reminder settings saved','success');document.getElementById('rfi-reminders-dialog').style.display='none'}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
112
|
+
window.rreSendNow=async function(){showToast('Sending reminders...','info');try{await fetch('/api/rfi/${rfiId}/remind',{method:'POST'});showToast('Reminders sent','success')}catch(e){showToast('Send failed','error')}};
|
|
113
|
+
</script>`;
|
|
114
|
+
}
|
|
115
|
+
export function rfiTemplateSelectDropdown(templates = []) {
|
|
116
|
+
const opts = templates.map(t => `<option value="${t.id}">${t.name} (${t.questionCount || 0} questions)</option>`).join('');
|
|
117
|
+
return `<div class="modal-form-group"><label for="rfi-template-select">RFI Template</label><select id="rfi-template-select" class="select select-bordered w-full" onchange="rfiTemplateChanged(this.value)"><option value="">-- Select Template --</option>${opts}</select><div id="rfi-template-desc" class="text-xs text-gray-500 mt-1"></div></div>
|
|
118
|
+
<script>window.rfiTemplateChanged=function(id){var desc=document.getElementById('rfi-template-desc');if(!id){desc.textContent='';return}fetch('/api/rfi-template/'+id).then(function(r){return r.json()}).then(function(d){desc.textContent=(d.description||'')}).catch(function(){desc.textContent=''})};</script>`;
|
|
119
|
+
}
|
|
120
|
+
export function rfiDataGridGrouping(questions = [], groupBy = 'section') {
|
|
121
|
+
const groups = {};
|
|
122
|
+
questions.forEach(q => {
|
|
123
|
+
const key = groupBy === 'section' ? (q.section_name || 'Unsectioned') : (q.category || 'Uncategorized');
|
|
124
|
+
if (!groups[key]) groups[key] = [];
|
|
125
|
+
groups[key].push(q);
|
|
126
|
+
});
|
|
127
|
+
const groupRows = Object.entries(groups).map(([name, items]) => {
|
|
128
|
+
const qRows = items.map(q => {
|
|
129
|
+
const statusCls = q.status === 'answered' ? 'text-green-600' : q.status === 'overdue' ? 'text-red-600' : 'text-gray-600';
|
|
130
|
+
return `<tr class="hover cursor-pointer" tabindex="0" role="link" data-action="openQuestionEdit" data-args='["${q.id}"]' onkeydown="if(event.key==='Enter')openQuestionEdit('${q.id}')"><td class="text-sm">${q.question_text || q.question || '-'}</td><td><span class="${statusCls}">${statusLabel(q.status || 'pending')}</span></td><td class="text-xs text-gray-500">${q.assigned_to_name || q.assigned_to || '-'}</td><td class="text-xs">${q.deadline ? new Date(q.deadline * 1000).toLocaleDateString() : '-'}</td></tr>`;
|
|
131
|
+
}).join('');
|
|
132
|
+
return `<tr class="dg-group-header" tabindex="0" onkeydown="if(event.key==='Enter')this.click()" data-action="toggleNextSibling" data-self><td colspan="4"><button class="dg-expand-btn">▶</button><strong>${name}</strong><span class="badge-status ml-2">${items.length}</span></td></tr><tbody>${qRows}</tbody>`;
|
|
133
|
+
}).join('');
|
|
134
|
+
return `<div class="dg-wrapper"><table class="dg-table w-full"><thead><tr><th class="dg-sortable" data-action="rfiSortCol" data-args='[0]'>Question</th><th class="dg-sortable" data-action="rfiSortCol" data-args='[1]'>Status</th><th>Assigned</th><th class="dg-sortable" data-action="rfiSortCol" data-args='[3]'>Deadline</th></tr></thead>${groupRows}</table></div>`;
|
|
135
|
+
}
|
|
136
|
+
export function rfiHideCompletedToggle(rfiId) {
|
|
137
|
+
return `<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="rfi-hide-completed" class="checkbox checkbox-sm" onchange="rfiToggleCompleted(this.checked)"/><span>Hide completed questions</span></label>
|
|
138
|
+
<script>window.rfiToggleCompleted=function(hide){document.querySelectorAll('tr[data-status=answered],tr[data-status=completed]').forEach(function(r){r.style.display=hide?'none':''})};</script>`;
|
|
139
|
+
}
|
|
140
|
+
export function rfiUnreadBadges(counts = {}) {
|
|
141
|
+
const badge = (key, label) => counts[key] ? `<span class="badge badge-sm badge-primary ml-1">${counts[key]}</span>` : '';
|
|
142
|
+
return { messages: badge('messages', 'Messages'), questions: badge('questions', 'Questions'), responses: badge('responses', 'Responses') };
|
|
143
|
+
}
|
|
144
|
+
export function rfiItemDialog() {
|
|
145
|
+
return `<div id="rfi-item-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="rfi-item-dialog-title" aria-hidden="true">
|
|
146
|
+
<div class="dialog-panel" style="max-width:640px"><div class="dialog-header"><span class="dialog-title" id="rfi-item-dialog-title">RFI Item</span><button class="dialog-close" data-dialog-close="rfi-item-dialog" aria-label="Close dialog">×</button></div>
|
|
147
|
+
<div class="dialog-body">
|
|
148
|
+
<div class="modal-form-group"><label for="rid-title">Title</label><input id="rid-title" class="input input-bordered w-full"/></div>
|
|
149
|
+
<div class="modal-form-group"><label for="rid-desc">Description</label><textarea id="rid-desc" class="textarea textarea-bordered w-full" rows="3"></textarea></div>
|
|
150
|
+
<div class="grid grid-cols-2 gap-4">
|
|
151
|
+
<div class="modal-form-group"><label for="rid-priority">Priority</label><select id="rid-priority" class="select select-bordered w-full"><option value="low">Low</option><option value="medium" selected>Medium</option><option value="high">High</option></select></div>
|
|
152
|
+
<div class="modal-form-group"><label for="rid-due">Due Date</label><input type="date" id="rid-due" class="input input-bordered w-full"/></div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-item-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="ridSave">Save</button></div>
|
|
156
|
+
</div></div>
|
|
157
|
+
<script>
|
|
158
|
+
var ridId=null;var ridRfiId=null;
|
|
159
|
+
window.openRfiItem=function(rfiId,itemId){ridRfiId=rfiId;ridId=itemId;document.getElementById('rfi-item-dialog').style.display='flex';if(itemId){fetch('/api/rfi_question/'+itemId).then(function(r){return r.json()}).then(function(d){var q=d.data||d;document.getElementById('rid-title').value=q.title||q.question_text||'';document.getElementById('rid-desc').value=q.description||'';document.getElementById('rid-priority').value=q.priority||'medium';document.getElementById('rid-due').value=q.deadline?new Date(q.deadline*1000).toISOString().split('T')[0]:''}).catch(function(){})}else{document.getElementById('rid-title').value='';document.getElementById('rid-desc').value='';document.getElementById('rid-priority').value='medium';document.getElementById('rid-due').value=''}};
|
|
160
|
+
window.ridSave=async function(){var body={title:document.getElementById('rid-title').value,description:document.getElementById('rid-desc').value,priority:document.getElementById('rid-priority').value};var dl=document.getElementById('rid-due').value;if(dl)body.deadline=Math.floor(new Date(dl).getTime()/1000);var url=ridId?'/api/rfi_question/'+ridId:'/api/rfi_question';var method=ridId?'PUT':'POST';if(!ridId)body.rfi_id=ridRfiId;try{var r=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(r.ok){showToast(ridId?'Updated':'Created','success');document.getElementById('rfi-item-dialog').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
161
|
+
</script>`;
|
|
162
|
+
}
|
|
163
|
+
export function rfiAttachDialog(rfiId) {
|
|
164
|
+
return `<div id="rfi-attach-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="rfi-attach-dialog-title" aria-hidden="true">
|
|
165
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rfi-attach-dialog-title">Attach File to RFI</span><button class="dialog-close" data-dialog-close="rfi-attach-dialog" aria-label="Close dialog">×</button></div>
|
|
166
|
+
<div class="dialog-body">
|
|
167
|
+
<div class="modal-form-group"><label for="rad-file">File</label><input type="file" id="rad-file" class="file-input file-input-bordered w-full"/></div>
|
|
168
|
+
<div class="modal-form-group"><label for="rad-desc">Description</label><input id="rad-desc" class="input input-bordered w-full" placeholder="File description (optional)"/></div>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-attach-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="radUpload">Upload</button></div>
|
|
171
|
+
</div></div>
|
|
172
|
+
<script>
|
|
173
|
+
window.openRfiAttach=function(){document.getElementById('rfi-attach-dialog').style.display='flex'};
|
|
174
|
+
window.radUpload=async function(){var file=document.getElementById('rad-file').files[0];if(!file){showToast('Select a file','error');return}var fd=new FormData();fd.append('file',file);fd.append('rfi_id','${rfiId}');fd.append('description',document.getElementById('rad-desc').value);try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok){showToast('File attached','success');document.getElementById('rfi-attach-dialog').style.display='none';location.reload()}else showToast('Upload failed','error')}catch(e){showToast('Error','error')}};
|
|
175
|
+
</script>`;
|
|
176
|
+
}
|
|
177
|
+
export function rfiAttachQuestionDialog(questionId) {
|
|
178
|
+
return `<div id="rfi-aq-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="rfi-aq-dialog-title" aria-hidden="true">
|
|
179
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rfi-aq-dialog-title">Attach File to Question</span><button class="dialog-close" data-dialog-close="rfi-aq-dialog" aria-label="Close dialog">×</button></div>
|
|
180
|
+
<div class="dialog-body"><div class="modal-form-group"><label for="raq-file">File</label><input type="file" id="raq-file" class="file-input file-input-bordered w-full"/></div><div class="modal-form-group"><label for="raq-note">Note</label><input id="raq-note" class="input input-bordered w-full" placeholder="Optional note"/></div></div>
|
|
181
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rfi-aq-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="raqUpload">Upload</button></div>
|
|
182
|
+
</div></div>
|
|
183
|
+
<script>
|
|
184
|
+
var raqQid=null;
|
|
185
|
+
window.openQuestionAttach=function(qid){raqQid=qid||'${questionId}';document.getElementById('rfi-aq-dialog').style.display='flex'};
|
|
186
|
+
window.raqUpload=async function(){var file=document.getElementById('raq-file').files[0];if(!file){showToast('Select a file','error');return}var fd=new FormData();fd.append('file',file);fd.append('question_id',raqQid);fd.append('note',document.getElementById('raq-note').value);try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok){showToast('Attached','success');document.getElementById('rfi-aq-dialog').style.display='none';location.reload()}else showToast('Upload failed','error')}catch(e){showToast('Error','error')}};
|
|
187
|
+
</script>`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export {
|
|
191
|
+
engagementFileSearchDialog, postRfiJournalDialog, postRfiFileUpload,
|
|
192
|
+
importReviewQueriesDialog, engagementNotificationSettings,
|
|
193
|
+
notificationTriggerDialog, teamChatChannelToggle
|
|
194
|
+
} from '@/ui/engagement-dialogs.js';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
2
|
+
|
|
3
|
+
export function renderRfiReport(user, rfi, engagement, questions, responses) {
|
|
4
|
+
const e = engagement || {};
|
|
5
|
+
const fmtDate = (v) => { if (!v) return '-'; const n = typeof v === 'number' ? (v > 1e10 ? v : v * 1000) : new Date(v).getTime(); return new Date(n).toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' }); };
|
|
6
|
+
const respMap = {};
|
|
7
|
+
for (const r of responses) {
|
|
8
|
+
if (!respMap[r.question_id]) respMap[r.question_id] = [];
|
|
9
|
+
respMap[r.question_id].push(r);
|
|
10
|
+
}
|
|
11
|
+
const statusBadge = (s) => { const color = s === 'answered' || s === 'accepted' ? '#22c55e' : s === 'overdue' ? '#ef4444' : '#f59e0b'; return `<span style="padding:2px 8px;border-radius:12px;font-size:0.7rem;font-weight:700;background:${color}20;color:${color}">${s || 'pending'}</span>`; };
|
|
12
|
+
const qRows = questions.map((q, i) => {
|
|
13
|
+
const resps = respMap[q.id] || [];
|
|
14
|
+
const respHtml = resps.length ? resps.map(r => `<div style="margin-top:6px;padding:6px 10px;background:#f8fafc;border-left:3px solid #3b82f6;font-size:0.75rem">${esc(r.response||'')}<div style="margin-top:4px;color:#94a3b8;font-size:0.65rem">${fmtDate(r.created_at)}</div></div>`).join('') : '<div style="color:#94a3b8;font-size:0.75rem;margin-top:4px">No response yet</div>';
|
|
15
|
+
return `<div style="margin-bottom:16px;padding:12px;border:1px solid #e2e8f0;border-radius:6px;break-inside:avoid">
|
|
16
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px">
|
|
17
|
+
<div style="font-size:0.85rem;font-weight:600">${i+1}. ${esc(q.question||q.text||'Question')}</div>
|
|
18
|
+
${statusBadge(q.status)}
|
|
19
|
+
</div>
|
|
20
|
+
${q.due_date ? `<div style="color:#64748b;font-size:0.7rem;margin-bottom:6px">Due: ${fmtDate(q.due_date)}</div>` : ''}
|
|
21
|
+
${q.assigned_to ? `<div style="color:#64748b;font-size:0.7rem;margin-bottom:6px">Assigned: ${esc(q.assigned_to_name||q.assigned_to)}</div>` : ''}
|
|
22
|
+
${respHtml}
|
|
23
|
+
</div>`;
|
|
24
|
+
}).join('');
|
|
25
|
+
const answered = questions.filter(q => ['answered','accepted','closed'].includes(q.status)).length;
|
|
26
|
+
const pct = questions.length ? Math.round(answered / questions.length * 100) : 0;
|
|
27
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/>
|
|
28
|
+
<title>RFI Report — ${esc(rfi.name || rfi.title || 'RFI')}</title>
|
|
29
|
+
<style>
|
|
30
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 24px; color: #1e293b; font-size: 14px; }
|
|
31
|
+
.header { border-bottom: 2px solid #3b82f6; padding-bottom: 16px; margin-bottom: 24px; }
|
|
32
|
+
.meta { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0; font-size: 0.8rem; color: #64748b; }
|
|
33
|
+
.meta-item { display: flex; flex-direction: column; }
|
|
34
|
+
.meta-label { font-weight: 600; margin-bottom: 2px; }
|
|
35
|
+
.progress-bar { height: 8px; background: #e2e8f0; border-radius: 4px; margin: 8px 0; }
|
|
36
|
+
.progress-fill { height: 100%; border-radius: 4px; background: #3b82f6; }
|
|
37
|
+
.no-print { padding: 12px 0; }
|
|
38
|
+
@media print { .no-print { display: none; } body { padding: 12px; } }
|
|
39
|
+
</style></head><body>
|
|
40
|
+
<div class="no-print"><button onclick="window.print()" style="padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem">Print / Save PDF</button> <a href="/rfi/${esc(rfi.id)}" style="margin-left:12px;font-size:0.85rem;color:#3b82f6">← Back to RFI</a></div>
|
|
41
|
+
<div class="header">
|
|
42
|
+
<div style="font-size:1.4rem;font-weight:700">${esc(rfi.name||rfi.title||'RFI')}</div>
|
|
43
|
+
<div style="color:#64748b;font-size:0.85rem;margin-top:4px">${esc(e.name||e.client_name||'')}</div>
|
|
44
|
+
<div class="meta">
|
|
45
|
+
<div class="meta-item"><span class="meta-label">Status</span>${esc(rfi.status||'-')}</div>
|
|
46
|
+
<div class="meta-item"><span class="meta-label">Created</span>${fmtDate(rfi.created_at)}</div>
|
|
47
|
+
${rfi.deadline ? `<div class="meta-item"><span class="meta-label">Deadline</span>${fmtDate(rfi.deadline)}</div>` : ''}
|
|
48
|
+
<div class="meta-item"><span class="meta-label">Questions</span>${questions.length}</div>
|
|
49
|
+
<div class="meta-item"><span class="meta-label">Complete</span>${pct}% (${answered}/${questions.length})</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div>
|
|
52
|
+
</div>
|
|
53
|
+
<div>${qRows || '<div style="color:#94a3b8;text-align:center;padding:24px">No questions found</div>'}</div>
|
|
54
|
+
<div style="margin-top:24px;padding-top:12px;border-top:1px solid #e2e8f0;font-size:0.7rem;color:#94a3b8;text-align:center">Generated ${new Date().toLocaleString('en-ZA')} • Moonlanding</div>
|
|
55
|
+
</body></html>`;
|
|
56
|
+
}
|