thatcher 1.0.7 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +5 -41
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +1 -1
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
|
@@ -1,24 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Notification Engine - In-app notifications, email triggers
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
1
|
import { getTransporter, sendEmail } from './email-sender.js';
|
|
6
2
|
import { getConfigEngineSync } from '../lib/config-generator-engine.js';
|
|
7
3
|
import { executeHook } from '../lib/hook-engine.js';
|
|
4
|
+
import { createLogger } from '../lib/logger.js';
|
|
5
|
+
|
|
6
|
+
const log = createLogger('[Notification]');
|
|
8
7
|
|
|
9
|
-
/**
|
|
10
|
-
* Get notification template from config
|
|
11
|
-
*/
|
|
12
8
|
export function getNotificationTemplate(name) {
|
|
13
9
|
const engine = getConfigEngineSync();
|
|
14
10
|
return engine.generateNotificationHandler(name);
|
|
15
11
|
}
|
|
16
12
|
|
|
17
|
-
/**
|
|
18
|
-
* Create notification record
|
|
19
|
-
*/
|
|
20
13
|
export async function createNotification(notification) {
|
|
21
|
-
const { create } = await import('../lib/
|
|
14
|
+
const { create } = await import('../lib/busybase-store.js');
|
|
22
15
|
return create('notification', {
|
|
23
16
|
...notification,
|
|
24
17
|
created_at: Math.floor(Date.now() / 1000),
|
|
@@ -26,20 +19,16 @@ export async function createNotification(notification) {
|
|
|
26
19
|
}, { id: notification.created_by || 'system', role: 'system' });
|
|
27
20
|
}
|
|
28
21
|
|
|
29
|
-
/**
|
|
30
|
-
* Send notification to user (in-app + optional email)
|
|
31
|
-
*/
|
|
32
22
|
export async function sendNotification(type, userId, context = {}, options = {}) {
|
|
33
23
|
const template = getNotificationTemplate(type);
|
|
34
24
|
if (!template) {
|
|
35
|
-
|
|
25
|
+
log.warn(`template not found: ${type}`);
|
|
36
26
|
return null;
|
|
37
27
|
}
|
|
38
28
|
|
|
39
29
|
const title = interpolate(template.title, context);
|
|
40
30
|
const message = interpolate(template.message, context);
|
|
41
31
|
|
|
42
|
-
// Create notification record
|
|
43
32
|
const notification = await createNotification({
|
|
44
33
|
type,
|
|
45
34
|
user_id: userId,
|
|
@@ -51,7 +40,6 @@ export async function sendNotification(type, userId, context = {}, options = {})
|
|
|
51
40
|
created_by: context.userId || 'system',
|
|
52
41
|
});
|
|
53
42
|
|
|
54
|
-
// Send email if enabled
|
|
55
43
|
if (options.sendEmail !== false) {
|
|
56
44
|
try {
|
|
57
45
|
const { getUser } = await import('../engine.server.js');
|
|
@@ -64,7 +52,7 @@ export async function sendNotification(type, userId, context = {}, options = {})
|
|
|
64
52
|
});
|
|
65
53
|
}
|
|
66
54
|
} catch (err) {
|
|
67
|
-
|
|
55
|
+
log.error('email failed:', { message: err.message });
|
|
68
56
|
}
|
|
69
57
|
}
|
|
70
58
|
|
|
@@ -72,35 +60,26 @@ export async function sendNotification(type, userId, context = {}, options = {})
|
|
|
72
60
|
notification,
|
|
73
61
|
user: { id: userId },
|
|
74
62
|
context,
|
|
75
|
-
}).catch(
|
|
63
|
+
}).catch(err => log.error('hook error:', { message: err?.message || String(err) }));
|
|
76
64
|
|
|
77
65
|
return notification;
|
|
78
66
|
}
|
|
79
67
|
|
|
80
|
-
/**
|
|
81
|
-
* Mark notification as read
|
|
82
|
-
*/
|
|
83
68
|
export async function markNotificationRead(notificationId, userId) {
|
|
84
|
-
const { update } = await import('../lib/
|
|
69
|
+
const { update } = await import('../lib/busybase-store.js');
|
|
85
70
|
return update('notification', notificationId, {
|
|
86
71
|
read_at: Math.floor(Date.now() / 1000),
|
|
87
|
-
}
|
|
72
|
+
});
|
|
88
73
|
}
|
|
89
74
|
|
|
90
|
-
/**
|
|
91
|
-
* Get unread count for user
|
|
92
|
-
*/
|
|
93
75
|
export async function getUnreadCount(userId) {
|
|
94
|
-
const { list } = await import('../lib/
|
|
95
|
-
return list('notification', {
|
|
76
|
+
const { list } = await import('../lib/busybase-store.js');
|
|
77
|
+
return (await list('notification', {
|
|
96
78
|
user_id: userId,
|
|
97
79
|
read_at: null,
|
|
98
|
-
}).length;
|
|
80
|
+
})).length;
|
|
99
81
|
}
|
|
100
82
|
|
|
101
|
-
/**
|
|
102
|
-
* Interpolate {{key}} in template strings
|
|
103
|
-
*/
|
|
104
83
|
function interpolate(template, context) {
|
|
105
84
|
if (!template) return '';
|
|
106
85
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
@@ -1,46 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
* Permission Service - Authorization logic
|
|
3
|
-
* Adapted from moonlanding/src/services/permission.service.js
|
|
4
|
-
*/
|
|
1
|
+
// Adapted from moonlanding/src/services/permission.service.js
|
|
5
2
|
|
|
6
3
|
import { getCollaboratorRole, checkCollaboratorAccess } from '../services/collaborator-role.service.js';
|
|
7
4
|
import { PermissionError } from '../lib/error-handler.js';
|
|
8
5
|
import { getConfigEngineSync } from '../lib/config-generator-engine.js';
|
|
9
6
|
|
|
10
7
|
class PermissionService {
|
|
11
|
-
/**
|
|
12
|
-
* Check if user can perform action on entity spec
|
|
13
|
-
* @param {object} user
|
|
14
|
-
* @param {object} spec
|
|
15
|
-
* @param {string} action
|
|
16
|
-
* @returns {boolean}
|
|
17
|
-
*/
|
|
18
8
|
can(user, spec, action) {
|
|
19
9
|
if (!user) return false;
|
|
20
10
|
if (!spec?.access?.[action]) return true; // No restriction defined = allow
|
|
21
11
|
return spec.access[action].includes(user.role);
|
|
22
12
|
}
|
|
23
13
|
|
|
24
|
-
/**
|
|
25
|
-
* Require permission (throw if denied)
|
|
26
|
-
* @param {object} user
|
|
27
|
-
* @param {object} spec
|
|
28
|
-
* @param {string} action
|
|
29
|
-
*/
|
|
30
14
|
require(user, spec, action) {
|
|
31
15
|
if (!this.can(user, spec, action)) {
|
|
32
16
|
throw new PermissionError(`Cannot ${action} ${spec?.name || 'unknown'}`);
|
|
33
17
|
}
|
|
34
18
|
}
|
|
35
19
|
|
|
36
|
-
/**
|
|
37
|
-
* Check field-level access
|
|
38
|
-
* @param {object} user
|
|
39
|
-
* @param {object} spec
|
|
40
|
-
* @param {string} fieldName
|
|
41
|
-
* @param {string} action - 'view' or 'edit'
|
|
42
|
-
* @returns {boolean}
|
|
43
|
-
*/
|
|
44
20
|
checkFieldAccess(user, spec, fieldName, action) {
|
|
45
21
|
if (!user) return false;
|
|
46
22
|
const perm = spec.fieldPermissions?.[fieldName];
|
|
@@ -50,13 +26,6 @@ class PermissionService {
|
|
|
50
26
|
return Array.isArray(allowed) && allowed.includes(user.role);
|
|
51
27
|
}
|
|
52
28
|
|
|
53
|
-
/**
|
|
54
|
-
* Check row-level access
|
|
55
|
-
* @param {object} user
|
|
56
|
-
* @param {object} spec
|
|
57
|
-
* @param {object} record
|
|
58
|
-
* @returns {boolean}
|
|
59
|
-
*/
|
|
60
29
|
checkRowAccess(user, spec, record) {
|
|
61
30
|
if (!user) return false;
|
|
62
31
|
const rowAccess = spec.rowAccess || spec.row_access;
|
|
@@ -68,17 +37,22 @@ class PermissionService {
|
|
|
68
37
|
const clientUserRole = Object.keys(roles).find(r => r === 'client_user');
|
|
69
38
|
|
|
70
39
|
const scope = rowAccess.scope || rowAccess;
|
|
40
|
+
// The owner field defaults to 'assigned_to' but is configurable, so an app
|
|
41
|
+
// whose ownership column is named differently (e.g. casey's case.assignee) can
|
|
42
|
+
// use scope=assigned without renaming its schema: row_access: { scope: assigned,
|
|
43
|
+
// field: assignee }.
|
|
44
|
+
const ownerField = (typeof rowAccess === 'object' && rowAccess.field) || 'assigned_to';
|
|
71
45
|
|
|
72
46
|
if (scope === 'team' && record.team_id && user.team_id && record.team_id !== user.team_id) {
|
|
73
47
|
return false;
|
|
74
48
|
}
|
|
75
49
|
|
|
76
|
-
if (scope === 'assigned' && record
|
|
50
|
+
if (scope === 'assigned' && record[ownerField] && record[ownerField] !== user.id && user.role !== partnerRole) {
|
|
77
51
|
return false;
|
|
78
52
|
}
|
|
79
53
|
|
|
80
54
|
if (scope === 'assigned_or_team' && user.role !== partnerRole) {
|
|
81
|
-
const assignedMatch = record
|
|
55
|
+
const assignedMatch = record[ownerField] && record[ownerField] === user.id;
|
|
82
56
|
const teamMatch = record.team_id && user.team_id && record.team_id === user.team_id;
|
|
83
57
|
if (!assignedMatch && !teamMatch) return false;
|
|
84
58
|
}
|
|
@@ -97,25 +71,11 @@ class PermissionService {
|
|
|
97
71
|
return true;
|
|
98
72
|
}
|
|
99
73
|
|
|
100
|
-
/**
|
|
101
|
-
* Filter records by row access
|
|
102
|
-
* @param {object} user
|
|
103
|
-
* @param {object} spec
|
|
104
|
-
* @param {Array} records
|
|
105
|
-
* @returns {Array}
|
|
106
|
-
*/
|
|
107
74
|
filterRecords(user, spec, records) {
|
|
108
75
|
if (!user || !Array.isArray(records)) return records;
|
|
109
76
|
return records.filter(r => this.checkRowAccess(user, spec, r));
|
|
110
77
|
}
|
|
111
78
|
|
|
112
|
-
/**
|
|
113
|
-
* Filter fields by field-level permissions
|
|
114
|
-
* @param {object} user
|
|
115
|
-
* @param {object} spec
|
|
116
|
-
* @param {object} record
|
|
117
|
-
* @returns {object}
|
|
118
|
-
*/
|
|
119
79
|
filterFields(user, spec, record) {
|
|
120
80
|
if (!user) return record;
|
|
121
81
|
const filtered = {};
|
|
@@ -128,12 +88,6 @@ class PermissionService {
|
|
|
128
88
|
return filtered;
|
|
129
89
|
}
|
|
130
90
|
|
|
131
|
-
/**
|
|
132
|
-
* Enforce edit permissions on data
|
|
133
|
-
* @param {object} user
|
|
134
|
-
* @param {object} spec
|
|
135
|
-
* @param {object} data
|
|
136
|
-
*/
|
|
137
91
|
enforceEditPermissions(user, spec, data) {
|
|
138
92
|
if (!user) throw new PermissionError(`Cannot edit ${spec.name}`);
|
|
139
93
|
if (!this.can(user, spec, 'edit')) throw new PermissionError(`Cannot edit ${spec.name}`);
|
|
@@ -145,35 +99,18 @@ class PermissionService {
|
|
|
145
99
|
}
|
|
146
100
|
}
|
|
147
101
|
|
|
148
|
-
/**
|
|
149
|
-
* Check if user owns record
|
|
150
|
-
* @param {object} user
|
|
151
|
-
* @param {object} spec
|
|
152
|
-
* @param {object} record
|
|
153
|
-
* @returns {boolean}
|
|
154
|
-
*/
|
|
155
102
|
checkOwnership(user, spec, record) {
|
|
156
103
|
if (!record) return false;
|
|
157
104
|
return record.created_by === user.id || record.user_id === user.id;
|
|
158
105
|
}
|
|
159
106
|
|
|
160
|
-
/**
|
|
161
|
-
* Check assignment for client access
|
|
162
|
-
* @param {object} user
|
|
163
|
-
* @param {object} spec
|
|
164
|
-
* @param {object} record
|
|
165
|
-
* @returns {boolean}
|
|
166
|
-
*/
|
|
167
107
|
checkAssignment(user, spec, record) {
|
|
168
108
|
// Simplified - check if user is assigned to record
|
|
169
109
|
return record.assigned_to === user.id || record.team_id === user.team_id;
|
|
170
110
|
}
|
|
171
111
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
*/
|
|
175
|
-
hasCollaboratorPermission(collaboratorId, permission) {
|
|
176
|
-
return getCollaboratorRole(collaboratorId)?.permissions?.includes(permission);
|
|
112
|
+
async hasCollaboratorPermission(collaboratorId, permission) {
|
|
113
|
+
return (await getCollaboratorRole(collaboratorId))?.permissions?.includes(permission);
|
|
177
114
|
}
|
|
178
115
|
}
|
|
179
116
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { statusLabel } from '@/ui/renderer.js';
|
|
2
2
|
import { page } from '@/ui/layout.js';
|
|
3
3
|
import { fmtDate, esc } from '@/ui/render-helpers.js';
|
|
4
|
+
import { emptyState } from '@/ui/format-helpers.js';
|
|
4
5
|
|
|
5
6
|
function resultCard(item, entityType) {
|
|
6
7
|
const sts = item.status ? statusLabel(item.status) : '';
|
|
@@ -32,11 +33,11 @@ export function renderAdvancedSearch(user, results = {}, options = {}) {
|
|
|
32
33
|
|
|
33
34
|
const resultCards = allResults.length > 0
|
|
34
35
|
? allResults.map(r => resultCard(r, r._type)).join('')
|
|
35
|
-
: '
|
|
36
|
+
: emptyState('Enter a search query to find engagements, clients, RFIs, and reviews', 'search');
|
|
36
37
|
|
|
37
38
|
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Advanced Search</h1></div>${filterPanel(teams, stages)}<div class="flex items-center gap-2 mb-4"><span class="text-sm text-gray-500">${totalCount} result${totalCount !== 1 ? 's' : ''}</span>${countBadges}</div><div id="search-results">${resultCards}</div>`;
|
|
38
39
|
|
|
39
40
|
const searchScript = `window.doSearch=async function(){const q=document.getElementById('search-query')?.value||'';const entity=document.getElementById('filter-entity')?.value||'';const status=document.getElementById('filter-status')?.value||'';const stage=document.getElementById('filter-stage')?.value||'';const team=document.getElementById('filter-team')?.value||'';const from=document.getElementById('filter-from')?.value||'';const to=document.getElementById('filter-to')?.value||'';const params=new URLSearchParams();if(q)params.set('q',q);if(entity)params.set('entity',entity);if(status)params.set('status',status);if(stage)params.set('stage',stage);if(team)params.set('team',team);if(from)params.set('from',from);if(to)params.set('to',to);window.location='/search?'+params.toString()};document.getElementById('search-query')?.addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})`;
|
|
40
41
|
|
|
41
|
-
return page(user, 'Search |
|
|
42
|
+
return page(user, 'Search | Thatcher', [{ href: '/', label: 'Dashboard' }, { label: 'Search' }], content, [searchScript]);
|
|
42
43
|
}
|
|
@@ -43,5 +43,5 @@ export function collapsibleSidebar(sections, currentPath) {
|
|
|
43
43
|
return `<div class="sidebar-section"><div class="sidebar-section-header" data-action="sidebarToggleSection" data-self>${section.title}<span class="sidebar-section-arrow"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></span></div><div class="sidebar-section-body">${links}</div></div>`
|
|
44
44
|
}).join('')
|
|
45
45
|
const script = `(function(){var sb=document.getElementById('${sidebarId}');var saved=localStorage.getItem('sidebar-width');if(saved)sb.style.width=saved+'px';var collapsed=localStorage.getItem('sidebar-collapsed')==='true';if(collapsed)sb.classList.add('sidebar-collapsed');window.sidebarToggleSection=function(el){el.parentElement.classList.toggle('sidebar-section-closed')};window.sidebarToggleCollapse=function(){sb.classList.toggle('sidebar-collapsed');localStorage.setItem('sidebar-collapsed',sb.classList.contains('sidebar-collapsed'))};var handle=sb.querySelector('.sidebar-resize-handle');var startX,startW;handle.addEventListener('mousedown',function(e){startX=e.clientX;startW=sb.offsetWidth;function onMove(ev){var w=startW+(ev.clientX-startX);if(w>48&&w<400){sb.style.width=w+'px';localStorage.setItem('sidebar-width',w)}}function onUp(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp)}document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp)})})();`
|
|
46
|
-
return `<aside id="${sidebarId}" class="sidebar-collapsible"><div class="sidebar-inner"><button class="sidebar-collapse-btn" data-action="sidebarToggleCollapse" title="Toggle sidebar"><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"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></button>${sectionHtml}</div><div class="sidebar-resize-handle"></div></aside><script>${script}</script>`
|
|
46
|
+
return `<aside id="${sidebarId}" class="sidebar-collapsible"><div class="sidebar-inner"><button class="sidebar-collapse-btn" data-action="sidebarToggleCollapse" title="Toggle sidebar" aria-label="Toggle sidebar"><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"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></button>${sectionHtml}</div><div class="sidebar-resize-handle"></div></aside><script>${script}</script>`
|
|
47
47
|
}
|
package/src/ui/auth-pages.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { generateHtml } from '@/ui/layout.js'
|
|
2
2
|
import { nav as navFn } from '@/ui/layout.js'
|
|
3
3
|
import { AUTH_CSS, GOOGLE_SVG, WARN_SVG, LOGO_SVG, authLogoHtml } from '@/ui/auth-styles.js'
|
|
4
|
+
import { esc } from '@/ui/render-helpers.js'
|
|
4
5
|
|
|
5
6
|
export function renderLogin(error = null, hasGoogleAuth = false) {
|
|
6
7
|
const errHtml = error
|
|
7
|
-
? `<div class="auth-err" role="alert">${WARN_SVG}${
|
|
8
|
+
? `<div class="auth-err" role="alert">${WARN_SVG}${esc(error)}</div>`
|
|
8
9
|
: ''
|
|
9
10
|
const googleBtn = hasGoogleAuth
|
|
10
11
|
? `<a href="/api/auth/google" class="auth-google-btn" aria-label="Continue with Google">${GOOGLE_SVG}Continue with Google</a>
|
|
@@ -37,7 +38,7 @@ form.addEventListener('submit',async function(e){
|
|
|
37
38
|
else{var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=data.error||'Login failed. Please check your credentials.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Sign In';btn.disabled=false;}
|
|
38
39
|
}catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Sign In';btn.disabled=false;}
|
|
39
40
|
});`
|
|
40
|
-
return generateHtml('Sign In |
|
|
41
|
+
return generateHtml('Sign In | Thatcher', body, [script])
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export function renderPasswordReset() {
|
|
@@ -65,7 +66,7 @@ form.addEventListener('submit',async function(e){
|
|
|
65
66
|
else{var d=await res.json().catch(function(){return{};});var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=d.error||'Request failed. Please try again.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Send Reset Link';btn.disabled=false;}
|
|
66
67
|
}catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Send Reset Link';btn.disabled=false;}
|
|
67
68
|
});`
|
|
68
|
-
return generateHtml('Reset Password |
|
|
69
|
+
return generateHtml('Reset Password | Thatcher', body, [script])
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export function renderPasswordResetConfirm(token) {
|
|
@@ -73,9 +74,9 @@ export function renderPasswordResetConfirm(token) {
|
|
|
73
74
|
<div class="auth-shell"><div class="auth-wrap">
|
|
74
75
|
${authLogoHtml('Set a new password')}
|
|
75
76
|
<div class="auth-card">
|
|
76
|
-
<div id="confirm-success" class="auth-success" style="display:none" role="alert">Password updated. <a href="/login" style="color
|
|
77
|
+
<div id="confirm-success" class="auth-success" style="display:none" role="alert">Password updated. <a href="/login" style="color:var(--color-success,#15803d);text-decoration:underline;font-weight:600">Sign in now</a></div>
|
|
77
78
|
<form id="confirm-form" aria-label="Set new password" novalidate>
|
|
78
|
-
<input type="hidden" name="token" value="${token}">
|
|
79
|
+
<input type="hidden" name="token" value="${esc(token)}">
|
|
79
80
|
<div class="auth-field"><label class="auth-label" for="password">New password</label>
|
|
80
81
|
<input type="password" name="password" id="password" class="auth-input" placeholder="At least 8 characters" minlength="8" required autocomplete="new-password" autofocus></div>
|
|
81
82
|
<div class="auth-field"><label class="auth-label" for="confirm_password">Confirm new password</label>
|
|
@@ -99,16 +100,16 @@ form.addEventListener('submit',async function(e){
|
|
|
99
100
|
else{var el=document.createElement('div');el.className='auth-err';el.setAttribute('role','alert');el.textContent=data.error||'Reset failed. Please try again.';errArea.innerHTML='';errArea.appendChild(el);btn.textContent='Update Password';btn.disabled=false;}
|
|
100
101
|
}catch(err){errArea.innerHTML='<div class="auth-err" role="alert">Network error. Please try again.</div>';btn.textContent='Update Password';btn.disabled=false;}
|
|
101
102
|
});`
|
|
102
|
-
return generateHtml('Set New Password |
|
|
103
|
+
return generateHtml('Set New Password | Thatcher', body, [script])
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
export function renderAccessDenied(user, entityName, action) {
|
|
106
107
|
const txt = { list: 'view this list', view: 'view this item', create: 'create items here', edit: 'edit this item', delete: 'delete this item' }
|
|
107
108
|
const body = `<div class="min-h-screen">${navFn(user)}<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:60vh;text-align:center;padding:40px 20px">
|
|
108
|
-
<
|
|
109
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted,#64748b)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="margin-bottom:16px" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
109
110
|
<h1 style="font-size:24px;font-weight:700;color:var(--color-text,#0f172a);margin:0 0 8px">Access Denied</h1>
|
|
110
|
-
<p style="color:var(--color-text-muted,#64748b);margin:0 0 24px;max-width:400px">You do not have permission to ${txt[action] || action} in ${entityName}.</p>
|
|
111
|
+
<p style="color:var(--color-text-muted,#64748b);margin:0 0 24px;max-width:400px">You do not have permission to ${esc(txt[action] || action)} in ${esc(entityName)}.</p>
|
|
111
112
|
<a href="/" class="btn-primary-clean">Return to Dashboard</a>
|
|
112
113
|
</div></div>`
|
|
113
|
-
return generateHtml('Access Denied |
|
|
114
|
+
return generateHtml('Access Denied | Thatcher', body)
|
|
114
115
|
}
|
package/src/ui/auth-styles.js
CHANGED
|
@@ -48,6 +48,6 @@ export const LOGO_SVG = `<svg width="26" height="26" viewBox="0 0 24 24" fill="n
|
|
|
48
48
|
export const authLogoHtml = (subtitle) => `
|
|
49
49
|
<div class="auth-logo">
|
|
50
50
|
<div class="auth-logo-mark">${LOGO_SVG}</div>
|
|
51
|
-
<span class="auth-logo-name">
|
|
51
|
+
<span class="auth-logo-name">Thatcher</span>
|
|
52
52
|
<div class="auth-logo-sub">${subtitle}</div>
|
|
53
53
|
</div>`;
|
package/src/ui/client.js
CHANGED
|
@@ -68,7 +68,7 @@ export async function submitForm(formId, url, method = 'POST') {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export async function confirmDelete(url, redirectUrl) {
|
|
71
|
-
if (!
|
|
71
|
+
if (!(await window.gmConfirm({ title: 'Please confirm', message: 'Are you sure you want to delete this?', confirmLabel: 'Delete', danger: true }))) return false;
|
|
72
72
|
try {
|
|
73
73
|
const res = await fetch(url, { method: 'DELETE' });
|
|
74
74
|
if (res.ok) {
|
|
@@ -34,7 +34,7 @@ export function renderComponent(componentId, props = {}) {
|
|
|
34
34
|
if (Array.isArray(value)) {
|
|
35
35
|
html = html.replace(regex, value.map(v => renderItem(v, key)).join(''));
|
|
36
36
|
} else {
|
|
37
|
-
html = html.replace(regex, JSON.stringify(value));
|
|
37
|
+
html = html.replace(regex, esc(JSON.stringify(value)));
|
|
38
38
|
}
|
|
39
39
|
} else {
|
|
40
40
|
html = html.replace(regex, esc(value));
|
|
@@ -42,7 +42,7 @@ export function renderDashboard(user, stats = {}) {
|
|
|
42
42
|
</div></div>` : '';
|
|
43
43
|
|
|
44
44
|
const recentRows = (stats.recentEngagements || []).map(e => {
|
|
45
|
-
const updated = e.updated_at ? new Date(typeof e.updated_at === 'number' ? e.updated_at * 1000 : e.updated_at).toLocaleDateString() : '-';
|
|
45
|
+
const updated = e.updated_at ? new Date(typeof e.updated_at === 'number' ? e.updated_at * 1000 : e.updated_at).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-';
|
|
46
46
|
return `<tr data-row data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer">
|
|
47
47
|
<td data-col="name"><strong>${esc(e.name || e.client_name || 'Untitled')}</strong></td>
|
|
48
48
|
<td data-col="client">${esc(e.client_id_display || e.client_name || '-')}</td>
|
|
@@ -82,14 +82,14 @@ export function renderDashboard(user, stats = {}) {
|
|
|
82
82
|
</div>
|
|
83
83
|
${statsHtml}${overdueAlert}${actionsHtml}${emptyEngHtml}${recentHtml}`;
|
|
84
84
|
|
|
85
|
-
return page(user, 'Dashboard |
|
|
85
|
+
return page(user, 'Dashboard | Thatcher', [], content, [TABLE_SCRIPT]);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function renderAuditDashboard(user, auditData = {}) {
|
|
89
89
|
const { summary = {}, recentActivity = [] } = auditData;
|
|
90
90
|
const actRows = recentActivity.slice(0, 20).map(a =>
|
|
91
91
|
`<tr data-row>
|
|
92
|
-
<td data-col="time">${new Date((a.timestamp||a.created_at)*1000).toLocaleString()}</td>
|
|
92
|
+
<td data-col="time">${new Date((a.timestamp||a.created_at)*1000).toLocaleString('en-ZA')}</td>
|
|
93
93
|
<td data-col="action"><span class="pill pill-info">${a.action||'-'}</span></td>
|
|
94
94
|
<td data-col="entity">${a.entity_type||'-'}</td>
|
|
95
95
|
<td data-col="id" style="font-size:12px">${a.entity_id||'-'}</td>
|
|
@@ -118,7 +118,7 @@ export function renderAuditDashboard(user, auditData = {}) {
|
|
|
118
118
|
</table>
|
|
119
119
|
</div>`;
|
|
120
120
|
|
|
121
|
-
return page(user, 'Audit Dashboard |
|
|
121
|
+
return page(user, 'Audit Dashboard | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/admin/audit', label: 'Audit' }], content, [TABLE_SCRIPT]);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
export function renderSystemHealth(user, healthData = {}) {
|
|
@@ -146,5 +146,5 @@ export function renderSystemHealth(user, healthData = {}) {
|
|
|
146
146
|
</table>
|
|
147
147
|
</div>`;
|
|
148
148
|
|
|
149
|
-
return page(user, 'System Health |
|
|
149
|
+
return page(user, 'System Health | Thatcher', [{ href: '/', label: 'Dashboard' }, { href: '/admin/health', label: 'Health' }], content, [TABLE_SCRIPT]);
|
|
150
150
|
}
|
package/src/ui/dialog-engine.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getConfigEngineSync } from '@/lib/config-generator-engine';
|
|
2
|
-
import { aria, role
|
|
2
|
+
import { aria, role } from '@/lib/accessibility';
|
|
3
3
|
|
|
4
4
|
export function renderDialog(dialogId, context = {}) {
|
|
5
5
|
const config = getConfigEngineSync().getConfig();
|
|
@@ -29,7 +29,7 @@ function generateDialogHtml(dialogId, title, fields, actions, width, context, on
|
|
|
29
29
|
}).join('');
|
|
30
30
|
|
|
31
31
|
return `
|
|
32
|
-
<div id="${dialogId}" class="dialog-overlay" style="display:none" ${role.dialog} aria-modal="true" ${aria.labelledBy(titleId)} ${aria.hidden(true)} data-
|
|
32
|
+
<div id="${dialogId}" class="dialog-overlay" style="display:none" ${role.dialog} aria-modal="true" ${aria.labelledBy(titleId)} ${aria.hidden(true)} data-dialog-close="${dialogId}">
|
|
33
33
|
<div id="${dialogId}-panel" class="dialog-panel" style="max-width:${width}">
|
|
34
34
|
<div class="dialog-header">
|
|
35
35
|
<h2 id="${titleId}" class="dialog-title">${escapeHtml(title)}</h2>
|
|
@@ -48,11 +48,8 @@ function generateDialogHtml(dialogId, title, fields, actions, width, context, on
|
|
|
48
48
|
const dlg = document.getElementById(id);
|
|
49
49
|
dlg.style.display = 'none';
|
|
50
50
|
dlg.setAttribute('aria-hidden', 'true');
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
dlg._previousFocus.focus();
|
|
54
|
-
delete dlg._previousFocus;
|
|
55
|
-
}
|
|
51
|
+
// Escape + focus-restore + tab-trap are owned centrally by dialogFocus
|
|
52
|
+
// (event-delegation.js, session-12) — no local focusTrap/_previousFocus here.
|
|
56
53
|
};
|
|
57
54
|
window.submitDialog = window.submitDialog || function(id) {
|
|
58
55
|
const form = document.getElementById(id + '-form');
|
|
@@ -60,10 +57,10 @@ function generateDialogHtml(dialogId, title, fields, actions, width, context, on
|
|
|
60
57
|
};
|
|
61
58
|
window.openDialog = window.openDialog || function(id) {
|
|
62
59
|
const dlg = document.getElementById(id);
|
|
63
|
-
dlg._previousFocus = document.activeElement;
|
|
64
60
|
dlg.style.display = 'flex';
|
|
65
61
|
dlg.setAttribute('aria-hidden', 'false');
|
|
66
|
-
|
|
62
|
+
// focus-move into the panel + Tab-trap are owned by dialogFocus
|
|
63
|
+
// (event-delegation.js MutationObserver watches the display flip).
|
|
67
64
|
};
|
|
68
65
|
</script>
|
|
69
66
|
`;
|
package/src/ui/dialog-factory.js
CHANGED
|
@@ -3,7 +3,7 @@ import { esc } from '@/ui/render-helpers.js';
|
|
|
3
3
|
export function createDialog(id, title, body, footer, opts = {}) {
|
|
4
4
|
const maxWidth = opts.maxWidth || '640px';
|
|
5
5
|
const extraClass = opts.class || '';
|
|
6
|
-
return `<div id="${esc(id)}" class="dialog-overlay ${extraClass}" style="display:none" data-dialog-close
|
|
6
|
+
return `<div id="${esc(id)}" class="dialog-overlay ${extraClass}" style="display:none" data-dialog-close="${esc(id)}" role="dialog" aria-modal="true" aria-labelledby="${esc(id)}-title" aria-hidden="true">
|
|
7
7
|
<div class="dialog-panel" style="max-width:${maxWidth}">
|
|
8
8
|
<div class="dialog-header">
|
|
9
9
|
<span class="dialog-title" id="${esc(id)}-title">${title}</span>
|
|
@@ -19,7 +19,7 @@ export function modalDialog(id, title, body, footer, opts = {}) {
|
|
|
19
19
|
const maxWidth = opts.maxWidth || 'md';
|
|
20
20
|
const sizeMap = { sm: '28rem', md: '36rem', lg: '48rem', xl: '64rem' };
|
|
21
21
|
const width = sizeMap[maxWidth] || maxWidth;
|
|
22
|
-
return `<div id="${esc(id)}" class="modal" style="display:none"
|
|
22
|
+
return `<div id="${esc(id)}" class="modal" style="display:none">
|
|
23
23
|
<div class="modal-overlay" data-dialog-close="${esc(id)}"></div>
|
|
24
24
|
<div class="modal-content rounded-box" style="max-width:${width};padding:1.5rem">
|
|
25
25
|
<h3 class="text-lg font-semibold mb-4">${title}</h3>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { h } from '@/ui/webjsx.js'
|
|
2
|
-
import { STAGE_COLORS, statusLabel, esc } from '@/ui/render-helpers.js'
|
|
2
|
+
import { STAGE_COLORS, statusLabel, esc, icon } from '@/ui/render-helpers.js'
|
|
3
3
|
import { teamAvatarGroup } from '@/ui/widgets.js'
|
|
4
4
|
import { canEdit } from '@/ui/permissions-ui.js'
|
|
5
5
|
|
|
@@ -10,7 +10,7 @@ export function engagementCard(engagement, user) {
|
|
|
10
10
|
const sts = e.status ? statusLabel(e.status) : ''
|
|
11
11
|
const progress = typeof e.progress === 'number' ? `<div class="engagement-card-progress"><div class="engagement-card-progress-bar" style="width:${Math.min(100, Math.max(0, e.progress))}%"></div></div>` : ''
|
|
12
12
|
const team = e.team?.length ? `<div class="engagement-card-team">${teamAvatarGroup(e.team, 3)}</div>` : ''
|
|
13
|
-
const dueDate = e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString() : e.due_date}</span>` : ''
|
|
13
|
+
const dueDate = e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : e.due_date}</span>` : ''
|
|
14
14
|
const clientName = e.client_name || e.client?.name || ''
|
|
15
15
|
return `<div class="engagement-card card-clean" data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer">
|
|
16
16
|
<div class="card-clean-body">
|
|
@@ -31,7 +31,7 @@ export function mobileEngagementCard(engagement) {
|
|
|
31
31
|
<div class="card-clean-body">
|
|
32
32
|
<div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${esc(e.name || e.title || 'Untitled')}</h3>${sts}</div>
|
|
33
33
|
${e.client_name ? `<div class="text-xs text-gray-500 mb-1">${esc(e.client_name)}</div>` : ''}
|
|
34
|
-
<div class="flex items-center gap-2 mb-2">${stageBadge}${e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString() : e.due_date}</span>` : ''}</div>
|
|
34
|
+
<div class="flex items-center gap-2 mb-2">${stageBadge}${e.due_date ? `<span class="text-xs text-gray-500">${typeof e.due_date === 'number' ? new Date(e.due_date * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : e.due_date}</span>` : ''}</div>
|
|
35
35
|
<div class="flex gap-1 mt-2"><a href="/engagement/${e.id}" class="btn btn-xs btn-primary">View</a><a href="/engagement/${e.id}/edit" class="btn btn-xs btn-outline">Edit</a></div>
|
|
36
36
|
</div></div>`
|
|
37
37
|
}
|
|
@@ -51,13 +51,13 @@ export function stagePipeline(currentStage, stages = null, clickable = false, on
|
|
|
51
51
|
export function activityTimeline(activities = [], showAll = false) {
|
|
52
52
|
const maxItems = showAll ? activities.length : 10
|
|
53
53
|
const items = activities.slice(0, maxItems)
|
|
54
|
-
const iconMap = { created:
|
|
54
|
+
const iconMap = { created: 'document', updated: 'clipboard', status_changed: 'flag', commented: 'chat', assigned: 'user', uploaded: 'inbox' }
|
|
55
55
|
const timelineItems = items.map(a => {
|
|
56
56
|
const action = a.action || 'updated'
|
|
57
|
-
const
|
|
57
|
+
const actIcon = icon(iconMap[action] || 'list', 13)
|
|
58
58
|
const ts = a.timestamp || a.created_at
|
|
59
|
-
const timeStr = ts ? (typeof ts === 'number' && ts > 1e9 ? new Date(ts * 1000).toLocaleString() : new Date(ts).toLocaleString()) : ''
|
|
60
|
-
return `<div class="act-tl-item"><div class="act-tl-icon act-tl-${action}">${
|
|
59
|
+
const timeStr = ts ? (typeof ts === 'number' && ts > 1e9 ? new Date(ts * 1000).toLocaleString('en-ZA') : new Date(ts).toLocaleString('en-ZA')) : ''
|
|
60
|
+
return `<div class="act-tl-item"><div class="act-tl-icon act-tl-${action}">${actIcon}</div><div class="act-tl-content"><div class="act-tl-header"><span class="act-tl-desc">${esc(a.description || a.action || '')}</span></div><div class="act-tl-time">${timeStr}${a.user_name ? ' by ' + esc(a.user_name) : ''}</div></div></div>`
|
|
61
61
|
}).join('')
|
|
62
62
|
const moreBtn = !showAll && activities.length > maxItems ? `<div class="act-tl-more"><button class="btn btn-ghost btn-sm" data-action="showAllActivity" aria-label="Show ${activities.length - maxItems} more activities">Show ${activities.length - maxItems} more</button></div>` : ''
|
|
63
63
|
return `<div class="act-timeline">${timelineItems}</div>${moreBtn}`
|
|
@@ -8,8 +8,7 @@ export function engagementFileSearchDialog(engagementId) {
|
|
|
8
8
|
return createDialog('file-search-dialog', 'Search Engagement Files', body, footer) + `
|
|
9
9
|
<script>
|
|
10
10
|
var efsTimer=null;
|
|
11
|
-
window.efsSearch=function(){clearTimeout(efsTimer);efsTimer=setTimeout(function(){var q=document.getElementById('efs-query').value.trim();
|
|
12
|
-
<div><div class="text-sm font-medium">'+(f.name||'Unknown')+'</div><div class="text-xs text-gray-500">'+(f.size?Math.round(f.size/1024)+'KB':'')+'</div></div><span class="badge-status text-xs">'+(f.type||'file')+'</span></div>'}).join('')}).catch(function(){document.getElementById('efs-results').innerHTML='<div class="text-red-500 text-sm">Search error</div>'})},300)};
|
|
11
|
+
window.efsSearch=function(){clearTimeout(efsTimer);efsTimer=setTimeout(function(){var q=document.getElementById('efs-query').value.trim();var el=document.getElementById('efs-results');if(!q){el.textContent='';return}fetch('/api/file?engagement_id=${engagementId}&search='+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];el.textContent='';if(!files.length){var none=document.createElement('div');none.className='text-gray-500 text-sm text-center py-4';none.textContent='No files found';el.appendChild(none);return}files.forEach(function(f){var row=document.createElement('div');row.className='flex items-center justify-between p-2 hover:bg-gray-50 rounded cursor-pointer';row.setAttribute('data-action','previewAttachment');row.setAttribute('data-args',JSON.stringify(['/api/file/'+f.id+'/download',f.name||'',f.mime_type||'']));var left=document.createElement('div');var nm=document.createElement('div');nm.className='text-sm font-medium';nm.textContent=f.name||'Unknown';var sz=document.createElement('div');sz.className='text-xs text-gray-500';sz.textContent=f.size?Math.round(f.size/1024)+'KB':'';left.appendChild(nm);left.appendChild(sz);var badge=document.createElement('span');badge.className='badge-status text-xs';badge.textContent=f.type||'file';row.appendChild(left);row.appendChild(badge);el.appendChild(row)})}).catch(function(){el.textContent='';var err=document.createElement('div');err.className='text-red-500 text-sm';err.textContent='Search error';el.appendChild(err)})},300)};
|
|
13
12
|
</script>`;
|
|
14
13
|
}
|
|
15
14
|
|
|
@@ -29,7 +28,7 @@ export function postRfiJournalDialog(engagementId) {
|
|
|
29
28
|
export function postRfiFileUpload(engagementId) {
|
|
30
29
|
return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body"><h3 style="font-size:0.875rem;font-weight:600">Upload Post-RFI Files</h3><div class="mt-3"><input type="file" id="prfi-files" class="file-input file-input-bordered w-full" multiple/><div class="flex justify-end mt-2"><button class="btn btn-primary btn-sm" data-action="prfiUpload">Upload</button></div><div id="prfi-progress" class="mt-2"></div></div></div></div>
|
|
31
30
|
<script>
|
|
32
|
-
window.prfiUpload=async function(){var files=document.getElementById('prfi-files').files;if(!files.length){showToast('Select files','error');return}var prog=document.getElementById('prfi-progress');prog.
|
|
31
|
+
window.prfiUpload=async function(){var files=document.getElementById('prfi-files').files;if(!files.length){showToast('Select files','error');return}var prog=document.getElementById('prfi-progress');prog.textContent='Uploading '+files.length+' file(s)...';var ok=0;for(var i=0;i<files.length;i++){var fd=new FormData();fd.append('file',files[i]);fd.append('engagement_id','${engagementId}');fd.append('type','post_rfi');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)ok++;prog.textContent='Uploaded '+(i+1)+' of '+files.length}catch(e){}}prog.textContent=ok+' of '+files.length+' uploaded successfully';if(ok>0)showToast(ok+' files uploaded','success')};
|
|
33
32
|
</script>`;
|
|
34
33
|
}
|
|
35
34
|
|