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,109 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { canCreate } from '@/ui/permissions-ui.js';
|
|
3
|
+
import { esc, stagePill, statusPill, progressBar, STAGE_CONFIG, TABLE_SCRIPT } from '@/ui/render-helpers.js';
|
|
4
|
+
|
|
5
|
+
function engRow(e) {
|
|
6
|
+
const name = esc(e.name || e.client_name || 'Untitled');
|
|
7
|
+
const client = esc(e.client_name || e.client_id_display || e.client_id || '-');
|
|
8
|
+
const type = esc(e.type || e.engagement_type || e.repeat_interval || '-');
|
|
9
|
+
const year = esc(e.year || '-');
|
|
10
|
+
const team = esc(e.team_name || e.team_id_display || e.team_id || '-');
|
|
11
|
+
const deadline = e.deadline ? new Date(e.deadline).toLocaleDateString() : '-';
|
|
12
|
+
const stageLbl = STAGE_CONFIG.find(s => s.key === e.stage)?.label || (e.stage || '-');
|
|
13
|
+
return `<tr data-row data-navigate="/engagement/${esc(e.id)}" style="cursor:pointer">
|
|
14
|
+
<td data-col="name"><strong>${name}</strong></td>
|
|
15
|
+
<td data-col="client">${client}</td>
|
|
16
|
+
<td data-col="type" class="eng-col-type">${type}</td>
|
|
17
|
+
<td data-col="year" class="eng-col-year">${year}</td>
|
|
18
|
+
<td data-col="team" class="eng-col-team">${team}</td>
|
|
19
|
+
<td data-col="stage">${stagePill(e.stage)}</td>
|
|
20
|
+
<td data-col="stage-raw" style="display:none">${stageLbl}</td>
|
|
21
|
+
<td data-col="status">${statusPill(e.status)}</td>
|
|
22
|
+
<td data-col="deadline">${deadline}</td>
|
|
23
|
+
<td data-col="rfi" class="eng-col-rfi" style="text-align:center">${e.rfi_count != null ? e.rfi_count : '-'}</td>
|
|
24
|
+
<td class="eng-col-progress">${progressBar(e.progress)}</td>
|
|
25
|
+
</tr>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderEngagementGrid(user, engagements, options = {}) {
|
|
29
|
+
const { filter = 'all', teams = [], years = [] } = options;
|
|
30
|
+
const myEng = engagements.filter(e => e.assigned_to === user?.id || (Array.isArray(e.users) && e.users.includes(user?.id)));
|
|
31
|
+
const teamEng = engagements.filter(e => e.team_id === user?.team_id);
|
|
32
|
+
|
|
33
|
+
const stageCounts = {};
|
|
34
|
+
STAGE_CONFIG.forEach(s => { stageCounts[s.key] = 0; });
|
|
35
|
+
engagements.forEach(e => { if (stageCounts[e.stage] !== undefined) stageCounts[e.stage]++; });
|
|
36
|
+
|
|
37
|
+
const stageStats = `<div class="stats-row">${STAGE_CONFIG.map(s =>
|
|
38
|
+
`<div class="stat-card stat-card-clickable" data-action="filterByStage" data-args='["${s.key}"]' id="stage-card-${s.key}">
|
|
39
|
+
<div class="stat-card-value">${stageCounts[s.key]||0}</div>
|
|
40
|
+
<div class="stat-card-label">${s.label}</div>
|
|
41
|
+
</div>`
|
|
42
|
+
).join('')}</div>`;
|
|
43
|
+
|
|
44
|
+
const tabs = [
|
|
45
|
+
{ key: 'all', label: 'All Engagements', count: engagements.length },
|
|
46
|
+
{ key: 'my', label: 'My Engagements', count: myEng.length },
|
|
47
|
+
{ key: 'team', label: 'Team', count: teamEng.length },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const tabBar = `<div class="tab-bar">${tabs.map(t =>
|
|
51
|
+
`<button class="tab-btn${t.key === filter ? ' active' : ''}" data-action="switchTab" data-args='["${t.key}"]' id="tab-${t.key}">${t.label}<span class="tab-count">${t.count}</span></button>`
|
|
52
|
+
).join('')}</div>`;
|
|
53
|
+
|
|
54
|
+
const addBtn = canCreate(user, 'engagement')
|
|
55
|
+
? `<a href="/engagement/new" class="btn-primary-clean"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> New Engagement</a>`
|
|
56
|
+
: '';
|
|
57
|
+
|
|
58
|
+
const stageOpts = STAGE_CONFIG.map(s => `<option value="${s.key}">${s.label}</option>`).join('');
|
|
59
|
+
const teamOpts = teams.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');
|
|
60
|
+
const yearOpts = years.map(y => `<option value="${esc(y)}">${esc(y)}</option>`).join('');
|
|
61
|
+
|
|
62
|
+
const rows = engagements.map(e => engRow(e)).join('') ||
|
|
63
|
+
`<tr><td colspan="11" style="text-align:center;padding:48px;color:var(--color-text-muted)">No engagements found</td></tr>`;
|
|
64
|
+
|
|
65
|
+
const emailReceiveDialog = `<div id="email-receive-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true">
|
|
66
|
+
<div class="dialog-panel" style="max-width:540px">
|
|
67
|
+
<div class="dialog-header"><span class="dialog-title">Receive Email</span><button class="dialog-close" onclick="document.getElementById('email-receive-dialog').style.display='none'">×</button></div>
|
|
68
|
+
<div class="dialog-body">
|
|
69
|
+
<div class="modal-form-group"><label>Email Content</label><textarea id="email-receive-content" class="form-input" rows="8" placeholder="Paste raw email content here..."></textarea></div>
|
|
70
|
+
<div id="email-receive-result" style="display:none;margin-top:8px"></div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="dialog-footer">
|
|
73
|
+
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('email-receive-dialog').style.display='none'">Cancel</button>
|
|
74
|
+
<button class="btn btn-primary btn-sm" onclick="submitEmailReceive()">Process Email</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>`;
|
|
78
|
+
|
|
79
|
+
const emailReceiveScript = `async function submitEmailReceive(){var content=document.getElementById('email-receive-content').value.trim();var res=document.getElementById('email-receive-result');if(!content){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Email content required.</div>';return}res.style.display='block';res.innerHTML='<div style="font-size:13px">Processing...</div>';try{var r=await fetch('/api/email/receive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:content})});var d=await r.json();if(r.ok&&d.success){res.innerHTML='<div style="color:var(--color-success);font-size:13px">Email processed successfully.'+(d.engagement_id?' Matched engagement: '+d.engagement_id:'')+(d.message?' '+d.message:'')+'</div>'}else{res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||d.message||'Failed to process email')+'</div>'}}catch(e){res.innerHTML='<div style="color:var(--color-danger);font-size:13px">Error: '+e.message+'</div>'}}`;
|
|
80
|
+
|
|
81
|
+
const content = `<div class="page-header">
|
|
82
|
+
<div><h1 class="page-title">Engagements</h1><p class="page-subtitle">${engagements.length} total engagements</p></div>
|
|
83
|
+
${addBtn}
|
|
84
|
+
</div>
|
|
85
|
+
${stageStats}${tabBar}
|
|
86
|
+
<div class="table-wrap eng-table-mobile-scroll">
|
|
87
|
+
<div class="table-toolbar">
|
|
88
|
+
<div class="table-search"><input id="search-input" type="text" placeholder="Search engagements..."></div>
|
|
89
|
+
${stageOpts ? `<div class="table-filter"><select data-filter="stage-raw" id="filter-stage"><option value="">All Stages</option>${stageOpts}</select></div>` : ''}
|
|
90
|
+
${teamOpts ? `<div class="table-filter"><select data-filter="team" id="filter-team"><option value="">All Teams</option>${teamOpts}</select></div>` : ''}
|
|
91
|
+
${yearOpts ? `<div class="table-filter"><select data-filter="year" id="filter-year"><option value="">All Years</option>${yearOpts}</select></div>` : ''}
|
|
92
|
+
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('email-receive-dialog').style.display='flex'">Receive Email</button>
|
|
93
|
+
<span class="table-count" id="row-count">${engagements.length} items</span>
|
|
94
|
+
</div>
|
|
95
|
+
<table class="data-table">
|
|
96
|
+
<thead><tr>
|
|
97
|
+
<th data-sort="name">Name</th><th data-sort="client">Client</th><th data-sort="type" class="eng-col-type">Type</th>
|
|
98
|
+
<th data-sort="year" class="eng-col-year">Year</th><th data-sort="team" class="eng-col-team">Team</th><th data-sort="stage-raw">Stage</th>
|
|
99
|
+
<th style="display:none"></th><th data-sort="status">Status</th><th data-sort="deadline">Deadline</th>
|
|
100
|
+
<th data-sort="rfi" class="eng-col-rfi" style="text-align:center">RFI</th><th class="eng-col-progress">Progress</th>
|
|
101
|
+
</tr></thead>
|
|
102
|
+
<tbody>${rows}</tbody>
|
|
103
|
+
</table>
|
|
104
|
+
</div>${emailReceiveDialog}`;
|
|
105
|
+
|
|
106
|
+
const stageFilterScript = `var _activeStage='';function filterByStage(stage){_activeStage=_activeStage===stage?'':stage;document.querySelectorAll('[id^="stage-card-"]').forEach(c=>{const s=c.id.replace('stage-card-','');c.style.outline=_activeStage===s?'2px solid var(--color-primary)':'none'});window.filterTable();}var _origFilter=window.filterTable||function(){};window.filterTable=function(){_origFilter();if(!_activeStage)return;const stageLabels=${JSON.stringify(Object.fromEntries(STAGE_CONFIG.map(s=>[s.key,s.label])))};document.querySelectorAll('tbody tr[data-row]').forEach(row=>{const stageCell=row.querySelector('[data-col="stage"]');if(stageCell&&!stageCell.textContent.toLowerCase().includes((stageLabels[_activeStage]||_activeStage||'').toLowerCase())){row.style.display='none';}})};function switchTab(tab){document.querySelectorAll('[id^="tab-"]').forEach(b=>b.classList.toggle('active',b.id==='tab-'+tab))}`;
|
|
107
|
+
|
|
108
|
+
return page(user, 'Engagements | Moonlanding', null, content, [TABLE_SCRIPT, stageFilterScript, emailReceiveScript]);
|
|
109
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { h } from '@/ui/webjsx.js'
|
|
2
|
+
import { page, confirmDialog, dataTable } from '@/ui/layout.js'
|
|
3
|
+
import { fmtVal, TOAST_SCRIPT } from '@/ui/render-helpers.js'
|
|
4
|
+
import { canCreate, canEdit, canDelete } from '@/ui/permissions-ui.js'
|
|
5
|
+
|
|
6
|
+
export function renderEntityList(entityName, items, spec, user, options = {}) {
|
|
7
|
+
const label = spec?.labelPlural || spec?.label || entityName
|
|
8
|
+
const fields = spec?.fields || {}
|
|
9
|
+
const { groupBy = null } = options
|
|
10
|
+
let listFields = Object.entries(fields).filter(([k, f]) => f.list).slice(0, 5)
|
|
11
|
+
if (!listFields.length) listFields = Object.entries(fields).filter(([k]) => !['created_by', 'updated_by'].includes(k)).slice(0, 6)
|
|
12
|
+
if (!listFields.length && items.length > 0) listFields = Object.keys(items[0]).filter(k => !['created_by', 'updated_by'].includes(k)).slice(0, 5).map(k => [k, { label: k }])
|
|
13
|
+
const headers = listFields.map(([k, f]) => `<th>${f?.label || k}</th>`).join('') + '<th>Actions</th>'
|
|
14
|
+
const userCanEdit = canEdit(user, entityName)
|
|
15
|
+
const userCanDelete = canDelete(user, entityName)
|
|
16
|
+
const userCanCreate = canCreate(user, entityName)
|
|
17
|
+
|
|
18
|
+
const buildRow = item => {
|
|
19
|
+
const cells = listFields.map(([k]) => `<td>${fmtVal(item[k], k, item)}</td>`).join('')
|
|
20
|
+
const editBtn = userCanEdit ? `<a href="/${entityName}/${item.id}/edit" class="btn btn-xs btn-outline">Edit</a>` : `<span class="btn btn-xs btn-outline btn-disabled tooltip" data-tip="No permission">Edit</span>`
|
|
21
|
+
const delBtn = userCanDelete ? `<button data-stop-propagation="true" data-action="confirmDelete" data-args='["${item.id}"]' class="btn btn-xs btn-error btn-outline">Delete</button>` : `<span class="btn btn-xs btn-error btn-outline btn-disabled tooltip" data-tip="No permission">Delete</span>`
|
|
22
|
+
return `<tr class="hover cursor-pointer" data-searchable tabindex="0" role="link" data-navigate="/${entityName}/${item.id}" onkeydown="if(event.key==='Enter'){window.location='/${entityName}/${item.id}'}">${cells}<td class="flex gap-1"><a href="/${entityName}/${item.id}" class="btn btn-xs btn-ghost">View</a>${editBtn}${delBtn}</td></tr>`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let tableContent
|
|
26
|
+
if (groupBy && items.length > 0) {
|
|
27
|
+
const groups = {}
|
|
28
|
+
items.forEach(item => { const key = String(item[groupBy] || '(No ' + groupBy + ')'); if (!groups[key]) groups[key] = []; groups[key].push(item) })
|
|
29
|
+
const sortedKeys = Object.keys(groups).sort()
|
|
30
|
+
const groupRows = sortedKeys.map(gkey => {
|
|
31
|
+
const groupItems = groups[gkey]
|
|
32
|
+
const groupId = `group-${gkey.replace(/\s+/g, '-').toLowerCase()}`
|
|
33
|
+
const togglerId = `toggle-${gkey.replace(/\s+/g, '-').toLowerCase()}`
|
|
34
|
+
const itemRows = groupItems.map(buildRow).join('')
|
|
35
|
+
return `<tbody class="group-section" data-group="${gkey}"><tr class="group-header hover cursor-pointer" tabindex="0" data-toggle="${togglerId}"><td colspan="100" style="padding:12px"><div class="flex items-center gap-2"><input type="checkbox" id="${togglerId}" class="group-toggle" style="cursor:pointer" checked/><span class="font-semibold text-base">${gkey}</span><span class="badge badge-sm">${groupItems.length}</span></div></td></tr><tr class="group-content" id="${groupId}" style="display:contents"><td colspan="100"><table class="data-table" style="background:transparent"><tbody>${itemRows}</tbody></table></td></tr></tbody>`
|
|
36
|
+
}).join('')
|
|
37
|
+
tableContent = `<div class="card-clean"><div class="table-wrap"><table class="data-table"><thead><tr>${headers}</tr></thead>${groupRows}</table></div></div>`
|
|
38
|
+
} else {
|
|
39
|
+
const rows = items.map(buildRow).join('')
|
|
40
|
+
tableContent = dataTable(headers, rows, items.length === 0 ? (userCanCreate ? `No items found. <a href="/${entityName}/new" class="text-primary hover:underline">Create your first ${label.toLowerCase()}</a>` : 'No items found.') : '')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const createBtn = userCanCreate ? `<a href="/${entityName}/new" class="btn btn-primary btn-sm">Create New</a>` : ''
|
|
44
|
+
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">${label}</h1><div class="flex gap-2"><label for="search-input" class="sr-only">Search</label><input type="text" id="search-input" placeholder="Search..." class="input input-solid input-sm" style="width:200px" aria-label="Search items"/>${createBtn}</div></div>${tableContent}${userCanDelete ? confirmDialog(entityName) : ''}`
|
|
45
|
+
|
|
46
|
+
const deleteScript = `let pendingDeleteId=null;window.confirmDelete=(id)=>{pendingDeleteId=id;document.getElementById('confirm-dialog').style.display='flex'};window.cancelDelete=()=>{pendingDeleteId=null;document.getElementById('confirm-dialog').style.display='none'};window.executeDelete=async()=>{if(!pendingDeleteId)return;const btn=document.getElementById('confirm-delete-btn');btn.classList.add('btn-loading');btn.textContent='Deleting...';try{const res=await fetch('/api/${entityName}/'+pendingDeleteId,{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>window.location.reload(),500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error');cancelDelete();btn.classList.remove('btn-loading');btn.textContent='Delete'}}catch(err){showToast('Error: '+err.message,'error');cancelDelete();btn.classList.remove('btn-loading');btn.textContent='Delete'}}`
|
|
47
|
+
const searchScript = `function initSearch(){const si=document.getElementById('search-input');const tb=document.querySelectorAll('[data-searchable]');const groups=document.querySelectorAll('[data-group]');let visibleGroupCount=0;si.addEventListener('input',(e)=>{const query=e.target.value.toLowerCase().trim();const terms=query.length>0?query.split(/\\s+/):[],hasResults=terms.length>0;visibleGroupCount=0;groups.forEach(g=>{const rows=g.querySelectorAll('[data-searchable]');let visibleRows=0;rows.forEach(r=>{const text=r.textContent.toLowerCase();const matches=terms.every(t=>text.includes(t));r.style.display=matches?'':'none';if(matches)visibleRows++});const groupVisible=visibleRows>0;g.style.display=groupVisible?'':'none';if(groupVisible)visibleGroupCount++;const badge=g.querySelector('.badge');if(badge)badge.textContent=visibleRows});tb.forEach(r=>{if(!r.closest('[data-group]')){const text=r.textContent.toLowerCase();const matches=query.length===0||terms.every(t=>text.includes(t));r.style.display=matches?'':'none'}});const totalVisible=document.querySelectorAll('[data-searchable]:not([style*="display: none"])').length,noResults=hasResults&&totalVisible===0;let msg=document.getElementById('search-no-results');if(noResults&&!msg){msg=document.createElement('tr');msg.id='search-no-results';msg.innerHTML='<td colspan="100" class="text-center py-8 text-base-content/50">No results found for "'+query+'"</td>';const tbody=document.getElementById('table-body')||document.querySelector('tbody');if(tbody)tbody.appendChild(msg)}else if(!noResults&&msg)msg.remove()});const toggles=document.querySelectorAll('.group-toggle');toggles.forEach(tog=>{tog.addEventListener('change',(e)=>{e.stopPropagation();const row=tog.closest('tr');const tbody=row.parentElement;const content=tbody.querySelector('.group-content');content.style.display=tog.checked?'contents':'none'})});if(si.value)si.dispatchEvent(new Event('input'))}document.addEventListener('DOMContentLoaded',initSearch)`
|
|
48
|
+
return page(user, `${label} | Moonlanding`, [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label }], content, [TOAST_SCRIPT, deleteScript, searchScript])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const HIDDEN_FIELDS = new Set(['password_hash', 'password', 'session_token', 'photo_url'])
|
|
52
|
+
const KNOWN_ROLE_LABELS = { admin:'Admin', partner:'Partner', manager:'Manager', clerk:'Clerk', user:'User', auditor:'Auditor', client_admin:'Client Admin', client_user:'Client User' }
|
|
53
|
+
|
|
54
|
+
function roleLabel(r) {
|
|
55
|
+
const key = (r || '').toLowerCase()
|
|
56
|
+
return KNOWN_ROLE_LABELS[key] || (key.length > 8 ? 'Staff' : (key.charAt(0).toUpperCase() + key.slice(1)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatFieldValue(k, v, entityName) {
|
|
60
|
+
if (entityName === 'user' && k === 'role') return `<span class="pill pill-neutral">${roleLabel(v)}</span>`
|
|
61
|
+
if (entityName === 'user' && k === 'status') {
|
|
62
|
+
const cls = v === 'active' ? 'pill-success' : v === 'deleted' ? 'pill-danger' : 'pill-neutral'
|
|
63
|
+
return `<span class="pill ${cls}">${v ? v.charAt(0).toUpperCase() + v.slice(1) : '-'}</span>`
|
|
64
|
+
}
|
|
65
|
+
if (entityName === 'user' && k === 'email' && v) return `<a href="mailto:${v}" class="text-primary hover:underline">${v}</a>`
|
|
66
|
+
if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${v}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'/>`
|
|
67
|
+
return fmtVal(v, k)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function renderEntityDetail(entityName, item, spec, user) {
|
|
71
|
+
const label = spec?.label || entityName
|
|
72
|
+
const fields = spec?.fields || {}
|
|
73
|
+
const userCanEdit = canEdit(user, entityName)
|
|
74
|
+
const userCanDelete = canDelete(user, entityName)
|
|
75
|
+
|
|
76
|
+
const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
|
|
77
|
+
|
|
78
|
+
const fieldRows = visibleFields.map(([k, f]) =>
|
|
79
|
+
`<div class="detail-row">
|
|
80
|
+
<span class="detail-row-label">${f.label || k}</span>
|
|
81
|
+
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName)}</span>
|
|
82
|
+
</div>`
|
|
83
|
+
).join('')
|
|
84
|
+
|
|
85
|
+
const displayName = item.name || item.title || label
|
|
86
|
+
const initials = (displayName || '?').charAt(0).toUpperCase()
|
|
87
|
+
const statusCls = item.status === 'active' ? 'pill-success' : item.status === 'deleted' ? 'pill-danger' : 'pill-neutral'
|
|
88
|
+
const statusLabel2 = item.status ? item.status.charAt(0).toUpperCase() + item.status.slice(1) : '-'
|
|
89
|
+
|
|
90
|
+
const headerExtra = entityName === 'user' ? `
|
|
91
|
+
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem">
|
|
92
|
+
<div style="width:3.5rem;height:3.5rem;border-radius:50%;background:#e0f2fe;display:flex;align-items:center;justify-content:center;font-size:1.25rem;font-weight:700;color:#0369a1;flex-shrink:0">
|
|
93
|
+
${item.photo_url ? `<img src="${item.photo_url}" style="width:3.5rem;height:3.5rem;border-radius:50%;object-fit:cover" alt="${displayName}" onerror="this.style.display='none'"/>` : initials}
|
|
94
|
+
</div>
|
|
95
|
+
<div>
|
|
96
|
+
<h1 style="font-size:1.5rem;font-weight:700;margin:0">${displayName}</h1>
|
|
97
|
+
<div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.375rem">
|
|
98
|
+
<span class="pill pill-neutral">${roleLabel(item.role)}</span>
|
|
99
|
+
<span class="pill ${statusCls}">${statusLabel2}</span>
|
|
100
|
+
${item.user_type ? `<span style="font-size:0.75rem;color:var(--color-text-muted)">${item.user_type}</span>` : ''}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>` : `<div style="margin-bottom:1.5rem"><h1 style="font-size:1.5rem;font-weight:700">${displayName}</h1></div>`
|
|
104
|
+
|
|
105
|
+
const editBtn = userCanEdit ? `<a href="/${entityName}/${item.id}/edit" class="btn btn-outline btn-sm">Edit</a>` : ''
|
|
106
|
+
const delBtn = userCanDelete ? `<button data-action="showDeleteConfirm" class="btn btn-error btn-outline btn-sm">Delete</button>` : ''
|
|
107
|
+
|
|
108
|
+
const content = `
|
|
109
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
|
110
|
+
<div style="flex:1">${headerExtra}</div>
|
|
111
|
+
<div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${delBtn}</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="card-clean">
|
|
114
|
+
<div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
|
|
115
|
+
</div>
|
|
116
|
+
${userCanDelete ? confirmDialog(entityName) : ''}`
|
|
117
|
+
|
|
118
|
+
const script = `${TOAST_SCRIPT}window.showDeleteConfirm=()=>{document.getElementById('confirm-dialog').style.display='flex'};window.hideDeleteConfirm=()=>{document.getElementById('confirm-dialog').style.display='none'};window.executeDelete=async()=>{const btn=document.getElementById('confirm-delete-btn');btn.classList.add('btn-loading');btn.textContent='Deleting...';try{const res=await fetch('/api/${entityName}/${item.id}',{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>{window.location='/${entityName}'},500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error');hideDeleteConfirm();btn.classList.remove('btn-loading');btn.textContent='Delete'}}catch(err){showToast('Error: '+err.message,'error');hideDeleteConfirm();btn.classList.remove('btn-loading');btn.textContent='Delete'}}`
|
|
119
|
+
const bc = [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: item.name || item.title || `#${item.id}` }]
|
|
120
|
+
return page(user, `${label} Detail`, bc, content, [script])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}) {
|
|
124
|
+
const label = spec?.label || entityName
|
|
125
|
+
const fields = spec?.fields || {}
|
|
126
|
+
const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${f.label||k}${req ? '<span class="req">*</span>' : ''}</label>`
|
|
127
|
+
const formFields = Object.entries(fields).filter(([k, f]) => k !== 'id' && !f.auto && !f.readOnly && !f.auto_generate && k !== 'password_hash').map(([k, f]) => {
|
|
128
|
+
const val = item?.[k] ?? f.default ?? ''
|
|
129
|
+
const req = f.required ? 'required' : ''
|
|
130
|
+
const type = f.type === 'number' || f.type === 'int' || f.type === 'decimal' ? 'number' : f.type === 'email' ? 'email' : f.type === 'timestamp' || f.type === 'date' ? 'date' : f.type === 'bool' ? 'checkbox' : 'text'
|
|
131
|
+
const placeholder = `placeholder="Enter ${(f.label||k).toLowerCase()}"`
|
|
132
|
+
if (entityName === 'user' && k === 'role') {
|
|
133
|
+
const opts = ['partner','manager','clerk','client_admin','client_user'].map(o => `<option value="${o}" ${val===o?'selected':''}>${o.charAt(0).toUpperCase()+o.slice(1).replace('_',' ')}</option>`).join('')
|
|
134
|
+
return `<div class="form-field">${lbl(k,{label:'Role'},true)}<select id="field-role" name="role" class="form-input" required>${opts}</select></div>`
|
|
135
|
+
}
|
|
136
|
+
if (entityName === 'user' && k === 'status') {
|
|
137
|
+
const opts = ['active','inactive','pending'].map(o => `<option value="${o}" ${val===o?'selected':''}>${o.charAt(0).toUpperCase()+o.slice(1)}</option>`).join('')
|
|
138
|
+
return `<div class="form-field">${lbl(k,{label:'Status'},false)}<select id="field-status" name="status" class="form-input">${opts}</select></div>`
|
|
139
|
+
}
|
|
140
|
+
if (f.type === 'ref' && refOptions[k]) {
|
|
141
|
+
const opts = refOptions[k].map(o => `<option value="${o.value}" ${val===o.value?'selected':''}>${o.label}</option>`).join('')
|
|
142
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${f.label||k}...</option>${opts}</select></div>`
|
|
143
|
+
}
|
|
144
|
+
if (f.type === 'textarea') return `<div class="form-field full">${lbl(k,f,f.required)}<textarea id="field-${k}" name="${k}" class="form-input" style="min-height:100px;resize:vertical" ${req} placeholder="Enter ${(f.label||k).toLowerCase()}">${val}</textarea></div>`
|
|
145
|
+
if (f.type === 'bool') return `<div class="form-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="field-${k}" name="${k}" class="checkbox checkbox-primary" ${val?'checked':''}/><span class="form-label" style="margin:0">${f.label||k}</span></label></div>`
|
|
146
|
+
if (f.type === 'enum' && f.options) {
|
|
147
|
+
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<option value="${ov}" ${val===ov?'selected':''}>${ol}</option>` }).join('')
|
|
148
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${f.label||k}...</option>${opts}</select></div>`
|
|
149
|
+
}
|
|
150
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${val}" class="form-input" ${req} ${placeholder}/></div>`
|
|
151
|
+
}).join('\n')
|
|
152
|
+
|
|
153
|
+
const pwField = entityName === 'user' ? `<div class="form-field"><label class="form-label" for="field-new-password">New Password <small style="font-weight:400;color:var(--color-text-muted)">(leave blank to keep unchanged)</small></label><input type="password" id="field-new-password" name="new_password" class="form-input" placeholder="Enter new password" autocomplete="new-password"/></div>` : ''
|
|
154
|
+
const bc = isNew
|
|
155
|
+
? [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: 'Create' }]
|
|
156
|
+
: [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { href: `/${entityName}/${item?.id}`, label: item?.name || item?.title || `#${item?.id}` }, { label: 'Edit' }]
|
|
157
|
+
const content = `<div class="form-shell"><div style="margin-bottom:24px"><h1 style="font-size:24px;font-weight:700">${isNew ? 'Create' : 'Edit'} ${label}</h1></div>
|
|
158
|
+
<div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${label}">${formFields}${pwField}
|
|
159
|
+
<div class="form-actions" style="grid-column:1/-1"><button type="submit" id="submit-btn" class="btn-primary-clean"><span class="btn-text">Save</span><span class="btn-loading-text" style="display:none">Saving...</span></button>
|
|
160
|
+
<a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
|
|
161
|
+
const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;const fd=new FormData(form);const data={};for(const[k,v]of fd.entries())data[k]=v;form.querySelectorAll('input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('input[type=number]').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
|
|
162
|
+
return page(user, `${isNew ? 'Create' : 'Edit'} ${label}`, bc, content, [script])
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderSettings(user, config = {}) {
|
|
166
|
+
const t = config.thresholds || {}
|
|
167
|
+
const sections = [
|
|
168
|
+
{ title: 'System Information', items: [['Database Type', config.database?.type || 'SQLite'], ['Server Port', config.server?.port || 3004], ['Session TTL', (t.cache?.session_ttl_seconds || 3600) + 's'], ['Page Size (Default)', t.system?.default_page_size || 50], ['Page Size (Max)', t.system?.max_page_size || 500]] },
|
|
169
|
+
{ title: 'RFI Configuration', items: [['Max Days Outstanding', (t.rfi?.max_days_outstanding || 90) + ' days'], ['Escalation Delay', (t.rfi?.escalation_delay_hours || 24) + ' hours'], ['Notification Days', (t.rfi?.notification_days || [7,3,1,0]).join(', ')]] },
|
|
170
|
+
{ title: 'Email Configuration', items: [['Batch Size', t.email?.send_batch_size || 10], ['Max Retries', t.email?.send_max_retries || 3], ['Rate Limit Delay', (t.email?.rate_limit_delay_ms || 6000) + 'ms']] },
|
|
171
|
+
{ title: 'Workflow Configuration', items: [['Stage Transition Lockout', (t.workflow?.stage_transition_lockout_minutes || 5) + ' minutes'], ['Collaborator Default Expiry', (t.collaborator?.default_expiry_days || 7) + ' days'], ['Collaborator Max Expiry', (t.collaborator?.max_expiry_days || 30) + ' days']] },
|
|
172
|
+
]
|
|
173
|
+
const cards = sections.map(s => `<div class="card-clean"><div class="card-clean-body"><h2 style="font-size:1rem;font-weight:600">${s.title}</h2><div class="space-y-4 mt-4">${s.items.map(([l, v]) => `<div class="flex justify-between py-2 border-b border-base-200"><span class="text-base-content/50 text-sm">${l}</span><span class="font-medium text-sm">${v}</span></div>`).join('')}</div></div></div>`).join('')
|
|
174
|
+
return page(user, 'System Settings | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }],
|
|
175
|
+
`<h1 class="text-2xl font-bold mb-6">System Settings</h1><div class="grid grid-cols-1 lg:grid-cols-2 gap-6">${cards}</div>`)
|
|
176
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
window.__events = window.__events || {
|
|
2
|
+
handlers: new Map(),
|
|
3
|
+
register(name, fn) { this.handlers.set(name, fn); },
|
|
4
|
+
dispatch(name, event, target, params) {
|
|
5
|
+
const fn = this.handlers.get(name);
|
|
6
|
+
if (fn) return fn(event, target, params);
|
|
7
|
+
if (typeof window[name] === 'function') {
|
|
8
|
+
const args = target?.dataset?.args ? JSON.parse(target.dataset.args) : [];
|
|
9
|
+
if ('passEvent' in (target?.dataset || {})) args.unshift(event);
|
|
10
|
+
if ('self' in (target?.dataset || {})) args.push(target);
|
|
11
|
+
return window[name](...args);
|
|
12
|
+
}
|
|
13
|
+
console.warn(`Unknown action: ${name}`);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const eventDelegation = {
|
|
18
|
+
closeDialog(dialogId) {
|
|
19
|
+
const el = document.getElementById(dialogId);
|
|
20
|
+
if (el) el.style.display = 'none';
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
openDialog(e, target, params) {
|
|
24
|
+
const id = params?.dialogId || (target?.dataset?.args ? JSON.parse(target.dataset.args)[0] : null);
|
|
25
|
+
const el = id ? document.getElementById(id) : null;
|
|
26
|
+
if (el) el.style.display = 'flex';
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
printPage() { window.print(); },
|
|
30
|
+
|
|
31
|
+
navigate(path) { window.location = path; },
|
|
32
|
+
|
|
33
|
+
toggle(elementId, attr = 'checked') {
|
|
34
|
+
const el = document.getElementById(elementId);
|
|
35
|
+
if (el?.type === 'checkbox') el.checked = !el.checked;
|
|
36
|
+
else el.classList.toggle('active');
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
toggleDisplay(elementId) {
|
|
40
|
+
const el = document.getElementById(elementId);
|
|
41
|
+
if (el) el.style.display = el.style.display === 'none' ? '' : 'none';
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
setValue(elementId, value) {
|
|
45
|
+
const el = document.getElementById(elementId);
|
|
46
|
+
if (el) el.value = value;
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
setDisplay(elementId, show) {
|
|
50
|
+
const el = document.getElementById(elementId);
|
|
51
|
+
if (el) el.style.display = show ? '' : 'none';
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
remove(elementId) {
|
|
55
|
+
const el = document.getElementById(elementId);
|
|
56
|
+
if (el) el.remove();
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
focus(elementId) {
|
|
60
|
+
const el = document.getElementById(elementId);
|
|
61
|
+
if (el) el.focus();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
Object.entries(eventDelegation).forEach(([name, fn]) => {
|
|
66
|
+
window.__events.register(name, fn);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
document.addEventListener('click', (e) => {
|
|
70
|
+
const stopEl = e.target.closest('[data-stop-propagation]');
|
|
71
|
+
if (stopEl) { e.stopPropagation(); if (!stopEl.dataset.action) return; }
|
|
72
|
+
|
|
73
|
+
const overlay = e.target.closest('[data-overlay-close]');
|
|
74
|
+
if (overlay && e.target === overlay) { overlay.style.display = 'none'; return; }
|
|
75
|
+
|
|
76
|
+
let target = e.target.closest('[data-action], [data-dialog-close], [data-navigate], [data-toggle]');
|
|
77
|
+
if (!target) return;
|
|
78
|
+
|
|
79
|
+
if (target.classList.contains('dialog-overlay') && e.target === target) {
|
|
80
|
+
target.style.display = 'none';
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (target.dataset.dialogClose) {
|
|
85
|
+
eventDelegation.closeDialog(target.dataset.dialogClose);
|
|
86
|
+
if (target.dataset.action) {
|
|
87
|
+
const params = target.dataset.params ? JSON.parse(target.dataset.params) : {};
|
|
88
|
+
window.__events.dispatch(target.dataset.action, e, target, params);
|
|
89
|
+
}
|
|
90
|
+
} else if (target.dataset.navigate) {
|
|
91
|
+
eventDelegation.navigate(target.dataset.navigate);
|
|
92
|
+
} else if (target.dataset.toggle) {
|
|
93
|
+
eventDelegation.toggle(target.dataset.toggle);
|
|
94
|
+
} else if (target.dataset.action) {
|
|
95
|
+
const params = target.dataset.params ? JSON.parse(target.dataset.params) : {};
|
|
96
|
+
window.__events.dispatch(target.dataset.action, e, target, params);
|
|
97
|
+
}
|
|
98
|
+
}, true);
|
|
99
|
+
|
|
100
|
+
document.addEventListener('keydown', (e) => {
|
|
101
|
+
if (e.key === 'Escape') {
|
|
102
|
+
const dialog = document.querySelector('[role="dialog"]:not([style*="display:none"])');
|
|
103
|
+
if (dialog) {
|
|
104
|
+
dialog.style.display = 'none';
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Defensive JSON fetcher for client-side scripts. Loud-fail when an endpoint
|
|
2
|
+
// returns non-JSON (almost always: a 302 -> /login HTML redirect, a 404 HTML
|
|
3
|
+
// page, or an unrouted path falling through to the catch-all). Throws an Error
|
|
4
|
+
// whose message identifies the URL, status, and observed content-type so the
|
|
5
|
+
// failure can be triaged from the console rather than the cryptic
|
|
6
|
+
// "Unexpected token '<', '<!DOCTYPE '... is not valid JSON" SyntaxError.
|
|
7
|
+
//
|
|
8
|
+
// This module is server-rendered as an inline string so client scripts can
|
|
9
|
+
// reference window.fetchJson without an import statement.
|
|
10
|
+
|
|
11
|
+
export const FETCH_JSON_SCRIPT = `window.fetchJson=async function(url,opts){
|
|
12
|
+
var o=Object.assign({credentials:'include',headers:{}},opts||{});
|
|
13
|
+
o.headers=Object.assign({'accept':'application/json'},o.headers||{});
|
|
14
|
+
var r=await fetch(url,o);
|
|
15
|
+
var ct=(r.headers.get('content-type')||'').split(';')[0].trim();
|
|
16
|
+
if(!ct.includes('json')){
|
|
17
|
+
var body=await r.text();
|
|
18
|
+
var snippet=body.slice(0,80).replace(/\\s+/g,' ');
|
|
19
|
+
throw new Error('fetchJson('+url+') expected JSON, got '+(ct||'(none)')+' status='+r.status+' body~='+snippet);
|
|
20
|
+
}
|
|
21
|
+
var data=await r.json();
|
|
22
|
+
if(!r.ok){var err=new Error((data&&(data.error||data.message))||('fetchJson('+url+') status='+r.status));err.status=r.status;err.data=data;throw err;}
|
|
23
|
+
return data;
|
|
24
|
+
};`;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { statusLabel } from '@/ui/renderer.js';
|
|
2
|
+
|
|
3
|
+
export function zipFileCreationDialog(engagementId) {
|
|
4
|
+
return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="zip-create-dialog-title" aria-hidden="true">
|
|
5
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="zip-create-dialog-title">Create Zip Archive</span><button class="dialog-close" data-dialog-close="zip-create-dialog" aria-label="Close dialog">×</button></div>
|
|
6
|
+
<div class="dialog-body"><div id="zcd-files" class="flex flex-col gap-2" style="max-height:300px;overflow:auto"></div><div class="flex items-center gap-2 mt-3"><label class="flex items-center gap-2 text-sm"><input type="checkbox" id="zcd-all" class="checkbox checkbox-sm" checked/><span>Select All</span></label></div></div>
|
|
7
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="zip-create-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="zcdCreate">Create Zip</button></div>
|
|
8
|
+
</div></div>
|
|
9
|
+
<script>
|
|
10
|
+
window.openZipCreation=function(){document.getElementById('zip-create-dialog').style.display='flex';fetch('/api/file?engagement_id=${engagementId}').then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];var el=document.getElementById('zcd-files');el.innerHTML=files.map(function(f){return'<label class="flex items-center gap-2"><input type="checkbox" class="checkbox checkbox-sm zcd-cb" value="'+f.id+'" checked/><span class="text-sm">'+(f.name||f.id)+'</span><span class="text-xs text-gray-400">'+(f.size?Math.round(f.size/1024)+'KB':'')+'</span></label>'}).join('')||'<div class="text-gray-500 text-sm">No files available</div>'}).catch(function(){})};
|
|
11
|
+
document.getElementById('zcd-all').addEventListener('change',function(){document.querySelectorAll('.zcd-cb').forEach(function(c){c.checked=this.checked}.bind(this))});
|
|
12
|
+
window.zcdCreate=async function(){var ids=[].slice.call(document.querySelectorAll('.zcd-cb:checked')).map(function(c){return c.value});if(!ids.length){showToast('Select files','error');return}showToast('Creating zip...','info');try{var r=await fetch('/api/file/zip',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_ids:ids,engagement_id:'${engagementId}'})});if(r.ok){var blob=await r.blob();var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='files.zip';a.click();showToast('Zip downloaded','success')}else showToast('Failed','error')}catch(e){showToast('Error','error')}document.getElementById('zip-create-dialog').style.display='none'};
|
|
13
|
+
</script>`;
|
|
14
|
+
}
|
|
15
|
+
export function crossEngagementFilePicker(currentEngId) {
|
|
16
|
+
return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="cross-file-picker-title" aria-hidden="true">
|
|
17
|
+
<div class="dialog-panel" style="max-width:640px"><div class="dialog-header"><span class="dialog-title" id="cross-file-picker-title">Pick File from Another Engagement</span><button class="dialog-close" data-dialog-close="cross-file-picker" aria-label="Close dialog">×</button></div>
|
|
18
|
+
<div class="dialog-body"><div class="modal-form-group"><label for="cfp-eng">Engagement</label><select id="cfp-eng" class="select select-bordered w-full" onchange="cfpLoadFiles()"></select></div><div id="cfp-files" class="flex flex-col gap-2 mt-3" style="max-height:300px;overflow:auto"></div></div>
|
|
19
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="cross-file-picker">Close</button></div>
|
|
20
|
+
</div></div>
|
|
21
|
+
<script>
|
|
22
|
+
window.openCrossFilePicker=function(){document.getElementById('cross-file-picker').style.display='flex';fetch('/api/engagement').then(function(r){return r.json()}).then(function(d){var engs=d.data||d||[];var sel=document.getElementById('cfp-eng');while(sel.options.length>0)sel.remove(0);engs.filter(function(e){return e.id!=='${currentEngId}'}).forEach(function(e){var o=document.createElement('option');o.value=e.id;o.textContent=e.name||e.id;sel.appendChild(o)});if(sel.options.length)cfpLoadFiles()}).catch(function(){})};
|
|
23
|
+
window.cfpLoadFiles=function(){var eid=document.getElementById('cfp-eng').value;if(!eid)return;fetch('/api/file?engagement_id='+eid).then(function(r){return r.json()}).then(function(d){var files=d.data||d||[];document.getElementById('cfp-files').innerHTML=files.map(function(f){return'<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded"><span class="text-sm">'+(f.name||f.id)+'</span><button class="btn btn-xs btn-primary" data-action="cfpLink" data-args='["'+f.id+'"]'>Link</button></div>'}).join('')||'<div class="text-gray-500 text-sm">No files</div>'}).catch(function(){})};
|
|
24
|
+
window.cfpLink=async function(fid){try{var r=await fetch('/api/file/link',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fid,engagement_id:'${currentEngId}'})});if(r.ok){showToast('File linked','success');document.getElementById('cross-file-picker').style.display='none'}else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
25
|
+
</script>`;
|
|
26
|
+
}
|
|
27
|
+
export function fileUploadPanel(entityType, entityId) {
|
|
28
|
+
return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body"><h3 style="font-size:0.875rem;font-weight:600">Upload Files</h3><div class="mt-3"><input type="file" id="fup-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="fupUpload">Upload</button></div><div id="fup-progress" class="text-sm text-gray-500 mt-2"></div></div></div></div>
|
|
29
|
+
<script>
|
|
30
|
+
window.fupUpload=async function(){var files=document.getElementById('fup-files').files;if(!files.length){showToast('Select files','error');return}var prog=document.getElementById('fup-progress');var ok=0;for(var i=0;i<files.length;i++){prog.textContent='Uploading '+(i+1)+'/'+files.length;var fd=new FormData();fd.append('file',files[i]);fd.append('entity_type','${entityType}');fd.append('entity_id','${entityId}');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)ok++}catch(e){}}prog.textContent=ok+'/'+files.length+' uploaded';if(ok>0)showToast(ok+' files uploaded','success')};
|
|
31
|
+
</script>`;
|
|
32
|
+
}
|
|
33
|
+
export function userCvUpload(userId) {
|
|
34
|
+
return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body"><h3 style="font-size:0.875rem;font-weight:600">Upload CV</h3><div class="mt-3"><input type="file" id="cv-file" class="file-input file-input-bordered w-full" accept=".pdf,.doc,.docx"/><div class="flex justify-end mt-2"><button class="btn btn-primary btn-sm" data-action="cvUpload">Upload CV</button></div></div></div></div>
|
|
35
|
+
<script>
|
|
36
|
+
window.cvUpload=async function(){var file=document.getElementById('cv-file').files[0];if(!file){showToast('Select a file','error');return}var fd=new FormData();fd.append('file',file);fd.append('entity_type','user_cv');fd.append('entity_id','${userId}');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)showToast('CV uploaded','success');else showToast('Upload failed','error')}catch(e){showToast('Error','error')}};
|
|
37
|
+
</script>`;
|
|
38
|
+
}
|
|
39
|
+
export function quickViewAttachment() {
|
|
40
|
+
return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="quick-view-title" aria-hidden="true">
|
|
41
|
+
<div class="dialog-panel" style="max-width:900px;max-height:90vh"><div class="dialog-header"><span class="dialog-title" id="quick-view-title">Preview</span><button class="dialog-close" data-dialog-close="quick-view" aria-label="Close dialog">×</button></div>
|
|
42
|
+
<div class="dialog-body" style="overflow:auto;max-height:75vh"><div id="qv-content" class="text-center"><div class="text-gray-500">Loading...</div></div></div>
|
|
43
|
+
<div class="dialog-footer"><a id="qv-download" href="#" download class="btn btn-primary btn-sm">Download</a><button class="btn btn-ghost btn-sm" data-dialog-close="quick-view">Close</button></div>
|
|
44
|
+
</div></div>
|
|
45
|
+
<script>
|
|
46
|
+
window.quickView=function(url,name,type){document.getElementById('quick-view').style.display='flex';document.getElementById('quick-view-title').textContent=name||'Preview';document.getElementById('qv-download').href=url;var c=document.getElementById('qv-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" alt="'+(name||'File preview')+'" style="max-width:100%;max-height:70vh"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:70vh;border:none"></iframe>'}else{c.innerHTML='<div class="py-8 text-gray-500"><div style="font-size:3rem">📄</div><div class="mt-2">'+name+'</div><div class="text-xs mt-1">Preview not available</div></div>'}};
|
|
47
|
+
</script>`;
|
|
48
|
+
}
|
|
49
|
+
export function fetchCachedPdf(fileId) {
|
|
50
|
+
return `<script>
|
|
51
|
+
window.fetchCachedPdf=function(id){var cacheKey='pdf_cache_'+id;var cached=sessionStorage.getItem(cacheKey);if(cached){return Promise.resolve(cached)}return fetch('/api/file/'+(id||'${fileId}')+'/download').then(function(r){return r.blob()}).then(function(b){var url=URL.createObjectURL(b);sessionStorage.setItem(cacheKey,url);return url})};
|
|
52
|
+
</script>`;
|
|
53
|
+
}
|
|
54
|
+
export function fileAttachmentBar(files = []) {
|
|
55
|
+
if (!files.length) return '';
|
|
56
|
+
const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${f.id}/download","${(f.name || '').replace(/"/g, '"')}","${f.mime_type || ''}"]'><span style="font-size:1.2rem">📎</span><span class="text-xs truncate" style="max-width:120px">${f.name || 'file'}</span></div>`).join('');
|
|
57
|
+
return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
|
|
58
|
+
}
|
|
59
|
+
export function fileLinksBar(links = []) {
|
|
60
|
+
if (!links.length) return '';
|
|
61
|
+
const items = links.map(l => `<a href="${l.url || '#'}" target="_blank" class="flex items-center gap-1 p-1 rounded hover:bg-gray-100 text-xs text-blue-600"><span>🔗</span><span class="truncate" style="max-width:150px">${l.name || l.url || 'Link'}</span></a>`).join('');
|
|
62
|
+
return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
|
|
63
|
+
}
|
|
64
|
+
export function reviewAttachmentChoiceDialog(reviewId) {
|
|
65
|
+
return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true" onkeydown="if(event.key==='Escape')this.style.display='none'" role="dialog" aria-modal="true" aria-labelledby="rev-attach-choice-title" aria-hidden="true">
|
|
66
|
+
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rev-attach-choice-title">Add Attachment</span><button class="dialog-close" data-dialog-close="rev-attach-choice" aria-label="Close dialog">×</button></div>
|
|
67
|
+
<div class="dialog-body"><div class="flex flex-col gap-3"><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="triggerUpload">📎 Upload New File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="openCrossFilePicker">🔗 Link Existing File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="racUrl">🌐 Add URL</button></div><input type="file" id="rac-upload" style="display:none" onchange="racUploadFile()"/></div>
|
|
68
|
+
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rev-attach-choice">Cancel</button></div>
|
|
69
|
+
</div></div>
|
|
70
|
+
<script>
|
|
71
|
+
window.openRevAttachChoice=function(){document.getElementById('rev-attach-choice').style.display='flex'};
|
|
72
|
+
window.racUploadFile=async function(){var file=document.getElementById('rac-upload').files[0];if(!file)return;var fd=new FormData();fd.append('file',file);fd.append('review_id','${reviewId}');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)showToast('Attached','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
73
|
+
window.racUrl=function(){var url=prompt('Enter URL:');if(!url)return;fetch('/api/file',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:'${reviewId}',url:url,name:url,type:'link'})}).then(function(r){if(r.ok)showToast('Link added','success');else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
|
|
74
|
+
</script>`;
|
|
75
|
+
}
|
|
76
|
+
export function uploadFridayFilesCrossApp(engagementId) {
|
|
77
|
+
return `<div class="card-clean" style="margin-bottom:1rem"><div class="card-clean-body"><h3 style="font-size:0.875rem;font-weight:600">Upload Friday Files</h3><p class="text-xs text-gray-500 mb-2">Upload files accessible from both Friday and MWR</p><input type="file" id="fxf-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="fxfUpload">Upload</button></div></div></div>
|
|
78
|
+
<script>
|
|
79
|
+
window.fxfUpload=async function(){var files=document.getElementById('fxf-files').files;if(!files.length){showToast('Select files','error');return}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('cross_app',true);try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)ok++}catch(e){}}if(ok)showToast(ok+' files uploaded','success')};
|
|
80
|
+
</script>`;
|
|
81
|
+
}
|