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,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) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { canCreate } from '@/ui/permissions-ui.js';
|
|
2
2
|
import { page } from '@/ui/layout.js';
|
|
3
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
4
|
import { reviewCreateDialog, reviewTemplateChoiceDialog, reviewContextMenu, reviewFlagsDialog, reviewTagsDialog, reviewValueDialog, reviewDeadlineDialog, reviewNotificationDialog } from '@/ui/review-dialogs.js';
|
|
4
5
|
import { SPACING, renderButton, renderEmptyState, renderStatsRow, renderPageHeader } from '@/ui/spacing-system.js';
|
|
5
6
|
|
|
@@ -19,7 +20,7 @@ const TAB_DEFS = [
|
|
|
19
20
|
function fmtDate(ts) {
|
|
20
21
|
if (!ts) return '-';
|
|
21
22
|
const n = Number(ts);
|
|
22
|
-
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
|
|
23
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
|
23
24
|
return String(ts);
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -33,13 +34,13 @@ function statusBadge(status) {
|
|
|
33
34
|
function reviewRow(r) {
|
|
34
35
|
const isPri = (r.is_priority || r.flagged || (r.flags_count > 0)) ? 1 : 0;
|
|
35
36
|
return `<tr class="review-row" data-status="${r.status || ''}" data-flags="${r.flags_count || 0}" data-highlights="${r.highlights_count || 0}" data-archived="${r.archived || 0}" data-priority="${isPri}" data-stage="${r.stage || r.status || ''}" data-navigate="/review/${r.id}" oncontextmenu="showCtxMenu(event,'${r.id}')" style="cursor:pointer">
|
|
36
|
-
<td style="width:32px"><button class="review-priority-star${isPri?' is-priority':''}" data-action="togglePriority" data-args='["${r.id}"]' aria-label="Toggle priority"
|
|
37
|
+
<td style="width:32px"><button class="review-priority-star${isPri?' is-priority':''}" data-action="togglePriority" data-args='["${r.id}"]' aria-label="Toggle priority" aria-pressed="${isPri?'true':'false'}" data-stop-propagation><svg width="16" height="16" viewBox="0 0 24 24" fill="${isPri?'currentColor':'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button></td>
|
|
37
38
|
<td class="review-row-name">${r.name || r.title || 'Untitled'}</td>
|
|
38
|
-
<td style="font-size:13px;color:var(--color-text
|
|
39
|
+
<td style="font-size:13px;color:var(--color-text)">${r.engagement_name || r.engagement_id_display || '-'}</td>
|
|
39
40
|
<td>${statusBadge(r.status)}</td>
|
|
40
41
|
<td style="text-align:center;font-size:13px">${r.highlights_count !== undefined ? r.highlights_count : '-'}</td>
|
|
41
42
|
<td style="font-size:13px">${fmtDate(r.deadline)}</td>
|
|
42
|
-
<td style="font-size:13px;color:var(--color-text-
|
|
43
|
+
<td style="font-size:13px;color:var(--color-text-muted)">${fmtDate(r.created_at)}</td>
|
|
43
44
|
</tr>`;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -127,7 +128,7 @@ export function renderReviewListTabbed(user, reviews) {
|
|
|
127
128
|
});
|
|
128
129
|
})()`;
|
|
129
130
|
|
|
130
|
-
return page(user, 'Reviews |
|
|
131
|
+
return page(user, 'Reviews | Thatcher', bc, content, [TOAST_SCRIPT, tabScript, ctxScript, searchScript, priorityScript]);
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
export function reviewSearchField() {
|
|
@@ -149,7 +150,7 @@ export function reviewGroupedList(reviews, groupBy) {
|
|
|
149
150
|
`<div class="card-clean" style="margin-bottom:${SPACING.md}">
|
|
150
151
|
<div class="card-clean-body" style="padding:${SPACING.md}">
|
|
151
152
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:${SPACING.sm};cursor:pointer" data-action="toggleReviewGroup" data-self>
|
|
152
|
-
<span style="font-weight:600;font-size:14px">${name}</span>
|
|
153
|
+
<span style="font-weight:600;font-size:14px">${esc(name)}</span>
|
|
153
154
|
<span class="pill pill-neutral">${items.length}</span>
|
|
154
155
|
</div>
|
|
155
156
|
<div class="review-group-items">
|
package/src/ui/review-widgets.js
CHANGED
|
@@ -5,7 +5,7 @@ import { teamAvatarGroup } from '@/ui/widgets.js'
|
|
|
5
5
|
export function mobileReviewCard(review) {
|
|
6
6
|
const r = review || {}
|
|
7
7
|
const sts = r.status ? statusLabel(r.status) : ''
|
|
8
|
-
const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at}</span>` : ''
|
|
8
|
+
const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.created_at}</span>` : ''
|
|
9
9
|
return `<div class="mobile-card mobile-review-card card-clean">
|
|
10
10
|
<div class="card-clean-body">
|
|
11
11
|
<div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${esc(r.name || r.title || 'Untitled Review')}</h3>${sts}</div>
|
|
@@ -19,8 +19,8 @@ export function sidebarReviewDetails(review) {
|
|
|
19
19
|
const r = review || {}
|
|
20
20
|
const sts = r.status ? statusLabel(r.status) : ''
|
|
21
21
|
const meta = [
|
|
22
|
-
r.created_at ? ['Created', typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at] : null,
|
|
23
|
-
r.updated_at ? ['Updated', typeof r.updated_at === 'number' ? new Date(r.updated_at * 1000).toLocaleDateString() : r.updated_at] : null,
|
|
22
|
+
r.created_at ? ['Created', typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.created_at] : null,
|
|
23
|
+
r.updated_at ? ['Updated', typeof r.updated_at === 'number' ? new Date(r.updated_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.updated_at] : null,
|
|
24
24
|
r.creator_name ? ['Creator', r.creator_name] : null,
|
|
25
25
|
r.manager_name ? ['Manager', r.manager_name] : null,
|
|
26
26
|
].filter(Boolean).map(([l, v]) => `<div class="sidebar-meta-row"><span class="text-gray-500 text-xs">${l}</span><span class="text-sm font-medium">${v}</span></div>`).join('')
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { page } from '@/ui/layout.js';
|
|
2
2
|
import { canEdit, isPartner, isManager, isClientUser } from '@/ui/permissions-ui.js';
|
|
3
|
-
import { esc, statusBadge, TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
3
|
+
import { esc, statusBadge, TOAST_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
|
|
4
4
|
import { SPACING } from '@/ui/spacing-system.js';
|
|
5
5
|
|
|
6
6
|
const TOAST = TOAST_SCRIPT;
|
|
7
7
|
|
|
8
8
|
function questionRow(q, i, sections = [], clientId = '', showRespond = false, showView = false) {
|
|
9
9
|
const section = sections.find(s => s.id === q.section_id || s.id === q.rfi_section_id);
|
|
10
|
-
const sLabel = section ? `<span class="badge badge-
|
|
10
|
+
const sLabel = section ? `<span class="badge badge-primary text-xs">${esc(section.name)}</span>` : '';
|
|
11
11
|
const hasResp = q.responses > 0 || q.response_count > 0;
|
|
12
12
|
const respBadge = hasResp ? `<span class="badge badge-success badge-flat-success text-xs">Responded</span>` : '';
|
|
13
13
|
const assignedLabel = q.assigned_to ? `<span class="badge badge-flat-secondary text-xs">Assigned</span>` : '';
|
|
@@ -16,10 +16,10 @@ function questionRow(q, i, sections = [], clientId = '', showRespond = false, sh
|
|
|
16
16
|
const viewBtn = showView && hasResp ? `<button data-action="viewResponses" data-args='["${esc(q.id)}","${esc(q.question_text||q.question||q.title||'')}"]' class="btn btn-ghost btn-xs">Responses</button>` : '';
|
|
17
17
|
const editDelBtns = !showRespond ? `<button data-action="openEditQuestion" data-args='["${esc(q.id)}"]' class="btn btn-ghost btn-xs">Edit</button>${assignBtn}<button data-action="deleteQuestion" data-args='["${esc(q.id)}"]' class="btn btn-error btn-xs">Del</button>` : '';
|
|
18
18
|
return `<tr class="hover">
|
|
19
|
-
<td class="text-center text-sm text-base-content
|
|
19
|
+
<td class="text-center text-sm text-base-content w-8">${i+1}</td>
|
|
20
20
|
<td class="text-sm max-w-sm">${esc(q.question_text||q.question||q.title||'Question')}</td>
|
|
21
21
|
<td>${sLabel}</td>
|
|
22
|
-
<td class="text-sm text-base-content
|
|
22
|
+
<td class="text-sm text-base-content">${q.deadline?new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'}</td>
|
|
23
23
|
<td>${respBadge}${assignedLabel}</td>
|
|
24
24
|
<td><div class="flex gap-2">${respondBtn}${viewBtn}${editDelBtns}</div></td>
|
|
25
25
|
</tr>`;
|
|
@@ -27,15 +27,15 @@ function questionRow(q, i, sections = [], clientId = '', showRespond = false, sh
|
|
|
27
27
|
|
|
28
28
|
function editQuestionDialog(rfiId, sections) {
|
|
29
29
|
const sectionOpts = sections.map(s => `<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');
|
|
30
|
-
return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><
|
|
30
|
+
return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="q-dialog-title"><h2 class="text-lg font-semibold mb-4" id="q-dialog-title">Edit Question</h2><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Question Text</span></label><textarea id="q-text" rows="4" class="textarea textarea-solid max-w-full"></textarea></div><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Section</span></label><select id="q-section" class="select select-solid max-w-full"><option value="">No section</option>${sectionOpts}</select></div><div class="grid grid-cols-2 gap-3 mb-4"><div class="form-group"><label class="label"><span class="label-text font-medium">Deadline</span></label><input type="date" id="q-deadline" class="input input-solid max-w-full"/></div><div class="form-group"><label class="label"><span class="label-text font-medium">Category</span></label><input type="text" id="q-category" placeholder="Category" class="input input-solid max-w-full"/></div></div><input type="hidden" id="q-edit-id"/><div class="modal-action"><button data-action="saveQuestion" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Save</button><button data-dialog-close="q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function assignQuestionDialog(rfiId) {
|
|
34
|
-
return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="assign-q-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><
|
|
34
|
+
return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="assign-q-dialog"></div><div class="modal-content rounded-box max-w-sm p-6" aria-labelledby="assign-q-dialog-title"><h2 class="text-lg font-semibold mb-4" id="assign-q-dialog-title">Assign to Client User</h2><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Client User</span></label><select id="assign-q-user" class="select select-solid max-w-full"><option value="">Loading...</option></select></div><input type="hidden" id="assign-q-id"/><div class="modal-action"><button data-action="saveAssignQuestion" class="btn btn-primary">Assign</button><button data-dialog-close="assign-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function addSectionDialog(rfiId) {
|
|
38
|
-
return `<div id="sec-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="sec-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><
|
|
38
|
+
return `<div id="sec-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="sec-dialog"></div><div class="modal-content rounded-box max-w-sm p-6" aria-labelledby="sec-dialog-title"><h2 class="text-lg font-semibold mb-4" id="sec-dialog-title">Add Section</h2><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Section Name</span></label><input type="text" id="sec-name" class="input input-solid max-w-full"/></div><div class="modal-action"><button data-action="saveSection" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Add</button><button data-dialog-close="sec-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], engagement = null) {
|
|
@@ -44,7 +44,11 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
44
44
|
const canViewResponses = isPartner(user) || isManager(user);
|
|
45
45
|
const clientId = engagement?.client_id || '';
|
|
46
46
|
const qRows = questions.map((q, i) => questionRow(q, i, sections, canEditRfi ? clientId : '', isClient, canViewResponses)).join('') ||
|
|
47
|
-
`<tr><td colspan="6" class="
|
|
47
|
+
`<tr><td colspan="6"><div class="empty-state">
|
|
48
|
+
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/></svg>
|
|
49
|
+
<div class="empty-state-title">No questions yet</div>
|
|
50
|
+
<div class="empty-state-desc">Add one using the + Question button above.</div>
|
|
51
|
+
</div></td></tr>`;
|
|
48
52
|
|
|
49
53
|
const sectionTabs = sections.length ? `<div class="tabs tabs-boxed bg-base-200 flex-wrap gap-1">
|
|
50
54
|
<button data-action="filterBySection" data-args='[""]' id="stab-all" class="tab-btn active">All</button>
|
|
@@ -64,16 +68,16 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
64
68
|
` : ''}</div>`;
|
|
65
69
|
|
|
66
70
|
const infoItems = [
|
|
67
|
-
['Engagement', engagement?.name ? esc(engagement.name) : '<span class="text-base-content
|
|
71
|
+
['Engagement', engagement?.name ? esc(engagement.name) : '<span class="text-base-content">—</span>'],
|
|
68
72
|
['Status', statusBadge(rfi.status)],
|
|
69
|
-
['Deadline', rfi.deadline?new Date(typeof rfi.deadline==='number'?rfi.deadline*1000:rfi.deadline).toLocaleDateString():'-'],
|
|
73
|
+
['Deadline', rfi.deadline?new Date(typeof rfi.deadline==='number'?rfi.deadline*1000:rfi.deadline).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'],
|
|
70
74
|
['Questions', questions.length],
|
|
71
75
|
['Sections', sections.length],
|
|
72
76
|
['Mandatory', rfi.mandatory!==false?'Yes':'No'],
|
|
73
77
|
];
|
|
74
78
|
|
|
75
79
|
const infoGrid = `<div class="rfi-info-grid">` +
|
|
76
|
-
infoItems.map(([l,v]) => `<div><div
|
|
80
|
+
infoItems.map(([l,v]) => `<div><div class="info-label-key">${l}</div><div class="info-label-value">${v}</div></div>`).join('') +
|
|
77
81
|
`</div>`;
|
|
78
82
|
|
|
79
83
|
const content = `
|
|
@@ -91,16 +95,16 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
91
95
|
<div class="card-clean">
|
|
92
96
|
<div class="card-clean-body">
|
|
93
97
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:${SPACING.md};flex-wrap:wrap;gap:${SPACING.sm}">
|
|
94
|
-
<
|
|
98
|
+
<h3 style="font-size:1rem;font-weight:600;color:var(--color-text)">Questions (${questions.length})</h3>
|
|
95
99
|
<div style="display:flex;gap:${SPACING.sm};align-items:center;flex-wrap:wrap">
|
|
96
100
|
${sectionTabs}
|
|
97
101
|
<input type="text" placeholder="Search..." oninput="filterQuestions(this.value)" class="input input-solid" style="max-width:160px;font-size:0.875rem"/>
|
|
98
102
|
</div>
|
|
99
103
|
</div>
|
|
100
104
|
<div class="table-wrap">
|
|
101
|
-
<table class="data-table" id="q-table">
|
|
102
|
-
<thead><tr><th>#</th><th>Question</th><th>Section</th><th>Deadline</th><th>Status</th><th>Actions</th></tr></thead>
|
|
103
|
-
<tbody id="q-tbody">${qRows}</tbody>
|
|
105
|
+
<table class="data-table" id="q-table" aria-label="Questions table">
|
|
106
|
+
<thead><tr><th scope="col">#</th><th scope="col">Question</th><th scope="col">Section</th><th scope="col">Deadline</th><th scope="col">Status</th><th scope="col">Actions</th></tr></thead>
|
|
107
|
+
<tbody id="q-tbody">${qRows || `<tr><td colspan="6" role="status" aria-live="polite" style="padding:${SPACING.md}"><span class="sr-only">Loading questions…</span><div class="skeleton" style="height:40px"></div></td></tr>`}</tbody>
|
|
104
108
|
</table>
|
|
105
109
|
</div>
|
|
106
110
|
</div>
|
|
@@ -108,11 +112,12 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
108
112
|
${editQuestionDialog(rfi.id, sections)}
|
|
109
113
|
${addSectionDialog(rfi.id)}
|
|
110
114
|
${clientId ? assignQuestionDialog(rfi.id) : ''}
|
|
111
|
-
${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="view-resp-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><
|
|
112
|
-
${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="respond-q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><
|
|
115
|
+
${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="view-resp-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="view-resp-dialog-title"><h2 class="text-lg font-semibold mb-1" id="view-resp-dialog-title">Responses</h2><p id="view-resp-qtext" class="text-sm text-base-content mb-3"></p><div id="view-resp-list" style="max-height:240px;overflow-y:auto"></div><div class="modal-action"><button data-dialog-close="view-resp-dialog" class="btn btn-ghost">Close</button></div></div></div>` : ''}
|
|
116
|
+
${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="respond-q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="respond-q-dialog-title"><h2 class="text-lg font-semibold mb-1" id="respond-q-dialog-title">Respond to Question</h2><p id="respond-q-text" class="text-sm text-base-content mb-4"></p><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Your Response</span></label><textarea id="respond-q-answer" rows="5" class="textarea textarea-solid max-w-full" placeholder="Enter your response..."></textarea></div><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Attach File (optional)</span></label><input type="file" id="respond-q-file" class="input input-solid max-w-full"/></div><input type="hidden" id="respond-q-id"/><div class="modal-action"><button data-action="submitResponse" class="btn btn-primary">Submit</button><button data-dialog-close="respond-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>` : ''}
|
|
113
117
|
`;
|
|
114
118
|
|
|
115
119
|
const script = `${TOAST}
|
|
120
|
+
(function(){if(!document.getElementById('rfi-detail-styles')){var style=document.createElement('style');style.id='rfi-detail-styles';style.textContent='.info-label-key{font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:var(--color-text-muted);margin-bottom:${SPACING.xs}} .info-label-value{font-size:0.875rem;color:var(--color-text)} .tab-btn:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}';document.head.appendChild(style)}})();
|
|
116
121
|
var activeSection='';
|
|
117
122
|
function filterBySection(sid){
|
|
118
123
|
activeSection=sid;
|
|
@@ -121,6 +126,17 @@ function filterBySection(sid){
|
|
|
121
126
|
}
|
|
122
127
|
function filterRows(){var q=(document.querySelector('#q-table input')?.value||'').toLowerCase();document.querySelectorAll('#q-tbody tr').forEach(function(r){var text=(r.textContent||'').toLowerCase();var secSpan=r.querySelector('td:nth-child(3) span');var rowSec=secSpan?secSpan.textContent:'';var showSec=!activeSection||rowSec===document.getElementById('stab-'+activeSection)?.textContent;r.style.display=(!q||text.includes(q))&&showSec?'':'none'})}
|
|
123
128
|
function filterQuestions(q){filterRows()}
|
|
129
|
+
(function(){
|
|
130
|
+
var tabBtns=document.querySelectorAll('[id^="stab-"]');
|
|
131
|
+
tabBtns.forEach(function(btn,idx){
|
|
132
|
+
btn.setAttribute('role','tab');
|
|
133
|
+
btn.setAttribute('tabindex',btn.classList.contains('active')?'0':'-1');
|
|
134
|
+
btn.setAttribute('aria-selected',btn.classList.contains('active')?'true':'false');
|
|
135
|
+
btn.addEventListener('click',function(){tabBtns.forEach(b=>b.setAttribute('tabindex','-1'));btn.setAttribute('tabindex','0');btn.focus()});
|
|
136
|
+
});
|
|
137
|
+
var searchInput=document.querySelector('#q-table input');
|
|
138
|
+
if(searchInput){searchInput.setAttribute('tabindex','0')}
|
|
139
|
+
})();
|
|
124
140
|
function openAddQuestion(){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Add Question';document.getElementById('q-edit-id').value='';document.getElementById('q-text').value='';document.getElementById('q-section').value='';document.getElementById('q-deadline').value='';document.getElementById('q-category').value=''}
|
|
125
141
|
async function openEditQuestion(id){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Edit Question';document.getElementById('q-edit-id').value=id;try{var rfiId=document.getElementById('q-dialog').dataset.rfiId;var r=await fetch('/api/rfi/'+rfiId+'/questions/'+id);var d=await r.json();var q=d.data||d;document.getElementById('q-text').value=q.question_text||q.question||q.title||'';document.getElementById('q-section').value=q.section_id||q.rfi_section_id||'';document.getElementById('q-deadline').value=q.deadline?(new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline)).toISOString().split('T')[0]:'';document.getElementById('q-category').value=q.category||''}catch(e){showToast('Error loading','error')}}
|
|
126
142
|
async function saveQuestion(rfiId){var id=document.getElementById('q-edit-id').value;var questionText=document.getElementById('q-text').value.trim();if(!questionText){showToast('Question text required','error');return}var data={question:questionText,category:document.getElementById('q-category').value.trim()||null,section_id:document.getElementById('q-section').value||null};var dl=document.getElementById('q-deadline').value;if(dl)data.due_date=Math.floor(new Date(dl).getTime()/1000);var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Saving...'}try{var url=id?'/api/rfi/'+rfiId+'/questions/'+id:'/api/rfi/'+rfiId+'/questions';var r=await fetch(url,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});var result=await r.json();if(r.ok){showToast(id?'Question updated':'Question added','success');document.getElementById('q-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{showToast(result.message||result.error||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}finally{if(btn){btn.disabled=false;btn.textContent='Save'}}}
|
|
@@ -129,6 +145,7 @@ async function saveSection(rfiId){var name=document.getElementById('sec-name').v
|
|
|
129
145
|
async function sendReminder(rfiId){try{var r=await fetch('/api/friday/rfi/reminder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rfi_id:rfiId})});if(r.ok)showToast('Reminder sent','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}}`;
|
|
130
146
|
|
|
131
147
|
const assignScript = clientId ? `
|
|
148
|
+
function rdEsc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
132
149
|
var _assignClientId=${JSON.stringify(clientId)};
|
|
133
150
|
window.openAssignQuestion=async function(qId){
|
|
134
151
|
var dlg=document.getElementById('assign-q-dialog');
|
|
@@ -140,7 +157,7 @@ window.openAssignQuestion=async function(qId){
|
|
|
140
157
|
var r=await fetch('/api/users?client_id='+_assignClientId+'&pageSize=100',{credentials:'include'});
|
|
141
158
|
var d=await r.json();
|
|
142
159
|
var users=(d.data||d||[]).filter(function(u){return !u.deleted_at});
|
|
143
|
-
sel.innerHTML='<option value="">Select client user...</option>'+users.map(function(u){return'<option value="'+u.id+'">'+(u.name||u.email)+'</option>'}).join('');
|
|
160
|
+
sel.innerHTML='<option value="">Select client user...</option>'+users.map(function(u){return'<option value="'+rdEsc(u.id)+'">'+rdEsc(u.name||u.email)+'</option>'}).join('');
|
|
144
161
|
}catch(e){sel.innerHTML='<option value="">Error loading users</option>'}
|
|
145
162
|
};
|
|
146
163
|
window.saveAssignQuestion=async function(){
|
|
@@ -157,7 +174,7 @@ window.saveAssignQuestion=async function(){
|
|
|
157
174
|
|
|
158
175
|
const viewRespScript = canViewResponses ? `
|
|
159
176
|
var _rfiIdForView=${JSON.stringify(rfi.id)};
|
|
160
|
-
window.viewResponses=async function(qId,qText){document.getElementById('view-resp-qtext').textContent=qText||'';document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-base-content/40">Loading...</div>';document.getElementById('view-resp-dialog').style.display='flex';try{var r=await fetch('/api/rfi/'+_rfiIdForView+'/questions/'+qId+'/responses',{credentials:'include'});var d=await r.json();var items=d.data||d||[];document.getElementById('view-resp-list').innerHTML=items.length?items.map(function(resp){return'<div style="border:1px solid var(--color-border);border-radius:6px;padding:8px;margin-bottom:6px"><div class="text-sm">'+resp.response+'</div><div class="text-xs text-base-content/40 mt-1">'+
|
|
177
|
+
window.viewResponses=async function(qId,qText){document.getElementById('view-resp-qtext').textContent=qText||'';document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-base-content/40">Loading...</div>';document.getElementById('view-resp-dialog').style.display='flex';try{var r=await fetch('/api/rfi/'+_rfiIdForView+'/questions/'+qId+'/responses',{credentials:'include'});var d=await r.json();var items=d.data||d||[];document.getElementById('view-resp-list').innerHTML=items.length?items.map(function(resp){return'<div style="border:1px solid var(--color-border);border-radius:6px;padding:8px;margin-bottom:6px"><div class="text-sm">'+resp.response+'</div><div class="text-xs text-base-content/40 mt-1">'+window.fmtDateTime(resp.created_at)+'</div></div>'}).join(''):'<div class="text-sm text-base-content/40">No responses yet</div>'}catch(e){document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-error">Error loading</div>'}};` : '';
|
|
161
178
|
|
|
162
179
|
const respondScript = isClient ? `
|
|
163
180
|
var _respondRfiId=${JSON.stringify(rfi.id)};
|
|
@@ -187,5 +204,5 @@ window.submitResponse=async function(){
|
|
|
187
204
|
finally{if(btn){btn.disabled=false;btn.textContent='Submit'}}
|
|
188
205
|
};` : '';
|
|
189
206
|
|
|
190
|
-
return page(user, `${esc(rfi.name||'RFI')} |
|
|
207
|
+
return page(user, `${esc(rfi.name||'RFI')} | Thatcher`, null, content, [script, assignScript, viewRespScript, respondScript]);
|
|
191
208
|
}
|