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
|
@@ -11,14 +11,14 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
|
|
|
11
11
|
const statusBadge = (s) => { const color = s === 'answered' || s === 'accepted' ? '#22c55e' : s === 'overdue' ? '#ef4444' : '#f59e0b'; return `<span style="padding:2px 8px;border-radius:12px;font-size:0.7rem;font-weight:700;background:${color}20;color:${color}">${s || 'pending'}</span>`; };
|
|
12
12
|
const qRows = questions.map((q, i) => {
|
|
13
13
|
const resps = respMap[q.id] || [];
|
|
14
|
-
const respHtml = resps.length ? resps.map(r => `<div style="margin-top:6px;padding:6px 10px;background:#f8fafc;border-left:3px solid #3b82f6;font-size:0.75rem">${esc(r.response||'')}<div style="margin-top:4px;color:#
|
|
14
|
+
const respHtml = resps.length ? resps.map(r => `<div style="margin-top:6px;padding:6px 10px;background:#f8fafc;border-left:3px solid #3b82f6;font-size:0.75rem">${esc(r.response||'')}<div style="margin-top:4px;color:#475569;font-size:0.65rem">${fmtDate(r.created_at)}</div></div>`).join('') : '<div style="color:#475569;font-size:0.75rem;margin-top:4px">No response yet</div>';
|
|
15
15
|
return `<div style="margin-bottom:16px;padding:12px;border:1px solid #e2e8f0;border-radius:6px;break-inside:avoid">
|
|
16
16
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px">
|
|
17
17
|
<div style="font-size:0.85rem;font-weight:600">${i+1}. ${esc(q.question||q.text||'Question')}</div>
|
|
18
18
|
${statusBadge(q.status)}
|
|
19
19
|
</div>
|
|
20
|
-
${q.due_date ? `<div style="color:#
|
|
21
|
-
${q.assigned_to ? `<div style="color:#
|
|
20
|
+
${q.due_date ? `<div style="color:#475569;font-size:0.7rem;margin-bottom:6px">Due: ${fmtDate(q.due_date)}</div>` : ''}
|
|
21
|
+
${q.assigned_to ? `<div style="color:#475569;font-size:0.7rem;margin-bottom:6px">Assigned: ${esc(q.assigned_to_name||q.assigned_to)}</div>` : ''}
|
|
22
22
|
${respHtml}
|
|
23
23
|
</div>`;
|
|
24
24
|
}).join('');
|
|
@@ -27,20 +27,25 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
|
|
|
27
27
|
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/>
|
|
28
28
|
<title>RFI Report — ${esc(rfi.name || rfi.title || 'RFI')}</title>
|
|
29
29
|
<style>
|
|
30
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 24px; color: #1e293b; font-size: 14px; }
|
|
30
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 24px; color: #1e293b; font-size: 14px; background: #fff; }
|
|
31
31
|
.header { border-bottom: 2px solid #3b82f6; padding-bottom: 16px; margin-bottom: 24px; }
|
|
32
|
-
.meta { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0; font-size: 0.8rem; color: #
|
|
32
|
+
.meta { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0; font-size: 0.8rem; color: #475569; }
|
|
33
33
|
.meta-item { display: flex; flex-direction: column; }
|
|
34
34
|
.meta-label { font-weight: 600; margin-bottom: 2px; }
|
|
35
35
|
.progress-bar { height: 8px; background: #e2e8f0; border-radius: 4px; margin: 8px 0; }
|
|
36
36
|
.progress-fill { height: 100%; border-radius: 4px; background: #3b82f6; }
|
|
37
37
|
.no-print { padding: 12px 0; }
|
|
38
38
|
@media print { .no-print { display: none; } body { padding: 12px; } }
|
|
39
|
+
@media (prefers-color-scheme: dark) {
|
|
40
|
+
body { color: #e2e8f0; background: #1e293b; }
|
|
41
|
+
.meta { color: #94a3b8; }
|
|
42
|
+
.progress-bar { background: #334155; }
|
|
43
|
+
}
|
|
39
44
|
</style></head><body>
|
|
40
|
-
<div class="no-print"><button onclick="window.print()" style="padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem">Print / Save PDF</button> <a href="/rfi/${esc(rfi.id)}" style="margin-left:12px;font-size:0.85rem;color:#3b82f6"
|
|
45
|
+
<div class="no-print"><button onclick="window.print()" aria-label="Print or save as PDF" style="padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem">Print / Save PDF</button> <a href="/rfi/${esc(rfi.id)}" title="Back to RFI" style="margin-left:12px;font-size:0.85rem;color:#3b82f6"><- Back to RFI</a></div>
|
|
41
46
|
<div class="header">
|
|
42
47
|
<div style="font-size:1.4rem;font-weight:700">${esc(rfi.name||rfi.title||'RFI')}</div>
|
|
43
|
-
<div style="color:#
|
|
48
|
+
<div style="color:#475569;font-size:0.85rem;margin-top:4px">${esc(e.name||e.client_name||'')}</div>
|
|
44
49
|
<div class="meta">
|
|
45
50
|
<div class="meta-item"><span class="meta-label">Status</span>${esc(rfi.status||'-')}</div>
|
|
46
51
|
<div class="meta-item"><span class="meta-label">Created</span>${fmtDate(rfi.created_at)}</div>
|
|
@@ -50,7 +55,7 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
|
|
|
50
55
|
</div>
|
|
51
56
|
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div>
|
|
52
57
|
</div>
|
|
53
|
-
<div>${qRows || '<div style="color:#
|
|
54
|
-
<div style="margin-top:24px;padding-top:12px;border-top:1px solid #e2e8f0;font-size:0.7rem;color:#
|
|
58
|
+
<div>${qRows || '<div style="color:#475569;text-align:center;padding:24px">No questions found</div>'}</div>
|
|
59
|
+
<div style="margin-top:24px;padding-top:12px;border-top:1px solid #e2e8f0;font-size:0.7rem;color:#475569;text-align:center">Generated ${new Date().toLocaleString('en-ZA')} - Thatcher</div>
|
|
55
60
|
</body></html>`;
|
|
56
61
|
}
|
|
@@ -23,7 +23,7 @@ export function renderSettingsChecklists(user, checklists = []) {
|
|
|
23
23
|
const rows = checklists.map(c => `<tr ${trClick('/checklist/'+c.id)}>
|
|
24
24
|
<td class="text-sm font-medium">${esc(c.name||'-')}</td>
|
|
25
25
|
<td class="text-sm">${esc(c.type||'-')}</td>
|
|
26
|
-
<td class="text-sm text-base-content/
|
|
26
|
+
<td class="text-sm text-base-content/70">${esc(c.review_id||'-')}</td>
|
|
27
27
|
<td>${editBtn('/checklist/'+c.id+'/edit')}</td>
|
|
28
28
|
</tr>`).join('');
|
|
29
29
|
return settingsPage(user, 'Checklists - Settings', bc('Checklists'), hdr('Checklists', '/checklist/new', '+ Add Checklist') + inlineTable(['Name', 'Type', 'Review', 'Actions'], rows, 'No checklists found'));
|
|
@@ -43,11 +43,12 @@ export function renderSettingsRecreation(user, _logs = [], _users = []) {
|
|
|
43
43
|
<div class="card-clean"><div class="card-clean-body" style="padding:0">
|
|
44
44
|
<div class="table-wrap"><table class="data-table">
|
|
45
45
|
<thead><tr><th>Timestamp</th><th>Operation</th><th>Entity</th><th>Entity ID</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
|
|
46
|
-
<tbody id="audit-tbody"><tr><td colspan="7" class="text-center py-8 text-base-content/
|
|
46
|
+
<tbody id="audit-tbody"><tr><td colspan="7" class="text-center py-8 text-base-content/70 text-sm">Loading...</td></tr></tbody>
|
|
47
47
|
</table></div>
|
|
48
48
|
</div></div>`;
|
|
49
49
|
const script = `${TOAST_SCRIPT}
|
|
50
|
-
|
|
50
|
+
function _esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
51
|
+
async function loadAuditLogs(params){const tbody=document.getElementById('audit-tbody');try{const q=params?'?'+new URLSearchParams(params):'';const r=await fetch('/api/audit/logs'+q);const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(l){const ts=l.timestamp;const date=ts?window.fmtDateTime(ts):'-';const details=l.details?JSON.stringify(l.details).substring(0,60)+'...':'-';return'<tr><td class="text-xs text-base-content/70">'+_esc(date)+'</td><td><span class="badge badge-flat-primary text-xs">'+_esc(l.operation||'-')+'</span></td><td class="text-sm">'+_esc(l.entity_type||'-')+'</td><td class="text-xs text-base-content/70">'+_esc(l.entity_id||'-')+'</td><td class="text-sm">'+_esc(l.user_id||'-')+'</td><td class="text-sm">'+_esc(l.action||'-')+'</td><td class="text-xs text-base-content/70">'+_esc(details)+'</td></tr>'}).join(''):'<tr><td colspan="7" class="text-center py-8 text-base-content/70 text-sm">No audit logs found</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="7" class="text-center py-4 text-error text-sm">Failed to load: '+_esc(e.message)+'</td></tr>'}}
|
|
51
52
|
function applyAuditFilters(){const level=document.getElementById('filter-level').value;const entity=document.getElementById('filter-entity').value;const start=document.getElementById('filter-start').value;const end=document.getElementById('filter-end').value;const p={};if(level)p.level=level;if(entity)p.entityType=entity;if(start)p.from=new Date(start).toISOString();if(end)p.to=new Date(end+'T23:59:59').toISOString();loadAuditLogs(p)}
|
|
52
53
|
loadAuditLogs();`;
|
|
53
54
|
return settingsPage(user, 'Audit Logs - Settings', bc('Audit Logs'), content, [script]);
|
|
@@ -67,7 +68,7 @@ export function renderSettingsIntegrations(user, integrations = {}) {
|
|
|
67
68
|
return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body">
|
|
68
69
|
<div class="flex items-center gap-4">
|
|
69
70
|
<div class="text-2xl">${integ.icon}</div>
|
|
70
|
-
<div class="flex-1"><div class="font-semibold">${integ.name}</div><div class="text-xs text-base-content/
|
|
71
|
+
<div class="flex-1"><div class="font-semibold">${integ.name}</div><div class="text-xs text-base-content/70">${integ.desc}</div></div>
|
|
71
72
|
<div class="flex flex-col items-end gap-2">
|
|
72
73
|
<span class="badge ${connected ? 'badge-success badge-flat-success' : 'badge-flat-secondary'} text-xs">${connected ? 'Connected' : 'Disconnected'}</span>
|
|
73
74
|
<button data-action="toggleConfig" data-args='["${integ.id}"]' class="btn btn-ghost btn-xs">Configure</button>
|
|
@@ -75,7 +76,7 @@ export function renderSettingsIntegrations(user, integrations = {}) {
|
|
|
75
76
|
</div>
|
|
76
77
|
<div id="config-${integ.id}" class="hidden mt-4 pt-4 border-t border-base-200">
|
|
77
78
|
<div class="flex gap-3 items-end flex-wrap">
|
|
78
|
-
<div class="form-group flex-1 min-w-48"><label class="label"><span class="label-text font-semibold">API Key / Credentials</span></label><input type="password" id="key-${integ.id}" class="input input-solid max-w-full" placeholder="
|
|
79
|
+
<div class="form-group flex-1 min-w-48"><label class="label"><span class="label-text font-semibold">API Key / Credentials</span></label><input type="password" id="key-${integ.id}" class="input input-solid max-w-full" placeholder="Paste the API key or credentials"/></div>
|
|
79
80
|
<div class="flex gap-2"><button data-action="saveIntegration" data-args='["${integ.id}"]' class="btn btn-primary btn-sm">Save</button><button data-action="testIntegration" data-args='["${integ.id}"]' class="btn btn-ghost btn-sm">Test</button></div>
|
|
80
81
|
</div>
|
|
81
82
|
</div>
|
|
@@ -90,19 +91,19 @@ export function renderSettingsIntegrations(user, integrations = {}) {
|
|
|
90
91
|
<button class="btn btn-ghost btn-sm" data-action="copyPrivateKey">Copy</button>
|
|
91
92
|
<button class="btn btn-primary btn-sm" data-action="generatePrivateKey">Generate New Key</button>
|
|
92
93
|
</div>
|
|
93
|
-
<div class="text-xs text-base-content/
|
|
94
|
+
<div class="text-xs text-base-content/70 mt-1">This key links Friday and My Review. Keep it confidential — anyone with access can import data.</div>
|
|
94
95
|
</div>
|
|
95
96
|
<div class="mb-4">
|
|
96
97
|
<label class="label"><span class="label-text font-semibold">My Review API Key (from MWR app)</span></label>
|
|
97
98
|
<input type="text" id="mwr-api-key" class="input input-solid w-full" value="${esc(mwr_api_key)}" placeholder="Paste the key from My Review Integrations settings"/>
|
|
98
|
-
<div class="text-xs text-base-content/
|
|
99
|
+
<div class="text-xs text-base-content/70 mt-1">Enter the private key generated in the My Review settings to enable cross-app access.</div>
|
|
99
100
|
</div>
|
|
100
101
|
<button class="btn btn-primary btn-sm" data-action="saveApiKeys">Save API Keys</button>
|
|
101
102
|
</div></div>`;
|
|
102
103
|
const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Integrations</h1>${apiKeysCard}${cards}`;
|
|
103
104
|
const script = `${TOAST_SCRIPT}
|
|
104
105
|
function toggleConfig(id){const el=document.getElementById('config-'+id);el.classList.toggle('hidden')}
|
|
105
|
-
function saveIntegration(id){const key=document.getElementById('key-'+id).value;if(!key){showToast('Enter credentials','error');return}showToast('Integration saved','success');toggleConfig(id)}
|
|
106
|
+
function saveIntegration(id){const key=document.getElementById('key-'+id).value.trim();if(!key){showToast('Enter the API key or credentials before saving','error');return}showToast('Integration credentials saved','success');toggleConfig(id)}
|
|
106
107
|
function testIntegration(id){showToast('Testing connection...','info');setTimeout(()=>showToast('Test complete','success'),1000)}
|
|
107
108
|
function generatePrivateKey(){const arr=new Uint8Array(32);crypto.getRandomValues(arr);const key=Array.from(arr,b=>b.toString(16).padStart(2,'0')).join('');document.getElementById('mwr-private-key').value=key}
|
|
108
109
|
function copyPrivateKey(){const v=document.getElementById('mwr-private-key').value;if(!v){showToast('No key to copy','error');return}navigator.clipboard.writeText(v).then(()=>showToast('Copied to clipboard','success')).catch(()=>showToast('Copy failed','error'))}
|
|
@@ -114,7 +115,7 @@ export function renderSettingsNotifications(user, config = {}) {
|
|
|
114
115
|
const t = config.thresholds || {};
|
|
115
116
|
const rfi = t.rfi || {};
|
|
116
117
|
const notif = t.notification || {};
|
|
117
|
-
const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/
|
|
118
|
+
const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/70">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
|
|
118
119
|
const toggles = [
|
|
119
120
|
{ id: 'rfi_reminders', label: 'RFI Reminders', desc: 'Send reminders for outstanding RFIs', checked: true },
|
|
120
121
|
{ id: 'deadline_alerts', label: 'Deadline Alerts', desc: 'Alert when deadlines are approaching', checked: true },
|
|
@@ -122,10 +123,10 @@ export function renderSettingsNotifications(user, config = {}) {
|
|
|
122
123
|
{ id: 'new_messages', label: 'New Messages', desc: 'Notify when new messages are received', checked: true },
|
|
123
124
|
{ id: 'weekly_reports', label: 'Weekly Reports', desc: 'Send weekly summary reports', checked: true },
|
|
124
125
|
];
|
|
125
|
-
const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none" >
|
|
126
|
+
const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="notif-trigger-dialog-title">
|
|
126
127
|
<div class="modal-overlay" data-dialog-close="notif-trigger-dialog"></div>
|
|
127
128
|
<div class="modal-content rounded-box max-w-md p-6">
|
|
128
|
-
<h3 class="text-lg font-semibold mb-4">Add Notification Trigger</h3>
|
|
129
|
+
<h3 id="notif-trigger-dialog-title" class="text-lg font-semibold mb-4">Add Notification Trigger</h3>
|
|
129
130
|
<div class="form-group mb-3"><label class="form-label">Trigger Type</label><select id="ntd-type" class="form-input"><option value="before">Before</option><option value="after">After</option></select></div>
|
|
130
131
|
<div class="form-group mb-3"><label class="form-label">Days</label><input id="ntd-days" type="number" min="0" max="365" value="7" class="form-input"/></div>
|
|
131
132
|
<div class="form-group mb-3"><label class="form-label">Reference Date</label><select id="ntd-ref" class="form-input"><option value="commencement_date">Commencement Date</option><option value="deadline_date">Deadline Date</option></select></div>
|
|
@@ -150,13 +151,14 @@ export function renderSettingsNotifications(user, config = {}) {
|
|
|
150
151
|
<div class="card-clean"><div class="card-clean-body" style="padding:0">
|
|
151
152
|
<div class="table-wrap"><table class="data-table" id="triggers-table">
|
|
152
153
|
<thead><tr><th>Type</th><th>Days</th><th>Reference</th><th>Recipient</th><th>Status</th><th></th></tr></thead>
|
|
153
|
-
<tbody id="triggers-tbody"><tr><td colspan="6" class="text-center py-8 text-base-content/
|
|
154
|
+
<tbody id="triggers-tbody"><tr><td colspan="6" class="text-center py-8 text-base-content/70 text-sm">Loading...</td></tr></tbody>
|
|
154
155
|
</table></div>
|
|
155
156
|
</div></div>
|
|
156
157
|
${triggerDialog}`;
|
|
157
158
|
const script = `${TOAST_SCRIPT}
|
|
158
159
|
document.getElementById('notif-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#notif-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});try{const res=await fetch('/api/admin/settings/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}});
|
|
159
|
-
|
|
160
|
+
function _esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
161
|
+
async function loadTriggers(){const tbody=document.getElementById('triggers-tbody');try{const r=await fetch('/api/friday/engagement/notifications');const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(t){return'<tr><td class="text-sm">'+_esc(t.trigger_type)+'</td><td class="text-sm">'+_esc(t.trigger_days)+' days</td><td class="text-sm">'+_esc(t.trigger_reference)+'</td><td class="text-sm">'+_esc(t.recipient_type)+'</td><td>'+(t.active?'<span class="badge badge-flat-success text-xs">Active</span>':'<span class="badge badge-flat-secondary text-xs">Inactive</span>')+'</td><td><button class="btn btn-ghost btn-xs" data-action="deleteNotifTrigger" data-args=\\'["\\'+encodeURIComponent(t.id)+\\'"]\\'>Delete</button></td></tr>'}).join(''):'<tr><td colspan="6" class="text-center py-8 text-base-content/70 text-sm">No triggers configured</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="6" class="text-center py-4 text-error text-sm">Failed to load</td></tr>'}}
|
|
160
162
|
function openNotifTriggerDialog(){document.getElementById('notif-trigger-dialog').style.display='flex'}
|
|
161
163
|
async function saveNotifTrigger(){const type=document.getElementById('ntd-type').value;const days=Number(document.getElementById('ntd-days').value);const ref=document.getElementById('ntd-ref').value;const recipient=document.getElementById('ntd-recipient').value;const active=document.getElementById('ntd-active').checked;if(!days&&days!==0){showToast('Enter number of days','error');return}try{const r=await fetch('/api/friday/engagement/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trigger_type:type,trigger_days:days,trigger_reference:ref,recipient_type:recipient,active})});if(r.ok){showToast('Trigger added','success');document.getElementById('notif-trigger-dialog').style.display='none';loadTriggers()}else{const d=await r.json();showToast(d.message||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
|
|
162
164
|
async function deleteNotifTrigger(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this trigger?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/friday/engagement/notifications',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});if(r.ok){showToast('Deleted','success');loadTriggers()}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
|
|
@@ -167,7 +169,7 @@ loadTriggers();`;
|
|
|
167
169
|
function renderTypeList(user, items, entityKey, title) {
|
|
168
170
|
const rows = items.map(t => `<tr class="hover">
|
|
169
171
|
<td class="font-medium text-sm">${esc(t.name || '-')}</td>
|
|
170
|
-
<td class="text-xs text-base-content/
|
|
172
|
+
<td class="text-xs text-base-content/70">${t.created_at ? new Date(t.created_at).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
|
|
171
173
|
<td><div class="flex gap-2">
|
|
172
174
|
<button type="button" class="btn btn-ghost btn-xs type-edit-btn" data-id="${esc(t.id)}" data-name="${esc(t.name||'')}">Edit</button>
|
|
173
175
|
<button type="button" class="btn btn-error btn-xs type-del-btn" data-id="${esc(t.id)}">Delete</button>
|
|
@@ -175,13 +177,14 @@ function renderTypeList(user, items, entityKey, title) {
|
|
|
175
177
|
</tr>`).join('');
|
|
176
178
|
const script = `${TOAST_SCRIPT}
|
|
177
179
|
var _eid='';
|
|
178
|
-
|
|
179
|
-
function
|
|
180
|
-
function
|
|
180
|
+
var _typeFormTrigger=null;
|
|
181
|
+
function openAdd(){_eid='';_typeFormTrigger=document.activeElement;document.getElementById('type-name').value='';document.getElementById('type-form').style.display='block';var n=document.getElementById('type-name');if(n)n.focus()}
|
|
182
|
+
function openEdit(id,name){_eid=id;_typeFormTrigger=document.activeElement;document.getElementById('type-name').value=name;document.getElementById('type-form').style.display='block';var n=document.getElementById('type-name');if(n)n.focus()}
|
|
183
|
+
function cancelForm(){document.getElementById('type-form').style.display='none';if(_typeFormTrigger&&typeof _typeFormTrigger.focus==='function')_typeFormTrigger.focus();_typeFormTrigger=null}
|
|
181
184
|
async function saveType(){const name=document.getElementById('type-name').value.trim();if(!name){showToast('Name required','error');return}const url=_eid?'/api/${entityKey}/'+_eid:'/api/${entityKey}';const method=_eid?'PUT':'POST';try{const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});if(r.ok){showToast(_eid?'Updated':'Created','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
182
185
|
async function delType(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/${entityKey}/'+id,{method:'DELETE'});if(r.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
183
186
|
document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.type-edit-btn').forEach(b=>b.addEventListener('click',function(){openEdit(this.dataset.id,this.dataset.name)}));document.querySelectorAll('.type-del-btn').forEach(b=>b.addEventListener('click',function(){delType(this.dataset.id)}));document.querySelectorAll('.type-add-btn').forEach(b=>b.addEventListener('click',openAdd))});`;
|
|
184
|
-
const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex gap-2 items-end"><div class="form-group"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid
|
|
187
|
+
const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex flex-wrap gap-2 items-end"><div class="form-group" style="flex:1;min-width:0"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid w-full" placeholder="Name"/></div><button data-action="saveType" class="btn btn-primary btn-sm">Save</button><button data-action="cancelForm" class="btn btn-ghost btn-sm">Cancel</button></div></div></div>`;
|
|
185
188
|
const content = `${settingsBack()}<div class="flex justify-between items-center mb-4"><h1 class="text-2xl font-bold">${title}</h1><button class="btn btn-primary btn-sm type-add-btn">Add</button></div>${formHtml}<div class="card-clean"><div class="card-clean-body" style="padding:0">${inlineTable(['Name','Created','Actions'],rows,'No items found.')}</div></div>`;
|
|
186
189
|
return settingsPage(user, `${title} - Settings`, bc(title), content, [script]);
|
|
187
190
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
|
|
2
|
-
import { esc } from '@/ui/render-helpers.js';
|
|
2
|
+
import { esc, icon } from '@/ui/render-helpers.js';
|
|
3
3
|
|
|
4
4
|
const bc = (label) => [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label }];
|
|
5
|
-
const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/
|
|
5
|
+
const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/80">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
|
|
6
6
|
|
|
7
7
|
export function renderSettingsReviewSettings(user, config = {}) {
|
|
8
8
|
const review = config.review || {};
|
|
@@ -39,7 +39,7 @@ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
|
|
|
39
39
|
{ id: 'mobile_resize', label: 'Mobile Resize', desc: 'Enable mobile-friendly resizable highlights', checked: fr.mobile_resize !== false },
|
|
40
40
|
{ id: 'coordinate_snap', label: 'Coordinate Snap', desc: 'Snap highlight coordinates to text boundaries', checked: !!fr.coordinate_snap },
|
|
41
41
|
];
|
|
42
|
-
const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button"
|
|
42
|
+
const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button" data-action="removeFlag" data-args='${esc(JSON.stringify([type, f]))}' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;line-height:1;color:inherit;display:inline-flex">${icon('close',16)}</button></span>`).join('');
|
|
43
43
|
const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">File Review Settings</h1>
|
|
44
44
|
<div id="fr-review-flags" data-flags='${JSON.stringify(reviewFlags)}'></div>
|
|
45
45
|
<div id="fr-tender-flags" data-flags='${JSON.stringify(tenderFlags)}'></div>
|
|
@@ -55,18 +55,19 @@ export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
|
|
|
55
55
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
56
56
|
<div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Review Flags</h2>
|
|
57
57
|
<div id="review-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(reviewFlags, 'review')}</div>
|
|
58
|
-
<div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button"
|
|
58
|
+
<div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["review"]' class="btn btn-primary btn-sm">Add</button></div>
|
|
59
59
|
</div></div>
|
|
60
60
|
<div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Tender Flags</h2>
|
|
61
61
|
<div id="tender-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(tenderFlags, 'tender')}</div>
|
|
62
|
-
<div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button"
|
|
62
|
+
<div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["tender"]' class="btn btn-primary btn-sm">Add</button></div>
|
|
63
63
|
</div></div>
|
|
64
64
|
</div>
|
|
65
65
|
<button type="submit" class="btn btn-primary">Save File Review Settings</button></form>`;
|
|
66
66
|
const script = `${TOAST_SCRIPT}
|
|
67
67
|
var _rvFlags=${JSON.stringify(reviewFlags)};
|
|
68
68
|
var _tdFlags=${JSON.stringify(tenderFlags)};
|
|
69
|
-
function
|
|
69
|
+
function _escFlag(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
70
|
+
function renderFlags(type){const list=type==='review'?_rvFlags:_tdFlags;const el=document.getElementById(type+'-flags-list');if(!el)return;el.innerHTML=list.map(f=>'<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">'+_escFlag(f)+'<button type="button" data-action="removeFlag" data-args=\\''+_escFlag(JSON.stringify([type,f]))+'\\' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">×</button></span>').join('')}
|
|
70
71
|
window.addFlag=function(type){const inp=document.getElementById('new-'+type+'-flag');const val=(inp?.value||'').trim();if(!val)return;if(type==='review'){_rvFlags=[..._rvFlags,val]}else{_tdFlags=[..._tdFlags,val]};inp.value='';renderFlags(type)};
|
|
71
72
|
window.removeFlag=function(type,flag){if(type==='review'){_rvFlags=_rvFlags.filter(f=>f!==flag)}else{_tdFlags=_tdFlags.filter(f=>f!==flag)};renderFlags(type)};
|
|
72
73
|
document.getElementById('file-review-settings-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#file-review-settings-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});document.querySelectorAll('#file-review-settings-form input[type=number]').forEach(n=>{if(data[n.name])data[n.name]=Number(data[n.name])});data.review_flags=_rvFlags;data.tender_flags=_tdFlags;try{const res=await fetch('/api/admin/settings/file-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}})`;
|
|
@@ -79,31 +80,31 @@ export function renderSettingsMwrPermissions(user, permissions = []) {
|
|
|
79
80
|
<td class="text-sm">${esc(p.entity_id||'-')}</td>
|
|
80
81
|
<td class="text-sm">${esc(p.user_id||'-')}</td>
|
|
81
82
|
<td class="text-sm">${esc(p.permission_type||'-')}</td>
|
|
82
|
-
<td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString() : '-'}</td>
|
|
83
|
-
<td><button class="btn btn-error btn-xs btn-outline"
|
|
83
|
+
<td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
|
|
84
|
+
<td><button type="button" class="btn btn-error btn-xs btn-outline" data-action="revokePermission" data-args='${esc(JSON.stringify([p.id]))}'>Revoke</button></td>
|
|
84
85
|
</tr>`).join('');
|
|
85
86
|
const content = `${settingsBack()}<div class="flex justify-between items-center mb-6">
|
|
86
87
|
<h1 class="text-2xl font-bold">MWR Permissions</h1>
|
|
87
|
-
<button class="btn btn-primary btn-sm"
|
|
88
|
+
<button class="btn btn-primary btn-sm" data-action="openDialog" data-args='["grant-perm-dialog"]'>+ Grant Permission</button>
|
|
88
89
|
</div>
|
|
89
90
|
${inlineTable(['Entity Type','Entity ID','User ID','Permission','Granted','Actions'], rows, 'No permissions configured')}
|
|
90
|
-
<div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
|
|
91
|
-
<div class="dialog-panel" style="max-width:460px">
|
|
92
|
-
<div class="dialog-header"><span class="dialog-title">Grant Permission</span><button class="dialog-close"
|
|
91
|
+
<div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="gp-dialog-title" data-dialog-close="grant-perm-dialog">
|
|
92
|
+
<div class="dialog-panel" style="max-width:min(460px,90vw)">
|
|
93
|
+
<div class="dialog-header"><span class="dialog-title" id="gp-dialog-title">Grant Permission</span><button class="dialog-close" data-dialog-close="grant-perm-dialog" aria-label="Close">×</button></div>
|
|
93
94
|
<div class="dialog-body">
|
|
94
|
-
<div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="review"/></div>
|
|
95
|
-
<div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="
|
|
96
|
-
<div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="
|
|
95
|
+
<div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="e.g. review"/></div>
|
|
96
|
+
<div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="e.g. 1a2b3c"/></div>
|
|
97
|
+
<div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="e.g. user_42"/></div>
|
|
97
98
|
<div class="modal-form-group"><label>Permission Type</label><select id="gp-perm-type" class="form-input"><option value="view">view</option><option value="edit">edit</option><option value="admin">admin</option></select></div>
|
|
98
99
|
<div id="gp-result" style="display:none;margin-top:8px"></div>
|
|
99
100
|
</div>
|
|
100
101
|
<div class="dialog-footer">
|
|
101
|
-
<button class="btn btn-ghost btn-sm"
|
|
102
|
-
<button class="btn btn-primary btn-sm"
|
|
102
|
+
<button class="btn btn-ghost btn-sm" data-dialog-close="grant-perm-dialog">Cancel</button>
|
|
103
|
+
<button class="btn btn-primary btn-sm" data-action="grantPermission">Grant</button>
|
|
103
104
|
</div>
|
|
104
105
|
</div>
|
|
105
106
|
</div>`;
|
|
106
|
-
const script = `${TOAST_SCRIPT}async function
|
|
107
|
+
const script = `${TOAST_SCRIPT}window.grantPermission=async function(){var body={entity_type:document.getElementById('gp-entity-type').value.trim(),entity_id:document.getElementById('gp-entity-id').value.trim(),user_id:document.getElementById('gp-user-id').value.trim(),permission_type:document.getElementById('gp-perm-type').value};var res=document.getElementById('gp-result');if(!body.entity_type||!body.entity_id||!body.user_id){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">All fields required.</div>';return}try{var r=await fetch('/api/mwr/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var d=await r.json();if(r.ok&&d.success){showToast('Permission granted','success');(window.closeDialog?window.closeDialog('grant-perm-dialog'):document.getElementById('grant-perm-dialog').style.display='none');setTimeout(function(){location.reload()},500)}else{res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||'Failed')+'</div>'}}catch(e){showToast('Error: '+e.message,'error')}};window.revokePermission=async function(id){if(!(await window.gmConfirm({title:'Revoke permission',message:'Revoke this permission?',confirmLabel:'Revoke',danger:true})))return;try{var r=await fetch('/api/permission/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
|
|
107
108
|
return settingsPage(user, 'MWR Permissions', bc('MWR Permissions'), content, [script]);
|
|
108
109
|
}
|
|
109
110
|
|
|
@@ -153,6 +154,6 @@ export function renderSettingsTemplateManage(user, template = {}, sections = [])
|
|
|
153
154
|
window.editTplSection=function(id,name,color){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value=id;document.getElementById('tpl-sec-name').value=name;document.getElementById('tpl-sec-color').value=color};
|
|
154
155
|
window.cancelTplSection=function(){document.getElementById('tpl-section-form').classList.add('hidden')};
|
|
155
156
|
window.saveTplSection=async function(){var id=document.getElementById('tpl-sec-id').value;var body={name:document.getElementById('tpl-sec-name').value,color:document.getElementById('tpl-sec-color').value,review_template_id:tplId};if(!body.name){showToast('Name required','error');return}var url=id?'/api/review_template_section/'+id:'/api/review_template_section';var method=id?'PUT':'POST';try{var res=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};
|
|
156
|
-
window.deleteTplSection=async function(id){if(!
|
|
157
|
+
window.deleteTplSection=async function(id){if(!(await window.gmConfirm({title:'Delete Section',message:'Delete this section? This cannot be undone.',confirmLabel:'Delete',danger:true})))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
|
|
157
158
|
return settingsPage(user, `Manage Template - ${esc(template.name || 'Template')}`, tplBc, content, [script]);
|
|
158
159
|
}
|
|
@@ -14,8 +14,8 @@ function parseMembers(t) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function renderSettingsTeams(user, teams = [], allUsers = []) {
|
|
17
|
-
const editDialog = `<div id="team-dialog" class="modal" style="display:none"
|
|
18
|
-
<div class="modal-overlay" data-
|
|
17
|
+
const editDialog = `<div id="team-dialog" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="team-dialog-title">
|
|
18
|
+
<div class="modal-overlay" data-dialog-close="team-dialog"></div>
|
|
19
19
|
<div class="modal-content rounded-box max-w-md p-6">
|
|
20
20
|
<h3 id="team-dialog-title" class="text-lg font-semibold mb-4">Edit Team</h3>
|
|
21
21
|
<input type="hidden" id="team-edit-id"/>
|
|
@@ -59,7 +59,7 @@ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAl
|
|
|
59
59
|
const content = `${settingsBack()}${editDialog}
|
|
60
60
|
<div class="flex justify-between items-center mb-4">
|
|
61
61
|
<h1 class="text-2xl font-bold">Teams</h1>
|
|
62
|
-
<button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
|
|
62
|
+
<button class="btn btn-primary btn-sm gap-1 team-add-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add Team</button>
|
|
63
63
|
</div>
|
|
64
64
|
<div class="card-clean"><div class="card-clean-body" style="padding:0rem">${inlineTable(['Name', 'Members', 'Actions'], rows, 'No teams found')}</div></div>`;
|
|
65
65
|
return settingsPage(user, 'Teams - Settings', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Teams' }], content, [script]);
|
|
@@ -101,7 +101,7 @@ export function renderSettingsRfiSections(user, sections = []) {
|
|
|
101
101
|
const script = `${TOAST_SCRIPT}
|
|
102
102
|
function toggleAddForm(){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value='';document.getElementById('section-name').value='';selectColor('#B0B0B0')}
|
|
103
103
|
function cancelForm(){document.getElementById('section-form').style.display='none'}
|
|
104
|
-
function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'
|
|
104
|
+
function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'var(--color-text)':'transparent'})}
|
|
105
105
|
function editSection(id,name,color){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value=id;document.getElementById('section-name').value=name;selectColor(color)}
|
|
106
106
|
async function saveSection(){const name=document.getElementById('section-name').value.trim();const color=document.getElementById('section-color').value;const id=document.getElementById('edit-id').value;if(!name){showToast('Name is required','error');return}const url=id?'/api/rfi_section/'+id:'/api/rfi_section';const method=id?'PUT':'POST';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name,color})});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(()=>location.reload(),500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
|
|
107
107
|
async function deleteSection(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this section?',danger:true,confirmLabel:'OK'})))return;try{const res=await fetch('/api/rfi_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}}
|
|
@@ -50,7 +50,7 @@ const SETTINGS_CARDS = [
|
|
|
50
50
|
export function renderSettingsHome(user, config = {}, counts = {}) {
|
|
51
51
|
const cards = SETTINGS_CARDS.map(c => {
|
|
52
52
|
const cnt = c.countKey && counts[c.countKey] !== undefined ? counts[c.countKey] : null;
|
|
53
|
-
const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString()} items</span>` : '';
|
|
53
|
+
const badge = cnt !== null ? `<span class="badge badge-flat-primary text-xs">${cnt.toLocaleString('en-ZA')} items</span>` : '';
|
|
54
54
|
return `<a href="${c.href}" class="card-clean" style="text-decoration:none;border:1px solid var(--color-border);transition:box-shadow 0.15s">
|
|
55
55
|
<div class="card-clean-body" style="padding:${SPACING.md}">
|
|
56
56
|
<div style="display:flex;align-items:flex-start;gap:${SPACING.md}">
|
|
@@ -60,7 +60,7 @@ export function renderSettingsHome(user, config = {}, counts = {}) {
|
|
|
60
60
|
<div style="font-size:0.75rem;color:var(--color-text-muted);margin-top:${SPACING.xs};line-height:1.4">${c.desc}</div>
|
|
61
61
|
${badge ? `<div style="margin-top:${SPACING.sm}">${badge}</div>` : ''}
|
|
62
62
|
</div>
|
|
63
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
|
|
63
|
+
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--color-text-muted);margin-top:${SPACING.xs}"><polyline points="9 18 15 12 9 6"/></svg>
|
|
64
64
|
</div>
|
|
65
65
|
</div>
|
|
66
66
|
</a>`;
|
|
@@ -120,10 +120,10 @@ export function renderSettingsUsers(user, users = []) {
|
|
|
120
120
|
<div class="flex justify-between items-center mb-2">
|
|
121
121
|
<div>
|
|
122
122
|
<h1 class="text-2xl font-bold">Users</h1>
|
|
123
|
-
<p class="text-sm text-base-content/50 mt-0.5">${users.length} total
|
|
123
|
+
<p class="text-sm text-base-content/50 mt-0.5">${users.length} total - ${active} active</p>
|
|
124
124
|
</div>
|
|
125
125
|
<a href="/user/new" class="btn btn-primary btn-sm gap-1">
|
|
126
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
|
|
126
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add User
|
|
127
127
|
</a>
|
|
128
128
|
</div>
|
|
129
129
|
<div class="table-toolbar mb-3">
|
package/src/ui/spacing-system.js
CHANGED
|
@@ -124,11 +124,14 @@ export function renderStatsRow(stats) {
|
|
|
124
124
|
</div>`).join('')}</div>`;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// title/subtitle are plain text (escaped here); actionsHtml is a caller-built
|
|
128
|
+
// HTML fragment by contract. esc() inline to keep this module dependency-free.
|
|
129
|
+
function phEsc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
127
130
|
export function renderPageHeader(title, subtitle, actionsHtml = '') {
|
|
128
131
|
return `<div class="page-header">
|
|
129
132
|
<div>
|
|
130
|
-
<h1 class="page-title">${title}</h1>
|
|
131
|
-
${subtitle ? `<p class="page-subtitle">${subtitle}</p>` : ''}
|
|
133
|
+
<h1 class="page-title">${phEsc(title)}</h1>
|
|
134
|
+
${subtitle ? `<p class="page-subtitle">${phEsc(subtitle)}</p>` : ''}
|
|
132
135
|
</div>
|
|
133
136
|
${actionsHtml ? `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">${actionsHtml}</div>` : ''}
|
|
134
137
|
</div>`;
|
|
@@ -4,7 +4,7 @@ export function renderStandaloneLogin(showGoogleAuth = false) {
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="UTF-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
-
<title>Sign In |
|
|
7
|
+
<title>Sign In | Thatcher</title>
|
|
8
8
|
<style>
|
|
9
9
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
10
10
|
:root{--bg:#f1f5f9;--surface:#fff;--border:#e2e8f0;--text:#0f172a;--text-muted:#64748b;--primary:#04141f;--primary-hover:#0a2535;--accent:#2563eb;--danger:#dc2626;--danger-bg:#fee2e2;--danger-border:#fca5a5;--radius:10px;--shadow:0 4px 24px rgba(0,0,0,0.09)}
|
|
@@ -45,7 +45,7 @@ export function renderStandaloneLogin(showGoogleAuth = false) {
|
|
|
45
45
|
<div class="wrap">
|
|
46
46
|
<div class="logo">
|
|
47
47
|
<div class="logo-mark"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.75" aria-hidden="true"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
|
|
48
|
-
<span class="logo-text">
|
|
48
|
+
<span class="logo-text">Thatcher</span>
|
|
49
49
|
<div class="logo-sub">Sign in to your account</div>
|
|
50
50
|
</div>
|
|
51
51
|
<div class="card">
|
package/src/ui/styles.css
CHANGED
|
@@ -1184,9 +1184,10 @@ tr:last-child td { border-bottom: none; }
|
|
|
1184
1184
|
.dialog-panel { background: white; border-radius: 0.75rem; padding: 1.5rem; max-width: 480px; width: 90%; max-height: 85vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.2); }
|
|
1185
1185
|
.dialog-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
|
1186
1186
|
.dialog-title { font-size: 1.125rem; font-weight: 600; color: #111827; }
|
|
1187
|
-
.dialog-close { background: none; border: none; font-size: 1.5rem; color: #9ca3af; cursor: pointer; line-height: 1; padding: 0; }
|
|
1187
|
+
.dialog-close { background: none; border: none; font-size: 1.5rem; color: #9ca3af; cursor: pointer; line-height: 1; padding: 0; min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; }
|
|
1188
1188
|
.dialog-close:hover { color: #374151; }
|
|
1189
1189
|
@media (prefers-color-scheme: dark) {
|
|
1190
|
+
.dialog-title { color: #f5f5f5; }
|
|
1190
1191
|
.dialog-close { color: #9ca3af; }
|
|
1191
1192
|
.dialog-close:hover { color: #f5f5f5; }
|
|
1192
1193
|
}
|