thatcher 1.0.6 → 1.0.33
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 +29 -88
- 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 +14 -5
- 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,5 +1,6 @@
|
|
|
1
1
|
import { canCreate } from '@/ui/permissions-ui.js';
|
|
2
2
|
import { page } from '@/ui/layout.js';
|
|
3
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
4
|
import { reviewCreateDialog, reviewTemplateChoiceDialog, reviewContextMenu, reviewFlagsDialog, reviewTagsDialog, reviewValueDialog, reviewDeadlineDialog, reviewNotificationDialog } from '@/ui/review-dialogs.js';
|
|
4
5
|
import { SPACING, renderButton, renderEmptyState, renderStatsRow, renderPageHeader } from '@/ui/spacing-system.js';
|
|
5
6
|
|
|
@@ -19,7 +20,7 @@ const TAB_DEFS = [
|
|
|
19
20
|
function fmtDate(ts) {
|
|
20
21
|
if (!ts) return '-';
|
|
21
22
|
const n = Number(ts);
|
|
22
|
-
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
|
|
23
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
|
23
24
|
return String(ts);
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -33,13 +34,13 @@ function statusBadge(status) {
|
|
|
33
34
|
function reviewRow(r) {
|
|
34
35
|
const isPri = (r.is_priority || r.flagged || (r.flags_count > 0)) ? 1 : 0;
|
|
35
36
|
return `<tr class="review-row" data-status="${r.status || ''}" data-flags="${r.flags_count || 0}" data-highlights="${r.highlights_count || 0}" data-archived="${r.archived || 0}" data-priority="${isPri}" data-stage="${r.stage || r.status || ''}" data-navigate="/review/${r.id}" oncontextmenu="showCtxMenu(event,'${r.id}')" style="cursor:pointer">
|
|
36
|
-
<td style="width:32px"><button class="review-priority-star${isPri?' is-priority':''}" data-action="togglePriority" data-args='["${r.id}"]' aria-label="Toggle priority"
|
|
37
|
+
<td style="width:32px"><button class="review-priority-star${isPri?' is-priority':''}" data-action="togglePriority" data-args='["${r.id}"]' aria-label="Toggle priority" aria-pressed="${isPri?'true':'false'}" data-stop-propagation><svg width="16" height="16" viewBox="0 0 24 24" fill="${isPri?'currentColor':'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></button></td>
|
|
37
38
|
<td class="review-row-name">${r.name || r.title || 'Untitled'}</td>
|
|
38
|
-
<td style="font-size:13px;color:var(--color-text
|
|
39
|
+
<td style="font-size:13px;color:var(--color-text)">${r.engagement_name || r.engagement_id_display || '-'}</td>
|
|
39
40
|
<td>${statusBadge(r.status)}</td>
|
|
40
41
|
<td style="text-align:center;font-size:13px">${r.highlights_count !== undefined ? r.highlights_count : '-'}</td>
|
|
41
42
|
<td style="font-size:13px">${fmtDate(r.deadline)}</td>
|
|
42
|
-
<td style="font-size:13px;color:var(--color-text-
|
|
43
|
+
<td style="font-size:13px;color:var(--color-text-muted)">${fmtDate(r.created_at)}</td>
|
|
43
44
|
</tr>`;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -127,7 +128,7 @@ export function renderReviewListTabbed(user, reviews) {
|
|
|
127
128
|
});
|
|
128
129
|
})()`;
|
|
129
130
|
|
|
130
|
-
return page(user, 'Reviews |
|
|
131
|
+
return page(user, 'Reviews | Thatcher', bc, content, [TOAST_SCRIPT, tabScript, ctxScript, searchScript, priorityScript]);
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
export function reviewSearchField() {
|
|
@@ -149,7 +150,7 @@ export function reviewGroupedList(reviews, groupBy) {
|
|
|
149
150
|
`<div class="card-clean" style="margin-bottom:${SPACING.md}">
|
|
150
151
|
<div class="card-clean-body" style="padding:${SPACING.md}">
|
|
151
152
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:${SPACING.sm};cursor:pointer" data-action="toggleReviewGroup" data-self>
|
|
152
|
-
<span style="font-weight:600;font-size:14px">${name}</span>
|
|
153
|
+
<span style="font-weight:600;font-size:14px">${esc(name)}</span>
|
|
153
154
|
<span class="pill pill-neutral">${items.length}</span>
|
|
154
155
|
</div>
|
|
155
156
|
<div class="review-group-items">
|
package/src/ui/review-widgets.js
CHANGED
|
@@ -5,7 +5,7 @@ import { teamAvatarGroup } from '@/ui/widgets.js'
|
|
|
5
5
|
export function mobileReviewCard(review) {
|
|
6
6
|
const r = review || {}
|
|
7
7
|
const sts = r.status ? statusLabel(r.status) : ''
|
|
8
|
-
const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at}</span>` : ''
|
|
8
|
+
const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.created_at}</span>` : ''
|
|
9
9
|
return `<div class="mobile-card mobile-review-card card-clean">
|
|
10
10
|
<div class="card-clean-body">
|
|
11
11
|
<div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${esc(r.name || r.title || 'Untitled Review')}</h3>${sts}</div>
|
|
@@ -19,8 +19,8 @@ export function sidebarReviewDetails(review) {
|
|
|
19
19
|
const r = review || {}
|
|
20
20
|
const sts = r.status ? statusLabel(r.status) : ''
|
|
21
21
|
const meta = [
|
|
22
|
-
r.created_at ? ['Created', typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at] : null,
|
|
23
|
-
r.updated_at ? ['Updated', typeof r.updated_at === 'number' ? new Date(r.updated_at * 1000).toLocaleDateString() : r.updated_at] : null,
|
|
22
|
+
r.created_at ? ['Created', typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.created_at] : null,
|
|
23
|
+
r.updated_at ? ['Updated', typeof r.updated_at === 'number' ? new Date(r.updated_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : r.updated_at] : null,
|
|
24
24
|
r.creator_name ? ['Creator', r.creator_name] : null,
|
|
25
25
|
r.manager_name ? ['Manager', r.manager_name] : null,
|
|
26
26
|
].filter(Boolean).map(([l, v]) => `<div class="sidebar-meta-row"><span class="text-gray-500 text-xs">${l}</span><span class="text-sm font-medium">${v}</span></div>`).join('')
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { page } from '@/ui/layout.js';
|
|
2
2
|
import { canEdit, isPartner, isManager, isClientUser } from '@/ui/permissions-ui.js';
|
|
3
|
-
import { esc, statusBadge, TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
3
|
+
import { esc, statusBadge, TOAST_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
|
|
4
4
|
import { SPACING } from '@/ui/spacing-system.js';
|
|
5
5
|
|
|
6
6
|
const TOAST = TOAST_SCRIPT;
|
|
7
7
|
|
|
8
8
|
function questionRow(q, i, sections = [], clientId = '', showRespond = false, showView = false) {
|
|
9
9
|
const section = sections.find(s => s.id === q.section_id || s.id === q.rfi_section_id);
|
|
10
|
-
const sLabel = section ? `<span class="badge badge-
|
|
10
|
+
const sLabel = section ? `<span class="badge badge-primary text-xs">${esc(section.name)}</span>` : '';
|
|
11
11
|
const hasResp = q.responses > 0 || q.response_count > 0;
|
|
12
12
|
const respBadge = hasResp ? `<span class="badge badge-success badge-flat-success text-xs">Responded</span>` : '';
|
|
13
13
|
const assignedLabel = q.assigned_to ? `<span class="badge badge-flat-secondary text-xs">Assigned</span>` : '';
|
|
@@ -16,10 +16,10 @@ function questionRow(q, i, sections = [], clientId = '', showRespond = false, sh
|
|
|
16
16
|
const viewBtn = showView && hasResp ? `<button data-action="viewResponses" data-args='["${esc(q.id)}","${esc(q.question_text||q.question||q.title||'')}"]' class="btn btn-ghost btn-xs">Responses</button>` : '';
|
|
17
17
|
const editDelBtns = !showRespond ? `<button data-action="openEditQuestion" data-args='["${esc(q.id)}"]' class="btn btn-ghost btn-xs">Edit</button>${assignBtn}<button data-action="deleteQuestion" data-args='["${esc(q.id)}"]' class="btn btn-error btn-xs">Del</button>` : '';
|
|
18
18
|
return `<tr class="hover">
|
|
19
|
-
<td class="text-center text-sm text-base-content
|
|
19
|
+
<td class="text-center text-sm text-base-content w-8">${i+1}</td>
|
|
20
20
|
<td class="text-sm max-w-sm">${esc(q.question_text||q.question||q.title||'Question')}</td>
|
|
21
21
|
<td>${sLabel}</td>
|
|
22
|
-
<td class="text-sm text-base-content
|
|
22
|
+
<td class="text-sm text-base-content">${q.deadline?new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'}</td>
|
|
23
23
|
<td>${respBadge}${assignedLabel}</td>
|
|
24
24
|
<td><div class="flex gap-2">${respondBtn}${viewBtn}${editDelBtns}</div></td>
|
|
25
25
|
</tr>`;
|
|
@@ -27,15 +27,15 @@ function questionRow(q, i, sections = [], clientId = '', showRespond = false, sh
|
|
|
27
27
|
|
|
28
28
|
function editQuestionDialog(rfiId, sections) {
|
|
29
29
|
const sectionOpts = sections.map(s => `<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');
|
|
30
|
-
return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><
|
|
30
|
+
return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="q-dialog-title"><h2 class="text-lg font-semibold mb-4" id="q-dialog-title">Edit Question</h2><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Question Text</span></label><textarea id="q-text" rows="4" class="textarea textarea-solid max-w-full"></textarea></div><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Section</span></label><select id="q-section" class="select select-solid max-w-full"><option value="">No section</option>${sectionOpts}</select></div><div class="grid grid-cols-2 gap-3 mb-4"><div class="form-group"><label class="label"><span class="label-text font-medium">Deadline</span></label><input type="date" id="q-deadline" class="input input-solid max-w-full"/></div><div class="form-group"><label class="label"><span class="label-text font-medium">Category</span></label><input type="text" id="q-category" placeholder="Category" class="input input-solid max-w-full"/></div></div><input type="hidden" id="q-edit-id"/><div class="modal-action"><button data-action="saveQuestion" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Save</button><button data-dialog-close="q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function assignQuestionDialog(rfiId) {
|
|
34
|
-
return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="assign-q-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><
|
|
34
|
+
return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="assign-q-dialog"></div><div class="modal-content rounded-box max-w-sm p-6" aria-labelledby="assign-q-dialog-title"><h2 class="text-lg font-semibold mb-4" id="assign-q-dialog-title">Assign to Client User</h2><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Client User</span></label><select id="assign-q-user" class="select select-solid max-w-full"><option value="">Loading...</option></select></div><input type="hidden" id="assign-q-id"/><div class="modal-action"><button data-action="saveAssignQuestion" class="btn btn-primary">Assign</button><button data-dialog-close="assign-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function addSectionDialog(rfiId) {
|
|
38
|
-
return `<div id="sec-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="sec-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><
|
|
38
|
+
return `<div id="sec-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="sec-dialog"></div><div class="modal-content rounded-box max-w-sm p-6" aria-labelledby="sec-dialog-title"><h2 class="text-lg font-semibold mb-4" id="sec-dialog-title">Add Section</h2><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Section Name</span></label><input type="text" id="sec-name" class="input input-solid max-w-full"/></div><div class="modal-action"><button data-action="saveSection" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Add</button><button data-dialog-close="sec-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], engagement = null) {
|
|
@@ -44,7 +44,11 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
44
44
|
const canViewResponses = isPartner(user) || isManager(user);
|
|
45
45
|
const clientId = engagement?.client_id || '';
|
|
46
46
|
const qRows = questions.map((q, i) => questionRow(q, i, sections, canEditRfi ? clientId : '', isClient, canViewResponses)).join('') ||
|
|
47
|
-
`<tr><td colspan="6" class="
|
|
47
|
+
`<tr><td colspan="6"><div class="empty-state">
|
|
48
|
+
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="9" y="3" width="6" height="4" rx="1"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="9" y1="16" x2="12" y2="16"/></svg>
|
|
49
|
+
<div class="empty-state-title">No questions yet</div>
|
|
50
|
+
<div class="empty-state-desc">Add one using the + Question button above.</div>
|
|
51
|
+
</div></td></tr>`;
|
|
48
52
|
|
|
49
53
|
const sectionTabs = sections.length ? `<div class="tabs tabs-boxed bg-base-200 flex-wrap gap-1">
|
|
50
54
|
<button data-action="filterBySection" data-args='[""]' id="stab-all" class="tab-btn active">All</button>
|
|
@@ -64,16 +68,16 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
64
68
|
` : ''}</div>`;
|
|
65
69
|
|
|
66
70
|
const infoItems = [
|
|
67
|
-
['Engagement', engagement?.name ? esc(engagement.name) : '<span class="text-base-content
|
|
71
|
+
['Engagement', engagement?.name ? esc(engagement.name) : '<span class="text-base-content">—</span>'],
|
|
68
72
|
['Status', statusBadge(rfi.status)],
|
|
69
|
-
['Deadline', rfi.deadline?new Date(typeof rfi.deadline==='number'?rfi.deadline*1000:rfi.deadline).toLocaleDateString():'-'],
|
|
73
|
+
['Deadline', rfi.deadline?new Date(typeof rfi.deadline==='number'?rfi.deadline*1000:rfi.deadline).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}):'-'],
|
|
70
74
|
['Questions', questions.length],
|
|
71
75
|
['Sections', sections.length],
|
|
72
76
|
['Mandatory', rfi.mandatory!==false?'Yes':'No'],
|
|
73
77
|
];
|
|
74
78
|
|
|
75
79
|
const infoGrid = `<div class="rfi-info-grid">` +
|
|
76
|
-
infoItems.map(([l,v]) => `<div><div
|
|
80
|
+
infoItems.map(([l,v]) => `<div><div class="info-label-key">${l}</div><div class="info-label-value">${v}</div></div>`).join('') +
|
|
77
81
|
`</div>`;
|
|
78
82
|
|
|
79
83
|
const content = `
|
|
@@ -91,16 +95,16 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
91
95
|
<div class="card-clean">
|
|
92
96
|
<div class="card-clean-body">
|
|
93
97
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:${SPACING.md};flex-wrap:wrap;gap:${SPACING.sm}">
|
|
94
|
-
<
|
|
98
|
+
<h3 style="font-size:1rem;font-weight:600;color:var(--color-text)">Questions (${questions.length})</h3>
|
|
95
99
|
<div style="display:flex;gap:${SPACING.sm};align-items:center;flex-wrap:wrap">
|
|
96
100
|
${sectionTabs}
|
|
97
101
|
<input type="text" placeholder="Search..." oninput="filterQuestions(this.value)" class="input input-solid" style="max-width:160px;font-size:0.875rem"/>
|
|
98
102
|
</div>
|
|
99
103
|
</div>
|
|
100
104
|
<div class="table-wrap">
|
|
101
|
-
<table class="data-table" id="q-table">
|
|
102
|
-
<thead><tr><th>#</th><th>Question</th><th>Section</th><th>Deadline</th><th>Status</th><th>Actions</th></tr></thead>
|
|
103
|
-
<tbody id="q-tbody">${qRows}</tbody>
|
|
105
|
+
<table class="data-table" id="q-table" aria-label="Questions table">
|
|
106
|
+
<thead><tr><th scope="col">#</th><th scope="col">Question</th><th scope="col">Section</th><th scope="col">Deadline</th><th scope="col">Status</th><th scope="col">Actions</th></tr></thead>
|
|
107
|
+
<tbody id="q-tbody">${qRows || `<tr><td colspan="6" role="status" aria-live="polite" style="padding:${SPACING.md}"><span class="sr-only">Loading questions…</span><div class="skeleton" style="height:40px"></div></td></tr>`}</tbody>
|
|
104
108
|
</table>
|
|
105
109
|
</div>
|
|
106
110
|
</div>
|
|
@@ -108,11 +112,12 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
108
112
|
${editQuestionDialog(rfi.id, sections)}
|
|
109
113
|
${addSectionDialog(rfi.id)}
|
|
110
114
|
${clientId ? assignQuestionDialog(rfi.id) : ''}
|
|
111
|
-
${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="view-resp-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><
|
|
112
|
-
${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="respond-q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><
|
|
115
|
+
${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="view-resp-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="view-resp-dialog-title"><h2 class="text-lg font-semibold mb-1" id="view-resp-dialog-title">Responses</h2><p id="view-resp-qtext" class="text-sm text-base-content mb-3"></p><div id="view-resp-list" style="max-height:240px;overflow-y:auto"></div><div class="modal-action"><button data-dialog-close="view-resp-dialog" class="btn btn-ghost">Close</button></div></div></div>` : ''}
|
|
116
|
+
${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="respond-q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6" aria-labelledby="respond-q-dialog-title"><h2 class="text-lg font-semibold mb-1" id="respond-q-dialog-title">Respond to Question</h2><p id="respond-q-text" class="text-sm text-base-content mb-4"></p><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Your Response</span></label><textarea id="respond-q-answer" rows="5" class="textarea textarea-solid max-w-full" placeholder="Enter your response..."></textarea></div><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Attach File (optional)</span></label><input type="file" id="respond-q-file" class="input input-solid max-w-full"/></div><input type="hidden" id="respond-q-id"/><div class="modal-action"><button data-action="submitResponse" class="btn btn-primary">Submit</button><button data-dialog-close="respond-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>` : ''}
|
|
113
117
|
`;
|
|
114
118
|
|
|
115
119
|
const script = `${TOAST}
|
|
120
|
+
(function(){if(!document.getElementById('rfi-detail-styles')){var style=document.createElement('style');style.id='rfi-detail-styles';style.textContent='.info-label-key{font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:var(--color-text-muted);margin-bottom:${SPACING.xs}} .info-label-value{font-size:0.875rem;color:var(--color-text)} .tab-btn:focus-visible{outline:2px solid var(--color-primary);outline-offset:-2px}';document.head.appendChild(style)}})();
|
|
116
121
|
var activeSection='';
|
|
117
122
|
function filterBySection(sid){
|
|
118
123
|
activeSection=sid;
|
|
@@ -121,6 +126,17 @@ function filterBySection(sid){
|
|
|
121
126
|
}
|
|
122
127
|
function filterRows(){var q=(document.querySelector('#q-table input')?.value||'').toLowerCase();document.querySelectorAll('#q-tbody tr').forEach(function(r){var text=(r.textContent||'').toLowerCase();var secSpan=r.querySelector('td:nth-child(3) span');var rowSec=secSpan?secSpan.textContent:'';var showSec=!activeSection||rowSec===document.getElementById('stab-'+activeSection)?.textContent;r.style.display=(!q||text.includes(q))&&showSec?'':'none'})}
|
|
123
128
|
function filterQuestions(q){filterRows()}
|
|
129
|
+
(function(){
|
|
130
|
+
var tabBtns=document.querySelectorAll('[id^="stab-"]');
|
|
131
|
+
tabBtns.forEach(function(btn,idx){
|
|
132
|
+
btn.setAttribute('role','tab');
|
|
133
|
+
btn.setAttribute('tabindex',btn.classList.contains('active')?'0':'-1');
|
|
134
|
+
btn.setAttribute('aria-selected',btn.classList.contains('active')?'true':'false');
|
|
135
|
+
btn.addEventListener('click',function(){tabBtns.forEach(b=>b.setAttribute('tabindex','-1'));btn.setAttribute('tabindex','0');btn.focus()});
|
|
136
|
+
});
|
|
137
|
+
var searchInput=document.querySelector('#q-table input');
|
|
138
|
+
if(searchInput){searchInput.setAttribute('tabindex','0')}
|
|
139
|
+
})();
|
|
124
140
|
function openAddQuestion(){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Add Question';document.getElementById('q-edit-id').value='';document.getElementById('q-text').value='';document.getElementById('q-section').value='';document.getElementById('q-deadline').value='';document.getElementById('q-category').value=''}
|
|
125
141
|
async function openEditQuestion(id){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Edit Question';document.getElementById('q-edit-id').value=id;try{var rfiId=document.getElementById('q-dialog').dataset.rfiId;var r=await fetch('/api/rfi/'+rfiId+'/questions/'+id);var d=await r.json();var q=d.data||d;document.getElementById('q-text').value=q.question_text||q.question||q.title||'';document.getElementById('q-section').value=q.section_id||q.rfi_section_id||'';document.getElementById('q-deadline').value=q.deadline?(new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline)).toISOString().split('T')[0]:'';document.getElementById('q-category').value=q.category||''}catch(e){showToast('Error loading','error')}}
|
|
126
142
|
async function saveQuestion(rfiId){var id=document.getElementById('q-edit-id').value;var questionText=document.getElementById('q-text').value.trim();if(!questionText){showToast('Question text required','error');return}var data={question:questionText,category:document.getElementById('q-category').value.trim()||null,section_id:document.getElementById('q-section').value||null};var dl=document.getElementById('q-deadline').value;if(dl)data.due_date=Math.floor(new Date(dl).getTime()/1000);var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Saving...'}try{var url=id?'/api/rfi/'+rfiId+'/questions/'+id:'/api/rfi/'+rfiId+'/questions';var r=await fetch(url,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});var result=await r.json();if(r.ok){showToast(id?'Question updated':'Question added','success');document.getElementById('q-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{showToast(result.message||result.error||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}finally{if(btn){btn.disabled=false;btn.textContent='Save'}}}
|
|
@@ -129,6 +145,7 @@ async function saveSection(rfiId){var name=document.getElementById('sec-name').v
|
|
|
129
145
|
async function sendReminder(rfiId){try{var r=await fetch('/api/friday/rfi/reminder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rfi_id:rfiId})});if(r.ok)showToast('Reminder sent','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}}`;
|
|
130
146
|
|
|
131
147
|
const assignScript = clientId ? `
|
|
148
|
+
function rdEsc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
132
149
|
var _assignClientId=${JSON.stringify(clientId)};
|
|
133
150
|
window.openAssignQuestion=async function(qId){
|
|
134
151
|
var dlg=document.getElementById('assign-q-dialog');
|
|
@@ -140,7 +157,7 @@ window.openAssignQuestion=async function(qId){
|
|
|
140
157
|
var r=await fetch('/api/users?client_id='+_assignClientId+'&pageSize=100',{credentials:'include'});
|
|
141
158
|
var d=await r.json();
|
|
142
159
|
var users=(d.data||d||[]).filter(function(u){return !u.deleted_at});
|
|
143
|
-
sel.innerHTML='<option value="">Select client user...</option>'+users.map(function(u){return'<option value="'+u.id+'">'+(u.name||u.email)+'</option>'}).join('');
|
|
160
|
+
sel.innerHTML='<option value="">Select client user...</option>'+users.map(function(u){return'<option value="'+rdEsc(u.id)+'">'+rdEsc(u.name||u.email)+'</option>'}).join('');
|
|
144
161
|
}catch(e){sel.innerHTML='<option value="">Error loading users</option>'}
|
|
145
162
|
};
|
|
146
163
|
window.saveAssignQuestion=async function(){
|
|
@@ -157,7 +174,7 @@ window.saveAssignQuestion=async function(){
|
|
|
157
174
|
|
|
158
175
|
const viewRespScript = canViewResponses ? `
|
|
159
176
|
var _rfiIdForView=${JSON.stringify(rfi.id)};
|
|
160
|
-
window.viewResponses=async function(qId,qText){document.getElementById('view-resp-qtext').textContent=qText||'';document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-base-content/40">Loading...</div>';document.getElementById('view-resp-dialog').style.display='flex';try{var r=await fetch('/api/rfi/'+_rfiIdForView+'/questions/'+qId+'/responses',{credentials:'include'});var d=await r.json();var items=d.data||d||[];document.getElementById('view-resp-list').innerHTML=items.length?items.map(function(resp){return'<div style="border:1px solid var(--color-border);border-radius:6px;padding:8px;margin-bottom:6px"><div class="text-sm">'+resp.response+'</div><div class="text-xs text-base-content/40 mt-1">'+
|
|
177
|
+
window.viewResponses=async function(qId,qText){document.getElementById('view-resp-qtext').textContent=qText||'';document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-base-content/40">Loading...</div>';document.getElementById('view-resp-dialog').style.display='flex';try{var r=await fetch('/api/rfi/'+_rfiIdForView+'/questions/'+qId+'/responses',{credentials:'include'});var d=await r.json();var items=d.data||d||[];document.getElementById('view-resp-list').innerHTML=items.length?items.map(function(resp){return'<div style="border:1px solid var(--color-border);border-radius:6px;padding:8px;margin-bottom:6px"><div class="text-sm">'+resp.response+'</div><div class="text-xs text-base-content/40 mt-1">'+window.fmtDateTime(resp.created_at)+'</div></div>'}).join(''):'<div class="text-sm text-base-content/40">No responses yet</div>'}catch(e){document.getElementById('view-resp-list').innerHTML='<div class="text-sm text-error">Error loading</div>'}};` : '';
|
|
161
178
|
|
|
162
179
|
const respondScript = isClient ? `
|
|
163
180
|
var _respondRfiId=${JSON.stringify(rfi.id)};
|
|
@@ -187,5 +204,5 @@ window.submitResponse=async function(){
|
|
|
187
204
|
finally{if(btn){btn.disabled=false;btn.textContent='Submit'}}
|
|
188
205
|
};` : '';
|
|
189
206
|
|
|
190
|
-
return page(user, `${esc(rfi.name||'RFI')} |
|
|
207
|
+
return page(user, `${esc(rfi.name||'RFI')} | Thatcher`, null, content, [script, assignScript, viewRespScript, respondScript]);
|
|
191
208
|
}
|
|
@@ -11,14 +11,14 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
|
|
|
11
11
|
const statusBadge = (s) => { const color = s === 'answered' || s === 'accepted' ? '#22c55e' : s === 'overdue' ? '#ef4444' : '#f59e0b'; return `<span style="padding:2px 8px;border-radius:12px;font-size:0.7rem;font-weight:700;background:${color}20;color:${color}">${s || 'pending'}</span>`; };
|
|
12
12
|
const qRows = questions.map((q, i) => {
|
|
13
13
|
const resps = respMap[q.id] || [];
|
|
14
|
-
const respHtml = resps.length ? resps.map(r => `<div style="margin-top:6px;padding:6px 10px;background:#f8fafc;border-left:3px solid #3b82f6;font-size:0.75rem">${esc(r.response||'')}<div style="margin-top:4px;color:#
|
|
14
|
+
const respHtml = resps.length ? resps.map(r => `<div style="margin-top:6px;padding:6px 10px;background:#f8fafc;border-left:3px solid #3b82f6;font-size:0.75rem">${esc(r.response||'')}<div style="margin-top:4px;color:#475569;font-size:0.65rem">${fmtDate(r.created_at)}</div></div>`).join('') : '<div style="color:#475569;font-size:0.75rem;margin-top:4px">No response yet</div>';
|
|
15
15
|
return `<div style="margin-bottom:16px;padding:12px;border:1px solid #e2e8f0;border-radius:6px;break-inside:avoid">
|
|
16
16
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px">
|
|
17
17
|
<div style="font-size:0.85rem;font-weight:600">${i+1}. ${esc(q.question||q.text||'Question')}</div>
|
|
18
18
|
${statusBadge(q.status)}
|
|
19
19
|
</div>
|
|
20
|
-
${q.due_date ? `<div style="color:#
|
|
21
|
-
${q.assigned_to ? `<div style="color:#
|
|
20
|
+
${q.due_date ? `<div style="color:#475569;font-size:0.7rem;margin-bottom:6px">Due: ${fmtDate(q.due_date)}</div>` : ''}
|
|
21
|
+
${q.assigned_to ? `<div style="color:#475569;font-size:0.7rem;margin-bottom:6px">Assigned: ${esc(q.assigned_to_name||q.assigned_to)}</div>` : ''}
|
|
22
22
|
${respHtml}
|
|
23
23
|
</div>`;
|
|
24
24
|
}).join('');
|
|
@@ -27,20 +27,25 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
|
|
|
27
27
|
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"/>
|
|
28
28
|
<title>RFI Report — ${esc(rfi.name || rfi.title || 'RFI')}</title>
|
|
29
29
|
<style>
|
|
30
|
-
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 24px; color: #1e293b; font-size: 14px; }
|
|
30
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 24px; color: #1e293b; font-size: 14px; background: #fff; }
|
|
31
31
|
.header { border-bottom: 2px solid #3b82f6; padding-bottom: 16px; margin-bottom: 24px; }
|
|
32
|
-
.meta { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0; font-size: 0.8rem; color: #
|
|
32
|
+
.meta { display: flex; gap: 24px; flex-wrap: wrap; margin: 12px 0; font-size: 0.8rem; color: #475569; }
|
|
33
33
|
.meta-item { display: flex; flex-direction: column; }
|
|
34
34
|
.meta-label { font-weight: 600; margin-bottom: 2px; }
|
|
35
35
|
.progress-bar { height: 8px; background: #e2e8f0; border-radius: 4px; margin: 8px 0; }
|
|
36
36
|
.progress-fill { height: 100%; border-radius: 4px; background: #3b82f6; }
|
|
37
37
|
.no-print { padding: 12px 0; }
|
|
38
38
|
@media print { .no-print { display: none; } body { padding: 12px; } }
|
|
39
|
+
@media (prefers-color-scheme: dark) {
|
|
40
|
+
body { color: #e2e8f0; background: #1e293b; }
|
|
41
|
+
.meta { color: #94a3b8; }
|
|
42
|
+
.progress-bar { background: #334155; }
|
|
43
|
+
}
|
|
39
44
|
</style></head><body>
|
|
40
|
-
<div class="no-print"><button onclick="window.print()" style="padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem">Print / Save PDF</button> <a href="/rfi/${esc(rfi.id)}" style="margin-left:12px;font-size:0.85rem;color:#3b82f6"
|
|
45
|
+
<div class="no-print"><button onclick="window.print()" aria-label="Print or save as PDF" style="padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.85rem">Print / Save PDF</button> <a href="/rfi/${esc(rfi.id)}" title="Back to RFI" style="margin-left:12px;font-size:0.85rem;color:#3b82f6"><- Back to RFI</a></div>
|
|
41
46
|
<div class="header">
|
|
42
47
|
<div style="font-size:1.4rem;font-weight:700">${esc(rfi.name||rfi.title||'RFI')}</div>
|
|
43
|
-
<div style="color:#
|
|
48
|
+
<div style="color:#475569;font-size:0.85rem;margin-top:4px">${esc(e.name||e.client_name||'')}</div>
|
|
44
49
|
<div class="meta">
|
|
45
50
|
<div class="meta-item"><span class="meta-label">Status</span>${esc(rfi.status||'-')}</div>
|
|
46
51
|
<div class="meta-item"><span class="meta-label">Created</span>${fmtDate(rfi.created_at)}</div>
|
|
@@ -50,7 +55,7 @@ export function renderRfiReport(user, rfi, engagement, questions, responses) {
|
|
|
50
55
|
</div>
|
|
51
56
|
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%"></div></div>
|
|
52
57
|
</div>
|
|
53
|
-
<div>${qRows || '<div style="color:#
|
|
54
|
-
<div style="margin-top:24px;padding-top:12px;border-top:1px solid #e2e8f0;font-size:0.7rem;color:#
|
|
58
|
+
<div>${qRows || '<div style="color:#475569;text-align:center;padding:24px">No questions found</div>'}</div>
|
|
59
|
+
<div style="margin-top:24px;padding-top:12px;border-top:1px solid #e2e8f0;font-size:0.7rem;color:#475569;text-align:center">Generated ${new Date().toLocaleString('en-ZA')} - Thatcher</div>
|
|
55
60
|
</body></html>`;
|
|
56
61
|
}
|
|
@@ -23,7 +23,7 @@ export function renderSettingsChecklists(user, checklists = []) {
|
|
|
23
23
|
const rows = checklists.map(c => `<tr ${trClick('/checklist/'+c.id)}>
|
|
24
24
|
<td class="text-sm font-medium">${esc(c.name||'-')}</td>
|
|
25
25
|
<td class="text-sm">${esc(c.type||'-')}</td>
|
|
26
|
-
<td class="text-sm text-base-content/
|
|
26
|
+
<td class="text-sm text-base-content/70">${esc(c.review_id||'-')}</td>
|
|
27
27
|
<td>${editBtn('/checklist/'+c.id+'/edit')}</td>
|
|
28
28
|
</tr>`).join('');
|
|
29
29
|
return settingsPage(user, 'Checklists - Settings', bc('Checklists'), hdr('Checklists', '/checklist/new', '+ Add Checklist') + inlineTable(['Name', 'Type', 'Review', 'Actions'], rows, 'No checklists found'));
|
|
@@ -43,11 +43,12 @@ export function renderSettingsRecreation(user, _logs = [], _users = []) {
|
|
|
43
43
|
<div class="card-clean"><div class="card-clean-body" style="padding:0">
|
|
44
44
|
<div class="table-wrap"><table class="data-table">
|
|
45
45
|
<thead><tr><th>Timestamp</th><th>Operation</th><th>Entity</th><th>Entity ID</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
|
|
46
|
-
<tbody id="audit-tbody"><tr><td colspan="7" class="text-center py-8 text-base-content/
|
|
46
|
+
<tbody id="audit-tbody"><tr><td colspan="7" class="text-center py-8 text-base-content/70 text-sm">Loading...</td></tr></tbody>
|
|
47
47
|
</table></div>
|
|
48
48
|
</div></div>`;
|
|
49
49
|
const script = `${TOAST_SCRIPT}
|
|
50
|
-
|
|
50
|
+
function _esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
51
|
+
async function loadAuditLogs(params){const tbody=document.getElementById('audit-tbody');try{const q=params?'?'+new URLSearchParams(params):'';const r=await fetch('/api/audit/logs'+q);const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(l){const ts=l.timestamp;const date=ts?window.fmtDateTime(ts):'-';const details=l.details?JSON.stringify(l.details).substring(0,60)+'...':'-';return'<tr><td class="text-xs text-base-content/70">'+_esc(date)+'</td><td><span class="badge badge-flat-primary text-xs">'+_esc(l.operation||'-')+'</span></td><td class="text-sm">'+_esc(l.entity_type||'-')+'</td><td class="text-xs text-base-content/70">'+_esc(l.entity_id||'-')+'</td><td class="text-sm">'+_esc(l.user_id||'-')+'</td><td class="text-sm">'+_esc(l.action||'-')+'</td><td class="text-xs text-base-content/70">'+_esc(details)+'</td></tr>'}).join(''):'<tr><td colspan="7" class="text-center py-8 text-base-content/70 text-sm">No audit logs found</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="7" class="text-center py-4 text-error text-sm">Failed to load: '+_esc(e.message)+'</td></tr>'}}
|
|
51
52
|
function applyAuditFilters(){const level=document.getElementById('filter-level').value;const entity=document.getElementById('filter-entity').value;const start=document.getElementById('filter-start').value;const end=document.getElementById('filter-end').value;const p={};if(level)p.level=level;if(entity)p.entityType=entity;if(start)p.from=new Date(start).toISOString();if(end)p.to=new Date(end+'T23:59:59').toISOString();loadAuditLogs(p)}
|
|
52
53
|
loadAuditLogs();`;
|
|
53
54
|
return settingsPage(user, 'Audit Logs - Settings', bc('Audit Logs'), content, [script]);
|
|
@@ -67,7 +68,7 @@ export function renderSettingsIntegrations(user, integrations = {}) {
|
|
|
67
68
|
return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body">
|
|
68
69
|
<div class="flex items-center gap-4">
|
|
69
70
|
<div class="text-2xl">${integ.icon}</div>
|
|
70
|
-
<div class="flex-1"><div class="font-semibold">${integ.name}</div><div class="text-xs text-base-content/
|
|
71
|
+
<div class="flex-1"><div class="font-semibold">${integ.name}</div><div class="text-xs text-base-content/70">${integ.desc}</div></div>
|
|
71
72
|
<div class="flex flex-col items-end gap-2">
|
|
72
73
|
<span class="badge ${connected ? 'badge-success badge-flat-success' : 'badge-flat-secondary'} text-xs">${connected ? 'Connected' : 'Disconnected'}</span>
|
|
73
74
|
<button data-action="toggleConfig" data-args='["${integ.id}"]' class="btn btn-ghost btn-xs">Configure</button>
|
|
@@ -75,7 +76,7 @@ export function renderSettingsIntegrations(user, integrations = {}) {
|
|
|
75
76
|
</div>
|
|
76
77
|
<div id="config-${integ.id}" class="hidden mt-4 pt-4 border-t border-base-200">
|
|
77
78
|
<div class="flex gap-3 items-end flex-wrap">
|
|
78
|
-
<div class="form-group flex-1 min-w-48"><label class="label"><span class="label-text font-semibold">API Key / Credentials</span></label><input type="password" id="key-${integ.id}" class="input input-solid max-w-full" placeholder="
|
|
79
|
+
<div class="form-group flex-1 min-w-48"><label class="label"><span class="label-text font-semibold">API Key / Credentials</span></label><input type="password" id="key-${integ.id}" class="input input-solid max-w-full" placeholder="Paste the API key or credentials"/></div>
|
|
79
80
|
<div class="flex gap-2"><button data-action="saveIntegration" data-args='["${integ.id}"]' class="btn btn-primary btn-sm">Save</button><button data-action="testIntegration" data-args='["${integ.id}"]' class="btn btn-ghost btn-sm">Test</button></div>
|
|
80
81
|
</div>
|
|
81
82
|
</div>
|
|
@@ -90,19 +91,19 @@ export function renderSettingsIntegrations(user, integrations = {}) {
|
|
|
90
91
|
<button class="btn btn-ghost btn-sm" data-action="copyPrivateKey">Copy</button>
|
|
91
92
|
<button class="btn btn-primary btn-sm" data-action="generatePrivateKey">Generate New Key</button>
|
|
92
93
|
</div>
|
|
93
|
-
<div class="text-xs text-base-content/
|
|
94
|
+
<div class="text-xs text-base-content/70 mt-1">This key links Friday and My Review. Keep it confidential — anyone with access can import data.</div>
|
|
94
95
|
</div>
|
|
95
96
|
<div class="mb-4">
|
|
96
97
|
<label class="label"><span class="label-text font-semibold">My Review API Key (from MWR app)</span></label>
|
|
97
98
|
<input type="text" id="mwr-api-key" class="input input-solid w-full" value="${esc(mwr_api_key)}" placeholder="Paste the key from My Review Integrations settings"/>
|
|
98
|
-
<div class="text-xs text-base-content/
|
|
99
|
+
<div class="text-xs text-base-content/70 mt-1">Enter the private key generated in the My Review settings to enable cross-app access.</div>
|
|
99
100
|
</div>
|
|
100
101
|
<button class="btn btn-primary btn-sm" data-action="saveApiKeys">Save API Keys</button>
|
|
101
102
|
</div></div>`;
|
|
102
103
|
const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Integrations</h1>${apiKeysCard}${cards}`;
|
|
103
104
|
const script = `${TOAST_SCRIPT}
|
|
104
105
|
function toggleConfig(id){const el=document.getElementById('config-'+id);el.classList.toggle('hidden')}
|
|
105
|
-
function saveIntegration(id){const key=document.getElementById('key-'+id).value;if(!key){showToast('Enter credentials','error');return}showToast('Integration saved','success');toggleConfig(id)}
|
|
106
|
+
function saveIntegration(id){const key=document.getElementById('key-'+id).value.trim();if(!key){showToast('Enter the API key or credentials before saving','error');return}showToast('Integration credentials saved','success');toggleConfig(id)}
|
|
106
107
|
function testIntegration(id){showToast('Testing connection...','info');setTimeout(()=>showToast('Test complete','success'),1000)}
|
|
107
108
|
function generatePrivateKey(){const arr=new Uint8Array(32);crypto.getRandomValues(arr);const key=Array.from(arr,b=>b.toString(16).padStart(2,'0')).join('');document.getElementById('mwr-private-key').value=key}
|
|
108
109
|
function copyPrivateKey(){const v=document.getElementById('mwr-private-key').value;if(!v){showToast('No key to copy','error');return}navigator.clipboard.writeText(v).then(()=>showToast('Copied to clipboard','success')).catch(()=>showToast('Copy failed','error'))}
|
|
@@ -114,7 +115,7 @@ export function renderSettingsNotifications(user, config = {}) {
|
|
|
114
115
|
const t = config.thresholds || {};
|
|
115
116
|
const rfi = t.rfi || {};
|
|
116
117
|
const notif = t.notification || {};
|
|
117
|
-
const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/
|
|
118
|
+
const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/70">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
|
|
118
119
|
const toggles = [
|
|
119
120
|
{ id: 'rfi_reminders', label: 'RFI Reminders', desc: 'Send reminders for outstanding RFIs', checked: true },
|
|
120
121
|
{ id: 'deadline_alerts', label: 'Deadline Alerts', desc: 'Alert when deadlines are approaching', checked: true },
|
|
@@ -122,10 +123,10 @@ export function renderSettingsNotifications(user, config = {}) {
|
|
|
122
123
|
{ id: 'new_messages', label: 'New Messages', desc: 'Notify when new messages are received', checked: true },
|
|
123
124
|
{ id: 'weekly_reports', label: 'Weekly Reports', desc: 'Send weekly summary reports', checked: true },
|
|
124
125
|
];
|
|
125
|
-
const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none" >
|
|
126
|
+
const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none" role="dialog" aria-modal="true" aria-labelledby="notif-trigger-dialog-title">
|
|
126
127
|
<div class="modal-overlay" data-dialog-close="notif-trigger-dialog"></div>
|
|
127
128
|
<div class="modal-content rounded-box max-w-md p-6">
|
|
128
|
-
<h3 class="text-lg font-semibold mb-4">Add Notification Trigger</h3>
|
|
129
|
+
<h3 id="notif-trigger-dialog-title" class="text-lg font-semibold mb-4">Add Notification Trigger</h3>
|
|
129
130
|
<div class="form-group mb-3"><label class="form-label">Trigger Type</label><select id="ntd-type" class="form-input"><option value="before">Before</option><option value="after">After</option></select></div>
|
|
130
131
|
<div class="form-group mb-3"><label class="form-label">Days</label><input id="ntd-days" type="number" min="0" max="365" value="7" class="form-input"/></div>
|
|
131
132
|
<div class="form-group mb-3"><label class="form-label">Reference Date</label><select id="ntd-ref" class="form-input"><option value="commencement_date">Commencement Date</option><option value="deadline_date">Deadline Date</option></select></div>
|
|
@@ -150,13 +151,14 @@ export function renderSettingsNotifications(user, config = {}) {
|
|
|
150
151
|
<div class="card-clean"><div class="card-clean-body" style="padding:0">
|
|
151
152
|
<div class="table-wrap"><table class="data-table" id="triggers-table">
|
|
152
153
|
<thead><tr><th>Type</th><th>Days</th><th>Reference</th><th>Recipient</th><th>Status</th><th></th></tr></thead>
|
|
153
|
-
<tbody id="triggers-tbody"><tr><td colspan="6" class="text-center py-8 text-base-content/
|
|
154
|
+
<tbody id="triggers-tbody"><tr><td colspan="6" class="text-center py-8 text-base-content/70 text-sm">Loading...</td></tr></tbody>
|
|
154
155
|
</table></div>
|
|
155
156
|
</div></div>
|
|
156
157
|
${triggerDialog}`;
|
|
157
158
|
const script = `${TOAST_SCRIPT}
|
|
158
159
|
document.getElementById('notif-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#notif-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});try{const res=await fetch('/api/admin/settings/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}});
|
|
159
|
-
|
|
160
|
+
function _esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
|
|
161
|
+
async function loadTriggers(){const tbody=document.getElementById('triggers-tbody');try{const r=await fetch('/api/friday/engagement/notifications');const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(t){return'<tr><td class="text-sm">'+_esc(t.trigger_type)+'</td><td class="text-sm">'+_esc(t.trigger_days)+' days</td><td class="text-sm">'+_esc(t.trigger_reference)+'</td><td class="text-sm">'+_esc(t.recipient_type)+'</td><td>'+(t.active?'<span class="badge badge-flat-success text-xs">Active</span>':'<span class="badge badge-flat-secondary text-xs">Inactive</span>')+'</td><td><button class="btn btn-ghost btn-xs" data-action="deleteNotifTrigger" data-args=\\'["\\'+encodeURIComponent(t.id)+\\'"]\\'>Delete</button></td></tr>'}).join(''):'<tr><td colspan="6" class="text-center py-8 text-base-content/70 text-sm">No triggers configured</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="6" class="text-center py-4 text-error text-sm">Failed to load</td></tr>'}}
|
|
160
162
|
function openNotifTriggerDialog(){document.getElementById('notif-trigger-dialog').style.display='flex'}
|
|
161
163
|
async function saveNotifTrigger(){const type=document.getElementById('ntd-type').value;const days=Number(document.getElementById('ntd-days').value);const ref=document.getElementById('ntd-ref').value;const recipient=document.getElementById('ntd-recipient').value;const active=document.getElementById('ntd-active').checked;if(!days&&days!==0){showToast('Enter number of days','error');return}try{const r=await fetch('/api/friday/engagement/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trigger_type:type,trigger_days:days,trigger_reference:ref,recipient_type:recipient,active})});if(r.ok){showToast('Trigger added','success');document.getElementById('notif-trigger-dialog').style.display='none';loadTriggers()}else{const d=await r.json();showToast(d.message||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
|
|
162
164
|
async function deleteNotifTrigger(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this trigger?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/friday/engagement/notifications',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});if(r.ok){showToast('Deleted','success');loadTriggers()}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
|
|
@@ -167,7 +169,7 @@ loadTriggers();`;
|
|
|
167
169
|
function renderTypeList(user, items, entityKey, title) {
|
|
168
170
|
const rows = items.map(t => `<tr class="hover">
|
|
169
171
|
<td class="font-medium text-sm">${esc(t.name || '-')}</td>
|
|
170
|
-
<td class="text-xs text-base-content/
|
|
172
|
+
<td class="text-xs text-base-content/70">${t.created_at ? new Date(t.created_at).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
|
|
171
173
|
<td><div class="flex gap-2">
|
|
172
174
|
<button type="button" class="btn btn-ghost btn-xs type-edit-btn" data-id="${esc(t.id)}" data-name="${esc(t.name||'')}">Edit</button>
|
|
173
175
|
<button type="button" class="btn btn-error btn-xs type-del-btn" data-id="${esc(t.id)}">Delete</button>
|
|
@@ -175,13 +177,14 @@ function renderTypeList(user, items, entityKey, title) {
|
|
|
175
177
|
</tr>`).join('');
|
|
176
178
|
const script = `${TOAST_SCRIPT}
|
|
177
179
|
var _eid='';
|
|
178
|
-
|
|
179
|
-
function
|
|
180
|
-
function
|
|
180
|
+
var _typeFormTrigger=null;
|
|
181
|
+
function openAdd(){_eid='';_typeFormTrigger=document.activeElement;document.getElementById('type-name').value='';document.getElementById('type-form').style.display='block';var n=document.getElementById('type-name');if(n)n.focus()}
|
|
182
|
+
function openEdit(id,name){_eid=id;_typeFormTrigger=document.activeElement;document.getElementById('type-name').value=name;document.getElementById('type-form').style.display='block';var n=document.getElementById('type-name');if(n)n.focus()}
|
|
183
|
+
function cancelForm(){document.getElementById('type-form').style.display='none';if(_typeFormTrigger&&typeof _typeFormTrigger.focus==='function')_typeFormTrigger.focus();_typeFormTrigger=null}
|
|
181
184
|
async function saveType(){const name=document.getElementById('type-name').value.trim();if(!name){showToast('Name required','error');return}const url=_eid?'/api/${entityKey}/'+_eid:'/api/${entityKey}';const method=_eid?'PUT':'POST';try{const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});if(r.ok){showToast(_eid?'Updated':'Created','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
182
185
|
async function delType(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/${entityKey}/'+id,{method:'DELETE'});if(r.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
183
186
|
document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.type-edit-btn').forEach(b=>b.addEventListener('click',function(){openEdit(this.dataset.id,this.dataset.name)}));document.querySelectorAll('.type-del-btn').forEach(b=>b.addEventListener('click',function(){delType(this.dataset.id)}));document.querySelectorAll('.type-add-btn').forEach(b=>b.addEventListener('click',openAdd))});`;
|
|
184
|
-
const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex gap-2 items-end"><div class="form-group"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid
|
|
187
|
+
const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex flex-wrap gap-2 items-end"><div class="form-group" style="flex:1;min-width:0"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid w-full" placeholder="Name"/></div><button data-action="saveType" class="btn btn-primary btn-sm">Save</button><button data-action="cancelForm" class="btn btn-ghost btn-sm">Cancel</button></div></div></div>`;
|
|
185
188
|
const content = `${settingsBack()}<div class="flex justify-between items-center mb-4"><h1 class="text-2xl font-bold">${title}</h1><button class="btn btn-primary btn-sm type-add-btn">Add</button></div>${formHtml}<div class="card-clean"><div class="card-clean-body" style="padding:0">${inlineTable(['Name','Created','Actions'],rows,'No items found.')}</div></div>`;
|
|
186
189
|
return settingsPage(user, `${title} - Settings`, bc(title), content, [script]);
|
|
187
190
|
}
|