thatcher 1.0.6 → 1.0.33
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 +29 -88
- 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 +14 -5
- 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
package/src/ui/picker-dialogs.js
CHANGED
|
@@ -3,22 +3,27 @@ import { STAGE_COLORS, TOAST_SCRIPT } from '@/ui/render-helpers.js'
|
|
|
3
3
|
|
|
4
4
|
const DIALOG_COLORS = ['#B0B0B0','#44BBA4','#FF4141','#7F7EFF','#3b82f6','#f59e0b','#ec4899','#8b5cf6','#ef4444','#22c55e','#06b6d4','#f97316','#84cc16','#e11d48','#14b8a6','#6366f1']
|
|
5
5
|
|
|
6
|
+
// Inline client-side HTML-escape for innerHTML sinks (user name/email/team name are user-sourced).
|
|
7
|
+
const PD_ESC = `function pdEsc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}`;
|
|
8
|
+
|
|
6
9
|
export function colorPickerDialog(id = 'cpd', selected = '#B0B0B0', onSelect = '') {
|
|
7
10
|
const swatches = DIALOG_COLORS.map(c =>
|
|
8
|
-
`<div class="cpd-swatch${c === selected ? ' cpd-selected' : ''}" style="background:${c}" data-color="${c}" role="option" tabindex="0" aria-label="Color ${c}" aria-selected="${c === selected}" data-action="cpdSelect" data-args='["${id}","${c}",${onSelect ? "true" : "false"}]'
|
|
11
|
+
`<div class="cpd-swatch${c === selected ? ' cpd-selected' : ''}" style="background:${c}" data-color="${c}" role="option" tabindex="0" aria-label="Color ${c}" aria-selected="${c === selected}"${c === selected ? ' aria-current="true"' : ''} data-action="cpdSelect" data-args='["${id}","${c}",${onSelect ? "true" : "false"}]'></div>`
|
|
9
12
|
).join('')
|
|
10
|
-
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
11
|
-
<div class="dialog-panel" style="max-width:360px">
|
|
13
|
+
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
|
|
14
|
+
<div class="dialog-panel" style="max-width:min(360px,90vw)">
|
|
12
15
|
<div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Choose Color</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">×</button></div>
|
|
13
16
|
<div class="dialog-body"><div class="color-picker-grid" role="listbox" aria-label="Color options">${swatches}</div>
|
|
14
|
-
<div style="margin-top:0.75rem;display:flex;align-items:center;gap:0.75rem"><label class="text-sm text-gray-500" for="${id}-custom">Custom:</label><input type="color" id="${id}-custom" value="${selected}" onchange="cpdSelect('${id}',this.value,true)" aria-label="Custom color picker" style="width:
|
|
17
|
+
<div style="margin-top:0.75rem;display:flex;align-items:center;gap:0.75rem"><label class="text-sm text-gray-500" for="${id}-custom">Custom:</label><input type="color" id="${id}-custom" value="${selected}" onchange="cpdSelect('${id}',this.value,true)" aria-label="Custom color picker" class="focus-visible-ring" style="width:44px;height:44px;min-width:44px;min-height:44px;border:none;cursor:pointer"/><span id="${id}-val" class="text-sm font-medium" aria-live="polite">${selected}</span></div>
|
|
15
18
|
</div>
|
|
16
19
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="${id}-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="cpdConfirm" data-args='["${id}"]'>Select</button></div>
|
|
17
20
|
</div></div>
|
|
18
21
|
<script>window._cpd=window._cpd||{};window._cpd['${id}']='${selected}';
|
|
19
|
-
window.cpdSelect=function(id,c){window._cpd[id]=c;document.getElementById(id+'-val').textContent=c;document.getElementById(id+'-custom').value=c;document.querySelectorAll('#'+id+'-dialog .cpd-swatch').forEach(function(el){el.classList.toggle('cpd-selected',el.
|
|
22
|
+
window.cpdSelect=function(id,c){window._cpd[id]=c;document.getElementById(id+'-val').textContent=c;document.getElementById(id+'-custom').value=c;document.querySelectorAll('#'+id+'-dialog .cpd-swatch').forEach(function(el){var sel=el.dataset.color===c;el.classList.toggle('cpd-selected',sel);el.setAttribute('aria-selected',sel?'true':'false');if(sel)el.setAttribute('aria-current','true');else el.removeAttribute('aria-current')})};
|
|
20
23
|
window.cpdConfirm=function(id){var c=window._cpd[id];document.getElementById(id+'-dialog').style.display='none';if(window._cpdCallback)window._cpdCallback(c)};
|
|
21
|
-
window.showColorPicker=function(id,current,cb){window._cpdCallback=cb;if(current)cpdSelect(id,current);document.getElementById(id+'-dialog').style.display='flex'}
|
|
24
|
+
window.showColorPicker=function(id,current,cb){window._cpdCallback=cb;if(current)cpdSelect(id,current);document.getElementById(id+'-dialog').style.display='flex'};
|
|
25
|
+
window.cpdKeydown=function(id,e){var sw=Array.prototype.slice.call(document.querySelectorAll('#'+id+'-dialog .cpd-swatch'));if(!sw.length)return;var cur=sw.indexOf(document.activeElement);if(cur<0)cur=0;var next=cur;var k=e.key;if(k==='ArrowRight'||k==='ArrowDown')next=Math.min(sw.length-1,cur+1);else if(k==='ArrowLeft'||k==='ArrowUp')next=Math.max(0,cur-1);else if(k==='Home')next=0;else if(k==='End')next=sw.length-1;else return;e.preventDefault();sw[next].focus()};
|
|
26
|
+
document.querySelectorAll('#'+'${id}'+'-dialog .cpd-swatch').forEach(function(el){el.addEventListener('keydown',function(e){window.cpdKeydown('${id}',e)})});</script>`
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function dateChoiceDialog(id = 'dcd') {
|
|
@@ -28,8 +33,8 @@ export function dateChoiceDialog(id = 'dcd') {
|
|
|
28
33
|
{ label: '+3 Months', days: 90 }, { label: 'End of Month', days: 'eom' }, { label: 'End of Quarter', days: 'eoq' },
|
|
29
34
|
]
|
|
30
35
|
const presetBtns = presets.map(p => `<button class="date-preset-btn" data-action="dcdPreset" data-args='["${id}",${typeof p.days === 'number' ? p.days : '"' + p.days + '"'}]'>${p.label}</button>`).join('')
|
|
31
|
-
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
32
|
-
<div class="dialog-panel" style="max-width:380px">
|
|
36
|
+
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
|
|
37
|
+
<div class="dialog-panel" style="max-width:min(380px,90vw)">
|
|
33
38
|
<div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Choose Date</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">×</button></div>
|
|
34
39
|
<div class="dialog-body">
|
|
35
40
|
<div class="modal-form-group"><label for="${id}-input">Date</label><input type="date" id="${id}-input" class="input input-bordered w-full"/></div>
|
|
@@ -45,7 +50,7 @@ export function dateChoiceDialog(id = 'dcd') {
|
|
|
45
50
|
|
|
46
51
|
export function stageTransitionDialog(stages = null) {
|
|
47
52
|
const stageKeys = stages || Object.keys(STAGE_COLORS)
|
|
48
|
-
return `<div id="stage-trans-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
53
|
+
return `<div id="stage-trans-dialog" class="dialog-overlay" style="display:none" data-dialog-close="stage-trans-dialog" role="dialog" aria-modal="true" aria-labelledby="stage-trans-dialog-title" aria-hidden="true">
|
|
49
54
|
<div class="dialog-panel">
|
|
50
55
|
<div class="dialog-header"><span class="dialog-title" id="stage-trans-dialog-title">Transition Stage</span><button class="dialog-close" data-dialog-close="stage-trans-dialog" aria-label="Close dialog">×</button></div>
|
|
51
56
|
<div class="dialog-body">
|
|
@@ -63,19 +68,20 @@ export function stageTransitionDialog(stages = null) {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export function teamAssignmentDialog(id = 'tad') {
|
|
66
|
-
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
71
|
+
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
|
|
67
72
|
<div class="dialog-panel">
|
|
68
73
|
<div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Assign Team Members</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">×</button></div>
|
|
69
74
|
<div class="dialog-body">
|
|
70
75
|
<input type="text" id="${id}-search" class="tad-search" placeholder="Search by name or email..." aria-label="Search team members" oninput="tadFilter('${id}')"/>
|
|
71
|
-
<div id="${id}-list" class="tad-list"></div>
|
|
76
|
+
<div id="${id}-list" class="tad-list" role="region" aria-live="polite" aria-label="Team member results"></div>
|
|
72
77
|
</div>
|
|
73
78
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="${id}-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="tadConfirm" data-args='["${id}"]'>Assign</button></div>
|
|
74
79
|
</div></div>
|
|
75
80
|
<script>${TOAST_SCRIPT}
|
|
81
|
+
${PD_ESC}
|
|
76
82
|
window._tad=window._tad||{};
|
|
77
83
|
window.showTeamAssignment=function(id,users,selected,cb){window._tad[id]={users:users,selected:new Set(selected||[]),cb:cb};document.getElementById(id+'-dialog').style.display='flex';tadRender(id)};
|
|
78
|
-
function tadRender(id){var d=window._tad[id];var c=document.getElementById(id+'-list');c.innerHTML='';d.users.forEach(function(u){var row=document.createElement('div');row.className='tad-row';row.dataset.name=(u.name||'').toLowerCase();row.dataset.email=(u.email||'').toLowerCase();var checked=d.selected.has(u.id)?'checked':'';row.innerHTML='<input type="checkbox" '+checked+' onchange="tadToggle(\\''+id+'\\',\\''+u.id+'\\') " aria-label="Select '+(u.name||'Unknown').replace(/"/g,'"')+'">'+'<div><div class="tad-name">'+(u.name||'Unknown')+'</div><div class="tad-email">'+(u.email||'')+'</div></div>';c.appendChild(row)})}
|
|
84
|
+
function tadRender(id){var d=window._tad[id];var c=document.getElementById(id+'-list');c.innerHTML='';d.users.forEach(function(u){var row=document.createElement('div');row.className='tad-row';row.dataset.name=(u.name||'').toLowerCase();row.dataset.email=(u.email||'').toLowerCase();var checked=d.selected.has(u.id)?'checked':'';row.innerHTML='<input type="checkbox" '+checked+' onchange="tadToggle(\\''+id+'\\',\\''+u.id+'\\') " aria-label="Select '+(u.name||'Unknown').replace(/"/g,'"')+'">'+'<div><div class="tad-name">'+pdEsc(u.name||'Unknown')+'</div><div class="tad-email">'+pdEsc(u.email||'')+'</div></div>';c.appendChild(row)})}
|
|
79
85
|
window.tadFilter=function(id){var q=document.getElementById(id+'-search').value.toLowerCase();document.querySelectorAll('#'+id+'-list .tad-row').forEach(function(r){r.style.display=(r.dataset.name.includes(q)||r.dataset.email.includes(q))?'':'none'})};
|
|
80
86
|
window.tadToggle=function(id,uid){var d=window._tad[id];if(d.selected.has(uid))d.selected.delete(uid);else d.selected.add(uid)};
|
|
81
87
|
window.tadConfirm=function(id){var d=window._tad[id];document.getElementById(id+'-dialog').style.display='none';if(d.cb)d.cb(Array.from(d.selected))};</script>`
|
|
@@ -83,18 +89,19 @@ export function teamAssignmentDialog(id = 'tad') {
|
|
|
83
89
|
|
|
84
90
|
export function teamSelector(id = 'ts', teams = []) {
|
|
85
91
|
const items = teams.map(t =>
|
|
86
|
-
`<div class="tad-row" data-name="${(t.name || '').toLowerCase()}" data-action="tsSelect" data-args='["${id}","${t.id}","${(t.name || '').replace(/"/g, '"')}"]'><div class="tad-name">${t.name || 'Unknown'}</div><div class="tad-email">${t.member_count || 0} members</div></div>`
|
|
92
|
+
`<div class="tad-row" role="option" aria-selected="false" tabindex="-1" data-name="${(t.name || '').toLowerCase()}" data-action="tsSelect" data-args='["${id}","${t.id}","${(t.name || '').replace(/"/g, '"')}"]'><div class="tad-name">${t.name || 'Unknown'}</div><div class="tad-email">${t.member_count || 0} members</div></div>`
|
|
87
93
|
).join('')
|
|
88
94
|
return `<div class="ts-wrap" id="${id}-wrap">
|
|
89
95
|
<div id="${id}-badges" class="ts-badges"></div>
|
|
90
|
-
<input type="text" id="${id}-search" class="tad-search" placeholder="Search teams..." aria-label="Search teams" oninput="tsFilter('${id}')" onfocus="document.getElementById('${id}-dropdown').classList.add('ts-open')" />
|
|
91
|
-
<div id="${id}-dropdown" class="ts-dropdown">${items}</div>
|
|
96
|
+
<input type="text" id="${id}-search" class="tad-search" placeholder="Search teams..." aria-label="Search teams" role="combobox" aria-expanded="false" aria-controls="${id}-dropdown" aria-autocomplete="list" oninput="tsFilter('${id}')" onfocus="document.getElementById('${id}-dropdown').classList.add('ts-open');this.setAttribute('aria-expanded','true')" />
|
|
97
|
+
<div id="${id}-dropdown" class="ts-dropdown" role="listbox" aria-label="Teams">${items}</div>
|
|
92
98
|
<input type="hidden" id="${id}-value" name="team_id" value=""/>
|
|
93
99
|
</div>
|
|
94
|
-
<script
|
|
100
|
+
<script>${PD_ESC}
|
|
101
|
+
window._ts=window._ts||{};window._ts['${id}']=[];
|
|
95
102
|
window.tsFilter=function(id){var q=document.getElementById(id+'-search').value.toLowerCase();document.querySelectorAll('#'+id+'-dropdown .tad-row').forEach(function(r){r.style.display=r.dataset.name.includes(q)?'':'none'})};
|
|
96
|
-
window.tsSelect=function(id,tid,name){window._ts[id].push({id:tid,name:name});tsRender(id);document.getElementById(id+'-dropdown').classList.remove('ts-open');document.getElementById(id+'-search').value=''};
|
|
97
|
-
function tsRender(id){var b=document.getElementById(id+'-badges');b.innerHTML='';window._ts[id].forEach(function(t,i){b.innerHTML+='<span class="ts-badge">'+t.name+' <span class="ts-badge-x" data-action="tsRemove" data-args=\\'["'+id+'",'+i+']\\' >×</span></span>'});document.getElementById(id+'-value').value=window._ts[id].map(function(t){return t.id}).join(',')}
|
|
103
|
+
window.tsSelect=function(id,tid,name){window._ts[id].push({id:tid,name:name});tsRender(id);document.getElementById(id+'-dropdown').classList.remove('ts-open');document.getElementById(id+'-search').setAttribute('aria-expanded','false');document.getElementById(id+'-search').value=''};
|
|
104
|
+
function tsRender(id){var b=document.getElementById(id+'-badges');b.innerHTML='';window._ts[id].forEach(function(t,i){b.innerHTML+='<span class="ts-badge">'+pdEsc(t.name)+' <span class="ts-badge-x" data-action="tsRemove" data-args=\\'["'+id+'",'+i+']\\' >×</span></span>'});document.getElementById(id+'-value').value=window._ts[id].map(function(t){return t.id}).join(',')}
|
|
98
105
|
window.tsRemove=function(id,idx){window._ts[id].splice(idx,1);tsRender(id)};
|
|
99
|
-
document.addEventListener('click',function(e){if(!document.getElementById('${id}-wrap').contains(e.target))document.getElementById('${id}-dropdown').classList.remove('ts-open')});</script>`
|
|
106
|
+
document.addEventListener('click',function(e){if(!document.getElementById('${id}-wrap').contains(e.target)){document.getElementById('${id}-dropdown').classList.remove('ts-open');document.getElementById('${id}-search').setAttribute('aria-expanded','false')}});</script>`
|
|
100
107
|
}
|
package/src/ui/render-helpers.js
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
import { h } from '@/ui/webjsx.js'
|
|
2
|
+
import { SPACING } from './spacing-system.js'
|
|
2
3
|
|
|
3
4
|
export const STAGE_COLORS = {
|
|
4
|
-
info_gathering: {
|
|
5
|
-
commencement: {
|
|
6
|
-
team_execution: {
|
|
7
|
-
partner_review: {
|
|
8
|
-
finalization: {
|
|
9
|
-
closeout: {
|
|
5
|
+
info_gathering: { label: 'Info Gathering' },
|
|
6
|
+
commencement: { label: 'Commencement' },
|
|
7
|
+
team_execution: { label: 'Team Execution' },
|
|
8
|
+
partner_review: { label: 'Partner Review' },
|
|
9
|
+
finalization: { label: 'Finalization' },
|
|
10
|
+
closeout: { label: 'Close Out' },
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export const STATUS_COLORS = {
|
|
13
|
-
pending: {
|
|
14
|
-
active: {
|
|
15
|
-
completed: {
|
|
16
|
-
archived: {
|
|
17
|
-
open: {
|
|
18
|
-
closed: {
|
|
19
|
-
draft: {
|
|
20
|
-
in_progress: {
|
|
21
|
-
review: {
|
|
22
|
-
approved: {
|
|
23
|
-
rejected: {
|
|
24
|
-
overdue: {
|
|
25
|
-
cancelled: {
|
|
26
|
-
on_hold: {
|
|
27
|
-
resolved: {
|
|
28
|
-
unresolved: {
|
|
29
|
-
flagged: {
|
|
30
|
-
responded: {
|
|
31
|
-
expired: {
|
|
32
|
-
private: {
|
|
33
|
-
public: {
|
|
14
|
+
pending: {},
|
|
15
|
+
active: {},
|
|
16
|
+
completed: {},
|
|
17
|
+
archived: {},
|
|
18
|
+
open: {},
|
|
19
|
+
closed: {},
|
|
20
|
+
draft: {},
|
|
21
|
+
in_progress: {},
|
|
22
|
+
review: {},
|
|
23
|
+
approved: {},
|
|
24
|
+
rejected: {},
|
|
25
|
+
overdue: {},
|
|
26
|
+
cancelled: {},
|
|
27
|
+
on_hold: {},
|
|
28
|
+
resolved: {},
|
|
29
|
+
unresolved: {},
|
|
30
|
+
flagged: {},
|
|
31
|
+
responded: {},
|
|
32
|
+
expired: {},
|
|
33
|
+
private: {},
|
|
34
|
+
public: {},
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export const STAGE_CONFIG = [
|
|
@@ -47,7 +48,7 @@ export const AVATAR_SIZES = { sm: 24, md: 32, lg: 40, xl: 48 }
|
|
|
47
48
|
|
|
48
49
|
export const TOAST_SCRIPT = `window.showToast=(m,t='info')=>{let c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';c.setAttribute('role','status');c.setAttribute('aria-live','polite');c.setAttribute('aria-atomic','true');document.body.appendChild(c)}const d=document.createElement('div');d.className='toast toast-'+t;d.textContent=m;c.appendChild(d);setTimeout(()=>{d.style.opacity='0';setTimeout(()=>d.remove(),300)},3000)};`
|
|
49
50
|
|
|
50
|
-
export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col){th.classList.add(sortDir===1?'sort-asc':'sort-desc');th.setAttribute('aria-sort',sortDir===1?'ascending':'descending')}else{th.removeAttribute('aria-sort')}});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>{th.setAttribute('tabindex','0');th.setAttribute('role','button');if(!th.getAttribute('aria-label'))th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim());th.addEventListener('click',()
|
|
51
|
+
export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col){th.classList.add(sortDir===1?'sort-asc':'sort-desc');th.setAttribute('aria-sort',sortDir===1?'ascending':'descending');th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim()+', '+(sortDir===1?'ascending':'descending'))}else{th.removeAttribute('aria-sort')}});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;const style=document.createElement('style');style.textContent='th[data-sort]:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}';document.head.appendChild(style);document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>{th.setAttribute('tabindex','0');th.setAttribute('role','button');if(!th.getAttribute('aria-label'))th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim());th.addEventListener('click',e=>{e.preventDefault();sortTable(th.dataset.sort)});th.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '||e.key==='Spacebar'){e.preventDefault();sortTable(th.dataset.sort)}});th.addEventListener('keypress',e=>{if(e.key==='Enter'){e.preventDefault();sortTable(th.dataset.sort)}})})})})();`
|
|
51
52
|
|
|
52
53
|
export function esc(s) {
|
|
53
54
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
|
@@ -62,11 +63,8 @@ export function emptyRow(colspan, message = 'No items found') {
|
|
|
62
63
|
export function stagePill(stage) {
|
|
63
64
|
const cfg = STAGE_CONFIG.find(s => s.key === stage)
|
|
64
65
|
const lbl = cfg ? cfg.label : (stage || '-')
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return `<span class="stage-pill stage-${esc(stage||'')}" data-stage="${stage}" style="background:${stageColor.bg};color:${stageColor.text}">${lbl}</span>`
|
|
68
|
-
}
|
|
69
|
-
return `<span class="stage-pill stage-${esc(stage||'')}">${lbl}</span>`
|
|
66
|
+
// Use CSS classes (stage-pill stage-${stage}) for dark-mode support via styles2.css
|
|
67
|
+
return `<span class="stage-pill stage-${esc(stage||'')}" data-stage="${stage}">${lbl}</span>`
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
export function statusPill(status) {
|
|
@@ -84,7 +82,8 @@ export function statusBadge(status) {
|
|
|
84
82
|
export function progressBar(pct) {
|
|
85
83
|
if (typeof pct !== 'number') return '-'
|
|
86
84
|
const p = Math.min(100, Math.max(0, Math.round(pct)))
|
|
87
|
-
|
|
85
|
+
// Use CSS variable for border color (defined in both light and dark mode), responsive min-width, 44px tap-target height, contrast-compliant text color
|
|
86
|
+
return `<div style="display:flex;align-items:center;gap:${SPACING.sm};min-width:min(60px,50vw);max-width:100%;min-height:44px"><div style="flex:1;height:6px;background:var(--color-border);border-radius:3px;overflow:hidden"><div style="height:100%;width:${p}%;background:var(--color-primary);border-radius:3px"></div></div><span style="font-size:13px;font-weight:600;color:var(--color-text);min-width:28px;line-height:1.4">${p}%</span></div>`
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
export function fmtVal(value, fieldKey, item = {}) {
|
|
@@ -95,23 +94,32 @@ export function fmtVal(value, fieldKey, item = {}) {
|
|
|
95
94
|
}
|
|
96
95
|
if (fieldKey === 'year') { const n = Number(value); if (!isNaN(n)) return String(Math.floor(n)) }
|
|
97
96
|
if (fieldKey === 'stage' && STAGE_COLORS[value]) {
|
|
98
|
-
const
|
|
99
|
-
|
|
97
|
+
const lbl = STAGE_COLORS[value].label || value.charAt(0).toUpperCase() + value.slice(1)
|
|
98
|
+
// Use CSS classes (stage-${value}) for dark-mode support via styles2.css
|
|
99
|
+
return h('span', { className: `badge-stage stage-${esc(value)}` }, lbl)
|
|
100
100
|
}
|
|
101
101
|
if (fieldKey === 'status' && STATUS_COLORS[value]) {
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
// Map status to pill class following the pattern in statusBadge
|
|
103
|
+
const statusClassMap = { active:'pill pill-success', inactive:'pill pill-danger', pending:'pill pill-warning', open:'pill pill-info', in_progress:'pill pill-info', completed:'pill pill-success', closed:'pill pill-neutral', archived:'pill pill-neutral', responded:'pill pill-info', overdue:'pill pill-danger', rejected:'pill pill-danger', unresolved:'pill pill-danger', approved:'pill pill-success', review:'pill pill-primary', flagged:'pill pill-danger', expired:'pill pill-neutral', private:'pill pill-primary', public:'pill pill-info' }
|
|
104
|
+
const cls = statusClassMap[value] || 'pill pill-neutral'
|
|
105
|
+
return h('span', { className: cls }, value.charAt(0).toUpperCase() + value.slice(1))
|
|
104
106
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
// *_display is the resolved label of a referenced record (set in
|
|
108
|
+
// page-handler-helpers from refCaches), i.e. DB-sourced plain text — escape it.
|
|
109
|
+
if (item[`${fieldKey}_display`]) return esc(String(item[`${fieldKey}_display`]))
|
|
110
|
+
// Default path returns raw DB/user data into table cells and detail values —
|
|
111
|
+
// escape it so a stored value cannot inject markup.
|
|
112
|
+
return esc(String(value))
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
export function statusLabel(status) {
|
|
110
116
|
if (!status) return ''
|
|
111
117
|
const key = status.toLowerCase().replace(/\s+/g, '_')
|
|
112
|
-
|
|
118
|
+
// Map to pill class following the pattern in statusBadge for consistent dark-mode styling
|
|
119
|
+
const statusClassMap = { active:'pill pill-success', inactive:'pill pill-danger', pending:'pill pill-warning', open:'pill pill-info', in_progress:'pill pill-info', completed:'pill pill-success', closed:'pill pill-neutral', archived:'pill pill-neutral', responded:'pill pill-info', overdue:'pill pill-danger', rejected:'pill pill-danger', unresolved:'pill pill-danger', approved:'pill pill-success', review:'pill pill-primary', flagged:'pill pill-danger', expired:'pill pill-neutral', private:'pill pill-primary', public:'pill pill-info' }
|
|
120
|
+
const cls = statusClassMap[key] || 'pill pill-neutral'
|
|
113
121
|
const label = status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ')
|
|
114
|
-
return h('span', { className:
|
|
122
|
+
return h('span', { className: `status-label ${cls}` }, label)
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
export function nameHash(name) {
|
|
@@ -123,9 +131,39 @@ export function nameHash(name) {
|
|
|
123
131
|
export { getInitials } from '@/lib/utils.js'
|
|
124
132
|
export { icon } from '@/ui/format-helpers.js'
|
|
125
133
|
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
// Canonical date helpers — fixed en-ZA locale + format so the same data renders
|
|
135
|
+
// identically for every user (browser-locale toLocaleDateString gives MM/DD to a
|
|
136
|
+
// US user and DD/MM to a ZA user for the same timestamp; that is the unpredictability
|
|
137
|
+
// these replace). Scale-detect seconds vs milliseconds vs ISO string internally.
|
|
138
|
+
const DATE_LOCALE = 'en-ZA';
|
|
139
|
+
function toDate(ts) {
|
|
140
|
+
if (ts == null || ts === '') return null;
|
|
141
|
+
if (ts instanceof Date) return isNaN(ts) ? null : ts;
|
|
128
142
|
const n = Number(ts);
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
// 10-digit (or smaller) numeric => unix seconds; 13-digit => ms; non-numeric => ISO/parseable
|
|
144
|
+
const d = !isNaN(n) && String(ts).trim() !== '' && /^\d+$/.test(String(ts).trim())
|
|
145
|
+
? new Date(n < 1e12 ? n * 1000 : n)
|
|
146
|
+
: new Date(ts);
|
|
147
|
+
return isNaN(d) ? null : d;
|
|
148
|
+
}
|
|
149
|
+
export function fmtDate(ts) {
|
|
150
|
+
const d = toDate(ts);
|
|
151
|
+
return d ? d.toLocaleDateString(DATE_LOCALE, { day: '2-digit', month: 'short', year: 'numeric' }) : '-';
|
|
152
|
+
}
|
|
153
|
+
export function fmtDateTime(ts) {
|
|
154
|
+
const d = toDate(ts);
|
|
155
|
+
return d ? d.toLocaleDateString(DATE_LOCALE, { day: '2-digit', month: 'short', year: 'numeric' })
|
|
156
|
+
+ ', ' + d.toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' }) : '-';
|
|
157
|
+
}
|
|
158
|
+
// YYYY-MM-DD for <input type=date> values
|
|
159
|
+
export function fmtDateInput(ts) {
|
|
160
|
+
const d = toDate(ts);
|
|
161
|
+
if (!d) return '';
|
|
162
|
+
const p = n => String(n).padStart(2, '0');
|
|
163
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
|
164
|
+
}
|
|
165
|
+
// parse any date-ish value to unix seconds (the storage scale)
|
|
166
|
+
export function dateToUnixTimestamp(v) {
|
|
167
|
+
const d = toDate(v);
|
|
168
|
+
return d ? Math.floor(d.getTime() / 1000) : null;
|
|
131
169
|
}
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import { statusLabel } from '@/ui/renderer.js';
|
|
2
2
|
import { page } from '@/ui/layout.js';
|
|
3
|
+
import { emptyRow, esc } from './render-helpers.js';
|
|
3
4
|
|
|
4
5
|
function reviewSummaryPanel(review, highlights, side) {
|
|
5
6
|
const total = highlights.length;
|
|
6
7
|
const resolved = highlights.filter(h => h.status === 'resolved').length;
|
|
7
8
|
const sts = review.status ? statusLabel(review.status) : '';
|
|
8
|
-
return `<div class="p-4 bg-gray-50 border-b border-gray-200"><div class="flex items-center justify-between mb-2"><span class="font-medium text-sm">${review.name || 'Untitled'}</span>${sts}</div><div class="grid grid-cols-3 gap-2 text-xs"><div class="text-center"><div class="font-bold text-lg">${total}</div><div class="text-gray-500">Highlights</div></div><div class="text-center"><div class="font-bold text-lg text-green-600">${resolved}</div><div class="text-gray-500">Resolved</div></div><div class="text-center"><div class="font-bold text-lg text-red-600">${total - resolved}</div><div class="text-gray-500">Open</div></div></div></div>`;
|
|
9
|
+
return `<div class="p-4 bg-gray-50 border-b border-gray-200"><div class="flex items-center justify-between mb-2"><span class="font-medium text-sm">${esc(review.name || 'Untitled')}</span>${sts}</div><div class="grid grid-cols-3 gap-2 text-xs"><div class="text-center"><div class="font-bold text-lg">${total}</div><div class="text-gray-500">Highlights</div></div><div class="text-center"><div class="font-bold text-lg text-green-600">${resolved}</div><div class="text-gray-500">Resolved</div></div><div class="text-center"><div class="font-bold text-lg text-red-600">${total - resolved}</div><div class="text-gray-500">Open</div></div></div></div>`;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
function highlightDiffRow(leftH, rightH, idx) {
|
|
12
13
|
const renderSide = (h) => {
|
|
13
14
|
if (!h) return '<td class="p-2 bg-gray-50 text-center text-xs text-gray-400">-</td>';
|
|
14
|
-
const colors = { resolved: '
|
|
15
|
-
const color = colors[h.status] || '
|
|
15
|
+
const colors = { resolved: 'var(--color-success)', partial_resolved: 'var(--color-warning)' };
|
|
16
|
+
const color = colors[h.status] || 'var(--color-danger)';
|
|
16
17
|
return `<td class="p-2 border-b border-gray-50"><div class="flex items-center gap-1"><span style="width:6px;height:6px;border-radius:50%;background:${color};display:inline-block"></span><span class="text-xs">${h.text || h.content || 'Area'}</span></div><div class="text-xs text-gray-400">p.${h.page_number || '?'}</div></td>`;
|
|
17
18
|
};
|
|
18
19
|
const match = leftH && rightH && leftH.text === rightH?.text ? 'bg-green-50' : leftH && rightH ? 'bg-yellow-50' : 'bg-red-50';
|
|
@@ -37,11 +38,11 @@ export function renderReviewComparison(user, leftReview, rightReview, leftHighli
|
|
|
37
38
|
|
|
38
39
|
const diffSummary = `<div class="grid grid-cols-3 gap-4 mb-6"><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-green-600">${stats.common}</div><div class="text-xs text-gray-500">Matching</div></div></div><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-blue-600">${stats.onlyLeft}</div><div class="text-xs text-gray-500">Only in Left</div></div></div><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-purple-600">${stats.onlyRight}</div><div class="text-xs text-gray-500">Only in Right</div></div></div></div>`;
|
|
39
40
|
|
|
40
|
-
const comparisonTable = `<div class="card-clean" style="overflow-x:auto"><div class="card-clean-body" style="padding:0rem"><div class="grid grid-cols-2 gap-0"><div class="border-r border-gray-200">${reviewSummaryPanel(leftReview, leftHighlights, 'left')}</div><div>${reviewSummaryPanel(rightReview, rightHighlights, 'right')}</div></div><table class="data-table"><thead class="bg-gray-50"><tr><th class="text-center w-12">#</th><th>${leftReview.name || 'Left Review'}</th><th>${rightReview.name || 'Right Review'}</th><th class="text-center w-20">Status</th></tr></thead><tbody>${rows ||
|
|
41
|
+
const comparisonTable = `<div class="card-clean" style="overflow-x:auto"><div class="card-clean-body" style="padding:0rem"><div class="grid grid-cols-2 gap-0"><div class="border-r border-gray-200">${reviewSummaryPanel(leftReview, leftHighlights, 'left')}</div><div>${reviewSummaryPanel(rightReview, rightHighlights, 'right')}</div></div><table class="data-table"><thead class="bg-gray-50"><tr><th class="text-center w-12">#</th><th>${esc(leftReview.name || 'Left Review')}</th><th>${esc(rightReview.name || 'Right Review')}</th><th class="text-center w-20">Status</th></tr></thead><tbody>${rows || emptyRow(4, 'No highlights to compare')}</tbody></table></div></div>`;
|
|
41
42
|
|
|
42
43
|
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Review Comparison</h1><a href="/reviews" class="btn btn-ghost btn-sm">Back to Reviews</a></div>${diffSummary}${comparisonTable}`;
|
|
43
44
|
|
|
44
|
-
return page(user, 'Review Comparison |
|
|
45
|
+
return page(user, 'Review Comparison | Thatcher', [
|
|
45
46
|
{ href: '/', label: 'Dashboard' },
|
|
46
47
|
{ href: '/reviews', label: 'Reviews' },
|
|
47
48
|
{ label: 'Comparison' }
|
|
@@ -49,10 +50,10 @@ export function renderReviewComparison(user, leftReview, rightReview, leftHighli
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export function renderComparisonPicker(user, reviews) {
|
|
52
|
-
const reviewOptions = reviews.map(r => `<option value="${r.id}">${r.name || r.title || 'Untitled'} ${r.status ? '(' + r.status + ')' : ''}</option>`).join('');
|
|
53
|
+
const reviewOptions = reviews.map(r => `<option value="${esc(String(r.id))}">${esc(r.name || r.title || 'Untitled')} ${r.status ? '(' + esc(r.status) + ')' : ''}</option>`).join('');
|
|
53
54
|
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Compare Reviews</h1><a href="/reviews" class="btn btn-ghost btn-sm">Back to Reviews</a></div><div class="card-clean" style="margin:autorem"><div class="card-clean-body"><p class="text-sm text-gray-500 mb-4">Select two reviews to compare their highlights side by side.</p><div class="form-group mb-4"><label class="text-sm font-medium block mb-1" for="left-review">Left Review</label><select id="left-review" class="select select-bordered w-full">${reviewOptions}</select></div><div class="form-group mb-4"><label class="text-sm font-medium block mb-1" for="right-review">Right Review</label><select id="right-review" class="select select-bordered w-full">${reviewOptions}</select></div><button class="btn btn-primary w-full" data-action="startComparison">Compare</button></div></div>`;
|
|
54
55
|
|
|
55
56
|
const script = `window.startComparison=function(){const l=document.getElementById('left-review')?.value;const r=document.getElementById('right-review')?.value;if(!l||!r)return showToast('Select both reviews','error');if(l===r)return showToast('Select different reviews','error');window.location='/reviews/compare?left='+l+'&right='+r}`;
|
|
56
57
|
|
|
57
|
-
return page(user, 'Compare Reviews |
|
|
58
|
+
return page(user, 'Compare Reviews | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/reviews', label: 'Reviews' }, { label: 'Compare' }], content, [script]);
|
|
58
59
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { esc } from '@/ui/render-helpers.js';
|
|
1
|
+
import { esc, AVATAR_COLORS } from '@/ui/render-helpers.js';
|
|
2
2
|
|
|
3
3
|
export function highlightRow(h) {
|
|
4
4
|
const resolved = h.resolved || h.status === 'resolved';
|
|
@@ -14,13 +14,13 @@ export function highlightRow(h) {
|
|
|
14
14
|
try { tags = JSON.parse(h.tags || '[]') || []; } catch {}
|
|
15
15
|
const isFlagged = flags.includes('flagged');
|
|
16
16
|
|
|
17
|
-
const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? '
|
|
18
|
-
const tagPills = tags.map(t => `<span style="display:inline-flex;align-items:center;gap:3px;background
|
|
17
|
+
const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? 'var(--color-warning)' : 'var(--color-text-muted)'}">${isFlagged ? '⚑' : '⚐'}</button>`;
|
|
18
|
+
const tagPills = tags.map(t => `<span style="display:inline-flex;align-items:center;gap:3px;background:var(--color-info-bg,#eff6ff);color:var(--color-info,#1e40af);font-size:11px;padding:2px 8px;border-radius:9999px;font-weight:500">${esc(t)}<button data-action="removeTag" data-args='["${esc(h.id)}","${esc(t)}"]' style="background:none;border:none;cursor:pointer;padding:0;margin-left:2px;font-size:11px;color:var(--color-text-muted);line-height:1">×</button></span>`).join(' ');
|
|
19
19
|
const addTagBtn = `<button data-action="openAddTag" data-args='["${esc(h.id)}"]' style="background:none;border:none;cursor:pointer;font-size:11px;color:var(--color-text-muted);padding:1px 5px;border:1px dashed var(--color-border,#e5e7eb);border-radius:9999px">+tag</button>`;
|
|
20
20
|
const tagsCell = `<div style="display:flex;flex-wrap:wrap;gap:3px;align-items:center;min-width:80px">${tagPills}${addTagBtn}</div>`;
|
|
21
21
|
|
|
22
22
|
return `<tr style="${rowStyle}" data-highlight-id="${esc(h.id)}" data-flagged="${isFlagged}" data-section-id="${esc(h.section_id||'')}">
|
|
23
|
-
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;${textStyle}">${flagBtn}${esc(h.text||h.content||h.comment||'Highlight')}</td>
|
|
23
|
+
<td title="${esc(h.text||h.content||h.comment||'Highlight')}" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;${textStyle}">${flagBtn}${esc(h.text||h.content||h.comment||'Highlight')}</td>
|
|
24
24
|
<td style="font-size:13px;color:var(--color-text-muted);white-space:nowrap">p.${h.page||h.page_number||'-'}</td>
|
|
25
25
|
<td>${badge}</td>
|
|
26
26
|
<td>${tagsCell}</td>
|
|
@@ -31,8 +31,7 @@ export function highlightRow(h) {
|
|
|
31
31
|
|
|
32
32
|
export function collaboratorRow(c) {
|
|
33
33
|
const initials = (c.user_name || c.email || '?').charAt(0).toUpperCase();
|
|
34
|
-
const
|
|
35
|
-
const bg = colors[(c.user_name || c.email || '').charCodeAt(0) % colors.length] || colors[0];
|
|
34
|
+
const bg = AVATAR_COLORS[(c.user_name || c.email || '').charCodeAt(0) % AVATAR_COLORS.length] || AVATAR_COLORS[0];
|
|
36
35
|
return `<tr>
|
|
37
36
|
<td>
|
|
38
37
|
<div style="display:flex;align-items:center;gap:8px">
|
|
@@ -41,15 +40,15 @@ export function collaboratorRow(c) {
|
|
|
41
40
|
</div>
|
|
42
41
|
</td>
|
|
43
42
|
<td><span class="pill pill-info" style="text-transform:capitalize">${esc(c.role||'viewer')}</span></td>
|
|
44
|
-
<td style="font-size:13px;color:var(--color-text-muted)">${c.expires_at?new Date(typeof c.expires_at==='number'?c.expires_at*1000:c.expires_at).toLocaleDateString():'-'}</td>
|
|
43
|
+
<td style="font-size:13px;color:var(--color-text-muted)">${c.expires_at?new Date(typeof c.expires_at==='number'?c.expires_at*1000:c.expires_at).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'}</td>
|
|
45
44
|
<td style="text-align:right"><button data-action="removeCollaborator" data-args='["${esc(c.id)}"]' class="btn-danger-clean" style="font-size:12px;padding:4px 10px;min-height:28px">Remove</button></td>
|
|
46
45
|
</tr>`;
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
export function addCollaboratorDialog(reviewId) {
|
|
50
|
-
return `<div id="collab-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
49
|
+
return `<div id="collab-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="collab-dialog-title" data-dialog-close="collab-dialog">
|
|
51
50
|
<div class="dialog-panel" style="max-width:400px;width:100%">
|
|
52
|
-
<h3 style="font-size:16px;font-weight:600;color:var(--color-text);margin:0 0 16px">Add Collaborator</h3>
|
|
51
|
+
<h3 id="collab-dialog-title" style="font-size:16px;font-weight:600;color:var(--color-text);margin:0 0 16px">Add Collaborator</h3>
|
|
53
52
|
<div class="form-field" style="margin-bottom:12px">
|
|
54
53
|
<label class="form-label">Email</label>
|
|
55
54
|
<input type="email" id="collab-email" class="form-input" placeholder="collaborator@example.com"/>
|
|
@@ -13,8 +13,8 @@ import { SPACING, renderCard, renderTable, renderButton, renderInfoGrid } from '
|
|
|
13
13
|
function fmtDate(ts) {
|
|
14
14
|
if (!ts) return '-';
|
|
15
15
|
const n = Number(ts);
|
|
16
|
-
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
|
|
17
|
-
try { return new Date(ts).toLocaleDateString(); } catch { return String(ts); }
|
|
16
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
|
17
|
+
try { return new Date(ts).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}); } catch { return String(ts); }
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function renderReviewDetail(user, review, highlights = [], collaborators = [], checklists = [], sections = [], tenders = []) {
|
|
@@ -166,5 +166,5 @@ window.sendCollabNotify=async function(reviewId){var msg=document.getElementById
|
|
|
166
166
|
const content = `${pageHeader}${reviewZoneNav(r.id, 'overview')}${tabBar}
|
|
167
167
|
<div>${overviewPanel}${highlightsPanel}${collabPanel}${checklistPanel}${sectionsPanel}${tenderPanelHtml(r.id, tenders, canEditReview, tablePanel)}${linksPanelHtml(r.id, parsedLinks, canEditReview, tablePanel)}${historyPanel}${addCollaboratorDialog(r.id)}${checklistPickerDialog}${tenderDialog(r.id)}${linkDialog(r.id)}${compareDialogHtml(r.id)}${notifyDialog}${notifyScript}${retryHighlightDialog(esc(r.id))}${reviewTemplateChoiceDialog()}</div>`;
|
|
168
168
|
|
|
169
|
-
return page(user, `${esc(r.name || 'Review')} |
|
|
169
|
+
return page(user, `${esc(r.name || 'Review')} | Thatcher`, bc, content, [reviewDetailScript(r.id)]);
|
|
170
170
|
}
|
|
@@ -5,6 +5,7 @@ import { reviewHighlightActionsScript } from '@/ui/review-highlight-actions-scri
|
|
|
5
5
|
export function reviewDetailScript(reviewId) {
|
|
6
6
|
const id = esc(reviewId || '');
|
|
7
7
|
return `${TOAST_SCRIPT}
|
|
8
|
+
function __esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');}
|
|
8
9
|
function switchTab(tab){
|
|
9
10
|
document.querySelectorAll('.rv-panel').forEach(function(p){p.style.display='none'});
|
|
10
11
|
var panel=document.getElementById('rvpanel-'+tab);if(panel)panel.style.display='';
|
|
@@ -12,20 +13,20 @@ function switchTab(tab){
|
|
|
12
13
|
var btn=document.getElementById('rvtab-'+tab);if(btn)btn.classList.add('active');
|
|
13
14
|
if(tab==='history')loadHistory('${id}');
|
|
14
15
|
}
|
|
15
|
-
async function resolveHighlight(id){if(!
|
|
16
|
-
async function bulkResolve(reviewId){if(!
|
|
16
|
+
async function resolveHighlight(id){if(!(await window.gmConfirm({title:'Resolve Highlight',message:'Mark this highlight as resolved?',confirmLabel:'Resolve',danger:true})))return;try{var r=await fetch('/api/mwr/review/${id}/highlights/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolved:true,status:'resolved'})});if(r.ok){showToast('Resolved','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
17
|
+
async function bulkResolve(reviewId){if(!(await window.gmConfirm({title:'Resolve All',message:'Resolve all highlights? This marks every highlight in this review as resolved.',confirmLabel:'Resolve all',danger:true})))return;try{var r=await fetch('/api/mwr/review/'+reviewId+'/highlights/bulk-resolve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolve_all:true})});if(r.ok){showToast('All resolved','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
17
18
|
async function exportPdf(reviewId){showToast('Generating PDF...','info');try{var r=await fetch('/api/mwr/review/'+reviewId+'/export-pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});if(r.ok){var b=await r.blob();var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='review-'+reviewId+'.pdf';a.click();showToast('PDF downloaded','success')}else showToast('Export failed','error')}catch(e){showToast('Error','error')}}
|
|
18
19
|
async function addCollaborator(reviewId){var email=document.getElementById('collab-email').value.trim();var role=document.getElementById('collab-role').value;if(!email){showToast('Email required','error');return}try{var r=await fetch('/api/mwr/review/'+reviewId+'/collaborators',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,role:role})});if(r.ok){showToast('Collaborator added','success');document.getElementById('collab-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
19
|
-
async function removeCollaborator(collabId){if(!
|
|
20
|
+
async function removeCollaborator(collabId){if(!(await window.gmConfirm({title:'Remove Collaborator',message:'Remove this collaborator? They will lose access to this review.',confirmLabel:'Remove',danger:true})))return;try{var r=await fetch('/api/mwr/review/${id}/collaborators/'+collabId,{method:'DELETE'});if(r.ok){showToast('Removed','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
20
21
|
async function openChecklistPicker(reviewId){
|
|
21
22
|
var dlg=document.getElementById('checklist-picker-dialog');if(!dlg)return;
|
|
22
23
|
dlg.style.display='flex';
|
|
23
|
-
var sel=document.getElementById('checklist-picker-template');sel.innerHTML='<option>Loading...</option>';
|
|
24
|
+
var sel=document.getElementById('checklist-picker-template');sel.setAttribute('aria-busy','true');sel.innerHTML='<option>Loading...</option>';
|
|
24
25
|
try{
|
|
25
26
|
var r=await fetch('/api/mwr/checklist-template',{credentials:'include'});var data=await r.json();
|
|
26
|
-
if(!data.success||!data.templates||!data.templates.length){sel.innerHTML='<option value="">No templates available</option>';return}
|
|
27
|
-
sel.innerHTML=data.templates.map(function(t){return '<option value="'+t.id+'">'+t.name
|
|
28
|
-
}catch(e){sel.innerHTML='<option value="">Failed to load templates</option>'}
|
|
27
|
+
if(!data.success||!data.templates||!data.templates.length){sel.removeAttribute('aria-busy');sel.innerHTML='<option value="">No templates available</option>';return}
|
|
28
|
+
sel.removeAttribute('aria-busy');sel.innerHTML=data.templates.map(function(t){return '<option value="'+__esc(t.id)+'">'+__esc(t.name)+' ('+__esc(t.item_count)+' items)</option>'}).join('');
|
|
29
|
+
}catch(e){sel.removeAttribute('aria-busy');sel.innerHTML='<option value="">Failed to load templates</option>'}
|
|
29
30
|
}
|
|
30
31
|
async function saveChecklistFromTemplate(reviewId){
|
|
31
32
|
var sel=document.getElementById('checklist-picker-template');var nameInput=document.getElementById('checklist-picker-name');
|
|
@@ -75,21 +76,21 @@ async function saveTender(reviewId){
|
|
|
75
76
|
}catch(e){showToast('Network error','error')}
|
|
76
77
|
finally{if(btn)btn.disabled=false}
|
|
77
78
|
}
|
|
78
|
-
async function deleteTender(tenderId){if(!
|
|
79
|
+
async function deleteTender(tenderId){if(!(await window.gmConfirm({title:'Delete Tender',message:'Delete this tender? This cannot be undone.',confirmLabel:'Delete',danger:true})))return;try{var r=await fetch('/api/mwr/review/${id}/tender/'+tenderId,{method:'DELETE',credentials:'include'});var data=await r.json();if(r.ok&&data.success){showToast('Tender deleted','success');setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Error','error')}}
|
|
79
80
|
function openAddLink(){document.getElementById('link-dialog').style.display='flex';document.getElementById('link-label').value='';document.getElementById('link-url').value='';}
|
|
80
81
|
async function saveLink(reviewId){var url=document.getElementById('link-url').value.trim();var label=document.getElementById('link-label').value.trim();if(!url){showToast('URL required','error');return}try{var r=await fetch('/api/mwr/review/'+reviewId+'/links',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({url:url,label:label||url,type:'url'})});var data=await r.json();if(r.ok&&data.success){showToast('Link added','success');document.getElementById('link-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Network error','error')}}
|
|
81
|
-
async function removeLink(reviewId,idx){if(!
|
|
82
|
+
async function removeLink(reviewId,idx){if(!(await window.gmConfirm({title:'Remove Link',message:'Remove this link?',confirmLabel:'Remove',danger:true})))return;try{var r=await fetch('/api/mwr/review/'+reviewId+'/links/'+idx,{method:'DELETE',credentials:'include'});var data=await r.json();if(r.ok&&data.success){showToast('Link removed','success');setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Error','error')}}
|
|
82
83
|
async function loadHistory(reviewId){
|
|
83
84
|
var list=document.getElementById('history-list');
|
|
84
85
|
if(!list)return;
|
|
85
|
-
list.innerHTML='<div style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">Loading...</div>';
|
|
86
|
+
list.innerHTML='<div aria-busy="true" style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">Loading...</div>';
|
|
86
87
|
try{
|
|
87
88
|
var r=await fetch('/api/audit/logs?entity_type=review&entity_id='+reviewId,{credentials:'include'});
|
|
88
89
|
var data=await r.json();
|
|
89
90
|
var logs=(data.logs||data.data||[]);
|
|
90
91
|
if(!logs.length){list.innerHTML='<div style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">No history found</div>';return}
|
|
91
|
-
list.innerHTML=logs.map(function(log){var ts=log.timestamp?
|
|
92
|
-
}catch(e){list.innerHTML='<div style="font-size:13px;color:var(--color-danger,#dc2626);padding:24px 0;text-align:center">Failed to load history</div>'}
|
|
92
|
+
list.innerHTML=logs.map(function(log){var ts=log.timestamp?window.fmtDateTime(log.timestamp):'-';var op=log.operation||log.action||'-';var uid=log.user_id?'User '+log.user_id:'System';var details='';var detailsFull='';if(log.details&&typeof log.details==='object'){var keys=Object.keys(log.details).slice(0,3);details=keys.map(function(k){return k+': '+String(log.details[k]).slice(0,40)}).join(', ');detailsFull=keys.map(function(k){return k+': '+String(log.details[k])}).join(', ');}return '<div style="display:flex;gap:12px;align-items:flex-start;max-width:100%;padding:10px 0;border-bottom:1px solid var(--color-border,#e5e7eb)"><div style="width:8px;height:8px;border-radius:50%;background:var(--color-primary,#2563eb);margin-top:5px;flex-shrink:0"></div><div style="flex:1;min-width:0;max-width:100%;word-break:break-word"><div style="display:flex;justify-content:space-between;gap:8px"><span style="font-size:13px;font-weight:500;color:var(--color-text)">'+__esc(op.replace(/_/g,' '))+'</span><span style="font-size:12px;color:var(--color-text-muted);white-space:nowrap">'+__esc(ts)+'</span></div><div'+(detailsFull?' title="'+__esc(detailsFull)+'"':'')+' style="font-size:12px;color:var(--color-text-muted);margin-top:2px">'+__esc(uid)+(details?' - '+__esc(details):'')+'</div></div></div>';}).join('');
|
|
93
|
+
}catch(e){list.innerHTML='<div role="alert" style="font-size:13px;color:var(--color-danger,#dc2626);padding:24px 0;text-align:center">Failed to load history</div>'}
|
|
93
94
|
}
|
|
94
95
|
${reviewHighlightActionsScript(reviewId)}`;
|
|
95
96
|
}
|
|
@@ -4,13 +4,14 @@ import { SPACING, renderCard, renderButton, renderProgress, renderEmptyState, re
|
|
|
4
4
|
import { icon, esc } from '@/ui/format-helpers.js';
|
|
5
5
|
import { TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
6
6
|
|
|
7
|
-
// Star icon
|
|
8
|
-
|
|
7
|
+
// Star icon for priority. Filled vs outline state is conveyed by the button's
|
|
8
|
+
// .is-priority class (CSS) and aria-pressed; the glyph itself is the shared icon().
|
|
9
|
+
const starIcon = () => icon('star', 16);
|
|
9
10
|
|
|
10
11
|
function fmtDate(ts) {
|
|
11
12
|
if (!ts) return '-';
|
|
12
13
|
const n = Number(ts);
|
|
13
|
-
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
|
|
14
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
|
14
15
|
return String(ts);
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -48,7 +49,7 @@ export function renderMwrHome(user, stats) {
|
|
|
48
49
|
const archiveLabel = r.status === 'archived' ? 'Unarchive' : 'Archive';
|
|
49
50
|
const archiveAct = r.status === 'archived' ? 'unarchive' : 'archive';
|
|
50
51
|
return `<div class="list-item-row" data-review-id="${r.id}">
|
|
51
|
-
<button type="button" class="row-action row-action-star${isPri ? ' is-priority' : ''}" data-action="toggleReviewStar" data-args='["${esc(r.id)}"]' aria-label="Toggle priority" aria-pressed="${isPri}" title="Star/Priority">${starIcon(
|
|
52
|
+
<button type="button" class="row-action row-action-star${isPri ? ' is-priority' : ''}" data-action="toggleReviewStar" data-args='["${esc(r.id)}"]' aria-label="Toggle priority" aria-pressed="${isPri}" title="Star/Priority">${starIcon()}</button>
|
|
52
53
|
<span class="list-item-name" data-action="openReview" data-args='["${esc(r.id)}"]' style="cursor:pointer;flex:1">${esc(r.name || 'Untitled')}</span>
|
|
53
54
|
<div class="list-item-meta">${statusBadge(r.status)}<span style="font-size:12px;color:var(--color-text-light)">${fmtDate(r.updated_at || r.created_at)}</span></div>
|
|
54
55
|
<button class="row-action row-action-flag" data-action="toggleReviewFlag" data-args='["${r.id}"]' aria-label="Flag" title="Flag">${icon('flag', 16)}</button>
|
|
@@ -68,7 +69,7 @@ export function renderMwrHome(user, stats) {
|
|
|
68
69
|
const content = `${renderPageHeader('MWR Home', `Welcome back, ${user?.name || 'User'}`)}${statsHtml}${tabBar}${renderCard(panels, { padding: 0 })}`;
|
|
69
70
|
const script = `${TOAST_SCRIPT}window.switchHomeTab=(key)=>{document.querySelectorAll('.tab-btn[data-tab]').forEach(t=>t.classList.toggle('active',t.dataset.tab===key));document.querySelectorAll('[id^="home-panel-"]').forEach(p=>p.style.display='none');const el=document.getElementById('home-panel-'+key);if(el)el.style.display='block'};window.openReview=(id)=>{location.href='/review/'+id};window.toggleReviewStar=async(id)=>{try{const r=await fetch('/api/mwr/review/star',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Star failed','error')}catch(e){window.showToast('Star failed','error')}};window.toggleReviewFlag=async(id)=>{try{const r=await fetch('/api/mwr/review/flag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Flag failed','error')}catch(e){window.showToast('Flag failed','error')}};window.openReviewTag=async(id)=>{const t=await window.gmPrompt({title:'Tag Review',label:'Tag',placeholder:'Enter a tag',confirmLabel:'Add Tag'});if(t)fetch('/api/mwr/review/tag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id,tag:t})}).then(r=>{if(r.ok)window.showToast('Tagged');else window.showToast('Tag failed','error')})};window.toggleReviewArchive=async(id,act)=>{try{const r=await fetch('/api/mwr/review/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_ids:[id],action:act})});if(r.ok)location.reload();else window.showToast('Archive failed','error')}catch(e){window.showToast('Archive failed','error')}};`;
|
|
70
71
|
const bc = [{ href: '/', label: 'Dashboard' }, { label: 'MWR Home' }];
|
|
71
|
-
return page(user, 'MWR Home |
|
|
72
|
+
return page(user, 'MWR Home | Thatcher', bc, content, [script]);
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
export function renderSectionReport(user, review, sections) {
|