thatcher 1.0.7 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +5 -41
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +1 -1
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { page } from '@/ui/layout.js';
|
|
2
2
|
import { canCreate } from '@/ui/permissions-ui.js';
|
|
3
|
-
import { esc, stagePill, statusPill, progressBar, STAGE_CONFIG, TABLE_SCRIPT } from '@/ui/render-helpers.js';
|
|
3
|
+
import { esc, stagePill, statusPill, progressBar, STAGE_CONFIG, TABLE_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
|
|
4
|
+
import { SPACING } from '@/ui/spacing-system.js';
|
|
4
5
|
|
|
5
6
|
function engRow(e) {
|
|
6
7
|
const name = esc(e.name || e.client_name || 'Untitled');
|
|
@@ -8,7 +9,7 @@ function engRow(e) {
|
|
|
8
9
|
const type = esc(e.type || e.engagement_type || e.repeat_interval || '-');
|
|
9
10
|
const year = esc(e.year || '-');
|
|
10
11
|
const team = esc(e.team_name || e.team_id_display || e.team_id || '-');
|
|
11
|
-
const deadline = e.deadline ? new Date(e.deadline).toLocaleDateString() : '-';
|
|
12
|
+
const deadline = e.deadline ? new Date(e.deadline).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-';
|
|
12
13
|
const stageCfg = STAGE_CONFIG.find(s => s.key === e.stage);
|
|
13
14
|
const stageLbl = stageCfg?.label || (e.stage || '-');
|
|
14
15
|
// 247420 indicator-rail: 3px left edge tinted by stage color (replaces border)
|
|
@@ -38,10 +39,10 @@ export function renderEngagementGrid(user, engagements, options = {}) {
|
|
|
38
39
|
engagements.forEach(e => { if (stageCounts[e.stage] !== undefined) stageCounts[e.stage]++; });
|
|
39
40
|
|
|
40
41
|
const stageStats = `<div class="stats-row">${STAGE_CONFIG.map(s =>
|
|
41
|
-
`<
|
|
42
|
+
`<button type="button" class="stat-card stat-card-clickable" data-action="filterByStage" data-args='["${s.key}"]' id="stage-card-${s.key}" aria-label="Filter by ${s.label} stage (${stageCounts[s.key]||0} engagements)">
|
|
42
43
|
<div class="stat-card-value">${stageCounts[s.key]||0}</div>
|
|
43
44
|
<div class="stat-card-label">${s.label}</div>
|
|
44
|
-
</
|
|
45
|
+
</button>`
|
|
45
46
|
).join('')}</div>`;
|
|
46
47
|
|
|
47
48
|
const tabs = [
|
|
@@ -63,23 +64,23 @@ export function renderEngagementGrid(user, engagements, options = {}) {
|
|
|
63
64
|
const yearOpts = years.map(y => `<option value="${esc(y)}">${esc(y)}</option>`).join('');
|
|
64
65
|
|
|
65
66
|
const rows = engagements.map(e => engRow(e)).join('') ||
|
|
66
|
-
|
|
67
|
+
emptyRow(11, 'No engagements found');
|
|
67
68
|
|
|
68
|
-
const emailReceiveDialog = `<div id="email-receive-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
|
|
69
|
+
const emailReceiveDialog = `<div id="email-receive-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="email-receive-dialog-title" data-dialog-close="email-receive-dialog">
|
|
69
70
|
<div class="dialog-panel" style="max-width:540px">
|
|
70
|
-
<div class="dialog-header"><span class="dialog-title">Receive Email</span><button class="dialog-close" aria-label="Close dialog" data-dialog-close="email-receive-dialog">×</button></div>
|
|
71
|
+
<div class="dialog-header"><span class="dialog-title" id="email-receive-dialog-title">Receive Email</span><button class="dialog-close" aria-label="Close dialog" data-dialog-close="email-receive-dialog">×</button></div>
|
|
71
72
|
<div class="dialog-body">
|
|
72
73
|
<div class="modal-form-group"><label>Email Content</label><textarea id="email-receive-content" class="form-input" rows="8" placeholder="Paste raw email content here..."></textarea></div>
|
|
73
74
|
<div id="email-receive-result" style="display:none;margin-top:8px"></div>
|
|
74
75
|
</div>
|
|
75
76
|
<div class="dialog-footer">
|
|
76
|
-
<button class="btn
|
|
77
|
-
<button class="btn
|
|
77
|
+
<button type="button" class="btn-ghost-clean" data-dialog-close="email-receive-dialog">Cancel</button>
|
|
78
|
+
<button type="button" class="btn-primary-clean" data-action="submitEmailReceive">Process Email</button>
|
|
78
79
|
</div>
|
|
79
80
|
</div>
|
|
80
81
|
</div>`;
|
|
81
82
|
|
|
82
|
-
const emailReceiveScript = `window.submitEmailReceive=async function(){var content=document.getElementById('email-receive-content').value.trim();var res=document.getElementById('email-receive-result');if(!content){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Email content required.</div>';return}res.style.display='block';res.innerHTML='<div style="font-size:13px">Processing...</div>';try{var r=await fetch('/api/email/receive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})});var d=await r.json();if(r.ok&&d.success){res.innerHTML='<div style="color:var(--color-success);font-size:13px">Email processed successfully.'+(d.engagement_id?' Matched engagement: '+d.engagement_id:'')+(d.message?' '+d.message:'')+'</div>'}else{res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||d.message||'Failed to process email')+'</div>'}}catch(e){res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Error: '+e.message+'</div>'}};`;
|
|
83
|
+
const emailReceiveScript = `window.submitEmailReceive=async function(){var content=document.getElementById('email-receive-content').value.trim();var res=document.getElementById('email-receive-result');if(!content){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px;margin-top:${SPACING.sm}">Email content required.</div>';return}res.style.display='block';res.innerHTML='<div style="font-size:13px;margin-top:${SPACING.sm}">Processing...</div>';try{var r=await fetch('/api/email/receive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})});var d=await r.json();if(r.ok&&d.success){res.innerHTML='<div style="color:var(--color-success);font-size:13px;margin-top:${SPACING.sm}">Email processed successfully.'+(d.engagement_id?' Matched engagement: '+d.engagement_id:'')+(d.message?' '+d.message:'')+'</div>'}else{res.innerHTML='<div style="color:var(--color-danger);font-size:13px;margin-top:${SPACING.sm}">'+(d.error||d.message||'Failed to process email')+'</div>'}}catch(e){res.innerHTML='<div style="color:var(--color-danger);font-size:13px;margin-top:${SPACING.sm}">Error: '+e.message+'</div>'}};`;
|
|
83
84
|
|
|
84
85
|
const content = `<div class="page-header">
|
|
85
86
|
<div><h1 class="page-title">Engagements</h1><p class="page-subtitle">${engagements.length} total engagements</p></div>
|
|
@@ -89,18 +90,18 @@ export function renderEngagementGrid(user, engagements, options = {}) {
|
|
|
89
90
|
<div class="table-wrap eng-table-mobile-scroll">
|
|
90
91
|
<div class="table-toolbar">
|
|
91
92
|
<div class="table-search"><input id="search-input" type="text" placeholder="Search engagements..."></div>
|
|
92
|
-
${stageOpts ? `<div class="table-filter"><select data-filter="stage-raw" id="filter-stage"><option value="">All Stages</option>${stageOpts}</select></div>` : ''}
|
|
93
|
-
${teamOpts ? `<div class="table-filter"><select data-filter="team" id="filter-team"><option value="">All Teams</option>${teamOpts}</select></div>` : ''}
|
|
94
|
-
${yearOpts ? `<div class="table-filter"><select data-filter="year" id="filter-year"><option value="">All Years</option>${yearOpts}</select></div>` : ''}
|
|
93
|
+
${stageOpts ? `<div class="table-filter"><select data-filter="stage-raw" id="filter-stage" aria-label="Filter by stage"><option value="">All Stages</option>${stageOpts}</select></div>` : ''}
|
|
94
|
+
${teamOpts ? `<div class="table-filter"><select data-filter="team" id="filter-team" aria-label="Filter by team"><option value="">All Teams</option>${teamOpts}</select></div>` : ''}
|
|
95
|
+
${yearOpts ? `<div class="table-filter"><select data-filter="year" id="filter-year" aria-label="Filter by year"><option value="">All Years</option>${yearOpts}</select></div>` : ''}
|
|
95
96
|
<button class="btn btn-ghost btn-sm" data-action="openDialog" data-args='["email-receive-dialog"]'>Receive Email</button>
|
|
96
97
|
<span class="table-count" id="row-count">${engagements.length} items</span>
|
|
97
98
|
</div>
|
|
98
|
-
<table class="data-table">
|
|
99
|
+
<table class="data-table" role="grid">
|
|
99
100
|
<thead><tr>
|
|
100
|
-
<th data-sort="name">Name</th><th data-sort="client">Client</th><th data-sort="type" class="eng-col-type">Type</th>
|
|
101
|
-
<th data-sort="year" class="eng-col-year">Year</th><th data-sort="team" class="eng-col-team">Team</th><th data-sort="stage-raw">Stage</th>
|
|
102
|
-
<th style="display:none"></th><th data-sort="status">Status</th><th data-sort="deadline">Deadline</th>
|
|
103
|
-
<th data-sort="rfi" class="eng-col-rfi" style="text-align:center">RFI</th><th class="eng-col-progress">Progress</th>
|
|
101
|
+
<th data-sort="name" aria-label="Sort by Name">Name</th><th data-sort="client" aria-label="Sort by Client">Client</th><th data-sort="type" class="eng-col-type" aria-label="Sort by Type">Type</th>
|
|
102
|
+
<th data-sort="year" class="eng-col-year" aria-label="Sort by Year">Year</th><th data-sort="team" class="eng-col-team" aria-label="Sort by Team">Team</th><th data-sort="stage-raw" aria-label="Sort by Stage">Stage</th>
|
|
103
|
+
<th style="display:none"></th><th data-sort="status" aria-label="Sort by Status">Status</th><th data-sort="deadline" aria-label="Sort by Deadline">Deadline</th>
|
|
104
|
+
<th data-sort="rfi" class="eng-col-rfi" style="text-align:center" aria-label="Sort by RFI">RFI</th><th class="eng-col-progress" aria-label="Progress">Progress</th>
|
|
104
105
|
</tr></thead>
|
|
105
106
|
<tbody>${rows}</tbody>
|
|
106
107
|
</table>
|
|
@@ -108,5 +109,5 @@ export function renderEngagementGrid(user, engagements, options = {}) {
|
|
|
108
109
|
|
|
109
110
|
const stageFilterScript = `var _activeStage='';function filterByStage(stage){_activeStage=_activeStage===stage?'':stage;document.querySelectorAll('[id^="stage-card-"]').forEach(c=>{const s=c.id.replace('stage-card-','');c.style.outline=_activeStage===s?'2px solid var(--color-primary)':'none'});window.filterTable();}var _origFilter=window.filterTable||function(){};window.filterTable=function(){_origFilter();if(!_activeStage)return;const stageLabels=${JSON.stringify(Object.fromEntries(STAGE_CONFIG.map(s=>[s.key,s.label])))};document.querySelectorAll('tbody tr[data-row]').forEach(row=>{const stageCell=row.querySelector('[data-col="stage"]');if(stageCell&&!stageCell.textContent.toLowerCase().includes((stageLabels[_activeStage]||_activeStage||'').toLowerCase())){row.style.display='none';}})};function switchTab(tab){document.querySelectorAll('[id^="tab-"]').forEach(b=>b.classList.toggle('active',b.id==='tab-'+tab))}`;
|
|
110
111
|
|
|
111
|
-
return page(user, 'Engagements |
|
|
112
|
+
return page(user, 'Engagements | Thatcher', null, content, [TABLE_SCRIPT, stageFilterScript, emailReceiveScript]);
|
|
112
113
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { h } from '@/ui/webjsx.js'
|
|
2
2
|
import { page, confirmDialog, dataTable } from '@/ui/layout.js'
|
|
3
|
-
import { fmtVal, TOAST_SCRIPT } from '@/ui/render-helpers.js'
|
|
3
|
+
import { fmtVal, TOAST_SCRIPT, esc } from '@/ui/render-helpers.js'
|
|
4
4
|
import { canCreate, canEdit, canDelete } from '@/ui/permissions-ui.js'
|
|
5
5
|
|
|
6
6
|
export function renderEntityList(entityName, items, spec, user, options = {}) {
|
|
@@ -10,16 +10,20 @@ export function renderEntityList(entityName, items, spec, user, options = {}) {
|
|
|
10
10
|
let listFields = Object.entries(fields).filter(([k, f]) => f.list).slice(0, 5)
|
|
11
11
|
if (!listFields.length) listFields = Object.entries(fields).filter(([k]) => !['created_by', 'updated_by'].includes(k)).slice(0, 6)
|
|
12
12
|
if (!listFields.length && items.length > 0) listFields = Object.keys(items[0]).filter(k => !['created_by', 'updated_by'].includes(k)).slice(0, 5).map(k => [k, { label: k }])
|
|
13
|
-
const headers = listFields.map(([k, f]) => `<th>${f?.label || k}</th>`).join('') + '<th>Actions</th>'
|
|
13
|
+
const headers = listFields.map(([k, f]) => `<th>${esc(f?.label || k)}</th>`).join('') + '<th>Actions</th>'
|
|
14
14
|
const userCanEdit = canEdit(user, entityName)
|
|
15
15
|
const userCanDelete = canDelete(user, entityName)
|
|
16
16
|
const userCanCreate = canCreate(user, entityName)
|
|
17
17
|
|
|
18
18
|
const buildRow = item => {
|
|
19
|
+
const id = esc(String(item.id))
|
|
20
|
+
const idArg = esc(JSON.stringify([String(item.id)]))
|
|
19
21
|
const cells = listFields.map(([k]) => `<td>${fmtVal(item[k], k, item)}</td>`).join('')
|
|
20
|
-
const editBtn = userCanEdit ? `<a href="/${entityName}/${
|
|
21
|
-
const delBtn = userCanDelete ? `<button data-stop-propagation="true" data-action="confirmDelete" data-args='
|
|
22
|
-
|
|
22
|
+
const editBtn = userCanEdit ? `<a href="/${entityName}/${id}/edit" class="btn btn-xs btn-outline">Edit</a>` : `<span class="btn btn-xs btn-outline btn-disabled tooltip" data-tip="No permission">Edit</span>`
|
|
23
|
+
const delBtn = userCanDelete ? `<button data-stop-propagation="true" data-action="confirmDelete" data-args='${idArg}' class="btn btn-xs btn-error btn-outline">Delete</button>` : `<span class="btn btn-xs btn-error btn-outline btn-disabled tooltip" data-tip="No permission">Delete</span>`
|
|
24
|
+
// Keyboard activation is handled centrally by event-delegation's data-navigate
|
|
25
|
+
// handler (Enter/Space on role=link); no inline onkeydown to avoid a competing path.
|
|
26
|
+
return `<tr class="hover cursor-pointer" data-searchable tabindex="0" role="link" data-navigate="/${entityName}/${id}">${cells}<td class="flex gap-1"><a href="/${entityName}/${id}" class="btn btn-xs btn-ghost">View</a>${editBtn}${delBtn}</td></tr>`
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
let tableContent
|
|
@@ -27,25 +31,29 @@ export function renderEntityList(entityName, items, spec, user, options = {}) {
|
|
|
27
31
|
const groups = {}
|
|
28
32
|
items.forEach(item => { const key = String(item[groupBy] || '(No ' + groupBy + ')'); if (!groups[key]) groups[key] = []; groups[key].push(item) })
|
|
29
33
|
const sortedKeys = Object.keys(groups).sort()
|
|
30
|
-
const groupRows = sortedKeys.map(gkey => {
|
|
34
|
+
const groupRows = sortedKeys.map((gkey, gi) => {
|
|
31
35
|
const groupItems = groups[gkey]
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
// Index-based ids: slugs collide when two group values differ only by
|
|
37
|
+
// punctuation/case (e.g. 'A/B' vs 'a-b'), which breaks the collapse toggle.
|
|
38
|
+
const groupId = `group-${gi}`
|
|
39
|
+
const togglerId = `toggle-${gi}`
|
|
34
40
|
const itemRows = groupItems.map(buildRow).join('')
|
|
35
|
-
|
|
41
|
+
const gkeySafe = esc(gkey)
|
|
42
|
+
return `<tbody class="group-section" data-group="${gkeySafe}"><tr class="group-header hover cursor-pointer" tabindex="0" data-toggle="${togglerId}"><td colspan="100" style="padding:12px"><div class="flex items-center gap-2"><input type="checkbox" id="${togglerId}" class="group-toggle" style="cursor:pointer" checked/><span class="font-semibold text-base">${gkeySafe}</span><span class="badge badge-sm">${groupItems.length}</span></div></td></tr><tr class="group-content" id="${groupId}" style="display:contents"><td colspan="100"><table class="data-table" style="background:transparent"><tbody>${itemRows}</tbody></table></td></tr></tbody>`
|
|
36
43
|
}).join('')
|
|
37
44
|
tableContent = `<div class="card-clean"><div class="table-wrap"><table class="data-table"><thead><tr>${headers}</tr></thead>${groupRows}</table></div></div>`
|
|
38
45
|
} else {
|
|
39
46
|
const rows = items.map(buildRow).join('')
|
|
40
|
-
tableContent = dataTable(headers, rows, items.length === 0 ? (userCanCreate ? `No items found. <a href="/${entityName}/new" class="text-primary hover:underline">Create your first ${label.toLowerCase()}</a>` : 'No items found.') : '')
|
|
47
|
+
tableContent = dataTable(headers, rows, items.length === 0 ? (userCanCreate ? `No items found. <a href="/${entityName}/new" class="text-primary hover:underline">Create your first ${esc(label.toLowerCase())}</a>` : 'No items found.') : '')
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
const createBtn = userCanCreate ? `<a href="/${entityName}/new" class="btn btn-primary btn-sm">Create New</a>` : ''
|
|
44
|
-
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">${label}</h1><div class="flex gap-2"><label for="search-input" class="sr-only">Search</label><input type="text" id="search-input" placeholder="Search..." class="input input-solid input-sm" style="width:200px" aria-label="Search items"/>${createBtn}</div></div>${tableContent}
|
|
51
|
+
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">${esc(label)}</h1><div class="flex gap-2"><label for="search-input" class="sr-only">Search</label><input type="text" id="search-input" placeholder="Search..." class="input input-solid input-sm" style="width:200px" aria-label="Search items"/>${createBtn}</div></div>${tableContent}`
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
// Canonical gmConfirm (session-13): replaces the bespoke confirm-dialog markup + confirmDelete/cancelDelete/executeDelete show-hide.
|
|
54
|
+
const deleteScript = `window.confirmDelete=async(id)=>{if(!id)return;const ok=await window.gmConfirm({title:'Delete ${entityName}',message:'Delete this ${entityName}? This cannot be undone.',confirmLabel:'Delete',danger:true});if(!ok)return;try{const res=await fetch('/api/${entityName}/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>window.location.reload(),500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error')}}catch(err){showToast('Error: '+err.message,'error')}}`
|
|
47
55
|
const searchScript = `function initSearch(){const si=document.getElementById('search-input');const tb=document.querySelectorAll('[data-searchable]');const groups=document.querySelectorAll('[data-group]');let visibleGroupCount=0;si.addEventListener('input',(e)=>{const query=e.target.value.toLowerCase().trim();const terms=query.length>0?query.split(/\\s+/):[],hasResults=terms.length>0;visibleGroupCount=0;groups.forEach(g=>{const rows=g.querySelectorAll('[data-searchable]');let visibleRows=0;rows.forEach(r=>{const text=r.textContent.toLowerCase();const matches=terms.every(t=>text.includes(t));r.style.display=matches?'':'none';if(matches)visibleRows++});const groupVisible=visibleRows>0;g.style.display=groupVisible?'':'none';if(groupVisible)visibleGroupCount++;const badge=g.querySelector('.badge');if(badge)badge.textContent=visibleRows});tb.forEach(r=>{if(!r.closest('[data-group]')){const text=r.textContent.toLowerCase();const matches=query.length===0||terms.every(t=>text.includes(t));r.style.display=matches?'':'none'}});const totalVisible=document.querySelectorAll('[data-searchable]:not([style*="display: none"])').length,noResults=hasResults&&totalVisible===0;let msg=document.getElementById('search-no-results');if(noResults&&!msg){msg=document.createElement('tr');msg.id='search-no-results';msg.innerHTML='<td colspan="100" class="text-center py-8 text-base-content/50">No results found for "'+query+'"</td>';const tbody=document.getElementById('table-body')||document.querySelector('tbody');if(tbody)tbody.appendChild(msg)}else if(!noResults&&msg)msg.remove()});const toggles=document.querySelectorAll('.group-toggle');toggles.forEach(tog=>{tog.addEventListener('change',(e)=>{e.stopPropagation();const row=tog.closest('tr');const tbody=row.parentElement;const content=tbody.querySelector('.group-content');content.style.display=tog.checked?'contents':'none'})});if(si.value)si.dispatchEvent(new Event('input'))}document.addEventListener('DOMContentLoaded',initSearch)`
|
|
48
|
-
return page(user, `${label} |
|
|
56
|
+
return page(user, `${label} | Thatcher`, [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label }], content, [TOAST_SCRIPT, deleteScript, searchScript])
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
const HIDDEN_FIELDS = new Set(['password_hash', 'password', 'session_token', 'photo_url'])
|
|
@@ -60,10 +68,10 @@ function formatFieldValue(k, v, entityName) {
|
|
|
60
68
|
if (entityName === 'user' && k === 'role') return `<span class="pill pill-neutral">${roleLabel(v)}</span>`
|
|
61
69
|
if (entityName === 'user' && k === 'status') {
|
|
62
70
|
const cls = v === 'active' ? 'pill-success' : v === 'deleted' ? 'pill-danger' : 'pill-neutral'
|
|
63
|
-
return `<span class="pill ${cls}">${v ? v.charAt(0).toUpperCase() + v.slice(1) : '-'}</span>`
|
|
71
|
+
return `<span class="pill ${cls}">${v ? esc(v.charAt(0).toUpperCase() + v.slice(1)) : '-'}</span>`
|
|
64
72
|
}
|
|
65
|
-
if (entityName === 'user' && k === 'email' && v) return `<a href="mailto:${
|
|
66
|
-
if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${v}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
|
|
73
|
+
if (entityName === 'user' && k === 'email' && v) { const e = esc(v); return `<a href="mailto:${e}" class="text-primary hover:underline">${e}</a>` }
|
|
74
|
+
if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${esc(v)}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
|
|
67
75
|
return fmtVal(v, k)
|
|
68
76
|
}
|
|
69
77
|
|
|
@@ -77,30 +85,31 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
77
85
|
|
|
78
86
|
const fieldRows = visibleFields.map(([k, f]) =>
|
|
79
87
|
`<div class="detail-row">
|
|
80
|
-
<span class="detail-row-label">${f.label || k}</span>
|
|
88
|
+
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
81
89
|
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName)}</span>
|
|
82
90
|
</div>`
|
|
83
91
|
).join('')
|
|
84
92
|
|
|
85
93
|
const displayName = item.name || item.title || label
|
|
86
|
-
const
|
|
94
|
+
const displayNameSafe = esc(displayName)
|
|
95
|
+
const initials = esc((displayName || '?').charAt(0).toUpperCase())
|
|
87
96
|
const statusCls = item.status === 'active' ? 'pill-success' : item.status === 'deleted' ? 'pill-danger' : 'pill-neutral'
|
|
88
97
|
const statusLabel2 = item.status ? item.status.charAt(0).toUpperCase() + item.status.slice(1) : '-'
|
|
89
98
|
|
|
90
99
|
const headerExtra = entityName === 'user' ? `
|
|
91
100
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem">
|
|
92
|
-
<div style="width:3.5rem;height:3.5rem;border-radius:50%;background
|
|
93
|
-
${item.photo_url ? `<img src="${item.photo_url}" style="width:3.5rem;height:3.5rem;border-radius:50%;object-fit:cover" alt="${
|
|
101
|
+
<div style="width:3.5rem;height:3.5rem;border-radius:50%;background:var(--color-avatar-bg,#e0f2fe);display:flex;align-items:center;justify-content:center;font-size:1.25rem;font-weight:700;color:var(--color-avatar-fg,#0369a1);flex-shrink:0">
|
|
102
|
+
${item.photo_url ? `<img src="${esc(item.photo_url)}" style="width:3.5rem;height:3.5rem;border-radius:50%;object-fit:cover" alt="${displayNameSafe}" onerror="this.style.display='none'"/>` : initials}
|
|
94
103
|
</div>
|
|
95
104
|
<div>
|
|
96
|
-
<h1 style="font-size:1.5rem;font-weight:700;margin:0">${
|
|
105
|
+
<h1 style="font-size:1.5rem;font-weight:700;margin:0">${displayNameSafe}</h1>
|
|
97
106
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.375rem">
|
|
98
|
-
<span class="pill pill-neutral">${roleLabel(item.role)}</span>
|
|
99
|
-
<span class="pill ${statusCls}">${statusLabel2}</span>
|
|
100
|
-
${item.user_type ? `<span style="font-size:0.75rem;color:var(--color-text-muted)">${item.user_type}</span>` : ''}
|
|
107
|
+
<span class="pill pill-neutral">${esc(roleLabel(item.role))}</span>
|
|
108
|
+
<span class="pill ${statusCls}">${esc(statusLabel2)}</span>
|
|
109
|
+
${item.user_type ? `<span style="font-size:0.75rem;color:var(--color-text-muted)">${esc(item.user_type)}</span>` : ''}
|
|
101
110
|
</div>
|
|
102
111
|
</div>
|
|
103
|
-
</div>` : `<div style="margin-bottom:1.5rem"><h1 style="font-size:1.5rem;font-weight:700">${
|
|
112
|
+
</div>` : `<div style="margin-bottom:1.5rem"><h1 style="font-size:1.5rem;font-weight:700">${displayNameSafe}</h1></div>`
|
|
104
113
|
|
|
105
114
|
const editBtn = userCanEdit ? `<a href="/${entityName}/${item.id}/edit" class="btn btn-outline btn-sm">Edit</a>` : ''
|
|
106
115
|
const delBtn = userCanDelete ? `<button data-action="showDeleteConfirm" class="btn btn-error btn-outline btn-sm">Delete</button>` : ''
|
|
@@ -113,9 +122,10 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
113
122
|
<div class="card-clean">
|
|
114
123
|
<div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
|
|
115
124
|
</div>
|
|
116
|
-
|
|
125
|
+
`
|
|
117
126
|
|
|
118
|
-
|
|
127
|
+
// Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
|
|
128
|
+
const script = `${TOAST_SCRIPT}window.showDeleteConfirm=async()=>{const ok=await window.gmConfirm({title:'Delete ${entityName}',message:'Delete this ${entityName}? This cannot be undone.',confirmLabel:'Delete',danger:true});if(!ok)return;try{const res=await fetch('/api/${entityName}/${item.id}',{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>{window.location='/${entityName}'},500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error')}}catch(err){showToast('Error: '+err.message,'error')}}`
|
|
119
129
|
const bc = [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: item.name || item.title || `#${item.id}` }]
|
|
120
130
|
return page(user, `${label} Detail`, bc, content, [script])
|
|
121
131
|
}
|
|
@@ -123,14 +133,14 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
123
133
|
export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}) {
|
|
124
134
|
const label = spec?.label || entityName
|
|
125
135
|
const fields = spec?.fields || {}
|
|
126
|
-
const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${f.label||k}${req ? '<span class="req">*</span>' : ''}</label>`
|
|
136
|
+
const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
|
|
127
137
|
const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readOnly && !f.auto_generate && k !== 'password_hash').map(([k, f]) => {
|
|
128
138
|
let val = item?.[k] ?? f.default ?? ''
|
|
129
139
|
const req = f.required ? 'required' : ''
|
|
130
140
|
const type = f.type === 'number' || f.type === 'int' || f.type === 'decimal' ? 'number' : f.type === 'email' ? 'email' : f.type === 'timestamp' || f.type === 'date' ? 'date' : f.type === 'bool' ? 'checkbox' : 'text'
|
|
131
141
|
// <input type=date> only populates from a strict YYYY-MM-DD; coerce a stored timestamp/ISO value so the existing date pre-fills (else it renders blank and saves empty).
|
|
132
142
|
if (type === 'date' && val) { const d = new Date(val); if (!isNaN(d)) val = d.toISOString().slice(0, 10) }
|
|
133
|
-
const placeholder = `placeholder="Enter ${(f.label||k).toLowerCase()}"`
|
|
143
|
+
const placeholder = `placeholder="Enter ${esc((f.label||k).toLowerCase())}"`
|
|
134
144
|
if (entityName === 'user' && k === 'role') {
|
|
135
145
|
const opts = ['partner','manager','clerk','client_admin','client_user'].map(o => `<option value="${o}" ${val===o?'selected':''}>${o.charAt(0).toUpperCase()+o.slice(1).replace('_',' ')}</option>`).join('')
|
|
136
146
|
return `<div class="form-field">${lbl(k,{label:'Role'},true)}<select id="field-role" name="role" class="form-input" required>${opts}</select></div>`
|
|
@@ -140,24 +150,24 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
140
150
|
return `<div class="form-field">${lbl(k,{label:'Status'},false)}<select id="field-status" name="status" class="form-input">${opts}</select></div>`
|
|
141
151
|
}
|
|
142
152
|
if (f.type === 'ref' && refOptions[k]) {
|
|
143
|
-
const opts = refOptions[k].map(o => `<option value="${o.value}" ${val===o.value?'selected':''}>${o.label}</option>`).join('')
|
|
144
|
-
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${f.label||k}...</option>${opts}</select></div>`
|
|
153
|
+
const opts = refOptions[k].map(o => `<option value="${esc(o.value)}" ${val===o.value?'selected':''}>${esc(o.label)}</option>`).join('')
|
|
154
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select></div>`
|
|
145
155
|
}
|
|
146
|
-
if (f.type === 'textarea') return `<div class="form-field full">${lbl(k,f,f.required)}<textarea id="field-${k}" name="${k}" class="form-input" style="min-height:100px;resize:vertical" ${req} placeholder="Enter ${(f.label||k).toLowerCase()}">${val}</textarea></div>`
|
|
147
|
-
if (f.type === 'bool') return `<div class="form-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="field-${k}" name="${k}" class="checkbox checkbox-primary" ${val?'checked':''}/><span class="form-label" style="margin:0">${f.label||k}</span></label></div>`
|
|
156
|
+
if (f.type === 'textarea') return `<div class="form-field full">${lbl(k,f,f.required)}<textarea id="field-${k}" name="${k}" class="form-input" style="min-height:100px;resize:vertical" ${req} placeholder="Enter ${esc((f.label||k).toLowerCase())}">${esc(val)}</textarea></div>`
|
|
157
|
+
if (f.type === 'bool') return `<div class="form-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="field-${k}" name="${k}" class="checkbox checkbox-primary" ${val?'checked':''}/><span class="form-label" style="margin:0">${esc(f.label||k)}</span></label></div>`
|
|
148
158
|
if (f.type === 'enum' && f.options) {
|
|
149
|
-
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<option value="${ov}" ${val===ov?'selected':''}>${ol}</option>` }).join('')
|
|
150
|
-
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${f.label||k}...</option>${opts}</select></div>`
|
|
159
|
+
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<option value="${esc(ov)}" ${val===ov?'selected':''}>${esc(ol)}</option>` }).join('')
|
|
160
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select></div>`
|
|
151
161
|
}
|
|
152
|
-
return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${val}" class="form-input" ${req} ${placeholder}/></div>`
|
|
162
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${esc(val)}" class="form-input" ${req} ${placeholder}/></div>`
|
|
153
163
|
}).join('\n')
|
|
154
164
|
|
|
155
165
|
const pwField = entityName === 'user' ? `<div class="form-field"><label class="form-label" for="field-new-password">New Password <small style="font-weight:400;color:var(--color-text-muted)">(leave blank to keep unchanged)</small></label><input type="password" id="field-new-password" name="new_password" class="form-input" placeholder="Enter new password" autocomplete="new-password"/></div>` : ''
|
|
156
166
|
const bc = isNew
|
|
157
167
|
? [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: 'Create' }]
|
|
158
168
|
: [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { href: `/${entityName}/${item?.id}`, label: item?.name || item?.title || `#${item?.id}` }, { label: 'Edit' }]
|
|
159
|
-
const content = `<div class="form-shell"><div style="margin-bottom:24px"><h1 style="font-size:24px;font-weight:700">${isNew ? 'Create' : 'Edit'} ${label}</h1></div>
|
|
160
|
-
<div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${label}">${formFields}${pwField}
|
|
169
|
+
const content = `<div class="form-shell"><div style="margin-bottom:24px"><h1 style="font-size:24px;font-weight:700">${isNew ? 'Create' : 'Edit'} ${esc(label)}</h1></div>
|
|
170
|
+
<div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${formFields}${pwField}
|
|
161
171
|
<div class="form-actions" style="grid-column:1/-1"><button type="submit" id="submit-btn" class="btn-primary-clean"><span class="btn-text">Save</span><span class="btn-loading-text" style="display:none">Saving...</span></button>
|
|
162
172
|
<a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
|
|
163
173
|
const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;const fd=new FormData(form);const data={};for(const[k,v]of fd.entries())data[k]=v;form.querySelectorAll('input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('input[type=number]').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
|
|
@@ -167,12 +177,16 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
167
177
|
export function renderSettings(user, config = {}) {
|
|
168
178
|
const t = config.thresholds || {}
|
|
169
179
|
const sections = [
|
|
170
|
-
{ title: 'System Information', items: [['Database Type', config.database?.type || 'SQLite'], ['Server Port', config.server?.port ||
|
|
180
|
+
{ title: 'System Information', items: [['Database Type', config.database?.type || 'SQLite'], ['Server Port', config.server?.port || 3000], ['Session TTL', (t.cache?.session_ttl_seconds || 3600) + 's'], ['Page Size (Default)', t.system?.default_page_size || 50], ['Page Size (Max)', t.system?.max_page_size || 500]] },
|
|
171
181
|
{ title: 'RFI Configuration', items: [['Max Days Outstanding', (t.rfi?.max_days_outstanding || 90) + ' days'], ['Escalation Delay', (t.rfi?.escalation_delay_hours || 24) + ' hours'], ['Notification Days', (t.rfi?.notification_days || [7,3,1,0]).join(', ')]] },
|
|
172
182
|
{ title: 'Email Configuration', items: [['Batch Size', t.email?.send_batch_size || 10], ['Max Retries', t.email?.send_max_retries || 3], ['Rate Limit Delay', (t.email?.rate_limit_delay_ms || 6000) + 'ms']] },
|
|
173
183
|
{ title: 'Workflow Configuration', items: [['Stage Transition Lockout', (t.workflow?.stage_transition_lockout_minutes || 5) + ' minutes'], ['Collaborator Default Expiry', (t.collaborator?.default_expiry_days || 7) + ' days'], ['Collaborator Max Expiry', (t.collaborator?.max_expiry_days || 30) + ' days']] },
|
|
174
184
|
]
|
|
175
|
-
const cards = sections.map(s => `<div class="card-clean"><div class="card-clean-body"><h2 style="font-size:1rem;font-weight:600">${s.title}</h2><div class="space-y-4 mt-4">${s.items.map(([l, v]) => `<div class="flex justify-between py-2 border-b border-base-200"><span class="text-base-content/50 text-sm">${l}</span><span class="font-medium text-sm">${v}</span></div>`).join('')}</div></div></div>`).join('')
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
const cards = sections.map(s => `<div class="card-clean"><div class="card-clean-body"><h2 style="font-size:1rem;font-weight:600">${esc(s.title)}</h2><div class="space-y-4 mt-4">${s.items.map(([l, v]) => `<div class="flex justify-between py-2 border-b border-base-200"><span class="text-base-content/50 text-sm">${esc(l)}</span><span class="font-medium text-sm">${esc(v)}</span></div>`).join('')}</div></div></div>`).join('')
|
|
186
|
+
// These values are config-driven (read-only here). State the source so an admin
|
|
187
|
+
// who lands expecting to edit knows where to change them, rather than hitting a
|
|
188
|
+
// silent dead-end page with no affordance.
|
|
189
|
+
const notice = `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body" style="display:flex;gap:0.75rem;align-items:flex-start"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted,#64748b)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;margin-top:1px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg><p style="margin:0;font-size:0.875rem;color:var(--color-text-muted,#64748b)">These settings are read-only. They are defined in <code>thatcher.config.yml</code> and applied on server start; edit that file and restart to change them.</p></div></div>`
|
|
190
|
+
return page(user, 'System Settings | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }],
|
|
191
|
+
`<h1 class="text-2xl font-bold mb-6">System Settings</h1>${notice}<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">${cards}</div>`)
|
|
178
192
|
}
|
package/src/ui/file-dialogs.js
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
import { statusLabel } from '@/ui/renderer.js';
|
|
2
2
|
|
|
3
|
+
// Inline HTML-escape for client-script innerHTML sinks (file names are DB-sourced).
|
|
4
|
+
const FD_ESC = `function fdEsc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}`;
|
|
5
|
+
|
|
3
6
|
export function zipFileCreationDialog(engagementId) {
|
|
4
|
-
return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close="zip-create-dialog" role="dialog" aria-modal="true" aria-labelledby="zip-create-dialog-title"
|
|
7
|
+
return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close="zip-create-dialog" role="dialog" aria-modal="true" aria-labelledby="zip-create-dialog-title">
|
|
5
8
|
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="zip-create-dialog-title">Create Zip Archive</span><button class="dialog-close" data-dialog-close="zip-create-dialog" aria-label="Close dialog">×</button></div>
|
|
6
9
|
<div class="dialog-body"><div id="zcd-files" class="flex flex-col gap-2" style="max-height:300px;overflow:auto"></div><div class="flex items-center gap-2 mt-3"><label class="flex items-center gap-2 text-sm"><input type="checkbox" id="zcd-all" class="checkbox checkbox-sm" checked/><span>Select All</span></label></div></div>
|
|
7
10
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="zip-create-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="zcdCreate">Create Zip</button></div>
|
|
8
11
|
</div></div>
|
|
9
12
|
<script>
|
|
10
|
-
|
|
13
|
+
${FD_ESC}
|
|
14
|
+
window.openZipCreation=function(){document.getElementById('zip-create-dialog').style.display='flex';fetch('/api/file?engagement_id=${engagementId}').then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];var el=document.getElementById('zcd-files');el.innerHTML=files.map(function(f){return'<label class="flex items-center gap-2"><input type="checkbox" class="checkbox checkbox-sm zcd-cb" value="'+fdEsc(f.id)+'" checked/><span class="text-sm">'+fdEsc(f.name||f.id)+'</span><span class="text-xs text-gray-400">'+(f.size?Math.round(f.size/1024)+'KB':'')+'</span></label>'}).join('')||'<div class="text-gray-500 text-sm">No files available</div>'}).catch(function(err){if(window.showToast)showToast(err.message||'Request failed','error');console.error(err)})};
|
|
11
15
|
document.getElementById('zcd-all').addEventListener('change',function(){document.querySelectorAll('.zcd-cb').forEach(function(c){c.checked=this.checked}.bind(this))});
|
|
12
16
|
window.zcdCreate=async function(){var ids=[].slice.call(document.querySelectorAll('.zcd-cb:checked')).map(function(c){return c.value});if(!ids.length){showToast('Select files','error');return}showToast('Creating zip...','info');try{var r=await fetch('/api/file/zip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_ids:ids,engagement_id:'${engagementId}'})});if(r.ok){var blob=await r.blob();var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='files.zip';a.click();showToast('Zip downloaded','success')}else showToast('Failed','error')}catch(e){showToast('Error','error')}document.getElementById('zip-create-dialog').style.display='none'};
|
|
13
17
|
</script>`;
|
|
14
18
|
}
|
|
15
19
|
export function crossEngagementFilePicker(currentEngId) {
|
|
16
|
-
return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close="cross-file-picker" role="dialog" aria-modal="true" aria-labelledby="cross-file-picker-title"
|
|
20
|
+
return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close="cross-file-picker" role="dialog" aria-modal="true" aria-labelledby="cross-file-picker-title">
|
|
17
21
|
<div class="dialog-panel" style="max-width:640px"><div class="dialog-header"><span class="dialog-title" id="cross-file-picker-title">Pick File from Another Engagement</span><button class="dialog-close" data-dialog-close="cross-file-picker" aria-label="Close dialog">×</button></div>
|
|
18
22
|
<div class="dialog-body"><div class="modal-form-group"><label for="cfp-eng">Engagement</label><select id="cfp-eng" class="select select-bordered w-full" onchange="cfpLoadFiles()"></select></div><div id="cfp-files" class="flex flex-col gap-2 mt-3" style="max-height:300px;overflow:auto"></div></div>
|
|
19
23
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="cross-file-picker">Close</button></div>
|
|
20
24
|
</div></div>
|
|
21
25
|
<script>
|
|
22
|
-
|
|
23
|
-
window.
|
|
26
|
+
${FD_ESC}
|
|
27
|
+
window.openCrossFilePicker=function(){document.getElementById('cross-file-picker').style.display='flex';fetch('/api/engagement').then(function(r){return r.json()}).then(function(d){var engs=d.data||d||[];var sel=document.getElementById('cfp-eng');while(sel.options.length>0)sel.remove(0);engs.filter(function(e){return e.id!=='${currentEngId}'}).forEach(function(e){var o=document.createElement('option');o.value=e.id;o.textContent=e.name||e.id;sel.appendChild(o)});if(sel.options.length)cfpLoadFiles()}).catch(function(err){if(window.showToast)showToast(err.message||'Request failed','error');console.error(err)})};
|
|
28
|
+
window.cfpLoadFiles=function(){var eid=document.getElementById('cfp-eng').value;if(!eid)return;fetch('/api/file?engagement_id='+eid).then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];document.getElementById('cfp-files').innerHTML=files.map(function(f){return'<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded"><span class="text-sm">'+fdEsc(f.name||f.id)+'</span><button class="btn btn-xs btn-primary" data-action="cfpLink" data-args='["'+fdEsc(f.id)+'"]'>Link</button></div>'}).join('')||'<div class="text-gray-500 text-sm">No files</div>'}).catch(function(err){if(window.showToast)showToast(err.message||'Request failed','error');console.error(err)})};
|
|
24
29
|
window.cfpLink=async function(fid){try{var r=await fetch('/api/file/link',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fid,engagement_id:'${currentEngId}'})});if(r.ok){showToast('File linked','success');document.getElementById('cross-file-picker').style.display='none'}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
25
30
|
</script>`;
|
|
26
31
|
}
|
|
@@ -37,7 +42,7 @@ export function userCvUpload(userId) {
|
|
|
37
42
|
</script>`;
|
|
38
43
|
}
|
|
39
44
|
export function quickViewAttachment() {
|
|
40
|
-
return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close="quick-view" role="dialog" aria-modal="true" aria-labelledby="quick-view-title"
|
|
45
|
+
return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close="quick-view" role="dialog" aria-modal="true" aria-labelledby="quick-view-title">
|
|
41
46
|
<div class="dialog-panel" style="max-width:900px;max-height:90vh"><div class="dialog-header"><span class="dialog-title" id="quick-view-title">Preview</span><button class="dialog-close" data-dialog-close="quick-view" aria-label="Close dialog">×</button></div>
|
|
42
47
|
<div class="dialog-body" style="overflow:auto;max-height:75vh"><div id="qv-content" class="text-center"><div class="text-gray-500">Loading...</div></div></div>
|
|
43
48
|
<div class="dialog-footer"><a id="qv-download" href="#" download class="btn btn-primary btn-sm">Download</a><button class="btn btn-ghost btn-sm" data-dialog-close="quick-view">Close</button></div>
|
|
@@ -48,7 +53,18 @@ export function quickViewAttachment() {
|
|
|
48
53
|
}
|
|
49
54
|
export function fetchCachedPdf(fileId) {
|
|
50
55
|
return `<script>
|
|
51
|
-
window.fetchCachedPdf
|
|
56
|
+
window.fetchCachedPdf = function(id) {
|
|
57
|
+
var cacheKey = 'pdf_cache_' + id;
|
|
58
|
+
var cached = sessionStorage.getItem(cacheKey);
|
|
59
|
+
if (cached) { return Promise.resolve(cached); }
|
|
60
|
+
return fetch('/api/file/' + (id || '${fileId}') + '/download')
|
|
61
|
+
.then(function(r) { return r.blob(); })
|
|
62
|
+
.then(function(b) {
|
|
63
|
+
var url = URL.createObjectURL(b);
|
|
64
|
+
sessionStorage.setItem(cacheKey, url);
|
|
65
|
+
return url;
|
|
66
|
+
});
|
|
67
|
+
};
|
|
52
68
|
</script>`;
|
|
53
69
|
}
|
|
54
70
|
export function fileAttachmentBar(files = []) {
|
|
@@ -62,14 +78,14 @@ export function fileLinksBar(links = []) {
|
|
|
62
78
|
return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
|
|
63
79
|
}
|
|
64
80
|
export function reviewAttachmentChoiceDialog(reviewId) {
|
|
65
|
-
return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close="rev-attach-choice" role="dialog" aria-modal="true" aria-labelledby="rev-attach-choice-title"
|
|
81
|
+
return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close="rev-attach-choice" role="dialog" aria-modal="true" aria-labelledby="rev-attach-choice-title">
|
|
66
82
|
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rev-attach-choice-title">Add Attachment</span><button class="dialog-close" data-dialog-close="rev-attach-choice" aria-label="Close dialog">×</button></div>
|
|
67
83
|
<div class="dialog-body"><div class="flex flex-col gap-3"><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="triggerUpload"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>Upload New File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="openCrossFilePicker"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>Link Existing File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="racUrl"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>Add URL</button></div><input type="file" id="rac-upload" style="display:none" onchange="racUploadFile()"/></div>
|
|
68
84
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rev-attach-choice">Cancel</button></div>
|
|
69
85
|
</div></div>
|
|
70
86
|
<script>
|
|
71
87
|
window.openRevAttachChoice=function(){document.getElementById('rev-attach-choice').style.display='flex'};
|
|
72
|
-
window.racUploadFile=async function(){var
|
|
88
|
+
window.racUploadFile=async function(){if(window.__racUploading)return;var inp=document.getElementById('rac-upload');var file=inp.files[0];if(!file)return;window.__racUploading=true;inp.disabled=true;showToast('Uploading...','info');var fd=new FormData();fd.append('file',file);fd.append('review_id','${reviewId}');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd,credentials:'include'});if(r.ok){showToast('Attached','success');setTimeout(function(){location.reload()},500)}else showToast('Upload failed','error')}catch(e){showToast('Upload error','error')}finally{window.__racUploading=false;inp.disabled=false;inp.value=''}};
|
|
73
89
|
window.racUrl=async function(){var url=await window.gmPrompt({title:'Add Link','label':'Enter URL','placeholder':'https://...','type':'url',confirmLabel:'Add Link',validate:function(v){return /^https?:\\/\\//i.test(v)?null:'Enter a valid http(s) URL.'}});if(!url)return;fetch('/api/file',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:'${reviewId}',url:url,name:url,type:'link'})}).then(function(r){if(r.ok)showToast('Link added','success');else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
|
|
74
90
|
</script>`;
|
|
75
91
|
}
|
|
@@ -31,7 +31,7 @@ function progressRing(value, label, color) {
|
|
|
31
31
|
stroke-dasharray="${circ}" stroke-dashoffset="${offset}" stroke-linecap="round"/>
|
|
32
32
|
</svg>
|
|
33
33
|
<div style="font-size:20px;font-weight:700;color:#111;margin-top:-54px;position:relative;height:24px">${value}%</div>
|
|
34
|
-
<div style="font-size:12px;font-weight:500;color:#
|
|
34
|
+
<div style="font-size:12px;font-weight:500;color:#475569;margin-top:4px">${esc(label)}</div>
|
|
35
35
|
</div>`;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -63,27 +63,27 @@ export function renderFlexupReport(user, engagement, client, rfis, reviews, high
|
|
|
63
63
|
const overdue = r.due_date && Number(r.due_date) < Math.floor(Date.now() / 1000) && (r.status || 'open') !== 'closed';
|
|
64
64
|
return `<tr>
|
|
65
65
|
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px">${esc(r.title || r.name || '-')}</td>
|
|
66
|
-
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#
|
|
67
|
-
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:${overdue ? '#dc2626' : '#
|
|
66
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#475569">${esc(r.status || 'open')}</td>
|
|
67
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:${overdue ? '#dc2626' : '#475569'}">${r.due_date ? fmtDate(r.due_date) : '-'}</td>
|
|
68
68
|
</tr>`;
|
|
69
|
-
}).join('') : `<tr><td colspan="3" style="padding:20px;text-align:center;color:#
|
|
69
|
+
}).join('') : `<tr><td colspan="3" style="padding:20px;text-align:center;color:#6b7280;font-size:13px">No RFIs</td></tr>`;
|
|
70
70
|
|
|
71
71
|
const reviewRows = reviews.length ? reviews.map((r) => `<tr>
|
|
72
72
|
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px">${esc(r.name || '-')}</td>
|
|
73
|
-
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#
|
|
74
|
-
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#
|
|
75
|
-
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#
|
|
76
|
-
</tr>`).join('') : `<tr><td colspan="4" style="padding:20px;text-align:center;color:#
|
|
73
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#475569">${esc(r.status || '-')}</td>
|
|
74
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#475569">${r.total_highlights || 0}</td>
|
|
75
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#475569">${r.resolved_highlights || 0}</td>
|
|
76
|
+
</tr>`).join('') : `<tr><td colspan="4" style="padding:20px;text-align:center;color:#6b7280;font-size:13px">No reviews linked</td></tr>`;
|
|
77
77
|
|
|
78
78
|
const activityRows = activity.slice(0, 10).map((a) => {
|
|
79
79
|
const ts = a.timestamp || a.created_at;
|
|
80
80
|
const when = ts ? (typeof ts === 'number' && ts > 1e9 ? new Date(ts * 1000).toLocaleString() : new Date(ts).toLocaleString()) : '-';
|
|
81
81
|
return `<tr>
|
|
82
|
-
<td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#
|
|
82
|
+
<td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#475569">${when}</td>
|
|
83
83
|
<td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:13px">${esc(a.action || a.operation || a.type || '-')}</td>
|
|
84
|
-
<td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#
|
|
84
|
+
<td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#475569">${esc(a.user_id || 'System')}</td>
|
|
85
85
|
</tr>`;
|
|
86
|
-
}).join('') || `<tr><td colspan="3" style="padding:12px;text-align:center;color:#
|
|
86
|
+
}).join('') || `<tr><td colspan="3" style="padding:12px;text-align:center;color:#6b7280;font-size:12px">No recent activity</td></tr>`;
|
|
87
87
|
|
|
88
88
|
const html = `<!DOCTYPE html>
|
|
89
89
|
<html lang="en"><head>
|
|
@@ -94,20 +94,23 @@ export function renderFlexupReport(user, engagement, client, rfis, reviews, high
|
|
|
94
94
|
.wrap { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
|
|
95
95
|
.accent { height: 6px; background: ${esc(themeColor)}; border-radius: 3px; margin-bottom: 24px; }
|
|
96
96
|
.h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px 0; }
|
|
97
|
-
.muted { color: #
|
|
97
|
+
.muted { color: #475569; font-size: 13px; }
|
|
98
98
|
.card { background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
99
99
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 32px; font-size: 14px; }
|
|
100
|
-
.grid .k { color: #
|
|
100
|
+
.grid .k { color: #475569; }
|
|
101
101
|
.rings { display: flex; gap: 24px; justify-content: center; padding: 12px 0; }
|
|
102
102
|
.kstat { display: flex; flex-direction: column; align-items: center; }
|
|
103
103
|
.kstat .val { font-size: 28px; font-weight: 700; color: #111; }
|
|
104
|
-
.kstat .lbl { font-size: 12px; color: #
|
|
104
|
+
.kstat .lbl { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
105
105
|
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; text-align: center; }
|
|
106
106
|
table { width: 100%; border-collapse: collapse; }
|
|
107
107
|
th { background: #f3f4f6; color: #374151; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 10px; text-align: left; }
|
|
108
108
|
.no-print { display: block; }
|
|
109
|
-
.foot { text-align: center; font-size: 11px; color: #
|
|
109
|
+
.foot { text-align: center; font-size: 11px; color: #6b7280; margin-top: 32px; padding-top: 16px; border-top: 1px solid #e5e7eb; }
|
|
110
110
|
.pbtn { background: ${esc(themeColor)}; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; }
|
|
111
|
+
@media (max-width: 640px) {
|
|
112
|
+
.grid { grid-template-columns: 1fr; }
|
|
113
|
+
}
|
|
111
114
|
@media print {
|
|
112
115
|
body { background: white; }
|
|
113
116
|
.no-print { display: none !important; }
|
|
@@ -118,13 +121,13 @@ export function renderFlexupReport(user, engagement, client, rfis, reviews, high
|
|
|
118
121
|
</head><body>
|
|
119
122
|
<div class="wrap">
|
|
120
123
|
<div class="no-print" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
|
121
|
-
<a href="/engagement/${esc(e.id)}" style="color:#
|
|
124
|
+
<a href="/engagement/${esc(e.id)}" style="color:#475569;text-decoration:none;font-size:13px"><- Back</a>
|
|
122
125
|
<button class="pbtn" onclick="window.print()">Print / Save as PDF</button>
|
|
123
126
|
</div>
|
|
124
127
|
<div class="accent"></div>
|
|
125
128
|
<div class="card">
|
|
126
129
|
<h1 class="h1">${esc(e.name || e.title || 'Engagement')}</h1>
|
|
127
|
-
<div class="muted">${esc(client?.name || e.client_name || '-')}
|
|
130
|
+
<div class="muted">${esc(client?.name || e.client_name || '-')} - ${esc(cfg?.label || e.stage || '-')} - FY ${esc(e.year || '-')}</div>
|
|
128
131
|
</div>
|
|
129
132
|
<div class="card">
|
|
130
133
|
<h2 style="font-size:14px;margin:0 0 16px 0;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#374151">Progress</h2>
|
|
@@ -165,7 +168,7 @@ export function renderFlexupReport(user, engagement, client, rfis, reviews, high
|
|
|
165
168
|
<table><thead><tr><th>When</th><th>Action</th><th>By</th></tr></thead><tbody>${activityRows}</tbody></table>
|
|
166
169
|
</div>
|
|
167
170
|
<div class="foot">
|
|
168
|
-
Generated ${new Date().toLocaleString('en-ZA')} by ${esc(user?.email || user?.name || 'system')}
|
|
171
|
+
Generated ${new Date().toLocaleString('en-ZA')} by ${esc(user?.email || user?.name || 'system')} - Thatcher
|
|
169
172
|
</div>
|
|
170
173
|
</div>
|
|
171
174
|
</body></html>`;
|