thatcher 1.0.4
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/LICENSE +21 -0
- package/README.md +398 -0
- package/package.json +73 -0
- package/src/adapters/google-auth.js +148 -0
- package/src/adapters/google-drive.js +209 -0
- package/src/app/api/[entity]/[[...path]]/route.js +33 -0
- package/src/app/api/audit/dashboard/route.js +77 -0
- package/src/app/api/audit/logs/route.js +46 -0
- package/src/app/api/audit/permissions/[id]/route.js +37 -0
- package/src/app/api/audit/permissions/route.js +93 -0
- package/src/app/api/audit/permissions/stats/route.js +29 -0
- package/src/app/api/audit/route.js +79 -0
- package/src/app/api/audit/stats/route.js +18 -0
- package/src/app/api/auth/google/callback/route.js +94 -0
- package/src/app/api/auth/google/route.js +58 -0
- package/src/app/api/auth/login/route.js +121 -0
- package/src/app/api/auth/logout/route.js +52 -0
- package/src/app/api/auth/me/route.js +16 -0
- package/src/app/api/auth/mwr-bridge/route.js +77 -0
- package/src/app/api/auth/password-reset/route.js +65 -0
- package/src/app/api/cron/trigger/route.js +58 -0
- package/src/app/api/csrf-token/route.js +8 -0
- package/src/app/api/debug/config/route.js +22 -0
- package/src/app/api/debug/hooks/route.js +16 -0
- package/src/app/api/debug/plugins/route.js +20 -0
- package/src/app/api/debug/sqlite/route.js +21 -0
- package/src/app/api/debug/sync/route.js +29 -0
- package/src/app/api/debug/workflow/route.js +20 -0
- package/src/app/api/domains/[domain]/route.js +26 -0
- package/src/app/api/domains/route.js +21 -0
- package/src/app/api/email/allocate/batch/route.js +106 -0
- package/src/app/api/email/allocate/route.js +141 -0
- package/src/app/api/email/receive/route.js +158 -0
- package/src/app/api/email/route.js +3 -0
- package/src/app/api/email/send/route.js +77 -0
- package/src/app/api/email/unallocated/route.js +47 -0
- package/src/app/api/files/[id]/route.js +38 -0
- package/src/app/api/health/route.js +95 -0
- package/src/app/api/metrics/route.js +73 -0
- package/src/app/api/monitoring/dashboard/route.js +18 -0
- package/src/cli.js +243 -0
- package/src/config/config-loader.js +112 -0
- package/src/config/constants.js +127 -0
- package/src/config/env.js +164 -0
- package/src/config/spec-helpers.js +232 -0
- package/src/engine.server.js +212 -0
- package/src/index.js +368 -0
- package/src/lib/accessibility.js +162 -0
- package/src/lib/action-factory.js +34 -0
- package/src/lib/action-utils.js +21 -0
- package/src/lib/alert-manager.js +189 -0
- package/src/lib/api-error-wrapper.js +125 -0
- package/src/lib/api-helpers.js +53 -0
- package/src/lib/api.js +82 -0
- package/src/lib/audit-logger-enhanced.js +117 -0
- package/src/lib/audit-logger.js +193 -0
- package/src/lib/auth-middleware.js +102 -0
- package/src/lib/auth-route-helpers.js +83 -0
- package/src/lib/business-rules-engine.js +86 -0
- package/src/lib/compression.js +44 -0
- package/src/lib/config-field-helpers.js +91 -0
- package/src/lib/config-generator-engine.js +445 -0
- package/src/lib/config-helpers.js +120 -0
- package/src/lib/connection-guard.js +79 -0
- package/src/lib/crud-action-helpers.js +34 -0
- package/src/lib/crud-factory.js +83 -0
- package/src/lib/crud-handlers.js +244 -0
- package/src/lib/csrf-protection.js +63 -0
- package/src/lib/database-core.js +258 -0
- package/src/lib/database-migrations.js +96 -0
- package/src/lib/date-utils.js +159 -0
- package/src/lib/db-backup.js +97 -0
- package/src/lib/db-monitor.js +127 -0
- package/src/lib/domain-loader.js +82 -0
- package/src/lib/email-sender.js +100 -0
- package/src/lib/error-boundary.js +134 -0
- package/src/lib/error-handler.js +84 -0
- package/src/lib/error-recovery.js +190 -0
- package/src/lib/error-resilience.js +130 -0
- package/src/lib/errors.js +69 -0
- package/src/lib/events-engine.js +182 -0
- package/src/lib/field-iterator.js +50 -0
- package/src/lib/field-registry.js +68 -0
- package/src/lib/field-types.js +154 -0
- package/src/lib/generic-crud-handler.js +32 -0
- package/src/lib/health-monitor.js +134 -0
- package/src/lib/hook-engine.js +169 -0
- package/src/lib/hot-reload/cache-invalidator.js +115 -0
- package/src/lib/hot-reload/checkpoint.js +95 -0
- package/src/lib/hot-reload/debug-exposure.js +67 -0
- package/src/lib/hot-reload/directory-watcher.js +96 -0
- package/src/lib/hot-reload/index.js +50 -0
- package/src/lib/hot-reload/mutex.js +75 -0
- package/src/lib/hot-reload/promise-container.js +66 -0
- package/src/lib/hot-reload/route-wrapper.js +46 -0
- package/src/lib/hot-reload/safe-error.js +51 -0
- package/src/lib/hot-reload/supervisor.js +161 -0
- package/src/lib/hot-reload/timeout-wrapper.js +52 -0
- package/src/lib/http-methods-factory.js +25 -0
- package/src/lib/index-optimizer.js +96 -0
- package/src/lib/index.js +35 -0
- package/src/lib/list-data-transform.js +39 -0
- package/src/lib/log-aggregator.js +116 -0
- package/src/lib/logger.js +55 -0
- package/src/lib/metrics-collector.js +102 -0
- package/src/lib/minifier.js +19 -0
- package/src/lib/monitoring-init.js +67 -0
- package/src/lib/next-compat.js +80 -0
- package/src/lib/next-polyfills.js +135 -0
- package/src/lib/perf-monitor.js +91 -0
- package/src/lib/progress-components.js +181 -0
- package/src/lib/query-cache.js +126 -0
- package/src/lib/query-engine-write.js +221 -0
- package/src/lib/query-engine.js +399 -0
- package/src/lib/query-perf.js +117 -0
- package/src/lib/query-string-adapter.js +75 -0
- package/src/lib/realtime-server.js +67 -0
- package/src/lib/render-cache.js +61 -0
- package/src/lib/request-tracker.js +43 -0
- package/src/lib/resource-hints.js +29 -0
- package/src/lib/resource-monitor.js +117 -0
- package/src/lib/response-formatter.js +80 -0
- package/src/lib/route-helpers.js +33 -0
- package/src/lib/route-resolver.js +142 -0
- package/src/lib/safe-json.js +8 -0
- package/src/lib/server-bootstrap.js +71 -0
- package/src/lib/stage-pipeline.js +153 -0
- package/src/lib/state-protocol.js +171 -0
- package/src/lib/state-transport-client.js +169 -0
- package/src/lib/state-transport-reconnect.js +121 -0
- package/src/lib/state-transport-server.js +181 -0
- package/src/lib/static-server.js +97 -0
- package/src/lib/status-helpers.js +98 -0
- package/src/lib/universal-handler.js +7 -0
- package/src/lib/utils.js +93 -0
- package/src/lib/validate.js +197 -0
- package/src/lib/validation/business-validators.js +61 -0
- package/src/lib/validation/csrf.js +51 -0
- package/src/lib/validation/file-validators.js +34 -0
- package/src/lib/validation/format-validators.js +106 -0
- package/src/lib/validation/index.js +19 -0
- package/src/lib/validation/rate-limit.js +31 -0
- package/src/lib/validation/security-validators.js +78 -0
- package/src/lib/validation-middleware.js +133 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/with-audit-logging.js +63 -0
- package/src/lib/with-error-handler.js +31 -0
- package/src/lib/workflow-engine.js +250 -0
- package/src/server/server.js +305 -0
- package/src/services/collaborator-role.service.js +205 -0
- package/src/services/email-sender.js +105 -0
- package/src/services/notification-engine.js +110 -0
- package/src/services/permission.service.js +181 -0
- package/src/ui/advanced-search-renderer.js +42 -0
- package/src/ui/advanced-widgets.js +47 -0
- package/src/ui/auth-pages.js +114 -0
- package/src/ui/auth-styles.js +53 -0
- package/src/ui/client.js +99 -0
- package/src/ui/collaboration-dialogs.js +31 -0
- package/src/ui/common-handlers.js +156 -0
- package/src/ui/component-engine.js +103 -0
- package/src/ui/dashboard-renderer.js +150 -0
- package/src/ui/dialog-engine.js +147 -0
- package/src/ui/dialog-factory.js +38 -0
- package/src/ui/engagement-cards.js +76 -0
- package/src/ui/engagement-dialogs.js +100 -0
- package/src/ui/engagement-grid-renderer.js +109 -0
- package/src/ui/entity-renderer.js +176 -0
- package/src/ui/event-delegation.js +108 -0
- package/src/ui/fetch-json.js +24 -0
- package/src/ui/file-dialogs.js +81 -0
- package/src/ui/flexup-report-renderer.js +173 -0
- package/src/ui/format-helpers.js +102 -0
- package/src/ui/global-tags.js +144 -0
- package/src/ui/highlight-threading-renderer.js +176 -0
- package/src/ui/idle-logout.js +156 -0
- package/src/ui/job-management-renderer.js +61 -0
- package/src/ui/layout.js +219 -0
- package/src/ui/letter-dialogs.js +37 -0
- package/src/ui/ml-console-renderer.js +108 -0
- package/src/ui/monitoring-dashboard-client.js +136 -0
- package/src/ui/monitoring-dashboard.js +134 -0
- package/src/ui/notifications-renderer.js +51 -0
- package/src/ui/page-handler-admin.js +121 -0
- package/src/ui/page-handler-helpers.js +111 -0
- package/src/ui/page-handler-reviews.js +165 -0
- package/src/ui/page-handler-rfi.js +42 -0
- package/src/ui/page-handler.js +210 -0
- package/src/ui/password-reset-page.js +146 -0
- package/src/ui/perf-helpers.js +96 -0
- package/src/ui/perf-renderer.js +71 -0
- package/src/ui/permissions-ui.js +163 -0
- package/src/ui/picker-dialogs.js +100 -0
- package/src/ui/render-helpers.js +124 -0
- package/src/ui/renderer.js +35 -0
- package/src/ui/review-comparison-renderer.js +58 -0
- package/src/ui/review-detail-panels.js +71 -0
- package/src/ui/review-detail-renderer.js +170 -0
- package/src/ui/review-detail-script.js +95 -0
- package/src/ui/review-mwr-renderer.js +113 -0
- package/src/ui/review-renderer.js +202 -0
- package/src/ui/review-widgets.js +88 -0
- package/src/ui/review-zone-nav.js +12 -0
- package/src/ui/rfi-detail-renderer.js +191 -0
- package/src/ui/rfi-renderer.js +194 -0
- package/src/ui/rfi-report-renderer.js +56 -0
- package/src/ui/rippleui.css +1 -0
- package/src/ui/settings-renderer-advanced.js +195 -0
- package/src/ui/settings-renderer-advanced2.js +158 -0
- package/src/ui/settings-renderer-teams.js +112 -0
- package/src/ui/settings-renderer.js +166 -0
- package/src/ui/spacing-system.js +155 -0
- package/src/ui/standalone-login.js +109 -0
- package/src/ui/styles.css +2530 -0
- package/src/ui/styles2.css +1602 -0
- package/src/ui/test-page.js +23 -0
- package/src/ui/validation-rules.js +73 -0
- package/src/ui/validation-ui.js +147 -0
- package/src/ui/virtual-scroll.js +107 -0
- package/src/ui/webjsx.js +61 -0
- package/src/ui/widgets.js +152 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js'
|
|
2
|
+
import { getQuickActions } from '@/ui/permissions-ui.js'
|
|
3
|
+
import { esc, stagePill, statusPill, TABLE_SCRIPT } from '@/ui/render-helpers.js'
|
|
4
|
+
import { SPACING, renderCard } from '@/ui/spacing-system.js'
|
|
5
|
+
|
|
6
|
+
export function renderDashboard(user, stats = {}) {
|
|
7
|
+
const isClerk = user?.role === 'clerk';
|
|
8
|
+
const statDefs = isClerk
|
|
9
|
+
? [
|
|
10
|
+
{ label: 'My RFIs', value: stats.myRfis?.length || 0, sub: 'Assigned to you', href: '/rfi' },
|
|
11
|
+
{ label: 'Overdue', value: stats.overdueRfis?.length || 0, sub: 'Need attention', href: '/rfi', warn: true },
|
|
12
|
+
{ label: 'Reviews', value: stats.reviews || 0, sub: 'Total reviews', href: '/review' },
|
|
13
|
+
{ label: 'Clients', value: stats.clients || 0, sub: 'Active clients', href: '/client' },
|
|
14
|
+
]
|
|
15
|
+
: [
|
|
16
|
+
{ label: 'Engagements', value: stats.engagements || 0, sub: 'Total engagements', href: '/engagements' },
|
|
17
|
+
{ label: 'Clients', value: stats.clients || 0, sub: 'Active clients', href: '/client' },
|
|
18
|
+
{ label: 'Open RFIs', value: stats.rfis || 0, sub: stats.overdueRfis?.length > 0 ? `${stats.overdueRfis.length} overdue` : 'All on track', href: '/rfi', warn: stats.overdueRfis?.length > 0 },
|
|
19
|
+
{ label: 'Reviews', value: stats.reviews || 0, sub: 'Active reviews', href: '/review' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const statsHtml = `<div class="stats-row">${statDefs.map(s => `<a href="${s.href}" class="stat-card stat-card-clickable" style="text-decoration:none">
|
|
23
|
+
<div class="stat-card-value${s.warn ? ' stat-card-warn' : ''}">${s.value}</div>
|
|
24
|
+
<div class="stat-card-label">${s.label}</div>
|
|
25
|
+
<div class="stat-card-sub">${esc(s.sub)}</div>
|
|
26
|
+
</a>`).join('')}</div>`;
|
|
27
|
+
|
|
28
|
+
const overdueAlert = stats.overdueRfis?.length > 0
|
|
29
|
+
? `<div class="alert alert-error" style="margin-bottom:1.25rem;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem">
|
|
30
|
+
<div style="display:flex;align-items:center;gap:0.5rem">
|
|
31
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
32
|
+
<span><strong>${stats.overdueRfis.length} overdue RFI${stats.overdueRfis.length !== 1 ? 's' : ''}</strong> require attention</span>
|
|
33
|
+
</div>
|
|
34
|
+
<a href="/rfi" class="btn-danger-clean" style="font-size:0.8125rem;padding:6px 14px;min-height:32px">View RFIs →</a>
|
|
35
|
+
</div>` : '';
|
|
36
|
+
|
|
37
|
+
const quickActions = getQuickActions(user);
|
|
38
|
+
const actionsHtml = quickActions.length > 0
|
|
39
|
+
? `<div class="card-clean" style="margin-bottom:${SPACING.lg}"><div class="card-clean-body" style="padding:${SPACING.md}">
|
|
40
|
+
<div class="card-header">Quick Actions</div>
|
|
41
|
+
<div style="display:flex;flex-wrap:wrap;gap:${SPACING.sm}">${quickActions.map(a => `<a href="${a.href}" class="${a.primary ? 'btn-primary-clean' : 'btn-ghost-clean'}">${a.label}</a>`).join('')}</div>
|
|
42
|
+
</div></div>` : '';
|
|
43
|
+
|
|
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() : '-';
|
|
46
|
+
return `<tr data-row data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer">
|
|
47
|
+
<td data-col="name"><strong>${esc(e.name || e.client_name || 'Untitled')}</strong></td>
|
|
48
|
+
<td data-col="client">${esc(e.client_id_display || e.client_name || '-')}</td>
|
|
49
|
+
<td data-col="stage">${stagePill(e.stage)}</td>
|
|
50
|
+
<td data-col="status">${statusPill(e.status)}</td>
|
|
51
|
+
<td data-col="updated">${updated}</td>
|
|
52
|
+
</tr>`;
|
|
53
|
+
}).join('');
|
|
54
|
+
|
|
55
|
+
const emptyEngHtml = !isClerk && (stats.recentEngagements || []).length === 0
|
|
56
|
+
? `<div class="card-clean" style="margin-bottom:${SPACING.lg}">
|
|
57
|
+
<div class="empty-state">
|
|
58
|
+
<svg class="empty-state-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 3H8L4 7h16l-4-4z"/></svg>
|
|
59
|
+
<div class="empty-state-title">No engagements yet</div>
|
|
60
|
+
<div class="empty-state-desc">Create your first engagement to get started tracking work.</div>
|
|
61
|
+
<a href="/engagements" class="btn-primary-clean">View Engagements</a>
|
|
62
|
+
</div>
|
|
63
|
+
</div>` : '';
|
|
64
|
+
|
|
65
|
+
const recentHtml = !isClerk && (stats.recentEngagements || []).length > 0
|
|
66
|
+
? `<div class="table-wrap">
|
|
67
|
+
<div class="table-toolbar">
|
|
68
|
+
<div class="table-search"><input id="search-input" type="text" placeholder="Search engagements..."></div>
|
|
69
|
+
<span class="table-count" id="row-count">${(stats.recentEngagements||[]).length} items</span>
|
|
70
|
+
</div>
|
|
71
|
+
<table class="data-table">
|
|
72
|
+
<thead><tr><th data-sort="name">Name</th><th data-sort="client">Client</th><th data-sort="stage">Stage</th><th data-sort="status">Status</th><th data-sort="updated">Updated</th></tr></thead>
|
|
73
|
+
<tbody>${recentRows}</tbody>
|
|
74
|
+
</table>
|
|
75
|
+
</div>` : '';
|
|
76
|
+
|
|
77
|
+
const content = `<div class="page-header">
|
|
78
|
+
<div>
|
|
79
|
+
<h1 class="page-title">Welcome back, ${esc(user?.name?.split(' ')[0] || 'there')}</h1>
|
|
80
|
+
<p class="page-subtitle">${new Date().toLocaleDateString('en-ZA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
${statsHtml}${overdueAlert}${actionsHtml}${emptyEngHtml}${recentHtml}`;
|
|
84
|
+
|
|
85
|
+
return page(user, 'Dashboard | Moonlanding', [], content, [TABLE_SCRIPT]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderAuditDashboard(user, auditData = {}) {
|
|
89
|
+
const { summary = {}, recentActivity = [] } = auditData;
|
|
90
|
+
const actRows = recentActivity.slice(0, 20).map(a =>
|
|
91
|
+
`<tr data-row>
|
|
92
|
+
<td data-col="time">${new Date((a.timestamp||a.created_at)*1000).toLocaleString()}</td>
|
|
93
|
+
<td data-col="action"><span class="pill pill-info">${a.action||'-'}</span></td>
|
|
94
|
+
<td data-col="entity">${a.entity_type||'-'}</td>
|
|
95
|
+
<td data-col="id" style="font-size:12px">${a.entity_id||'-'}</td>
|
|
96
|
+
<td data-col="user">${a.user_name||a.user_id||'-'}</td>
|
|
97
|
+
<td data-col="reason" style="font-size:12px;color:var(--color-text-muted)">${a.reason||'-'}</td>
|
|
98
|
+
</tr>`
|
|
99
|
+
).join('') || `<tr><td colspan="6" style="text-align:center;padding:${SPACING.xl};color:var(--color-text-muted)">No audit records found</td></tr>`;
|
|
100
|
+
|
|
101
|
+
const statsHtml = `<div class="stats-row">${[
|
|
102
|
+
{ label: 'Total Actions (30d)', value: summary.total_actions || 0 },
|
|
103
|
+
{ label: 'Permission Grants', value: summary.grants || 0 },
|
|
104
|
+
{ label: 'Permission Revokes', value: summary.revokes || 0 },
|
|
105
|
+
{ label: 'Role Changes', value: summary.role_changes || 0 },
|
|
106
|
+
].map(s => `<div class="stat-card"><div class="stat-card-value">${s.value}</div><div class="stat-card-label">${s.label}</div></div>`).join('')}</div>`;
|
|
107
|
+
|
|
108
|
+
const content = `<div class="page-header"><h1 class="page-title">Audit Dashboard</h1><a href="/permission_audit" class="btn-ghost-clean">View All Records</a></div>
|
|
109
|
+
${statsHtml}
|
|
110
|
+
<div class="table-wrap">
|
|
111
|
+
<div class="table-toolbar">
|
|
112
|
+
<div class="table-search"><input id="search-input" type="text" placeholder="Search audit log..."></div>
|
|
113
|
+
<span class="table-count" id="row-count">${recentActivity.length} items</span>
|
|
114
|
+
</div>
|
|
115
|
+
<table class="data-table">
|
|
116
|
+
<thead><tr><th data-sort="time">Time</th><th data-sort="action">Action</th><th data-sort="entity">Entity Type</th><th data-sort="id">Entity ID</th><th data-sort="user">User</th><th data-sort="reason">Reason</th></tr></thead>
|
|
117
|
+
<tbody>${actRows}</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</div>`;
|
|
120
|
+
|
|
121
|
+
return page(user, 'Audit Dashboard | MOONLANDING', [{ href: '/', label: 'Dashboard' }, { href: '/admin/audit', label: 'Audit' }], content, [TABLE_SCRIPT]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function renderSystemHealth(user, healthData = {}) {
|
|
125
|
+
const { database = {}, server: srv = {}, entities = {} } = healthData;
|
|
126
|
+
const entRows = Object.entries(entities).map(([n, c]) =>
|
|
127
|
+
`<tr data-row><td data-col="entity">${n}</td><td data-col="count" style="text-align:right">${c}</td></tr>`
|
|
128
|
+
).join('') || `<tr><td colspan="2" style="text-align:center;padding:${SPACING.xl};color:var(--color-text-muted)">No data</td></tr>`;
|
|
129
|
+
|
|
130
|
+
const statsHtml = `<div class="stats-row">${[
|
|
131
|
+
{ label: 'Server Status', value: 'Online', sub: 'Port: '+(srv.port||3000) },
|
|
132
|
+
{ label: 'Database', value: database.status||'Connected', sub: 'Size: '+(database.size||'N/A') },
|
|
133
|
+
{ label: 'Uptime', value: srv.uptime||'N/A', sub: 'Started: '+(srv.startTime||'N/A') },
|
|
134
|
+
].map(s => `<div class="stat-card"><div class="stat-card-value" style="font-size:20px">${s.value}</div><div class="stat-card-label">${s.label}</div><div class="stat-card-sub">${s.sub}</div></div>`).join('')}</div>`;
|
|
135
|
+
|
|
136
|
+
const content = `<div class="page-header"><h1 class="page-title">System Health</h1></div>
|
|
137
|
+
${statsHtml}
|
|
138
|
+
<div class="table-wrap">
|
|
139
|
+
<div class="table-toolbar">
|
|
140
|
+
<div class="table-search"><input id="search-input" type="text" placeholder="Search entities..."></div>
|
|
141
|
+
<span class="table-count" id="row-count">${Object.keys(entities).length} items</span>
|
|
142
|
+
</div>
|
|
143
|
+
<table class="data-table">
|
|
144
|
+
<thead><tr><th data-sort="entity">Entity</th><th data-sort="count" style="text-align:right">Count</th></tr></thead>
|
|
145
|
+
<tbody>${entRows}</tbody>
|
|
146
|
+
</table>
|
|
147
|
+
</div>`;
|
|
148
|
+
|
|
149
|
+
return page(user, 'System Health | MOONLANDING', [{ href: '/', label: 'Dashboard' }, { href: '/admin/health', label: 'Health' }], content, [TABLE_SCRIPT]);
|
|
150
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { getConfigEngineSync } from '@/lib/config-generator-engine';
|
|
2
|
+
import { aria, role, focusTrap, keyboard } from '@/lib/accessibility';
|
|
3
|
+
|
|
4
|
+
export function renderDialog(dialogId, context = {}) {
|
|
5
|
+
const config = getConfigEngineSync().getConfig();
|
|
6
|
+
const dialogConfig = config.dialogs?.[dialogId];
|
|
7
|
+
|
|
8
|
+
if (!dialogConfig) {
|
|
9
|
+
console.warn(`Dialog config not found: ${dialogId}`);
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { title, fields = [], actions = [], width = '640px', onSubmit } = dialogConfig;
|
|
14
|
+
const dialogHtml = generateDialogHtml(dialogId, title, fields, actions, width, context, onSubmit);
|
|
15
|
+
|
|
16
|
+
return dialogHtml;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function generateDialogHtml(dialogId, title, fields, actions, width, context, onSubmit) {
|
|
20
|
+
const titleId = `${dialogId}-title`;
|
|
21
|
+
const fieldHtml = fields.map(f => generateFieldHtml(f, dialogId, context)).join('');
|
|
22
|
+
const actionButtons = (actions || []).map(a => {
|
|
23
|
+
if (a.type === 'cancel') {
|
|
24
|
+
return `<button type="button" class="btn btn-ghost btn-sm" ${aria.label('Cancel dialog')} data-action="closeDialog" data-args='["${dialogId}"]'>${a.label || 'Cancel'}</button>`;
|
|
25
|
+
} else if (a.type === 'submit') {
|
|
26
|
+
return `<button type="button" class="btn btn-primary btn-sm" ${aria.label('Submit dialog')} data-action="submitDialog" data-args='["${dialogId}"]'>${a.label || 'Submit'}</button>`;
|
|
27
|
+
}
|
|
28
|
+
return '';
|
|
29
|
+
}).join('');
|
|
30
|
+
|
|
31
|
+
return `
|
|
32
|
+
<div id="${dialogId}" class="dialog-overlay" style="display:none" ${role.dialog} aria-modal="true" ${aria.labelledBy(titleId)} ${aria.hidden(true)} data-overlay-close="true" data-action="closeDialog" data-args='["${dialogId}"]' onkeydown="${keyboard.escape(`closeDialog('${dialogId}')`)}">
|
|
33
|
+
<div id="${dialogId}-panel" class="dialog-panel" style="max-width:${width}">
|
|
34
|
+
<div class="dialog-header">
|
|
35
|
+
<h2 id="${titleId}" class="dialog-title">${escapeHtml(title)}</h2>
|
|
36
|
+
<button type="button" class="dialog-close" ${aria.label('Close dialog')} data-action="closeDialog" data-args='["${dialogId}"]'>×</button>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="dialog-body">
|
|
39
|
+
${fieldHtml}
|
|
40
|
+
</div>
|
|
41
|
+
<div class="dialog-footer">
|
|
42
|
+
${actionButtons}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<script>
|
|
47
|
+
window.closeDialog = window.closeDialog || function(id) {
|
|
48
|
+
const dlg = document.getElementById(id);
|
|
49
|
+
dlg.style.display = 'none';
|
|
50
|
+
dlg.setAttribute('aria-hidden', 'true');
|
|
51
|
+
${focusTrap.deactivate('${dialogId}-panel')}
|
|
52
|
+
if (dlg._previousFocus) {
|
|
53
|
+
dlg._previousFocus.focus();
|
|
54
|
+
delete dlg._previousFocus;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
window.submitDialog = window.submitDialog || function(id) {
|
|
58
|
+
const form = document.getElementById(id + '-form');
|
|
59
|
+
if (form) form.dispatchEvent(new Event('submit'));
|
|
60
|
+
};
|
|
61
|
+
window.openDialog = window.openDialog || function(id) {
|
|
62
|
+
const dlg = document.getElementById(id);
|
|
63
|
+
dlg._previousFocus = document.activeElement;
|
|
64
|
+
dlg.style.display = 'flex';
|
|
65
|
+
dlg.setAttribute('aria-hidden', 'false');
|
|
66
|
+
${focusTrap.activate('${dialogId}-panel')}
|
|
67
|
+
};
|
|
68
|
+
</script>
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function generateFieldHtml(field, dialogId, context) {
|
|
73
|
+
const { name, label, type, required, placeholder, options, value } = field;
|
|
74
|
+
const fieldId = `${dialogId}-${name}`;
|
|
75
|
+
const requiredAttr = required ? 'required' : '';
|
|
76
|
+
const ariaRequired = required ? aria.required() : '';
|
|
77
|
+
const descId = field.description ? `${fieldId}-desc` : '';
|
|
78
|
+
const ariaDesc = descId ? aria.describedBy(descId) : '';
|
|
79
|
+
|
|
80
|
+
let inputHtml = '';
|
|
81
|
+
|
|
82
|
+
switch (type) {
|
|
83
|
+
case 'text':
|
|
84
|
+
inputHtml = `<input type="text" id="${fieldId}" name="${name}" class="input input-bordered w-full" placeholder="${placeholder || ''}" ${requiredAttr} ${ariaRequired} ${ariaDesc}/>`;
|
|
85
|
+
break;
|
|
86
|
+
case 'textarea':
|
|
87
|
+
inputHtml = `<textarea id="${fieldId}" name="${name}" class="textarea textarea-bordered w-full" rows="5" placeholder="${placeholder || ''}" ${requiredAttr} ${ariaRequired} ${ariaDesc}></textarea>`;
|
|
88
|
+
break;
|
|
89
|
+
case 'email':
|
|
90
|
+
inputHtml = `<input type="email" id="${fieldId}" name="${name}" class="input input-bordered w-full" placeholder="${placeholder || ''}" ${requiredAttr} ${ariaRequired} ${ariaDesc}/>`;
|
|
91
|
+
break;
|
|
92
|
+
case 'number':
|
|
93
|
+
inputHtml = `<input type="number" id="${fieldId}" name="${name}" class="input input-bordered w-full" placeholder="${placeholder || ''}" ${requiredAttr} ${ariaRequired} ${ariaDesc}/>`;
|
|
94
|
+
break;
|
|
95
|
+
case 'date':
|
|
96
|
+
inputHtml = `<input type="date" id="${fieldId}" name="${name}" class="input input-bordered w-full" ${requiredAttr} ${ariaRequired} ${ariaDesc}/>`;
|
|
97
|
+
break;
|
|
98
|
+
case 'select':
|
|
99
|
+
const optionsHtml = (options || []).map(opt =>
|
|
100
|
+
`<option value="${opt.value}">${escapeHtml(opt.label || opt.value)}</option>`
|
|
101
|
+
).join('');
|
|
102
|
+
inputHtml = `<select id="${fieldId}" name="${name}" class="select select-bordered w-full" ${requiredAttr} ${ariaRequired} ${ariaDesc}><option value="">Select...</option>${optionsHtml}</select>`;
|
|
103
|
+
break;
|
|
104
|
+
case 'checkbox':
|
|
105
|
+
inputHtml = `<label class="flex items-center gap-2"><input type="checkbox" id="${fieldId}" name="${name}" class="checkbox" ${requiredAttr} ${ariaRequired} ${ariaDesc}/><span class="text-sm">${label}</span></label>`;
|
|
106
|
+
break;
|
|
107
|
+
case 'multi-select':
|
|
108
|
+
const multiOptHtml = (options || []).map(opt =>
|
|
109
|
+
`<label class="flex items-center gap-2"><input type="checkbox" name="${name}" value="${opt.value}" class="checkbox"/><span class="text-sm">${escapeHtml(opt.label || opt.value)}</span></label>`
|
|
110
|
+
).join('');
|
|
111
|
+
inputHtml = `<div class="flex flex-col gap-2" ${role.list}>${multiOptHtml}</div>`;
|
|
112
|
+
break;
|
|
113
|
+
case 'file':
|
|
114
|
+
inputHtml = `<input type="file" id="${fieldId}" name="${name}" class="file-input file-input-bordered w-full" ${requiredAttr} ${ariaRequired} ${ariaDesc}/>`;
|
|
115
|
+
break;
|
|
116
|
+
case 'color':
|
|
117
|
+
inputHtml = `<input type="color" id="${fieldId}" name="${name}" class="input input-bordered w-full" ${requiredAttr} ${ariaRequired} ${ariaDesc}/>`;
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
inputHtml = `<input type="text" id="${fieldId}" name="${name}" class="input input-bordered w-full" placeholder="${placeholder || ''}" ${requiredAttr} ${ariaRequired} ${ariaDesc}/>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const descEl = field.description ? `<p id="${descId}" class="text-xs text-gray-500 mt-1">${escapeHtml(field.description)}</p>` : '';
|
|
124
|
+
|
|
125
|
+
return `
|
|
126
|
+
<div class="modal-form-group">
|
|
127
|
+
${type !== 'checkbox' ? `<label for="${fieldId}">${escapeHtml(label || name)}${required ? ' <span aria-hidden="true">*</span>' : ''}</label>` : ''}
|
|
128
|
+
${inputHtml}
|
|
129
|
+
${descEl}
|
|
130
|
+
</div>
|
|
131
|
+
`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function escapeHtml(text) {
|
|
135
|
+
if (text == null) return '';
|
|
136
|
+
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function getDialogConfig(dialogId) {
|
|
140
|
+
const config = getConfigEngineSync().getConfig();
|
|
141
|
+
return config.dialogs?.[dialogId];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function listDialogs() {
|
|
145
|
+
const config = getConfigEngineSync().getConfig();
|
|
146
|
+
return Object.keys(config.dialogs || {});
|
|
147
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
2
|
+
|
|
3
|
+
export function createDialog(id, title, body, footer, opts = {}) {
|
|
4
|
+
const maxWidth = opts.maxWidth || '640px';
|
|
5
|
+
const extraClass = opts.class || '';
|
|
6
|
+
return `<div id="${esc(id)}" class="dialog-overlay ${extraClass}" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="${esc(id)}-title" aria-hidden="true">
|
|
7
|
+
<div class="dialog-panel" style="max-width:${maxWidth}">
|
|
8
|
+
<div class="dialog-header">
|
|
9
|
+
<span class="dialog-title" id="${esc(id)}-title">${title}</span>
|
|
10
|
+
<button class="dialog-close" data-dialog-close="${esc(id)}" aria-label="Close dialog">×</button>
|
|
11
|
+
</div>
|
|
12
|
+
<div class="dialog-body">${body}</div>
|
|
13
|
+
${footer ? `<div class="dialog-footer">${footer}</div>` : ''}
|
|
14
|
+
</div>
|
|
15
|
+
</div>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function modalDialog(id, title, body, footer, opts = {}) {
|
|
19
|
+
const maxWidth = opts.maxWidth || 'md';
|
|
20
|
+
const sizeMap = { sm: '28rem', md: '36rem', lg: '48rem', xl: '64rem' };
|
|
21
|
+
const width = sizeMap[maxWidth] || maxWidth;
|
|
22
|
+
return `<div id="${esc(id)}" class="modal" style="display:none" data-dialog-close-overlay="true">
|
|
23
|
+
<div class="modal-overlay" data-dialog-close="${esc(id)}"></div>
|
|
24
|
+
<div class="modal-content rounded-box" style="max-width:${width};padding:1.5rem">
|
|
25
|
+
<h3 class="text-lg font-semibold mb-4">${title}</h3>
|
|
26
|
+
${body}
|
|
27
|
+
${footer ? `<div class="modal-action mt-4">${footer}</div>` : ''}
|
|
28
|
+
</div>
|
|
29
|
+
</div>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function openDialog(id) {
|
|
33
|
+
return `document.getElementById('${esc(id)}').style.display='flex';document.getElementById('${esc(id)}').setAttribute('aria-hidden','false')`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function closeDialog(id) {
|
|
37
|
+
return `document.getElementById('${esc(id)}').style.display='none';document.getElementById('${esc(id)}').setAttribute('aria-hidden','true')`;
|
|
38
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { h } from '@/ui/webjsx.js'
|
|
2
|
+
import { STAGE_COLORS, statusLabel } from '@/ui/render-helpers.js'
|
|
3
|
+
import { teamAvatarGroup } from '@/ui/widgets.js'
|
|
4
|
+
import { canEdit } from '@/ui/permissions-ui.js'
|
|
5
|
+
|
|
6
|
+
export function engagementCard(engagement, user) {
|
|
7
|
+
const e = engagement || {}
|
|
8
|
+
const stage = e.stage ? (STAGE_COLORS[e.stage] || { bg: '#f3f4f6', text: '#6b7280', label: e.stage }) : null
|
|
9
|
+
const stageBadge = stage ? `<span class="badge-stage" data-stage="${e.stage}" style="background:${stage.bg};color:${stage.text}">${stage.label || e.stage}</span>` : ''
|
|
10
|
+
const sts = e.status ? statusLabel(e.status) : ''
|
|
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
|
+
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>` : ''
|
|
14
|
+
const clientName = e.client_name || e.client?.name || ''
|
|
15
|
+
return `<div class="engagement-card card-clean" data-navigate="/engagement/${e.id}" style="cursor:pointer">
|
|
16
|
+
<div class="card-clean-body">
|
|
17
|
+
<div class="flex justify-between items-center mb-2">${clientName ? `<span class="text-xs text-gray-500">${clientName}</span>` : '<span></span>'}${sts}</div>
|
|
18
|
+
<h3 class="font-medium mb-2">${e.name || e.title || 'Untitled'}</h3>
|
|
19
|
+
<div class="flex items-center gap-2 mb-2">${stageBadge}${dueDate}</div>
|
|
20
|
+
${progress}
|
|
21
|
+
<div class="flex justify-between items-center mt-2">${team}<div class="flex gap-1"><a href="/engagement/${e.id}" class="btn btn-xs btn-ghost" data-stop-propagation="true">View</a>${canEdit(user, 'engagement') ? `<a href="/engagement/${e.id}/edit" class="btn btn-xs btn-outline" data-stop-propagation="true">Edit</a>` : ''}</div></div>
|
|
22
|
+
</div></div>`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mobileEngagementCard(engagement) {
|
|
26
|
+
const e = engagement || {}
|
|
27
|
+
const stage = e.stage ? (STAGE_COLORS[e.stage] || { bg: '#f3f4f6', text: '#6b7280', label: e.stage }) : null
|
|
28
|
+
const stageBadge = stage ? `<span class="badge-stage" data-stage="${e.stage}" style="background:${stage.bg};color:${stage.text};font-size:0.65rem;padding:0.15rem 0.5rem">${stage.label || e.stage}</span>` : ''
|
|
29
|
+
const sts = e.status ? statusLabel(e.status) : ''
|
|
30
|
+
return `<div class="mobile-card mobile-engagement-card card-clean">
|
|
31
|
+
<div class="card-clean-body">
|
|
32
|
+
<div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${e.name || e.title || 'Untitled'}</h3>${sts}</div>
|
|
33
|
+
${e.client_name ? `<div class="text-xs text-gray-500 mb-1">${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>
|
|
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
|
+
</div></div>`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function stagePipeline(currentStage, stages = null, clickable = false, onClickFn = '') {
|
|
40
|
+
const stageKeys = stages || Object.keys(STAGE_COLORS)
|
|
41
|
+
const activeIdx = stageKeys.indexOf(currentStage)
|
|
42
|
+
const pills = stageKeys.map((key, i) => {
|
|
43
|
+
const s = STAGE_COLORS[key] || { bg: '#f3f4f6', text: '#4b5563', label: key }
|
|
44
|
+
const cls = i === activeIdx ? 'spl-current' : i < activeIdx ? 'spl-past' : 'spl-future'
|
|
45
|
+
const click = clickable ? ` spl-clickable" data-action="${onClickFn}" data-args='["${key}"]'` : '"'
|
|
46
|
+
return `<div class="spl-pill ${cls}" data-stage="${key}"${click} style="background:${s.bg};color:${s.text}">${s.label}</div>`
|
|
47
|
+
}).join('')
|
|
48
|
+
return `<div class="stage-pipeline">${pills}</div>`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function activityTimeline(activities = [], showAll = false) {
|
|
52
|
+
const maxItems = showAll ? activities.length : 10
|
|
53
|
+
const items = activities.slice(0, maxItems)
|
|
54
|
+
const iconMap = { created: '+', updated: '✎', status_changed: '●', commented: '💬', assigned: '👤', uploaded: '📎' }
|
|
55
|
+
const timelineItems = items.map(a => {
|
|
56
|
+
const action = a.action || 'updated'
|
|
57
|
+
const icon = iconMap[action] || '●'
|
|
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}">${icon}</div><div class="act-tl-content"><div class="act-tl-header"><span class="act-tl-desc">${a.description || a.action || ''}</span></div><div class="act-tl-time">${timeStr}${a.user_name ? ' by ' + a.user_name : ''}</div></div></div>`
|
|
61
|
+
}).join('')
|
|
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
|
+
return `<div class="act-timeline">${timelineItems}</div>${moreBtn}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function splashScreen(domain) {
|
|
67
|
+
const isFriday = domain === 'friday'
|
|
68
|
+
const name = isFriday ? 'Friday' : 'MyWorkReview'
|
|
69
|
+
const color = isFriday ? '#3b82f6' : '#8b5cf6'
|
|
70
|
+
const initial = isFriday ? 'F' : 'M'
|
|
71
|
+
return `<div id="splash-screen" class="splash-screen" style="--splash-color:${color}"><div class="splash-content"><div class="splash-logo" style="background:${color}">${initial}</div><div class="splash-name">${name}</div><div class="splash-spinner"><div class="splash-spinner-ring"></div></div></div></div><script>(function(){window.addEventListener('load',function(){var s=document.getElementById('splash-screen');if(s){setTimeout(function(){s.classList.add('splash-hide');setTimeout(function(){s.remove()},400)},600)}})})()</script>`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function swUpdateBanner() {
|
|
75
|
+
return `<div id="sw-update-banner" class="sw-banner" style="display:none"><span class="sw-banner-text">A new version is available.</span><button class="btn btn-sm btn-primary sw-banner-btn" data-action="swDoUpdate" aria-label="Update to new version">Update</button><button class="btn btn-sm btn-ghost sw-banner-btn" data-action="swDismiss" aria-label="Dismiss update notification">Dismiss</button></div><script>(function(){function show(){document.getElementById('sw-update-banner').style.display='flex'}window.swDoUpdate=function(){window.location.reload()};window.swDismiss=function(){document.getElementById('sw-update-banner').style.display='none'};if('serviceWorker' in navigator){navigator.serviceWorker.addEventListener('controllerchange',show);navigator.serviceWorker.getRegistration().then(function(reg){if(reg)reg.addEventListener('updatefound',function(){var nw=reg.installing;if(nw)nw.addEventListener('statechange',function(){if(nw.state==='installed'&&navigator.serviceWorker.controller)show()})})})}})()</script>`
|
|
76
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { statusLabel } from '@/ui/renderer.js';
|
|
2
|
+
import { createDialog } from '@/ui/dialog-factory.js';
|
|
3
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
4
|
+
|
|
5
|
+
export function engagementFileSearchDialog(engagementId) {
|
|
6
|
+
const body = `<div class="modal-form-group"><label for="efs-query" class="sr-only">Search files</label><input type="text" id="efs-query" class="input input-bordered w-full" placeholder="Search files..." aria-label="Search engagement files" oninput="efsSearch()"/></div><div id="efs-results" class="flex flex-col gap-2" style="max-height:400px;overflow:auto"></div>`;
|
|
7
|
+
const footer = `<button class="btn btn-ghost btn-sm" data-dialog-close="file-search-dialog">Close</button>`;
|
|
8
|
+
return createDialog('file-search-dialog', 'Search Engagement Files', body, footer) + `
|
|
9
|
+
<script>
|
|
10
|
+
var efsTimer=null;
|
|
11
|
+
window.efsSearch=function(){clearTimeout(efsTimer);efsTimer=setTimeout(function(){var q=document.getElementById('efs-query').value.trim();if(!q){document.getElementById('efs-results').innerHTML='';return}fetch('/api/file?engagement_id=${engagementId}&search='+encodeURIComponent(q)).then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];var el=document.getElementById('efs-results');if(!files.length){el.innerHTML='<div class="text-gray-500 text-sm text-center py-4">No files found</div>';return}el.innerHTML=files.map(function(f){return'<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded cursor-pointer" data-action="previewAttachment" data-args=\\'["/api/file/'+f.id+'/download","'+((f.name||'').replace(/"/g,'"'))+'","'+((f.mime_type||'').replace(/"/g,'"'))+'"]\\'>
|
|
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)};
|
|
13
|
+
</script>`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function postRfiJournalDialog(engagementId) {
|
|
17
|
+
const body = `<div class="modal-form-group"><label for="prj-type">Entry Type</label><select id="prj-type" class="select select-bordered w-full"><option value="note">Note</option><option value="finding">Finding</option><option value="follow_up">Follow Up</option><option value="resolution">Resolution</option></select></div>
|
|
18
|
+
<div class="modal-form-group"><label for="prj-title">Title</label><input id="prj-title" class="input input-bordered w-full" placeholder="Journal entry title"/></div>
|
|
19
|
+
<div class="modal-form-group"><label for="prj-content">Content</label><textarea id="prj-content" class="textarea textarea-bordered w-full" rows="5" placeholder="Describe the entry..."></textarea></div>
|
|
20
|
+
<div class="modal-form-group"><label for="prj-file">File Attachment</label><input type="file" id="prj-file" class="file-input file-input-bordered w-full file-input-sm"/></div>`;
|
|
21
|
+
const footer = `<button class="btn btn-ghost btn-sm" data-dialog-close="post-rfi-journal">Cancel</button><button class="btn btn-primary btn-sm" data-action="prjSave">Save Entry</button>`;
|
|
22
|
+
return createDialog('post-rfi-journal', 'Post-RFI Journal Entry', body, footer) + `
|
|
23
|
+
<script>
|
|
24
|
+
window.openPostRfiJournal=function(){document.getElementById('post-rfi-journal').style.display='flex'};
|
|
25
|
+
window.prjSave=async function(){var title=document.getElementById('prj-title').value.trim();var content=document.getElementById('prj-content').value.trim();if(!title||!content){showToast('Title and content required','error');return}var fd=new FormData();fd.append('engagement_id','${engagementId}');fd.append('type',document.getElementById('prj-type').value);fd.append('title',title);fd.append('content',content);var file=document.getElementById('prj-file').files[0];if(file)fd.append('file',file);try{var r=await fetch('/api/journal',{method:'POST',body:fd});if(r.ok){showToast('Journal entry saved','success');document.getElementById('post-rfi-journal').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
26
|
+
</script>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function postRfiFileUpload(engagementId) {
|
|
30
|
+
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
|
+
<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.innerHTML='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.innerHTML='Uploaded '+(i+1)+' of '+files.length}catch(e){}}prog.innerHTML=ok+' of '+files.length+' uploaded successfully';if(ok>0)showToast(ok+' files uploaded','success')};
|
|
33
|
+
</script>`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function importReviewQueriesDialog(engagementId) {
|
|
37
|
+
const body = `<div class="modal-form-group"><label for="irq-source">Source</label><select id="irq-source" class="select select-bordered w-full"><option value="csv">CSV File</option><option value="review">From Another Review</option><option value="template">From Template</option></select></div>
|
|
38
|
+
<div id="irq-csv-upload"><div class="modal-form-group"><label for="irq-file">CSV File</label><input type="file" id="irq-file" class="file-input file-input-bordered w-full" accept=".csv,.xlsx"/></div></div>
|
|
39
|
+
<div id="irq-review-select" style="display:none"><div class="modal-form-group"><label for="irq-review">Select Review</label><select id="irq-review" class="select select-bordered w-full"></select></div></div>
|
|
40
|
+
<div id="irq-preview" class="text-sm text-gray-500 mt-2"></div>`;
|
|
41
|
+
const footer = `<button class="btn btn-ghost btn-sm" data-dialog-close="import-queries-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="irqImport">Import</button>`;
|
|
42
|
+
return createDialog('import-queries-dialog', 'Import Review Queries', body, footer) + `
|
|
43
|
+
<script>
|
|
44
|
+
document.getElementById('irq-source').addEventListener('change',function(){var v=this.value;document.getElementById('irq-csv-upload').style.display=v==='csv'?'':'none';document.getElementById('irq-review-select').style.display=v==='review'?'':'none';if(v==='review'){fetch('/api/review?engagement_id=${engagementId}').then(function(r){return r.json()}).then(function(d){var sel=document.getElementById('irq-review');while(sel.options.length>0)sel.remove(0);(d.data||d||[]).forEach(function(r){var o=document.createElement('option');o.value=r.id;o.textContent=r.name||r.title||r.id;sel.appendChild(o)})}).catch(function(){})}});
|
|
45
|
+
window.irqImport=async function(){var source=document.getElementById('irq-source').value;if(source==='csv'){var file=document.getElementById('irq-file').files[0];if(!file){showToast('Select a file','error');return}var fd=new FormData();fd.append('file',file);fd.append('engagement_id','${engagementId}');try{var r=await fetch('/api/rfi_question/import',{method:'POST',body:fd});if(r.ok){var d=await r.json();showToast((d.count||'')+'queries imported','success');document.getElementById('import-queries-dialog').style.display='none';location.reload()}else showToast('Import failed','error')}catch(e){showToast('Error','error')}}else if(source==='review'){var rid=document.getElementById('irq-review').value;if(!rid){showToast('Select a review','error');return}try{var r=await fetch('/api/rfi_question/import-from-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:rid,engagement_id:'${engagementId}'})});if(r.ok){showToast('Queries imported','success');document.getElementById('import-queries-dialog').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}}};
|
|
46
|
+
</script>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function engagementNotificationSettings(engagementId) {
|
|
50
|
+
const body = `<div class="flex flex-col gap-3">
|
|
51
|
+
<label class="flex items-center gap-2"><input type="checkbox" class="checkbox eng-notif-cb" value="rfi_response" checked/><span class="text-sm">RFI Responses</span></label>
|
|
52
|
+
<label class="flex items-center gap-2"><input type="checkbox" class="checkbox eng-notif-cb" value="rfi_overdue" checked/><span class="text-sm">Overdue RFI Items</span></label>
|
|
53
|
+
<label class="flex items-center gap-2"><input type="checkbox" class="checkbox eng-notif-cb" value="review_comment" checked/><span class="text-sm">Review Comments</span></label>
|
|
54
|
+
<label class="flex items-center gap-2"><input type="checkbox" class="checkbox eng-notif-cb" value="file_upload"/><span class="text-sm">File Uploads</span></label>
|
|
55
|
+
<label class="flex items-center gap-2"><input type="checkbox" class="checkbox eng-notif-cb" value="stage_change" checked/><span class="text-sm">Stage Changes</span></label>
|
|
56
|
+
<label class="flex items-center gap-2"><input type="checkbox" class="checkbox eng-notif-cb" value="team_message"/><span class="text-sm">Team Messages</span></label>
|
|
57
|
+
</div>`;
|
|
58
|
+
const footer = `<button class="btn btn-ghost btn-sm" data-dialog-close="eng-notif-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="ensNotifSave">Save</button>`;
|
|
59
|
+
return createDialog('eng-notif-dialog', 'Notification Settings', body, footer) + `
|
|
60
|
+
<script>
|
|
61
|
+
window.openEngNotifSettings=function(){document.getElementById('eng-notif-dialog').style.display='flex';fetch('/api/engagement/${engagementId}').then(function(r){return r.json()}).then(function(d){var settings;try{settings=JSON.parse((d.data||d).notification_settings||'[]')}catch(e){settings=[]}document.querySelectorAll('.eng-notif-cb').forEach(function(cb){cb.checked=!settings.length||settings.includes(cb.value)})}).catch(function(){})};
|
|
62
|
+
window.ensNotifSave=async function(){var vals=[].slice.call(document.querySelectorAll('.eng-notif-cb:checked')).map(function(c){return c.value});try{var r=await fetch('/api/engagement/${engagementId}',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({notification_settings:JSON.stringify(vals)})});if(r.ok){showToast('Notification settings saved','success');document.getElementById('eng-notif-dialog').style.display='none'}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
63
|
+
</script>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function notificationTriggerDialog() {
|
|
67
|
+
const body = `<div class="modal-form-group"><label for="ntr-event">Event</label><select id="ntr-event" class="select select-bordered w-full"><option value="rfi_overdue">RFI Overdue</option><option value="deadline_approaching">Deadline Approaching</option><option value="stage_change">Stage Change</option><option value="assignment">New Assignment</option><option value="response_received">Response Received</option></select></div>
|
|
68
|
+
<div class="modal-form-group"><label for="ntr-channel">Channel</label><select id="ntr-channel" class="select select-bordered w-full"><option value="email">Email</option><option value="in_app">In-App</option><option value="both">Both</option></select></div>
|
|
69
|
+
<div class="modal-form-group"><label for="ntr-recipients">Recipients</label><select id="ntr-recipients" class="select select-bordered w-full"><option value="team">Engagement Team</option><option value="assigned">Assigned User Only</option><option value="managers">Managers Only</option><option value="all">All Stakeholders</option></select></div>`;
|
|
70
|
+
const footer = `<button class="btn btn-ghost btn-sm" data-dialog-close="notif-trigger-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="ntrSave">Create Trigger</button>`;
|
|
71
|
+
return createDialog('notif-trigger-dialog', 'Create Notification Trigger', body, footer) + `
|
|
72
|
+
<script>
|
|
73
|
+
window.openNotifTrigger=function(){document.getElementById('notif-trigger-dialog').style.display='flex'};
|
|
74
|
+
window.ntrSave=async function(){try{var r=await fetch('/api/notification_trigger',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({event:document.getElementById('ntr-event').value,channel:document.getElementById('ntr-channel').value,recipients:document.getElementById('ntr-recipients').value})});if(r.ok){showToast('Trigger created','success');document.getElementById('notif-trigger-dialog').style.display='none';location.reload()}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
75
|
+
</script>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function retryHighlightDialog(reviewId) {
|
|
79
|
+
const body = `<div class="modal-form-group"><label for="rhd-hid">Highlight ID</label><input id="rhd-hid" class="input input-bordered w-full" placeholder="highlight identifier"/></div><div class="modal-form-group"><label for="rhd-prompt">Override prompt (optional)</label><textarea id="rhd-prompt" class="textarea textarea-bordered w-full" rows="3" placeholder="Custom ML prompt"></textarea></div><div class="text-xs" style="color:var(--ds-muted)">Reruns ML extraction for the chosen highlight.</div>`;
|
|
80
|
+
const footer = `<button class="btn btn-ghost btn-sm" data-dialog-close="retry-highlight-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="rhdSubmit">Retry</button>`;
|
|
81
|
+
return createDialog('retry-highlight-dialog', 'Retry Highlight', body, footer) + `
|
|
82
|
+
<script>
|
|
83
|
+
window.openRetryHighlight=function(hid){document.getElementById('retry-highlight-dialog').style.display='flex';if(hid)document.getElementById('rhd-hid').value=hid};
|
|
84
|
+
window.rhdSubmit=async function(){var hid=document.getElementById('rhd-hid').value.trim();if(!hid){showToast('Highlight id required','error');return}try{var r=await fetch('/api/mwr/review/${reviewId}/highlights/'+encodeURIComponent(hid),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({retry:true,prompt:document.getElementById('rhd-prompt').value.trim()||undefined})});if(r.ok){showToast('Highlight rerun queued','success');document.getElementById('retry-highlight-dialog').style.display='none'}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}};
|
|
85
|
+
</script>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function switchTemplateButton(reviewId) {
|
|
89
|
+
return `<button class="btn btn-outline btn-sm" type="button" data-action="openSwitchTemplate" aria-label="Switch template">Switch template</button>
|
|
90
|
+
<script>
|
|
91
|
+
window.openSwitchTemplate=function(){if(typeof window.showTemplateChoice==='function'){window.showTemplateChoice(async function(t){try{var r=await fetch('/api/review/${reviewId}',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_template_id:t.id})});if(r.ok){showToast('Template switched','success');setTimeout(function(){location.reload()},400)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}})}else{showToast('Template chooser unavailable','error')}};
|
|
92
|
+
</script>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function teamChatChannelToggle(engagementId, chatEnabled) {
|
|
96
|
+
return `<div class="flex items-center gap-3"><span class="text-sm" id="team-chat-label">Team Chat</span><div class="rvw-toggle ${chatEnabled ? 'rvw-toggle-on' : ''}" role="switch" tabindex="0" aria-checked="${chatEnabled}" aria-labelledby="team-chat-label" data-action="toggleTeamChat" data-self onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleTeamChat(this)}"><div class="rvw-toggle-track"><div class="rvw-toggle-knob"></div></div></div></div>
|
|
97
|
+
<script>
|
|
98
|
+
window.toggleTeamChat=async function(el){var on=el.classList.toggle('rvw-toggle-on');el.setAttribute('aria-checked',on);try{await fetch('/api/engagement/${engagementId}',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({chat_enabled:on})});showToast('Chat '+(on?'enabled':'disabled'),'success')}catch(e){el.classList.toggle('rvw-toggle-on');el.setAttribute('aria-checked',!on);showToast('Error','error')}};
|
|
99
|
+
</script>`;
|
|
100
|
+
}
|