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/page-handler.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { getUser, setCurrentRequest } from '@/engine.server.js';
|
|
2
2
|
import { hasGoogleAuth } from '@/config/env.js';
|
|
3
3
|
import { getSpec } from '@/config/spec-helpers.js';
|
|
4
|
-
import { list, get } from '@/lib/
|
|
5
|
-
import { getDatabase } from '@/lib/database-core.js';
|
|
4
|
+
import { list, get } from '@/lib/busybase-store.js';
|
|
6
5
|
import { renderLogin, renderDashboard, renderEntityList, renderEntityDetail, renderEntityForm, renderAccessDenied, renderPasswordReset, renderPasswordResetConfirm, REDIRECT } from '@/ui/renderer.js';
|
|
7
6
|
import { renderClientDashboard, renderClientList } from '@/ui/client-renderer.js';
|
|
8
7
|
import { canList, canView, canCreate, canEdit, isPartner, isClerk, isClientUser, canClientAccessEntity } from '@/ui/permissions-ui.js';
|
|
@@ -25,25 +24,32 @@ function reqUrl(req) {
|
|
|
25
24
|
|
|
26
25
|
async function handleEngagementDetail(user, engId) {
|
|
27
26
|
if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
|
|
28
|
-
const
|
|
29
|
-
const engagement = get('engagement', engId) || db.prepare('SELECT * FROM engagement WHERE id=?').get(engId);
|
|
27
|
+
const engagement = await get('engagement', engId);
|
|
30
28
|
if (!engagement) return null;
|
|
31
|
-
let client = null; try { client = engagement.client_id ? get('client', engagement.client_id) : null; } catch {}
|
|
32
|
-
let rfis = []; try { rfis =
|
|
33
|
-
let sections = [];
|
|
34
|
-
|
|
29
|
+
let client = null; try { client = engagement.client_id ? await get('client', engagement.client_id) : null; } catch {}
|
|
30
|
+
let rfis = []; try { rfis = await list('rfi', { engagement_id: engId }); } catch {}
|
|
31
|
+
let sections = [];
|
|
32
|
+
try {
|
|
33
|
+
sections = await list('rfi_section', { engagement_id: engId }, { sort: { field: 'sort_order', dir: 'ASC' } });
|
|
34
|
+
} catch {}
|
|
35
|
+
let team = null; try { team = engagement.team_id ? await get('team', engagement.team_id) : null; } catch {}
|
|
35
36
|
let assignedUsers = [];
|
|
36
|
-
try {
|
|
37
|
+
try {
|
|
38
|
+
const ids = JSON.parse(engagement.users || '[]');
|
|
39
|
+
const users = await list('user', {});
|
|
40
|
+
const um = Object.fromEntries(users.map(u => [u.id, u.name || u.email]));
|
|
41
|
+
assignedUsers = ids.map(id => ({ id, name: um[id] || null })).filter(u => u.name);
|
|
42
|
+
} catch {}
|
|
37
43
|
const { renderEngagementDetail } = await lazyRenderer('engagement-detail-renderer.js');
|
|
38
44
|
return renderEngagementDetail(user, { ...engagement, team_name: team?.name || engagement.team_name, client_name: client?.name || engagement.client_name, assigned_users_resolved: assignedUsers }, client, rfis, sections);
|
|
39
45
|
}
|
|
40
46
|
async function handleEngagementList(user, req) {
|
|
41
47
|
if (!canList(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'list');
|
|
42
|
-
let engagements = list('engagement', {});
|
|
43
|
-
const clientMap = Object.fromEntries(list('client', {}).map(c => [c.id, c.name]));
|
|
48
|
+
let engagements = await list('engagement', {});
|
|
49
|
+
const clientMap = Object.fromEntries((await list('client', {})).map(c => [c.id, c.name]));
|
|
44
50
|
engagements = engagements.map(e => ({ ...e, client_name: clientMap[e.client_id] || e.client_name || '-' }));
|
|
45
51
|
const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
|
|
46
|
-
let teams = []; try { teams = list('team', {}); } catch {}
|
|
52
|
+
let teams = []; try { teams = await list('team', {}); } catch {}
|
|
47
53
|
const teamMap = Object.fromEntries(teams.map(t => [t.id, t.name]));
|
|
48
54
|
engagements = engagements.map(e => ({ ...e, team_name: teamMap[e.team_id] || e.team_name || '-' }));
|
|
49
55
|
const years = [...new Set(engagements.map(e => { if (!e.year) return null; const m = String(e.year).match(/\b(20\d{2}|19\d{2})\b/); return m ? m[1] : null; }).filter(Boolean))].sort().reverse();
|
|
@@ -53,10 +59,10 @@ async function handleEngagementList(user, req) {
|
|
|
53
59
|
async function handleSearch(user, req) {
|
|
54
60
|
const url = reqUrl(req);
|
|
55
61
|
const q = url.searchParams.get('q') || '', entityFilter = url.searchParams.get('entity') || '', statusFilter = url.searchParams.get('status') || '';
|
|
56
|
-
let teams = []; try { teams = list('team', {}); } catch {}
|
|
62
|
+
let teams = []; try { teams = await list('team', {}); } catch {}
|
|
57
63
|
const results = {};
|
|
58
64
|
for (const eName of (entityFilter ? [entityFilter] : ['engagement', 'client', 'rfi', 'review'])) {
|
|
59
|
-
try { let items = list(eName, {}); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
|
|
65
|
+
try { let items = await list(eName, {}); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
|
|
60
66
|
}
|
|
61
67
|
return renderAdvancedSearch(user, results, { teams, stages: ['info_gathering', 'commencement', 'team_execution', 'partner_review', 'finalization', 'closeout'] });
|
|
62
68
|
}
|
|
@@ -67,10 +73,10 @@ async function handleGenericEntityView(user, entityName, id) {
|
|
|
67
73
|
if (!canCreate(user, entityName)) return renderAccessDenied(user, entityName, 'create');
|
|
68
74
|
const resolvedSpec = resolveEnumOptions(spec);
|
|
69
75
|
const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
|
|
70
|
-
return lazyEntityForm(entityName, null, resolvedSpec, user, true, getRefOptions(resolvedSpec));
|
|
76
|
+
return lazyEntityForm(entityName, null, resolvedSpec, user, true, await getRefOptions(resolvedSpec));
|
|
71
77
|
}
|
|
72
78
|
if (!canView(user, entityName)) return renderAccessDenied(user, entityName, 'view');
|
|
73
|
-
const item = get(entityName, id); if (!item) return null;
|
|
79
|
+
const item = await get(entityName, id); if (!item) return null;
|
|
74
80
|
if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
|
|
75
81
|
if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
|
|
76
82
|
const [resolvedItem] = resolveRefFields([item], spec);
|
|
@@ -79,14 +85,14 @@ async function handleGenericEntityView(user, entityName, id) {
|
|
|
79
85
|
}
|
|
80
86
|
async function handleClientSubRoute(user, clientId, subRoute) {
|
|
81
87
|
if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
|
|
82
|
-
const client = get('client', clientId); if (!client) return null;
|
|
88
|
+
const client = await get('client', clientId); if (!client) return null;
|
|
83
89
|
if (isClientUser(user) && user.client_id && user.client_id !== clientId) return renderAccessDenied(user, 'client', 'view');
|
|
84
|
-
if (subRoute === 'dashboard' || subRoute === 'users') return renderClientDashboard(user, client, getClientDashboardStats(clientId));
|
|
90
|
+
if (subRoute === 'dashboard' || subRoute === 'users') return renderClientDashboard(user, client, await getClientDashboardStats(clientId));
|
|
85
91
|
if (subRoute === 'progress') {
|
|
86
|
-
let engagements = []; try { engagements = list('engagement', {}).filter(e => e.client_id === clientId); } catch {}
|
|
92
|
+
let engagements = []; try { engagements = (await list('engagement', {})).filter(e => e.client_id === clientId); } catch {}
|
|
87
93
|
const spec = getSpec('engagement'); if (spec) engagements = resolveRefFields(engagements, spec);
|
|
88
94
|
let rfiStats = { total: 0, responded: 0, overdue: 0 };
|
|
89
|
-
try { const allRfis = list('rfi', {}).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
|
|
95
|
+
try { const allRfis = (await list('rfi', {})).filter(r => engagements.some(e => e.id === r.engagement_id)); const now = Math.floor(Date.now() / 1000); rfiStats = { total: allRfis.length, responded: allRfis.filter(r => r.status === 'responded' || r.status === 'completed').length, overdue: allRfis.filter(r => r.due_date && r.due_date < now && r.status !== 'closed').length }; } catch {}
|
|
90
96
|
return renderClientProgress(user, client, engagements, rfiStats);
|
|
91
97
|
}
|
|
92
98
|
return null;
|
|
@@ -94,11 +100,11 @@ async function handleClientSubRoute(user, clientId, subRoute) {
|
|
|
94
100
|
async function handleGenericEntityEdit(user, entityName, id) {
|
|
95
101
|
const spec = getSpec(entityName); if (!spec) return null;
|
|
96
102
|
if (!canEdit(user, entityName)) return renderAccessDenied(user, entityName, 'edit');
|
|
97
|
-
const item = get(entityName, id); if (!item) return null;
|
|
103
|
+
const item = await get(entityName, id); if (!item) return null;
|
|
98
104
|
if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'edit');
|
|
99
105
|
const resolvedSpec = resolveEnumOptions(spec);
|
|
100
106
|
const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
|
|
101
|
-
return lazyEntityForm(entityName, item, resolvedSpec, user, false, getRefOptions(resolvedSpec));
|
|
107
|
+
return lazyEntityForm(entityName, item, resolvedSpec, user, false, await getRefOptions(resolvedSpec));
|
|
102
108
|
}
|
|
103
109
|
export async function handlePage(pathname, req, res) {
|
|
104
110
|
setCurrentRequest(req);
|
|
@@ -119,18 +125,31 @@ export async function handlePage(pathname, req, res) {
|
|
|
119
125
|
const user = await getUser();
|
|
120
126
|
if (!user) { res.writeHead(302, { Location: '/login' }); res.end(); return REDIRECT; }
|
|
121
127
|
if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
|
|
122
|
-
if (normalized === '/notifications') { let notifs=[]; try{notifs=
|
|
128
|
+
if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
|
|
123
129
|
if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
|
|
124
130
|
if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user);
|
|
125
131
|
if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
|
|
126
132
|
if (isClerk(user) && segments.length >= 1 && ['user', 'team'].includes(segments[0])) return renderAccessDenied(user, segments[0], 'list');
|
|
127
133
|
if (normalized === '/mwr' || normalized === '/mwr/home') {
|
|
128
134
|
if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
|
|
129
|
-
const db = getDatabase();
|
|
130
135
|
let myReviews = [], sharedReviews = [], recentActivity = [];
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
|
|
136
|
+
// OR across two columns: busybase eq() is single-column, so fetch + filter in JS.
|
|
137
|
+
try {
|
|
138
|
+
const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } });
|
|
139
|
+
myReviews = reviews.filter(r => r.created_by === user.id || r.assigned_to === user.id).slice(0, 100);
|
|
140
|
+
} catch {}
|
|
141
|
+
// Old JOIN collaborator -> two-step: collaborator rows for this user, then their reviews.
|
|
142
|
+
try {
|
|
143
|
+
const collabs = await list('collaborator', { user_id: user.id });
|
|
144
|
+
const ids = new Set(collabs.map(c => c.review_id));
|
|
145
|
+
if (ids.size) {
|
|
146
|
+
const reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } });
|
|
147
|
+
sharedReviews = reviews.filter(r => ids.has(r.id)).slice(0, 100);
|
|
148
|
+
}
|
|
149
|
+
} catch {}
|
|
150
|
+
try {
|
|
151
|
+
recentActivity = (await list('audit_logs', { entity_type: 'review' }, { sort: { field: 'created_at', dir: 'DESC' } })).slice(0, 50);
|
|
152
|
+
} catch {}
|
|
134
153
|
const all = [...myReviews, ...sharedReviews];
|
|
135
154
|
const stats = { myReviews, sharedReviews, recentActivity, totalReviews: all.length, activeReviews: all.filter(r => (r.status||'open') !== 'archived' && (r.status||'open') !== 'completed' && (r.status||'open') !== 'closed').length, flaggedReviews: all.filter(r => r.flagged).length, overdueReviews: 0 };
|
|
136
155
|
const { renderMwrHome } = await lazyRenderer('review-mwr-renderer.js');
|
|
@@ -151,18 +170,16 @@ export async function handlePage(pathname, req, res) {
|
|
|
151
170
|
return renderFlexUpView(user);
|
|
152
171
|
}
|
|
153
172
|
if (normalized === '/ml-console') {
|
|
154
|
-
const db = getDatabase();
|
|
155
173
|
let candidates = []; let reviewMap = {};
|
|
156
174
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
ORDER BY created_at DESC LIMIT 50`).all();
|
|
175
|
+
// Old: highlights with a non-trivial comment (LIKE '%?' OR length>40), newest 50.
|
|
176
|
+
const all = await list('highlight', {}, { sort: { field: 'created_at', dir: 'DESC' } });
|
|
177
|
+
candidates = all.filter(c => c.comment && (c.comment.includes('?') || c.comment.length > 40)).slice(0, 50);
|
|
161
178
|
const revIds = [...new Set(candidates.map((c) => c.review_id))];
|
|
162
179
|
if (revIds.length) {
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
reviewMap = Object.fromEntries(
|
|
180
|
+
const reviews = await list('review', {});
|
|
181
|
+
const byId = new Map(reviews.map(r => [r.id, r.name || '-']));
|
|
182
|
+
reviewMap = Object.fromEntries(revIds.map(id => [id, byId.get(id) || '-']));
|
|
166
183
|
}
|
|
167
184
|
} catch {}
|
|
168
185
|
const { renderMlConsole } = await lazyRenderer('ml-console-renderer.js');
|
|
@@ -171,19 +188,19 @@ export async function handlePage(pathname, req, res) {
|
|
|
171
188
|
if (segments.length === 2 && (segments[0] === 'engagements' || segments[0] === 'engagement') && segments[1] !== 'new') return handleEngagementDetail(user, segments[1]);
|
|
172
189
|
if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'letter') {
|
|
173
190
|
if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
|
|
174
|
-
const engagement = get('engagement', segments[1]); if (!engagement) return null;
|
|
191
|
+
const engagement = await get('engagement', segments[1]); if (!engagement) return null;
|
|
175
192
|
return renderLetterWorkflow(user, engagement);
|
|
176
193
|
}
|
|
177
194
|
if (segments[0] === 'engagement' && segments.length === 3 && segments[2] === 'report') {
|
|
178
195
|
if (!canView(user, 'engagement')) return renderAccessDenied(user, 'engagement', 'view');
|
|
179
|
-
const engId = segments[1]; const engagement = get('engagement', engId); if (!engagement) return null;
|
|
180
|
-
|
|
181
|
-
try{client=engagement.client_id?get('client',engagement.client_id):null}catch{}
|
|
182
|
-
try{team=engagement.team_id?get('team',engagement.team_id):null}catch{}
|
|
183
|
-
try{rfis=
|
|
184
|
-
try{reviews=
|
|
185
|
-
try{const rids=reviews.map(r=>r.id);if(rids.
|
|
186
|
-
try{activity=
|
|
196
|
+
const engId = segments[1]; const engagement = await get('engagement', engId); if (!engagement) return null;
|
|
197
|
+
let client=null,team=null,rfis=[],reviews=[],highlights=[],activity=[];
|
|
198
|
+
try{client=engagement.client_id?await get('client',engagement.client_id):null}catch{}
|
|
199
|
+
try{team=engagement.team_id?await get('team',engagement.team_id):null}catch{}
|
|
200
|
+
try{rfis=await list('rfi',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'}})}catch{}
|
|
201
|
+
try{reviews=await list('review',{engagement_id:engId},{sort:{field:'created_at',dir:'DESC'}})}catch{}
|
|
202
|
+
try{const rids=new Set(reviews.map(r=>r.id));if(rids.size){const allHl=await list('highlight',{});highlights=allHl.filter(h=>rids.has(h.review_id))}}catch{}
|
|
203
|
+
try{activity=(await list('audit_logs',{entity_type:'engagement',entity_id:engId},{sort:{field:'created_at',dir:'DESC'}})).slice(0,20)}catch{}
|
|
187
204
|
const{renderFlexupReport}=await lazyRenderer('flexup-report-renderer.js');
|
|
188
205
|
return renderFlexupReport(user,engagement,client,rfis,reviews,highlights,activity,team);
|
|
189
206
|
}
|
|
@@ -192,7 +209,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
192
209
|
if (segments.length === 1 && segments[0] === 'rfi') return handleRfiList(user);
|
|
193
210
|
if (segments.length === 1 && segments[0] === 'client') {
|
|
194
211
|
if (!canList(user, 'client')) return renderAccessDenied(user, 'client', 'list');
|
|
195
|
-
let clients = list('client', {});
|
|
212
|
+
let clients = await list('client', {});
|
|
196
213
|
if (isClientUser(user) && user.client_id) clients = clients.filter(c => c.id === user.client_id);
|
|
197
214
|
return renderClientList(user, clients);
|
|
198
215
|
}
|
|
@@ -201,7 +218,7 @@ export async function handlePage(pathname, req, res) {
|
|
|
201
218
|
const spec = getSpec(entityName); if (!spec) return null;
|
|
202
219
|
if (isClientUser(user) && !canClientAccessEntity(user, entityName)) return renderAccessDenied(user, entityName, 'list');
|
|
203
220
|
if (!canList(user, entityName)) return renderAccessDenied(user, entityName, 'list');
|
|
204
|
-
let items = list(entityName, {});
|
|
221
|
+
let items = await list(entityName, {});
|
|
205
222
|
if (isClientUser(user) && user.client_id) items = items.filter(item => { if (item.client_id) return item.client_id === user.client_id; if (item.assigned_to) return item.assigned_to === user.id; return true; });
|
|
206
223
|
const { renderEntityList: lazyEntityList } = await lazyRenderer('entity-renderer.js');
|
|
207
224
|
return lazyEntityList(entityName, resolveRefFields(items, spec), spec, user);
|
|
@@ -209,8 +226,8 @@ export async function handlePage(pathname, req, res) {
|
|
|
209
226
|
if (segments.length === 2 && segments[0] === 'client' && segments[1] !== 'new') {
|
|
210
227
|
if (!canView(user, 'client')) return renderAccessDenied(user, 'client', 'view');
|
|
211
228
|
if (isClientUser(user) && user.client_id && user.client_id !== segments[1]) return renderAccessDenied(user, 'client', 'view');
|
|
212
|
-
const client = get('client', segments[1]); if (!client) return null;
|
|
213
|
-
return renderClientDashboard(user, client, getClientDashboardStats(segments[1]));
|
|
229
|
+
const client = await get('client', segments[1]); if (!client) return null;
|
|
230
|
+
return renderClientDashboard(user, client, await getClientDashboardStats(segments[1]));
|
|
214
231
|
}
|
|
215
232
|
if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1]);
|
|
216
233
|
if (segments.length === 3 && segments[2] === 'edit') return handleGenericEntityEdit(user, segments[0], segments[1]);
|
package/src/ui/picker-dialogs.js
CHANGED
|
@@ -3,22 +3,27 @@ import { STAGE_COLORS, TOAST_SCRIPT } from '@/ui/render-helpers.js'
|
|
|
3
3
|
|
|
4
4
|
const DIALOG_COLORS = ['#B0B0B0','#44BBA4','#FF4141','#7F7EFF','#3b82f6','#f59e0b','#ec4899','#8b5cf6','#ef4444','#22c55e','#06b6d4','#f97316','#84cc16','#e11d48','#14b8a6','#6366f1']
|
|
5
5
|
|
|
6
|
+
// Inline client-side HTML-escape for innerHTML sinks (user name/email/team name are user-sourced).
|
|
7
|
+
const PD_ESC = `function pdEsc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}`;
|
|
8
|
+
|
|
6
9
|
export function colorPickerDialog(id = 'cpd', selected = '#B0B0B0', onSelect = '') {
|
|
7
10
|
const swatches = DIALOG_COLORS.map(c =>
|
|
8
|
-
`<div class="cpd-swatch${c === selected ? ' cpd-selected' : ''}" style="background:${c}" data-color="${c}" role="option" tabindex="0" aria-label="Color ${c}" aria-selected="${c === selected}" data-action="cpdSelect" data-args='["${id}","${c}",${onSelect ? "true" : "false"}]'
|
|
11
|
+
`<div class="cpd-swatch${c === selected ? ' cpd-selected' : ''}" style="background:${c}" data-color="${c}" role="option" tabindex="0" aria-label="Color ${c}" aria-selected="${c === selected}"${c === selected ? ' aria-current="true"' : ''} data-action="cpdSelect" data-args='["${id}","${c}",${onSelect ? "true" : "false"}]'></div>`
|
|
9
12
|
).join('')
|
|
10
|
-
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
11
|
-
<div class="dialog-panel" style="max-width:360px">
|
|
13
|
+
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
|
|
14
|
+
<div class="dialog-panel" style="max-width:min(360px,90vw)">
|
|
12
15
|
<div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Choose Color</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">×</button></div>
|
|
13
16
|
<div class="dialog-body"><div class="color-picker-grid" role="listbox" aria-label="Color options">${swatches}</div>
|
|
14
|
-
<div style="margin-top:0.75rem;display:flex;align-items:center;gap:0.75rem"><label class="text-sm text-gray-500" for="${id}-custom">Custom:</label><input type="color" id="${id}-custom" value="${selected}" onchange="cpdSelect('${id}',this.value,true)" aria-label="Custom color picker" style="width:
|
|
17
|
+
<div style="margin-top:0.75rem;display:flex;align-items:center;gap:0.75rem"><label class="text-sm text-gray-500" for="${id}-custom">Custom:</label><input type="color" id="${id}-custom" value="${selected}" onchange="cpdSelect('${id}',this.value,true)" aria-label="Custom color picker" class="focus-visible-ring" style="width:44px;height:44px;min-width:44px;min-height:44px;border:none;cursor:pointer"/><span id="${id}-val" class="text-sm font-medium" aria-live="polite">${selected}</span></div>
|
|
15
18
|
</div>
|
|
16
19
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="${id}-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="cpdConfirm" data-args='["${id}"]'>Select</button></div>
|
|
17
20
|
</div></div>
|
|
18
21
|
<script>window._cpd=window._cpd||{};window._cpd['${id}']='${selected}';
|
|
19
|
-
window.cpdSelect=function(id,c){window._cpd[id]=c;document.getElementById(id+'-val').textContent=c;document.getElementById(id+'-custom').value=c;document.querySelectorAll('#'+id+'-dialog .cpd-swatch').forEach(function(el){el.classList.toggle('cpd-selected',el.
|
|
22
|
+
window.cpdSelect=function(id,c){window._cpd[id]=c;document.getElementById(id+'-val').textContent=c;document.getElementById(id+'-custom').value=c;document.querySelectorAll('#'+id+'-dialog .cpd-swatch').forEach(function(el){var sel=el.dataset.color===c;el.classList.toggle('cpd-selected',sel);el.setAttribute('aria-selected',sel?'true':'false');if(sel)el.setAttribute('aria-current','true');else el.removeAttribute('aria-current')})};
|
|
20
23
|
window.cpdConfirm=function(id){var c=window._cpd[id];document.getElementById(id+'-dialog').style.display='none';if(window._cpdCallback)window._cpdCallback(c)};
|
|
21
|
-
window.showColorPicker=function(id,current,cb){window._cpdCallback=cb;if(current)cpdSelect(id,current);document.getElementById(id+'-dialog').style.display='flex'}
|
|
24
|
+
window.showColorPicker=function(id,current,cb){window._cpdCallback=cb;if(current)cpdSelect(id,current);document.getElementById(id+'-dialog').style.display='flex'};
|
|
25
|
+
window.cpdKeydown=function(id,e){var sw=Array.prototype.slice.call(document.querySelectorAll('#'+id+'-dialog .cpd-swatch'));if(!sw.length)return;var cur=sw.indexOf(document.activeElement);if(cur<0)cur=0;var next=cur;var k=e.key;if(k==='ArrowRight'||k==='ArrowDown')next=Math.min(sw.length-1,cur+1);else if(k==='ArrowLeft'||k==='ArrowUp')next=Math.max(0,cur-1);else if(k==='Home')next=0;else if(k==='End')next=sw.length-1;else return;e.preventDefault();sw[next].focus()};
|
|
26
|
+
document.querySelectorAll('#'+'${id}'+'-dialog .cpd-swatch').forEach(function(el){el.addEventListener('keydown',function(e){window.cpdKeydown('${id}',e)})});</script>`
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function dateChoiceDialog(id = 'dcd') {
|
|
@@ -28,8 +33,8 @@ export function dateChoiceDialog(id = 'dcd') {
|
|
|
28
33
|
{ label: '+3 Months', days: 90 }, { label: 'End of Month', days: 'eom' }, { label: 'End of Quarter', days: 'eoq' },
|
|
29
34
|
]
|
|
30
35
|
const presetBtns = presets.map(p => `<button class="date-preset-btn" data-action="dcdPreset" data-args='["${id}",${typeof p.days === 'number' ? p.days : '"' + p.days + '"'}]'>${p.label}</button>`).join('')
|
|
31
|
-
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
32
|
-
<div class="dialog-panel" style="max-width:380px">
|
|
36
|
+
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
|
|
37
|
+
<div class="dialog-panel" style="max-width:min(380px,90vw)">
|
|
33
38
|
<div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Choose Date</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">×</button></div>
|
|
34
39
|
<div class="dialog-body">
|
|
35
40
|
<div class="modal-form-group"><label for="${id}-input">Date</label><input type="date" id="${id}-input" class="input input-bordered w-full"/></div>
|
|
@@ -45,7 +50,7 @@ export function dateChoiceDialog(id = 'dcd') {
|
|
|
45
50
|
|
|
46
51
|
export function stageTransitionDialog(stages = null) {
|
|
47
52
|
const stageKeys = stages || Object.keys(STAGE_COLORS)
|
|
48
|
-
return `<div id="stage-trans-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
53
|
+
return `<div id="stage-trans-dialog" class="dialog-overlay" style="display:none" data-dialog-close="stage-trans-dialog" role="dialog" aria-modal="true" aria-labelledby="stage-trans-dialog-title" aria-hidden="true">
|
|
49
54
|
<div class="dialog-panel">
|
|
50
55
|
<div class="dialog-header"><span class="dialog-title" id="stage-trans-dialog-title">Transition Stage</span><button class="dialog-close" data-dialog-close="stage-trans-dialog" aria-label="Close dialog">×</button></div>
|
|
51
56
|
<div class="dialog-body">
|
|
@@ -63,19 +68,20 @@ export function stageTransitionDialog(stages = null) {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export function teamAssignmentDialog(id = 'tad') {
|
|
66
|
-
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
71
|
+
return `<div id="${id}-dialog" class="dialog-overlay" style="display:none" data-dialog-close="${id}-dialog" role="dialog" aria-modal="true" aria-labelledby="${id}-dialog-title" aria-hidden="true">
|
|
67
72
|
<div class="dialog-panel">
|
|
68
73
|
<div class="dialog-header"><span class="dialog-title" id="${id}-dialog-title">Assign Team Members</span><button class="dialog-close" data-dialog-close="${id}-dialog" aria-label="Close dialog">×</button></div>
|
|
69
74
|
<div class="dialog-body">
|
|
70
75
|
<input type="text" id="${id}-search" class="tad-search" placeholder="Search by name or email..." aria-label="Search team members" oninput="tadFilter('${id}')"/>
|
|
71
|
-
<div id="${id}-list" class="tad-list"></div>
|
|
76
|
+
<div id="${id}-list" class="tad-list" role="region" aria-live="polite" aria-label="Team member results"></div>
|
|
72
77
|
</div>
|
|
73
78
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="${id}-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="tadConfirm" data-args='["${id}"]'>Assign</button></div>
|
|
74
79
|
</div></div>
|
|
75
80
|
<script>${TOAST_SCRIPT}
|
|
81
|
+
${PD_ESC}
|
|
76
82
|
window._tad=window._tad||{};
|
|
77
83
|
window.showTeamAssignment=function(id,users,selected,cb){window._tad[id]={users:users,selected:new Set(selected||[]),cb:cb};document.getElementById(id+'-dialog').style.display='flex';tadRender(id)};
|
|
78
|
-
function tadRender(id){var d=window._tad[id];var c=document.getElementById(id+'-list');c.innerHTML='';d.users.forEach(function(u){var row=document.createElement('div');row.className='tad-row';row.dataset.name=(u.name||'').toLowerCase();row.dataset.email=(u.email||'').toLowerCase();var checked=d.selected.has(u.id)?'checked':'';row.innerHTML='<input type="checkbox" '+checked+' onchange="tadToggle(\\''+id+'\\',\\''+u.id+'\\') " aria-label="Select '+(u.name||'Unknown').replace(/"/g,'"')+'">'+'<div><div class="tad-name">'+(u.name||'Unknown')+'</div><div class="tad-email">'+(u.email||'')+'</div></div>';c.appendChild(row)})}
|
|
84
|
+
function tadRender(id){var d=window._tad[id];var c=document.getElementById(id+'-list');c.innerHTML='';d.users.forEach(function(u){var row=document.createElement('div');row.className='tad-row';row.dataset.name=(u.name||'').toLowerCase();row.dataset.email=(u.email||'').toLowerCase();var checked=d.selected.has(u.id)?'checked':'';row.innerHTML='<input type="checkbox" '+checked+' onchange="tadToggle(\\''+id+'\\',\\''+u.id+'\\') " aria-label="Select '+(u.name||'Unknown').replace(/"/g,'"')+'">'+'<div><div class="tad-name">'+pdEsc(u.name||'Unknown')+'</div><div class="tad-email">'+pdEsc(u.email||'')+'</div></div>';c.appendChild(row)})}
|
|
79
85
|
window.tadFilter=function(id){var q=document.getElementById(id+'-search').value.toLowerCase();document.querySelectorAll('#'+id+'-list .tad-row').forEach(function(r){r.style.display=(r.dataset.name.includes(q)||r.dataset.email.includes(q))?'':'none'})};
|
|
80
86
|
window.tadToggle=function(id,uid){var d=window._tad[id];if(d.selected.has(uid))d.selected.delete(uid);else d.selected.add(uid)};
|
|
81
87
|
window.tadConfirm=function(id){var d=window._tad[id];document.getElementById(id+'-dialog').style.display='none';if(d.cb)d.cb(Array.from(d.selected))};</script>`
|
|
@@ -83,18 +89,19 @@ export function teamAssignmentDialog(id = 'tad') {
|
|
|
83
89
|
|
|
84
90
|
export function teamSelector(id = 'ts', teams = []) {
|
|
85
91
|
const items = teams.map(t =>
|
|
86
|
-
`<div class="tad-row" data-name="${(t.name || '').toLowerCase()}" data-action="tsSelect" data-args='["${id}","${t.id}","${(t.name || '').replace(/"/g, '"')}"]'><div class="tad-name">${t.name || 'Unknown'}</div><div class="tad-email">${t.member_count || 0} members</div></div>`
|
|
92
|
+
`<div class="tad-row" role="option" aria-selected="false" tabindex="-1" data-name="${(t.name || '').toLowerCase()}" data-action="tsSelect" data-args='["${id}","${t.id}","${(t.name || '').replace(/"/g, '"')}"]'><div class="tad-name">${t.name || 'Unknown'}</div><div class="tad-email">${t.member_count || 0} members</div></div>`
|
|
87
93
|
).join('')
|
|
88
94
|
return `<div class="ts-wrap" id="${id}-wrap">
|
|
89
95
|
<div id="${id}-badges" class="ts-badges"></div>
|
|
90
|
-
<input type="text" id="${id}-search" class="tad-search" placeholder="Search teams..." aria-label="Search teams" oninput="tsFilter('${id}')" onfocus="document.getElementById('${id}-dropdown').classList.add('ts-open')" />
|
|
91
|
-
<div id="${id}-dropdown" class="ts-dropdown">${items}</div>
|
|
96
|
+
<input type="text" id="${id}-search" class="tad-search" placeholder="Search teams..." aria-label="Search teams" role="combobox" aria-expanded="false" aria-controls="${id}-dropdown" aria-autocomplete="list" oninput="tsFilter('${id}')" onfocus="document.getElementById('${id}-dropdown').classList.add('ts-open');this.setAttribute('aria-expanded','true')" />
|
|
97
|
+
<div id="${id}-dropdown" class="ts-dropdown" role="listbox" aria-label="Teams">${items}</div>
|
|
92
98
|
<input type="hidden" id="${id}-value" name="team_id" value=""/>
|
|
93
99
|
</div>
|
|
94
|
-
<script
|
|
100
|
+
<script>${PD_ESC}
|
|
101
|
+
window._ts=window._ts||{};window._ts['${id}']=[];
|
|
95
102
|
window.tsFilter=function(id){var q=document.getElementById(id+'-search').value.toLowerCase();document.querySelectorAll('#'+id+'-dropdown .tad-row').forEach(function(r){r.style.display=r.dataset.name.includes(q)?'':'none'})};
|
|
96
|
-
window.tsSelect=function(id,tid,name){window._ts[id].push({id:tid,name:name});tsRender(id);document.getElementById(id+'-dropdown').classList.remove('ts-open');document.getElementById(id+'-search').value=''};
|
|
97
|
-
function tsRender(id){var b=document.getElementById(id+'-badges');b.innerHTML='';window._ts[id].forEach(function(t,i){b.innerHTML+='<span class="ts-badge">'+t.name+' <span class="ts-badge-x" data-action="tsRemove" data-args=\\'["'+id+'",'+i+']\\' >×</span></span>'});document.getElementById(id+'-value').value=window._ts[id].map(function(t){return t.id}).join(',')}
|
|
103
|
+
window.tsSelect=function(id,tid,name){window._ts[id].push({id:tid,name:name});tsRender(id);document.getElementById(id+'-dropdown').classList.remove('ts-open');document.getElementById(id+'-search').setAttribute('aria-expanded','false');document.getElementById(id+'-search').value=''};
|
|
104
|
+
function tsRender(id){var b=document.getElementById(id+'-badges');b.innerHTML='';window._ts[id].forEach(function(t,i){b.innerHTML+='<span class="ts-badge">'+pdEsc(t.name)+' <span class="ts-badge-x" data-action="tsRemove" data-args=\\'["'+id+'",'+i+']\\' >×</span></span>'});document.getElementById(id+'-value').value=window._ts[id].map(function(t){return t.id}).join(',')}
|
|
98
105
|
window.tsRemove=function(id,idx){window._ts[id].splice(idx,1);tsRender(id)};
|
|
99
|
-
document.addEventListener('click',function(e){if(!document.getElementById('${id}-wrap').contains(e.target))document.getElementById('${id}-dropdown').classList.remove('ts-open')});</script>`
|
|
106
|
+
document.addEventListener('click',function(e){if(!document.getElementById('${id}-wrap').contains(e.target)){document.getElementById('${id}-dropdown').classList.remove('ts-open');document.getElementById('${id}-search').setAttribute('aria-expanded','false')}});</script>`
|
|
100
107
|
}
|
package/src/ui/render-helpers.js
CHANGED
|
@@ -1,36 +1,37 @@
|
|
|
1
1
|
import { h } from '@/ui/webjsx.js'
|
|
2
|
+
import { SPACING } from './spacing-system.js'
|
|
2
3
|
|
|
3
4
|
export const STAGE_COLORS = {
|
|
4
|
-
info_gathering: {
|
|
5
|
-
commencement: {
|
|
6
|
-
team_execution: {
|
|
7
|
-
partner_review: {
|
|
8
|
-
finalization: {
|
|
9
|
-
closeout: {
|
|
5
|
+
info_gathering: { label: 'Info Gathering' },
|
|
6
|
+
commencement: { label: 'Commencement' },
|
|
7
|
+
team_execution: { label: 'Team Execution' },
|
|
8
|
+
partner_review: { label: 'Partner Review' },
|
|
9
|
+
finalization: { label: 'Finalization' },
|
|
10
|
+
closeout: { label: 'Close Out' },
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export const STATUS_COLORS = {
|
|
13
|
-
pending: {
|
|
14
|
-
active: {
|
|
15
|
-
completed: {
|
|
16
|
-
archived: {
|
|
17
|
-
open: {
|
|
18
|
-
closed: {
|
|
19
|
-
draft: {
|
|
20
|
-
in_progress: {
|
|
21
|
-
review: {
|
|
22
|
-
approved: {
|
|
23
|
-
rejected: {
|
|
24
|
-
overdue: {
|
|
25
|
-
cancelled: {
|
|
26
|
-
on_hold: {
|
|
27
|
-
resolved: {
|
|
28
|
-
unresolved: {
|
|
29
|
-
flagged: {
|
|
30
|
-
responded: {
|
|
31
|
-
expired: {
|
|
32
|
-
private: {
|
|
33
|
-
public: {
|
|
14
|
+
pending: {},
|
|
15
|
+
active: {},
|
|
16
|
+
completed: {},
|
|
17
|
+
archived: {},
|
|
18
|
+
open: {},
|
|
19
|
+
closed: {},
|
|
20
|
+
draft: {},
|
|
21
|
+
in_progress: {},
|
|
22
|
+
review: {},
|
|
23
|
+
approved: {},
|
|
24
|
+
rejected: {},
|
|
25
|
+
overdue: {},
|
|
26
|
+
cancelled: {},
|
|
27
|
+
on_hold: {},
|
|
28
|
+
resolved: {},
|
|
29
|
+
unresolved: {},
|
|
30
|
+
flagged: {},
|
|
31
|
+
responded: {},
|
|
32
|
+
expired: {},
|
|
33
|
+
private: {},
|
|
34
|
+
public: {},
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export const STAGE_CONFIG = [
|
|
@@ -47,7 +48,7 @@ export const AVATAR_SIZES = { sm: 24, md: 32, lg: 40, xl: 48 }
|
|
|
47
48
|
|
|
48
49
|
export const TOAST_SCRIPT = `window.showToast=(m,t='info')=>{let c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';c.setAttribute('role','status');c.setAttribute('aria-live','polite');c.setAttribute('aria-atomic','true');document.body.appendChild(c)}const d=document.createElement('div');d.className='toast toast-'+t;d.textContent=m;c.appendChild(d);setTimeout(()=>{d.style.opacity='0';setTimeout(()=>d.remove(),300)},3000)};`
|
|
49
50
|
|
|
50
|
-
export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col){th.classList.add(sortDir===1?'sort-asc':'sort-desc');th.setAttribute('aria-sort',sortDir===1?'ascending':'descending')}else{th.removeAttribute('aria-sort')}});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>{th.setAttribute('tabindex','0');th.setAttribute('role','button');if(!th.getAttribute('aria-label'))th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim());th.addEventListener('click',()
|
|
51
|
+
export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col){th.classList.add(sortDir===1?'sort-asc':'sort-desc');th.setAttribute('aria-sort',sortDir===1?'ascending':'descending');th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim()+', '+(sortDir===1?'ascending':'descending'))}else{th.removeAttribute('aria-sort')}});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;const style=document.createElement('style');style.textContent='th[data-sort]:focus-visible{outline:2px solid var(--color-primary);outline-offset:2px}';document.head.appendChild(style);document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>{th.setAttribute('tabindex','0');th.setAttribute('role','button');if(!th.getAttribute('aria-label'))th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim());th.addEventListener('click',e=>{e.preventDefault();sortTable(th.dataset.sort)});th.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '||e.key==='Spacebar'){e.preventDefault();sortTable(th.dataset.sort)}});th.addEventListener('keypress',e=>{if(e.key==='Enter'){e.preventDefault();sortTable(th.dataset.sort)}})})})})();`
|
|
51
52
|
|
|
52
53
|
export function esc(s) {
|
|
53
54
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
|
@@ -62,11 +63,8 @@ export function emptyRow(colspan, message = 'No items found') {
|
|
|
62
63
|
export function stagePill(stage) {
|
|
63
64
|
const cfg = STAGE_CONFIG.find(s => s.key === stage)
|
|
64
65
|
const lbl = cfg ? cfg.label : (stage || '-')
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return `<span class="stage-pill stage-${esc(stage||'')}" data-stage="${stage}" style="background:${stageColor.bg};color:${stageColor.text}">${lbl}</span>`
|
|
68
|
-
}
|
|
69
|
-
return `<span class="stage-pill stage-${esc(stage||'')}">${lbl}</span>`
|
|
66
|
+
// Use CSS classes (stage-pill stage-${stage}) for dark-mode support via styles2.css
|
|
67
|
+
return `<span class="stage-pill stage-${esc(stage||'')}" data-stage="${stage}">${lbl}</span>`
|
|
70
68
|
}
|
|
71
69
|
|
|
72
70
|
export function statusPill(status) {
|
|
@@ -84,7 +82,8 @@ export function statusBadge(status) {
|
|
|
84
82
|
export function progressBar(pct) {
|
|
85
83
|
if (typeof pct !== 'number') return '-'
|
|
86
84
|
const p = Math.min(100, Math.max(0, Math.round(pct)))
|
|
87
|
-
|
|
85
|
+
// Use CSS variable for border color (defined in both light and dark mode), responsive min-width, 44px tap-target height, contrast-compliant text color
|
|
86
|
+
return `<div style="display:flex;align-items:center;gap:${SPACING.sm};min-width:min(60px,50vw);max-width:100%;min-height:44px"><div style="flex:1;height:6px;background:var(--color-border);border-radius:3px;overflow:hidden"><div style="height:100%;width:${p}%;background:var(--color-primary);border-radius:3px"></div></div><span style="font-size:13px;font-weight:600;color:var(--color-text);min-width:28px;line-height:1.4">${p}%</span></div>`
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
export function fmtVal(value, fieldKey, item = {}) {
|
|
@@ -95,23 +94,32 @@ export function fmtVal(value, fieldKey, item = {}) {
|
|
|
95
94
|
}
|
|
96
95
|
if (fieldKey === 'year') { const n = Number(value); if (!isNaN(n)) return String(Math.floor(n)) }
|
|
97
96
|
if (fieldKey === 'stage' && STAGE_COLORS[value]) {
|
|
98
|
-
const
|
|
99
|
-
|
|
97
|
+
const lbl = STAGE_COLORS[value].label || value.charAt(0).toUpperCase() + value.slice(1)
|
|
98
|
+
// Use CSS classes (stage-${value}) for dark-mode support via styles2.css
|
|
99
|
+
return h('span', { className: `badge-stage stage-${esc(value)}` }, lbl)
|
|
100
100
|
}
|
|
101
101
|
if (fieldKey === 'status' && STATUS_COLORS[value]) {
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
// Map status to pill class following the pattern in statusBadge
|
|
103
|
+
const statusClassMap = { active:'pill pill-success', inactive:'pill pill-danger', pending:'pill pill-warning', open:'pill pill-info', in_progress:'pill pill-info', completed:'pill pill-success', closed:'pill pill-neutral', archived:'pill pill-neutral', responded:'pill pill-info', overdue:'pill pill-danger', rejected:'pill pill-danger', unresolved:'pill pill-danger', approved:'pill pill-success', review:'pill pill-primary', flagged:'pill pill-danger', expired:'pill pill-neutral', private:'pill pill-primary', public:'pill pill-info' }
|
|
104
|
+
const cls = statusClassMap[value] || 'pill pill-neutral'
|
|
105
|
+
return h('span', { className: cls }, value.charAt(0).toUpperCase() + value.slice(1))
|
|
104
106
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
// *_display is the resolved label of a referenced record (set in
|
|
108
|
+
// page-handler-helpers from refCaches), i.e. DB-sourced plain text — escape it.
|
|
109
|
+
if (item[`${fieldKey}_display`]) return esc(String(item[`${fieldKey}_display`]))
|
|
110
|
+
// Default path returns raw DB/user data into table cells and detail values —
|
|
111
|
+
// escape it so a stored value cannot inject markup.
|
|
112
|
+
return esc(String(value))
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
export function statusLabel(status) {
|
|
110
116
|
if (!status) return ''
|
|
111
117
|
const key = status.toLowerCase().replace(/\s+/g, '_')
|
|
112
|
-
|
|
118
|
+
// Map to pill class following the pattern in statusBadge for consistent dark-mode styling
|
|
119
|
+
const statusClassMap = { active:'pill pill-success', inactive:'pill pill-danger', pending:'pill pill-warning', open:'pill pill-info', in_progress:'pill pill-info', completed:'pill pill-success', closed:'pill pill-neutral', archived:'pill pill-neutral', responded:'pill pill-info', overdue:'pill pill-danger', rejected:'pill pill-danger', unresolved:'pill pill-danger', approved:'pill pill-success', review:'pill pill-primary', flagged:'pill pill-danger', expired:'pill pill-neutral', private:'pill pill-primary', public:'pill pill-info' }
|
|
120
|
+
const cls = statusClassMap[key] || 'pill pill-neutral'
|
|
113
121
|
const label = status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, ' ')
|
|
114
|
-
return h('span', { className:
|
|
122
|
+
return h('span', { className: `status-label ${cls}` }, label)
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
export function nameHash(name) {
|
|
@@ -123,9 +131,39 @@ export function nameHash(name) {
|
|
|
123
131
|
export { getInitials } from '@/lib/utils.js'
|
|
124
132
|
export { icon } from '@/ui/format-helpers.js'
|
|
125
133
|
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
// Canonical date helpers — fixed en-ZA locale + format so the same data renders
|
|
135
|
+
// identically for every user (browser-locale toLocaleDateString gives MM/DD to a
|
|
136
|
+
// US user and DD/MM to a ZA user for the same timestamp; that is the unpredictability
|
|
137
|
+
// these replace). Scale-detect seconds vs milliseconds vs ISO string internally.
|
|
138
|
+
const DATE_LOCALE = 'en-ZA';
|
|
139
|
+
function toDate(ts) {
|
|
140
|
+
if (ts == null || ts === '') return null;
|
|
141
|
+
if (ts instanceof Date) return isNaN(ts) ? null : ts;
|
|
128
142
|
const n = Number(ts);
|
|
129
|
-
|
|
130
|
-
|
|
143
|
+
// 10-digit (or smaller) numeric => unix seconds; 13-digit => ms; non-numeric => ISO/parseable
|
|
144
|
+
const d = !isNaN(n) && String(ts).trim() !== '' && /^\d+$/.test(String(ts).trim())
|
|
145
|
+
? new Date(n < 1e12 ? n * 1000 : n)
|
|
146
|
+
: new Date(ts);
|
|
147
|
+
return isNaN(d) ? null : d;
|
|
148
|
+
}
|
|
149
|
+
export function fmtDate(ts) {
|
|
150
|
+
const d = toDate(ts);
|
|
151
|
+
return d ? d.toLocaleDateString(DATE_LOCALE, { day: '2-digit', month: 'short', year: 'numeric' }) : '-';
|
|
152
|
+
}
|
|
153
|
+
export function fmtDateTime(ts) {
|
|
154
|
+
const d = toDate(ts);
|
|
155
|
+
return d ? d.toLocaleDateString(DATE_LOCALE, { day: '2-digit', month: 'short', year: 'numeric' })
|
|
156
|
+
+ ', ' + d.toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' }) : '-';
|
|
157
|
+
}
|
|
158
|
+
// YYYY-MM-DD for <input type=date> values
|
|
159
|
+
export function fmtDateInput(ts) {
|
|
160
|
+
const d = toDate(ts);
|
|
161
|
+
if (!d) return '';
|
|
162
|
+
const p = n => String(n).padStart(2, '0');
|
|
163
|
+
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
|
|
164
|
+
}
|
|
165
|
+
// parse any date-ish value to unix seconds (the storage scale)
|
|
166
|
+
export function dateToUnixTimestamp(v) {
|
|
167
|
+
const d = toDate(v);
|
|
168
|
+
return d ? Math.floor(d.getTime() / 1000) : null;
|
|
131
169
|
}
|