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
package/src/ui/format-helpers.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { STAGE_COLORS, STATUS_COLORS } from './render-helpers.js';
|
|
2
|
+
|
|
1
3
|
export function formatDate(ts, opts) {
|
|
2
|
-
if (!ts) return '
|
|
4
|
+
if (!ts) return '-';
|
|
3
5
|
const d = typeof ts === 'number' ? new Date(ts < 1e10 ? ts * 1000 : ts) : new Date(ts);
|
|
4
|
-
if (isNaN(d)) return '
|
|
6
|
+
if (isNaN(d)) return '-';
|
|
5
7
|
return d.toLocaleDateString('en-ZA', opts || { day: '2-digit', month: 'short', year: 'numeric' });
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
export function timeAgo(ts) {
|
|
9
|
-
if (!ts) return '
|
|
11
|
+
if (!ts) return '-';
|
|
10
12
|
const d = typeof ts === 'number' ? new Date(ts < 1e10 ? ts * 1000 : ts) : new Date(ts);
|
|
11
13
|
const diff = Date.now() - d;
|
|
12
14
|
if (diff < 60000) return 'just now';
|
|
@@ -17,7 +19,7 @@ export function timeAgo(ts) {
|
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export function formatCurrency(amount, currency) {
|
|
20
|
-
if (amount == null || amount === '') return '
|
|
22
|
+
if (amount == null || amount === '') return '-';
|
|
21
23
|
const n = Number(amount);
|
|
22
24
|
if (isNaN(n)) return String(amount);
|
|
23
25
|
const sym = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : 'R';
|
|
@@ -25,35 +27,28 @@ export function formatCurrency(amount, currency) {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function formatPercent(val) {
|
|
28
|
-
if (val == null || val === '') return '
|
|
30
|
+
if (val == null || val === '') return '-';
|
|
29
31
|
const n = Number(val);
|
|
30
32
|
if (isNaN(n)) return val;
|
|
31
33
|
return (n > 1 ? n : Math.round(n * 100)) + '%';
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function stagePill(stage, stageConfig) {
|
|
35
|
-
const STAGES = stageConfig ||
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
closeout: { label: 'Close Out', color: '#33691e', bg: '#f1f8e9' },
|
|
42
|
-
};
|
|
43
|
-
const cfg = STAGES[stage];
|
|
44
|
-
if (!cfg) return stage ? `<span style="background:var(--color-bg,#f5f5f5);color:var(--color-text-muted,#555);padding:2px 8px;border-radius:10px;font-size:0.7rem;font-weight:700">${stage}</span>` : '—';
|
|
37
|
+
const STAGES = stageConfig || STAGE_COLORS;
|
|
38
|
+
const raw = STAGES[stage];
|
|
39
|
+
// Normalize both the local {color,bg,label} shape and the shared
|
|
40
|
+
// STAGE_COLORS {text,bg,label} shape into {color,bg,label}.
|
|
41
|
+
const cfg = raw ? { label: raw.label, bg: raw.bg, color: raw.color || raw.text } : null;
|
|
42
|
+
if (!cfg) return stage ? `<span style="background:var(--color-bg,#f5f5f5);color:var(--color-text-muted,#555);padding:2px 8px;border-radius:10px;font-size:0.7rem;font-weight:700">${stage}</span>` : '-';
|
|
45
43
|
return `<span style="background:${cfg.bg};color:${cfg.color};padding:2px 8px;border-radius:10px;font-size:0.7rem;font-weight:700;white-space:nowrap;border:1px solid ${cfg.color}44">${cfg.label}</span>`;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
// Role/extra status keys not present in the shared STATUS_COLORS table.
|
|
47
|
+
// Shared statuses (active, pending, draft, closed, overdue, responded, …)
|
|
48
|
+
// are sourced from STATUS_COLORS so the token set stays single-source.
|
|
49
|
+
const EXTRA_STATUS_MAP = {
|
|
50
50
|
inactive: ['#555', '#f5f5f5'],
|
|
51
|
-
pending: ['#e65100', '#fff3e0'],
|
|
52
|
-
draft: ['#888', '#f5f5f5'],
|
|
53
51
|
sent: ['#e65100', '#fff3e0'],
|
|
54
|
-
responded: ['#2e7d32', '#e8f5e9'],
|
|
55
|
-
closed: ['#555', '#eeeeee'],
|
|
56
|
-
overdue: ['#c62828', '#ffebee'],
|
|
57
52
|
deleted: ['#c62828', '#fdecea'],
|
|
58
53
|
admin: ['#6a1b9a', '#f3e5f5'],
|
|
59
54
|
partner: ['#1565c0', '#e3f2fd'],
|
|
@@ -65,26 +60,29 @@ const STATUS_MAP = {
|
|
|
65
60
|
|
|
66
61
|
export function statusBadge(status, labelOverride) {
|
|
67
62
|
const s = (status || '').toLowerCase();
|
|
68
|
-
const
|
|
69
|
-
const
|
|
63
|
+
const shared = STATUS_COLORS[s];
|
|
64
|
+
const [color, bg] = shared
|
|
65
|
+
? [shared.text, shared.bg]
|
|
66
|
+
: (EXTRA_STATUS_MAP[s] || ['#888', '#f5f5f5']);
|
|
67
|
+
const label = labelOverride || (s ? s.charAt(0).toUpperCase() + s.slice(1) : '-');
|
|
70
68
|
return `<span style="background:${bg};color:${color};padding:2px 9px;border-radius:10px;font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.3px;white-space:nowrap">${label}</span>`;
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
export function roleBadge(role) {
|
|
74
72
|
const known = ['admin','partner','manager','clerk','user','auditor'];
|
|
75
|
-
if (!role) return statusBadge('', '
|
|
73
|
+
if (!role) return statusBadge('', '-');
|
|
76
74
|
if (!known.includes(role.toLowerCase())) return statusBadge('', 'Unknown');
|
|
77
75
|
return statusBadge(role);
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
export function formatName(user) {
|
|
81
|
-
if (!user) return '
|
|
82
|
-
return user.name || user.display_name || user.email || '
|
|
79
|
+
if (!user) return '-';
|
|
80
|
+
return user.name || user.display_name || user.email || '-';
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
export function truncate(str, n) {
|
|
86
84
|
const max = n || 50;
|
|
87
|
-
if (!str) return '
|
|
85
|
+
if (!str) return '-';
|
|
88
86
|
const s = String(str);
|
|
89
87
|
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
90
88
|
}
|
|
@@ -115,6 +113,7 @@ const ICON_PATHS = {
|
|
|
115
113
|
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
116
114
|
undo: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
|
|
117
115
|
external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
|
116
|
+
close: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
|
118
117
|
};
|
|
119
118
|
|
|
120
119
|
export function icon(name, size = 18) {
|
|
@@ -7,16 +7,16 @@ import { SPACING, renderStatsRow, renderPageHeader, renderButton, renderEmptySta
|
|
|
7
7
|
function fmtDate(ts) {
|
|
8
8
|
if (!ts) return '';
|
|
9
9
|
const n = Number(ts);
|
|
10
|
-
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString();
|
|
10
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString('en-ZA');
|
|
11
11
|
return String(ts);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
const RESOLUTION_STATES = {
|
|
15
|
-
unresolved: { label: 'Unresolved', color: '
|
|
16
|
-
partial_resolved: { label: 'Partial', color: '
|
|
17
|
-
manager_resolved: { label: 'Manager', color: '
|
|
18
|
-
partner_resolved: { label: 'Partner', color: '
|
|
19
|
-
resolved: { label: 'Resolved', color: '
|
|
15
|
+
unresolved: { label: 'Unresolved', color: 'var(--color-danger,#ef4444)', pill: 'pill pill-danger' },
|
|
16
|
+
partial_resolved: { label: 'Partial', color: 'var(--color-warning,#f59e0b)', pill: 'pill pill-warning' },
|
|
17
|
+
manager_resolved: { label: 'Manager', color: 'var(--color-info,#3b82f6)', pill: 'pill pill-info' },
|
|
18
|
+
partner_resolved: { label: 'Partner', color: 'var(--color-primary,#8b5cf6)', pill: 'pill pill-primary' },
|
|
19
|
+
resolved: { label: 'Resolved', color: 'var(--color-success,#22c55e)', pill: 'pill pill-success' },
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
function resolutionBadge(status) {
|
|
@@ -47,14 +47,14 @@ function responseItem(response) {
|
|
|
47
47
|
function highlightThread(highlight, responses, canResolve, idx) {
|
|
48
48
|
const status = highlight.status || 'unresolved';
|
|
49
49
|
const badge = resolutionBadge(status);
|
|
50
|
-
const color = RESOLUTION_STATES[status]?.color || '
|
|
50
|
+
const color = RESOLUTION_STATES[status]?.color || 'var(--color-danger,#ef4444)';
|
|
51
51
|
|
|
52
52
|
const threadItems = responses.map(r => responseItem(r)).join('');
|
|
53
53
|
const sectionTag = highlight.section_name ? `<span class="review-meta-tag">${esc(highlight.section_name)}</span>` : '';
|
|
54
54
|
|
|
55
55
|
const resolveSelect = canResolve ? `<div class="thread-card-actions">
|
|
56
56
|
<label style="font-size:12px;color:var(--color-text-muted)">Mark as:</label>
|
|
57
|
-
<select id="resolve-status-${highlight.id}" class="form-input" style="width:auto;padding
|
|
57
|
+
<select id="resolve-status-${highlight.id}" class="form-input" style="width:auto;padding:${SPACING.xs} ${SPACING.sm};font-size:13px;min-height:auto" aria-label="Resolution status" onchange="updateResolution('${highlight.id}')">
|
|
58
58
|
<option value="unresolved" ${status === 'unresolved' ? 'selected' : ''}>Unresolved</option>
|
|
59
59
|
<option value="partial_resolved" ${status === 'partial_resolved' ? 'selected' : ''}>Partial</option>
|
|
60
60
|
<option value="manager_resolved" ${status === 'manager_resolved' ? 'selected' : ''}>Manager</option>
|
|
@@ -65,7 +65,7 @@ function highlightThread(highlight, responses, canResolve, idx) {
|
|
|
65
65
|
|
|
66
66
|
const replyForm = `<div class="thread-reply">
|
|
67
67
|
<textarea id="reply-${highlight.id}" class="form-input" style="resize:vertical;min-height:60px" rows="2" placeholder="Add a response..."></textarea>
|
|
68
|
-
<button class="btn-primary-clean" style="align-self:flex-start;padding
|
|
68
|
+
<button class="btn-primary-clean" style="align-self:flex-start;padding:${SPACING.sm} ${SPACING.md}" data-action="submitResponse" data-args='["${highlight.id}"]'>Reply</button>
|
|
69
69
|
</div>`;
|
|
70
70
|
|
|
71
71
|
return `<div class="thread-card" id="thread-${highlight.id}" data-thread-status="${status}">
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { statusLabel } from '@/ui/renderer.js';
|
|
2
2
|
import { page } from '@/ui/layout.js';
|
|
3
3
|
import { isPartner } from '@/ui/permissions-ui.js';
|
|
4
|
+
import { STATUS_COLORS, esc } from '@/ui/render-helpers.js';
|
|
4
5
|
|
|
5
6
|
function fmtDate(ts) {
|
|
6
7
|
if (!ts) return '-';
|
|
7
8
|
const n = Number(ts);
|
|
8
|
-
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString();
|
|
9
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString('en-ZA');
|
|
9
10
|
return String(ts);
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
function jobStatusBadge(status) {
|
|
13
14
|
const colors = {
|
|
14
|
-
running: {
|
|
15
|
-
success: {
|
|
16
|
-
failed: {
|
|
17
|
-
scheduled: {
|
|
18
|
-
disabled: {
|
|
15
|
+
running: { ...STATUS_COLORS.active, icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>` },
|
|
16
|
+
success: { ...STATUS_COLORS.completed, icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>' },
|
|
17
|
+
failed: { ...STATUS_COLORS.rejected, icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>` },
|
|
18
|
+
scheduled: { ...STATUS_COLORS.pending, icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>` },
|
|
19
|
+
disabled: { ...STATUS_COLORS.draft, icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>` },
|
|
19
20
|
};
|
|
20
21
|
const c = colors[status] || colors.scheduled;
|
|
21
22
|
return `<span style="background:${c.bg};color:${c.text};padding:2px 10px;border-radius:9999px;font-size:0.75rem;font-weight:500">${c.icon} ${(status || 'unknown').replace(/_/g, ' ')}</span>`;
|
|
@@ -25,9 +26,10 @@ function jobRow(job, canManage) {
|
|
|
25
26
|
const lastRun = fmtDate(job.last_run_at);
|
|
26
27
|
const nextRun = fmtDate(job.next_run_at);
|
|
27
28
|
const duration = job.last_duration ? (job.last_duration < 1000 ? job.last_duration + 'ms' : Math.round(job.last_duration / 1000) + 's') : '-';
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const jobNameArg = esc(JSON.stringify(String(job.name)));
|
|
30
|
+
const triggerBtn = canManage ? `<button class="btn btn-xs btn-primary" data-action="triggerJob" data-args='[${jobNameArg}]'>Run Now</button>` : '';
|
|
31
|
+
const toggleBtn = canManage ? `<button class="btn btn-xs ${job.enabled !== false ? 'btn-warning' : 'btn-success'}" data-action="toggleJob" data-args='[${jobNameArg},${!job.enabled}]'>${job.enabled !== false ? 'Disable' : 'Enable'}</button>` : '';
|
|
32
|
+
return `<tr><td class="font-medium">${esc(job.label || job.name)}</td><td>${esc(job.schedule || job.cron || '-')}</td><td>${jobStatusBadge(job.status || (job.enabled === false ? 'disabled' : 'scheduled'))}</td><td class="text-sm">${lastRun}</td><td class="text-sm">${nextRun}</td><td class="text-sm">${duration}</td><td class="text-sm">${esc(job.last_result || '-')}</td><td class="flex gap-1">${triggerBtn}${toggleBtn}</td></tr>`;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export function renderJobManagement(user, jobs, recentLogs = []) {
|
|
@@ -41,21 +43,21 @@ export function renderJobManagement(user, jobs, recentLogs = []) {
|
|
|
41
43
|
{ label: 'Running', value: runningJobs, color: runningJobs > 0 ? 'blue' : '' },
|
|
42
44
|
{ label: 'Failed', value: failedJobs, color: failedJobs > 0 ? 'red' : '' },
|
|
43
45
|
{ label: 'Healthy', value: totalJobs - failedJobs, color: 'green' },
|
|
44
|
-
].map(s => `<div class="card-clean"><div class="card-clean-body" style="padding:0.75rem 0"><h3 class="text-gray-500 text-sm">${s.label}</h3><p class="text-xl font-bold${s.color ? ` text-${s.color}-600` : ''}">${s.value}</p></div></div>`).join('')}</div>`;
|
|
46
|
+
].map(s => `<div class="card-clean"><div class="card-clean-body" style="padding:0.75rem 0"><h3 class="text-gray-600 dark:text-gray-500 text-sm">${s.label}</h3><p class="text-xl font-bold${s.color ? ` text-${s.color}-600 dark:text-${s.color}-400` : ''}">${s.value}</p></div></div>`).join('')}</div>`;
|
|
45
47
|
|
|
46
48
|
const headers = '<th>Job</th><th>Schedule</th><th>Status</th><th>Last Run</th><th>Next Run</th><th>Duration</th><th>Result</th><th>Actions</th>';
|
|
47
49
|
const rows = jobs.map(j => jobRow(j, canManage)).join('');
|
|
48
|
-
const table = `<div class="card-clean" style="margin-bottom:1.5rem"
|
|
50
|
+
const table = `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body"><h3 class="font-semibold mb-3">Scheduled Jobs</h3><div style="overflow-x:auto"><table class="data-table"><thead><tr>${headers}</tr></thead><tbody>${rows || '<tr><td colspan="8" class="text-center py-8 text-gray-600 dark:text-gray-500">No jobs configured</td></tr>'}</tbody></table></div></div></div>`;
|
|
49
51
|
|
|
50
52
|
const logRows = recentLogs.slice(0, 20).map(l => {
|
|
51
|
-
const sts = l.success ? '<span class="text-green-600">OK</span>' : '<span class="text-red-600">FAIL</span>';
|
|
52
|
-
return `<tr><td class="text-sm">${l.job_name || '-'}</td><td>${sts}</td><td class="text-xs text-gray-500">${fmtDate(l.started_at)}</td><td class="text-xs">${l.duration ? l.duration + 'ms' : '-'}</td><td class="text-xs text-gray-500 max-w-xs truncate">${l.message || '-'}</td></tr>`;
|
|
53
|
+
const sts = l.success ? '<span class="text-green-600 dark:text-green-400">OK</span>' : '<span class="text-red-600 dark:text-red-400">FAIL</span>';
|
|
54
|
+
return `<tr><td class="text-sm">${esc(l.job_name || '-')}</td><td>${sts}</td><td class="text-xs text-gray-600 dark:text-gray-500">${fmtDate(l.started_at)}</td><td class="text-xs">${l.duration ? l.duration + 'ms' : '-'}</td><td class="text-xs text-gray-600 dark:text-gray-500 max-w-xs truncate">${esc(l.message || '-')}</td></tr>`;
|
|
53
55
|
}).join('');
|
|
54
|
-
const logTable = `<div class="card-clean"><div class="card-clean-body"><h3 class="font-semibold mb-3">Recent Execution Logs</h3><table class="data-table"><thead><tr><th>Job</th><th>Result</th><th>Time</th><th>Duration</th><th>Message</th></tr></thead><tbody>${logRows || '<tr><td colspan="5" class="text-center py-4 text-gray-
|
|
56
|
+
const logTable = `<div class="card-clean"><div class="card-clean-body"><h3 class="font-semibold mb-3">Recent Execution Logs</h3><div style="overflow-x:auto"><table class="data-table"><thead><tr><th>Job</th><th>Result</th><th>Time</th><th>Duration</th><th>Message</th></tr></thead><tbody>${logRows || '<tr><td colspan="5" class="text-center py-4 text-gray-600 dark:text-gray-500">No recent logs</td></tr>'}</tbody></table></div></div></div>`;
|
|
55
57
|
|
|
56
|
-
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Scheduled Jobs</h1>${canManage ? '<button class="btn btn-primary btn-sm" data-action="triggerAll">Run All Now</button>' : ''}</div>${statCards}${table}${logTable}
|
|
58
|
+
const content = `<main><div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Scheduled Jobs</h1>${canManage ? '<button class="btn btn-primary btn-sm" data-action="triggerAll">Run All Now</button>' : ''}</div>${statCards}${table}${logTable}</main>`;
|
|
57
59
|
|
|
58
60
|
const jobScript = `window.triggerJob=async function(name){try{const r=await fetch('/api/admin/jobs/'+name+'/trigger',{method:'POST'});if(r.ok){location.reload()}else{showToast('Trigger failed: '+(await r.text()),'error')}}catch(e){showToast(e.message,'error')}};window.toggleJob=async function(name,enabled){try{const r=await fetch('/api/admin/jobs/'+name,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});if(r.ok)location.reload();else showToast('Toggle failed','error')}catch(e){showToast(e.message,'error')}};window.triggerAll=async function(){if(!(await window.gmConfirm({title:'Please confirm',message:'Run all jobs now?',danger:false,confirmLabel:'OK'})))return;try{const r=await fetch('/api/admin/jobs/trigger-all',{method:'POST'});if(r.ok)location.reload();else showToast('Failed','error')}catch(e){showToast(e.message,'error')}}`;
|
|
59
61
|
|
|
60
|
-
return page(user, 'Scheduled Jobs |
|
|
62
|
+
return page(user, 'Scheduled Jobs | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Jobs' }], content, [jobScript]);
|
|
61
63
|
}
|
package/src/ui/layout.js
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { h } from '@/ui/webjsx.js'
|
|
2
2
|
import { getNavItems, getAdminItems, isPartner, isClerk } from '@/ui/permissions-ui.js'
|
|
3
|
-
import { TOAST_SCRIPT } from '@/ui/render-helpers.js'
|
|
3
|
+
import { TOAST_SCRIPT, AVATAR_COLORS, esc } from '@/ui/render-helpers.js'
|
|
4
4
|
import { FETCH_JSON_SCRIPT } from '@/ui/fetch-json.js'
|
|
5
5
|
import { aria, role, skipLink, liveRegion } from '@/lib/accessibility'
|
|
6
6
|
|
|
7
|
+
// Normalize a page title so the app-name suffix is config-driven, not hardcoded
|
|
8
|
+
// per call site. A caller may pass either a bare title ("Engagements") or one
|
|
9
|
+
// with a legacy suffix ("Engagements | Thatcher"); both resolve to
|
|
10
|
+
// "Engagements | <APP_NAME>" so a consuming app sets APP_NAME once instead of
|
|
11
|
+
// editing every renderer. APP_NAME is read at call time (not module load) so it
|
|
12
|
+
// reflects the env after the consuming app's loadEnv() runs.
|
|
13
|
+
function withAppName(title) {
|
|
14
|
+
const appName = process.env.APP_NAME || 'Thatcher App'
|
|
15
|
+
const base = String(title ?? '').replace(/\s*\|\s*[^|]*$/, '').trim() || appName
|
|
16
|
+
return base === appName ? base : `${base} | ${appName}`
|
|
17
|
+
}
|
|
18
|
+
|
|
7
19
|
export function generateHtml(title, bodyContent, scripts = [], pathname = '/') {
|
|
20
|
+
title = withAppName(title)
|
|
8
21
|
const scriptTags = scripts.map(s =>
|
|
9
22
|
typeof s === 'string' ? `<script>${s.replace(/<\/script/gi, '<\\/script').replace(/<!--/g, '<\\!--')}</script>` : `<script type="module" src="${s.src}"></script>`
|
|
10
23
|
).join('\n')
|
|
@@ -17,9 +30,10 @@ export function generateHtml(title, bodyContent, scripts = [], pathname = '/') {
|
|
|
17
30
|
}`
|
|
18
31
|
|
|
19
32
|
return `<!DOCTYPE html>
|
|
20
|
-
<html lang="en"
|
|
33
|
+
<html lang="en">
|
|
21
34
|
<head>
|
|
22
35
|
<meta charset="UTF-8">
|
|
36
|
+
<script>(function(){try{var t=localStorage.getItem('thatcher-theme');if(!t){t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light'}document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}})();</script>
|
|
23
37
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
24
38
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
25
39
|
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
|
@@ -75,8 +89,8 @@ export function breadcrumb(items) {
|
|
|
75
89
|
if (!items?.length) return ''
|
|
76
90
|
return `<nav ${aria.label('Breadcrumb')} class="breadcrumb-clean">${items.map((item, i) =>
|
|
77
91
|
i === items.length - 1
|
|
78
|
-
? `<span ${aria.current('page')}>${item.label}</span>`
|
|
79
|
-
: `<a href="${item.href}">${item.label}</a><span class="breadcrumb-sep" aria-hidden="true">/</span>`
|
|
92
|
+
? `<span ${aria.current('page')}>${esc(item.label)}</span>`
|
|
93
|
+
: `<a href="${esc(item.href)}">${esc(item.label)}</a><span class="breadcrumb-sep" aria-hidden="true">/</span>`
|
|
80
94
|
).join('')}</nav>`
|
|
81
95
|
}
|
|
82
96
|
|
|
@@ -93,39 +107,38 @@ export function nav(user, pathname = '') {
|
|
|
93
107
|
const reviewLink = `<a href="/review" class="${linkClass('/review')}">My Review</a>`
|
|
94
108
|
|
|
95
109
|
const avatarInitial = user?.name?.charAt(0)?.toUpperCase() || 'U'
|
|
96
|
-
const
|
|
97
|
-
const avatarBg = avatarColors[(user?.name?.charCodeAt(0) || 0) % avatarColors.length]
|
|
110
|
+
const avatarBg = AVATAR_COLORS[(user?.name?.charCodeAt(0) || 0) % AVATAR_COLORS.length]
|
|
98
111
|
|
|
99
112
|
return `<nav id="main-nav" class="main-nav" ${role.navigation} ${aria.label('Main navigation')}>
|
|
100
113
|
<div class="main-nav-brand">
|
|
101
114
|
<a href="/" class="main-nav-brand-link" ${aria.label('Home')}>
|
|
102
115
|
${logoSvg}
|
|
103
|
-
<span class="main-nav-brand-text">
|
|
116
|
+
<span class="main-nav-brand-text">Thatcher</span>
|
|
104
117
|
</a>
|
|
105
118
|
</div>
|
|
106
119
|
<div class="nav-links-desktop" style="align-items:center;gap:1.25rem;flex-shrink:0">
|
|
107
120
|
${engLink}${clientLink}${settingsLink}${reviewLink}
|
|
108
121
|
</div>
|
|
109
122
|
<div class="main-nav-actions">
|
|
110
|
-
<button id="theme-toggle" type="button" data-action="toggleTheme" aria-label="Toggle dark mode" title="Toggle theme"
|
|
123
|
+
<button id="theme-toggle" type="button" data-action="toggleTheme" aria-label="Toggle dark mode" title="Toggle theme" class="nav-icon-btn">
|
|
111
124
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
|
112
125
|
</button>
|
|
113
|
-
<a href="/notifications" id="notif-bell" aria-label="Notifications" title="Notifications"
|
|
126
|
+
<a href="/notifications" id="notif-bell" aria-label="Notifications" title="Notifications" class="nav-icon-btn nav-notif-bell">
|
|
114
127
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
|
115
|
-
<span id="notif-count" style="display:none;position:absolute;top:2px;right:2px;background
|
|
128
|
+
<span id="notif-count" style="display:none;position:absolute;top:2px;right:2px;background:var(--color-danger,#ef4444);color:#fff;border-radius:9999px;font-size:10px;font-weight:700;min-width:16px;height:16px;line-height:16px;text-align:center;padding:0 3px"></span>
|
|
116
129
|
</a>
|
|
117
|
-
<div class="nav-avatar" style="background:${avatarBg}" id="user-avatar" data-action="toggleUserMenu" data-pass-event title="${user?.name || 'User'}" aria-label="User menu" role="button" tabindex="0">
|
|
130
|
+
<div class="nav-avatar" style="background:${avatarBg}" id="user-avatar" data-action="toggleUserMenu" data-pass-event title="${esc(user?.name || 'User')}" aria-label="User menu" role="button" tabindex="0">
|
|
118
131
|
${avatarInitial}
|
|
119
132
|
</div>
|
|
120
|
-
<button class="nav-hamburger" data-action="toggleMobileNav" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="mobile-nav-drawer"
|
|
133
|
+
<button class="nav-hamburger" data-action="toggleMobileNav" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="mobile-nav-drawer">
|
|
121
134
|
${hamburgerSvg}
|
|
122
135
|
</button>
|
|
123
136
|
</div>
|
|
124
137
|
<div id="user-dropdown" class="user-dropdown" ${role.menu}>
|
|
125
138
|
<div class="user-dropdown-header">
|
|
126
|
-
<div class="user-dropdown-name">${user?.name || ''}</div>
|
|
127
|
-
<div class="user-dropdown-email">${user?.email || ''}</div>
|
|
128
|
-
<div class="user-dropdown-role" style="text-transform:capitalize">${user?.role || ''}</div>
|
|
139
|
+
<div class="user-dropdown-name">${esc(user?.name || '')}</div>
|
|
140
|
+
<div class="user-dropdown-email">${esc(user?.email || '')}</div>
|
|
141
|
+
<div class="user-dropdown-role" style="text-transform:capitalize">${esc(user?.role || '')}</div>
|
|
129
142
|
</div>
|
|
130
143
|
<a href="/api/auth/logout" class="user-dropdown-item">
|
|
131
144
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
|
@@ -135,9 +148,9 @@ export function nav(user, pathname = '') {
|
|
|
135
148
|
</nav>
|
|
136
149
|
<div id="mobile-nav-drawer" style="display:none;position:fixed;inset:0;z-index:200;pointer-events:none" aria-hidden="true">
|
|
137
150
|
<div data-action="closeMobileNav" style="position:absolute;inset:0;background:rgba(0,0,0,0.5);pointer-events:auto"></div>
|
|
138
|
-
<div style="position:absolute;left:0;top:0;bottom:0;width:280px;max-width:80vw;background:#04141f;padding:0;display:flex;flex-direction:column;transform:translateX(-100%);transition:transform 0.25s ease" id="mobile-nav-panel">
|
|
151
|
+
<div style="position:absolute;left:0;top:0;bottom:0;width:min(280px,70vw);max-width:80vw;background:#04141f;padding:0;display:flex;flex-direction:column;transform:translateX(-100%);transition:transform 0.25s ease" id="mobile-nav-panel">
|
|
139
152
|
<div class="mobile-nav-header-bar">
|
|
140
|
-
<span class="mobile-nav-header-title">
|
|
153
|
+
<span class="mobile-nav-header-title">Thatcher</span>
|
|
141
154
|
<button data-action="closeMobileNav" class="mobile-nav-close-btn" aria-label="Close menu">×</button>
|
|
142
155
|
</div>
|
|
143
156
|
<nav style="flex:1;padding:0.5rem 0;overflow-y:auto">
|
|
@@ -148,8 +161,8 @@ export function nav(user, pathname = '') {
|
|
|
148
161
|
${isPartner(user) ? `<a href="/admin/settings" class="mobile-nav-link">Settings</a>` : ''}
|
|
149
162
|
</nav>
|
|
150
163
|
<div class="mobile-nav-footer">
|
|
151
|
-
<div class="mobile-nav-user-name">${user?.name || ''}</div>
|
|
152
|
-
<div class="mobile-nav-user-email">${user?.email || ''}</div>
|
|
164
|
+
<div class="mobile-nav-user-name">${esc(user?.name || '')}</div>
|
|
165
|
+
<div class="mobile-nav-user-email">${esc(user?.email || '')}</div>
|
|
153
166
|
<a href="/api/auth/logout" class="mobile-nav-logout">Sign out</a>
|
|
154
167
|
</div>
|
|
155
168
|
</div>
|
|
@@ -162,10 +175,19 @@ export function nav(user, pathname = '') {
|
|
|
162
175
|
</div>
|
|
163
176
|
</div>
|
|
164
177
|
<script>
|
|
165
|
-
window.loadingBtn=function(btn,loading,label){if(!btn)return;btn.disabled=loading;btn.style.opacity=loading?'0.7':'1';if(loading){btn._orig=btn.innerHTML;btn.innerHTML='<span class="btn-spinner"></span>'+(label||'Loading...');}else{btn.innerHTML=btn._orig||label||btn.innerHTML;btn.disabled=false}}
|
|
178
|
+
window.loadingBtn=function(btn,loading,label){if(!btn)return;btn.disabled=loading;btn.style.opacity=loading?'0.7':'1';if(loading){btn._orig=btn.innerHTML;btn.innerHTML='<span class="btn-spinner"></span>'+(label||'Loading...');}else{btn.innerHTML=btn._orig||label||btn.innerHTML;btn.disabled=false}};
|
|
179
|
+
// Canonical client-side date/currency formatters — mirror render-helpers fmtDate so
|
|
180
|
+
// client-rendered template strings format identically to server-rendered ones (fixed
|
|
181
|
+
// en-ZA locale, sec/ms/ISO scale-detect, '-' fallback). Use window.fmtDate(ts) in any
|
|
182
|
+
// client script string instead of inline new Date(...).toLocaleDateString().
|
|
183
|
+
(function(){function toDate(ts){if(ts==null||ts==='')return null;if(ts instanceof Date)return isNaN(ts)?null:ts;var s=String(ts).trim();var n=Number(ts);var d=(!isNaN(n)&&s!==''&&/^\d+$/.test(s))?new Date(n<1e12?n*1000:n):new Date(ts);return isNaN(d)?null:d}
|
|
184
|
+
window.fmtDate=function(ts){var d=toDate(ts);return d?d.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'};
|
|
185
|
+
window.fmtDateTime=function(ts){var d=toDate(ts);return d?d.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'})+', '+d.toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'}):'-'};
|
|
186
|
+
window.fmtDateInput=function(ts){var d=toDate(ts);if(!d)return '';var p=function(x){return String(x).padStart(2,'0')};return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())};
|
|
187
|
+
window.dateToUnixTimestamp=function(v){var d=toDate(v);return d?Math.floor(d.getTime()/1000):null};
|
|
188
|
+
window.fmtCurrency=function(amount,currency){if(amount==null||amount==='')return '-';var n=Number(amount);if(isNaN(n))return String(amount);var sym=currency==='USD'?'$':currency==='EUR'?'€':'R';return sym+' '+n.toLocaleString('en-ZA',{minimumFractionDigits:0,maximumFractionDigits:2})};})();
|
|
166
189
|
window.showToast=window.showToast||function(m,t){var c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';document.body.appendChild(c)}var d=document.createElement('div');d.className='toast toast-'+(t||'info');d.textContent=m;c.appendChild(d);setTimeout(function(){d.style.opacity='0';setTimeout(function(){d.remove()},300)},3000)}
|
|
167
|
-
window.toggleTheme=function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var next=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',next);try{localStorage.setItem('
|
|
168
|
-
;(function(){try{var saved=localStorage.getItem('moonlanding-theme');if(saved){document.documentElement.setAttribute('data-theme',saved)}else if(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches){document.documentElement.setAttribute('data-theme','dark')}}catch(_){}})();
|
|
190
|
+
window.toggleTheme=function(){var cur=document.documentElement.getAttribute('data-theme')||'light';var next=cur==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',next);try{localStorage.setItem('thatcher-theme',next)}catch(_){}}
|
|
169
191
|
function toggleUserMenu(e){e.stopPropagation();var d=document.getElementById('user-dropdown');var visible=d.style.display==='block';d.style.display=visible?'none':'block'}
|
|
170
192
|
document.addEventListener('click',function(e){var d=document.getElementById('user-dropdown');if(d&&!d.contains(e.target)&&e.target.id!=='user-avatar'){d.style.display='none'}})
|
|
171
193
|
function toggleMobileNav(){var btn=document.querySelector('.nav-hamburger');var open=btn.getAttribute('aria-expanded')==='true';btn.setAttribute('aria-expanded',String(!open));var drawer=document.getElementById('mobile-nav-drawer');var panel=document.getElementById('mobile-nav-panel');drawer.style.display=open?'none':'block';drawer.setAttribute('aria-hidden',String(open));panel.style.transform=open?'translateX(-100%)':'translateX(0)'}
|
|
@@ -202,18 +224,22 @@ export function fullPage(user, title, content, scripts = []) {
|
|
|
202
224
|
|
|
203
225
|
export function statCards(cards) {
|
|
204
226
|
return `<div class="stats shadow w-full mb-6 flex-wrap">` +
|
|
205
|
-
cards.map(c => `<div class="stat"><div class="stat-title">${c.label}</div><div class="stat-value text-2xl${c.textClass
|
|
227
|
+
cards.map(c => `<div class="stat"><div class="stat-title">${esc(c.label)}</div><div class="stat-value text-2xl${c.textClass ? ' ' + esc(c.textClass) : ''}">${esc(c.value)}</div>${c.sub ? `<div class="stat-desc">${esc(c.sub)}</div>` : ''}</div>`).join('') +
|
|
206
228
|
`</div>`
|
|
207
229
|
}
|
|
208
230
|
|
|
209
231
|
export function confirmDialog(entityName) {
|
|
232
|
+
const noun = entityName ? `this ${esc(String(entityName).replace(/_/g, ' '))}` : 'this item'
|
|
210
233
|
return `<div id="confirm-dialog" class="confirm-overlay" style="display:none" ${role.alertdialog} ${aria.labelledBy('confirm-title')} ${aria.describedBy('confirm-msg')} ${aria.hidden('true')}>
|
|
211
234
|
<div class="confirm-dialog"><h2 id="confirm-title" class="confirm-title">Confirm Delete</h2>
|
|
212
|
-
<div id="confirm-msg" class="confirm-message">Are you sure you want to delete
|
|
235
|
+
<div id="confirm-msg" class="confirm-message">Are you sure you want to delete ${noun}? This action cannot be undone.</div>
|
|
213
236
|
<div class="confirm-actions"><button type="button" data-action="cancelDelete" class="btn btn-ghost" ${aria.label('Cancel deletion')}>Cancel</button>
|
|
214
237
|
<button type="button" id="confirm-delete-btn" data-action="executeDelete" class="btn btn-error" ${aria.label('Confirm deletion')}>Delete</button></div></div></div>`
|
|
215
238
|
}
|
|
216
239
|
|
|
240
|
+
// headers and rows are pre-rendered HTML fragments composed by the caller; emptyMsg
|
|
241
|
+
// may carry a caller-provided HTML link (e.g. "Create your first ..."), so it is
|
|
242
|
+
// trusted-by-contract here. Callers MUST esc() any user/db data they fold into these.
|
|
217
243
|
export function dataTable(headers, rows, emptyMsg) {
|
|
218
|
-
return `<div class="card-clean"><div class="table-wrap" ${role.region} ${aria.label('Data table')}><table class="data-table"><thead><tr>${headers}</tr></thead><tbody id="table-body">${rows || `<tr><td colspan="100" class="text-center py-8 text-base-content/50">${emptyMsg}</td></tr>`}</tbody></table></div></div>`
|
|
244
|
+
return `<div class="card-clean"><div class="table-wrap" ${role.region} ${aria.label('Data table')}><table class="data-table"><thead><tr>${headers}</tr></thead><tbody id="table-body">${rows || `<tr><td colspan="100" class="text-center py-8 text-base-content/50">${emptyMsg || 'No items found'}</td></tr>`}</tbody></table></div></div>`
|
|
219
245
|
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// ML / AI query console — lists highlight comments flagged as ML-candidate queries.
|
|
2
|
-
// MVP: read-only list of highlights with comment text + link back to the source review.
|
|
3
|
-
// Full AI integration (model invocation, response streaming) is out of scope — stub.
|
|
4
|
-
|
|
5
1
|
import { page } from '@/ui/layout.js';
|
|
6
2
|
import { esc } from '@/ui/render-helpers.js';
|
|
7
3
|
import { SPACING } from '@/ui/spacing-system.js';
|
|
@@ -17,13 +13,13 @@ export function renderMlConsole(user, candidates, reviewMap) {
|
|
|
17
13
|
const rows = candidates.map((h) => {
|
|
18
14
|
const reviewName = reviewMap[h.review_id] || '-';
|
|
19
15
|
const comment = h.comment || h.text || '';
|
|
20
|
-
const truncated = comment.length > 240 ? comment.slice(0, 240) + '
|
|
16
|
+
const truncated = comment.length > 240 ? comment.slice(0, 240) + '...' : comment;
|
|
21
17
|
return `<tr>
|
|
22
18
|
<td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:13px">
|
|
23
19
|
<div style="font-weight:500">${esc(reviewName)}</div>
|
|
24
20
|
<div style="font-size:11px;color:var(--color-text-muted);margin-top:2px">p.${h.page_number || '?'}</div>
|
|
25
21
|
</td>
|
|
26
|
-
<td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:13px;max-width:560px">${esc(truncated)}</td>
|
|
22
|
+
<td title="${esc(comment)}" style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:13px;max-width:560px">${esc(truncated)}</td>
|
|
27
23
|
<td style="padding:8px 10px;border-bottom:1px solid var(--color-border);font-size:12px;color:var(--color-text-muted);white-space:nowrap">${fmtDate(h.created_at)}</td>
|
|
28
24
|
<td style="padding:8px 10px;border-bottom:1px solid var(--color-border);text-align:right">
|
|
29
25
|
<a href="/review/${esc(h.review_id)}/pdf#h-${esc(h.id)}" class="btn-ghost-clean" style="padding:4px 10px;font-size:12px">Open</a>
|
|
@@ -57,19 +53,14 @@ export function renderMlConsole(user, candidates, reviewMap) {
|
|
|
57
53
|
<tbody>${rows}</tbody>
|
|
58
54
|
</table>
|
|
59
55
|
</div>
|
|
60
|
-
<div style="margin-top:${SPACING.md};padding:${SPACING.md};background:var(--color-surface-muted,#f8fafc);border-radius:8px;font-size:13px;color:var(--color-text-muted)">
|
|
61
|
-
<strong>Note:</strong> Full AI integration requires an external model service.
|
|
62
|
-
Clicking "Ask AI" currently records an intent; plumbing to a model endpoint is
|
|
63
|
-
the next implementation step.
|
|
64
|
-
</div>
|
|
65
56
|
</div>
|
|
66
|
-
<div id="ml-ask-dialog" class="modal" style="display:none"
|
|
57
|
+
<div id="ml-ask-dialog" class="modal" style="display:none">
|
|
67
58
|
<div class="modal-overlay" data-dialog-close="ml-ask-dialog"></div>
|
|
68
59
|
<div class="modal-content rounded-box max-w-lg p-6">
|
|
69
60
|
<h3 style="font-size:16px;font-weight:600;margin:0 0 12px 0">AI Suggestion</h3>
|
|
70
61
|
<div id="ml-ask-query" style="font-size:13px;color:var(--color-text-muted);margin-bottom:12px;padding:8px 10px;background:var(--color-surface-muted,#f8fafc);border-radius:6px"></div>
|
|
71
|
-
<div id="ml-ask-result" style="font-size:14px;white-space:pre-wrap;min-height:60px;padding:10px;background:
|
|
72
|
-
<div style="color:var(--color-text-muted);font-style:italic">Generating suggestion
|
|
62
|
+
<div id="ml-ask-result" style="font-size:14px;white-space:pre-wrap;min-height:60px;padding:10px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:6px">
|
|
63
|
+
<div style="color:var(--color-text-muted);font-style:italic">Generating suggestion...</div>
|
|
73
64
|
</div>
|
|
74
65
|
<div id="ml-ask-meta" style="font-size:11px;color:var(--color-text-muted);margin-top:8px"></div>
|
|
75
66
|
<div class="modal-action" style="margin-top:16px">
|
|
@@ -84,7 +75,13 @@ export function renderMlConsole(user, candidates, reviewMap) {
|
|
|
84
75
|
var qText = row ? row.querySelector('td:nth-child(2)').textContent.trim() : '';
|
|
85
76
|
var dlg = document.getElementById('ml-ask-dialog');
|
|
86
77
|
document.getElementById('ml-ask-query').textContent = qText;
|
|
87
|
-
document.getElementById('ml-ask-result')
|
|
78
|
+
var resultEl = document.getElementById('ml-ask-result');
|
|
79
|
+
resultEl.textContent = '';
|
|
80
|
+
var loadingEl = document.createElement('div');
|
|
81
|
+
loadingEl.style.color = '#64748b';
|
|
82
|
+
loadingEl.style.fontStyle = 'italic';
|
|
83
|
+
loadingEl.textContent = 'Generating suggestion...';
|
|
84
|
+
resultEl.appendChild(loadingEl);
|
|
88
85
|
document.getElementById('ml-ask-meta').textContent = '';
|
|
89
86
|
dlg.style.display = 'flex';
|
|
90
87
|
try {
|
|
@@ -98,11 +95,13 @@ export function renderMlConsole(user, candidates, reviewMap) {
|
|
|
98
95
|
}
|
|
99
96
|
} catch (e) {
|
|
100
97
|
document.getElementById('ml-ask-result').textContent = 'Network error: ' + e.message;
|
|
98
|
+
if (window.showToast) window.showToast(e.message || 'Request failed', 'error');
|
|
99
|
+
console.error(e);
|
|
101
100
|
}
|
|
102
101
|
};
|
|
103
102
|
})();
|
|
104
103
|
</script>`;
|
|
105
104
|
|
|
106
105
|
const bc = [{ href: '/', label: 'Home' }, { href: '/review', label: 'Reviews' }, { label: 'ML Console' }];
|
|
107
|
-
return page(user, 'AI / ML Query Console |
|
|
106
|
+
return page(user, 'AI / ML Query Console | Thatcher', bc, content);
|
|
108
107
|
}
|
|
@@ -2,6 +2,7 @@ export function monitoringDashboardScript() {
|
|
|
2
2
|
return `
|
|
3
3
|
<script>
|
|
4
4
|
let currentTab = 'requests'
|
|
5
|
+
function mdEsc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
|
5
6
|
|
|
6
7
|
function showTab(name) {
|
|
7
8
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'))
|
|
@@ -69,7 +70,7 @@ export function monitoringDashboardScript() {
|
|
|
69
70
|
function updateRequests(requests) {
|
|
70
71
|
const html = Object.entries(requests).map(([endpoint, stats]) => \`
|
|
71
72
|
<div class="mb-4 p-3 bg-base-100 rounded">
|
|
72
|
-
<div class="font-bold">\${endpoint}</div>
|
|
73
|
+
<div class="font-bold">\${mdEsc(endpoint)}</div>
|
|
73
74
|
<div class="grid grid-cols-7 gap-2 text-sm mt-2">
|
|
74
75
|
<div>Count: \${stats.count}</div>
|
|
75
76
|
<div>Min: \${stats.min.toFixed(1)}ms</div>
|
|
@@ -86,9 +87,9 @@ export function monitoringDashboardScript() {
|
|
|
86
87
|
|
|
87
88
|
function updateAlerts(alerts) {
|
|
88
89
|
const html = alerts.map(alert => \`
|
|
89
|
-
<div class="p-3 rounded alert-\${alert.severity}">
|
|
90
|
-
<div class="font-bold">[\${alert.severity.toUpperCase()}] \${alert.type}</div>
|
|
91
|
-
<div>\${alert.message}</div>
|
|
90
|
+
<div class="p-3 rounded alert-\${mdEsc(alert.severity)}">
|
|
91
|
+
<div class="font-bold">[\${mdEsc(String(alert.severity).toUpperCase())}] \${mdEsc(alert.type)}</div>
|
|
92
|
+
<div>\${mdEsc(alert.message)}</div>
|
|
92
93
|
<div class="text-sm text-gray-600">\${new Date(alert.timestamp).toLocaleString()}</div>
|
|
93
94
|
</div>
|
|
94
95
|
\`).join('')
|
|
@@ -104,7 +105,7 @@ export function monitoringDashboardScript() {
|
|
|
104
105
|
<div class="mt-4 font-bold">Slow Queries:</div>
|
|
105
106
|
\${(db.slowQueries || []).map(q => \`
|
|
106
107
|
<div class="p-2 bg-base-100 rounded text-sm">
|
|
107
|
-
<div class="font-mono">\${q.sql}</div>
|
|
108
|
+
<div class="font-mono">\${mdEsc(q.sql)}</div>
|
|
108
109
|
<div>Duration: \${q.duration.toFixed(2)}ms</div>
|
|
109
110
|
</div>
|
|
110
111
|
\`).join('') || '<p>None</p>'}
|
|
@@ -136,9 +137,9 @@ export function monitoringDashboardScript() {
|
|
|
136
137
|
|
|
137
138
|
function updateLogs(logs) {
|
|
138
139
|
const html = logs.map(log => \`
|
|
139
|
-
<div class="log-\${log.level} mb-1">
|
|
140
|
-
[\${log.iso}] [\${log.level.toUpperCase()}] \${log.message}
|
|
141
|
-
\${Object.keys(log.metadata).length ? ' ' + JSON.stringify(log.metadata) : ''}
|
|
140
|
+
<div class="log-\${mdEsc(log.level)} mb-1">
|
|
141
|
+
[\${mdEsc(log.iso)}] [\${mdEsc(String(log.level).toUpperCase())}] \${mdEsc(log.message)}
|
|
142
|
+
\${Object.keys(log.metadata).length ? ' ' + mdEsc(JSON.stringify(log.metadata)) : ''}
|
|
142
143
|
</div>
|
|
143
144
|
\`).join('')
|
|
144
145
|
document.getElementById('log-list').innerHTML = html || '<p>No logs</p>'
|