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,173 @@
|
|
|
1
|
+
// FlexUp-style summary report for an engagement — print-friendly HTML.
|
|
2
|
+
// Aggregates: engagement details, RFI status counts, review progress, highlight resolution,
|
|
3
|
+
// activity timeline. Users hit "Print" to save as PDF in-browser (no server-side puppeteer
|
|
4
|
+
// dependency needed for this report).
|
|
5
|
+
|
|
6
|
+
import { esc, STAGE_CONFIG } from '@/ui/render-helpers.js';
|
|
7
|
+
|
|
8
|
+
function fmtDate(v) {
|
|
9
|
+
if (!v) return '-';
|
|
10
|
+
const n = typeof v === 'number' ? (v > 1e10 ? v : v * 1000) : Date.parse(v);
|
|
11
|
+
if (!n || isNaN(n)) return '-';
|
|
12
|
+
return new Date(n).toLocaleDateString('en-ZA', { day: '2-digit', month: 'short', year: 'numeric' });
|
|
13
|
+
}
|
|
14
|
+
function fmtCurrency(v) {
|
|
15
|
+
if (v == null || v === '') return '-';
|
|
16
|
+
return 'R ' + Number(v).toLocaleString('en-ZA', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pct(part, total) {
|
|
20
|
+
if (!total || total === 0) return 0;
|
|
21
|
+
return Math.round((Number(part) / Number(total)) * 100);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function progressRing(value, label, color) {
|
|
25
|
+
const radius = 32, circ = 2 * Math.PI * radius;
|
|
26
|
+
const offset = circ - (value / 100) * circ;
|
|
27
|
+
return `<div style="display:flex;flex-direction:column;align-items:center;gap:4px">
|
|
28
|
+
<svg width="80" height="80" viewBox="0 0 80 80" style="transform:rotate(-90deg)">
|
|
29
|
+
<circle cx="40" cy="40" r="${radius}" fill="none" stroke="#e5e7eb" stroke-width="6"/>
|
|
30
|
+
<circle cx="40" cy="40" r="${radius}" fill="none" stroke="${color}" stroke-width="6"
|
|
31
|
+
stroke-dasharray="${circ}" stroke-dashoffset="${offset}" stroke-linecap="round"/>
|
|
32
|
+
</svg>
|
|
33
|
+
<div style="font-size:20px;font-weight:700;color:#111;margin-top:-54px;position:relative;height:24px">${value}%</div>
|
|
34
|
+
<div style="font-size:12px;font-weight:500;color:#64748b;margin-top:4px">${esc(label)}</div>
|
|
35
|
+
</div>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function renderFlexupReport(user, engagement, client, rfis, reviews, highlights, activity, team) {
|
|
39
|
+
const e = engagement;
|
|
40
|
+
const cfg = STAGE_CONFIG.find((s) => s.key === e.stage);
|
|
41
|
+
const themeColor = e.color || cfg?.color || '#3b82f6';
|
|
42
|
+
|
|
43
|
+
const rfiStats = {
|
|
44
|
+
total: rfis.length,
|
|
45
|
+
open: rfis.filter((r) => (r.status || 'open') === 'open').length,
|
|
46
|
+
closed: rfis.filter((r) => ['closed', 'completed', 'resolved'].includes((r.status || '').toLowerCase())).length,
|
|
47
|
+
overdue: rfis.filter((r) => r.due_date && Number(r.due_date) < Math.floor(Date.now() / 1000) && (r.status || 'open') !== 'closed').length,
|
|
48
|
+
};
|
|
49
|
+
const hlStats = {
|
|
50
|
+
total: highlights.length,
|
|
51
|
+
resolved: highlights.filter((h) => h.status === 'resolved').length,
|
|
52
|
+
partial: highlights.filter((h) => h.status === 'partial_resolved' || h.partial_resolved).length,
|
|
53
|
+
partner: highlights.filter((h) => h.partner_resolved).length,
|
|
54
|
+
manager: highlights.filter((h) => h.manager_resolved).length,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const resolvedPct = pct(hlStats.resolved, hlStats.total);
|
|
58
|
+
const partnerPct = pct(hlStats.partner, hlStats.total);
|
|
59
|
+
const managerPct = pct(hlStats.manager, hlStats.total);
|
|
60
|
+
const engProgress = Number(e.progress) || 0;
|
|
61
|
+
|
|
62
|
+
const rfiRows = rfis.length ? rfis.map((r) => {
|
|
63
|
+
const overdue = r.due_date && Number(r.due_date) < Math.floor(Date.now() / 1000) && (r.status || 'open') !== 'closed';
|
|
64
|
+
return `<tr>
|
|
65
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px">${esc(r.title || r.name || '-')}</td>
|
|
66
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#64748b">${esc(r.status || 'open')}</td>
|
|
67
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:${overdue ? '#dc2626' : '#64748b'}">${r.due_date ? fmtDate(r.due_date) : '-'}</td>
|
|
68
|
+
</tr>`;
|
|
69
|
+
}).join('') : `<tr><td colspan="3" style="padding:20px;text-align:center;color:#9ca3af;font-size:13px">No RFIs</td></tr>`;
|
|
70
|
+
|
|
71
|
+
const reviewRows = reviews.length ? reviews.map((r) => `<tr>
|
|
72
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px">${esc(r.name || '-')}</td>
|
|
73
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#64748b">${esc(r.status || '-')}</td>
|
|
74
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#64748b">${r.total_highlights || 0}</td>
|
|
75
|
+
<td style="padding:6px 10px;border-bottom:1px solid #e5e7eb;font-size:13px;color:#64748b">${r.resolved_highlights || 0}</td>
|
|
76
|
+
</tr>`).join('') : `<tr><td colspan="4" style="padding:20px;text-align:center;color:#9ca3af;font-size:13px">No reviews linked</td></tr>`;
|
|
77
|
+
|
|
78
|
+
const activityRows = activity.slice(0, 10).map((a) => {
|
|
79
|
+
const ts = a.timestamp || a.created_at;
|
|
80
|
+
const when = ts ? (typeof ts === 'number' && ts > 1e9 ? new Date(ts * 1000).toLocaleString() : new Date(ts).toLocaleString()) : '-';
|
|
81
|
+
return `<tr>
|
|
82
|
+
<td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#64748b">${when}</td>
|
|
83
|
+
<td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:13px">${esc(a.action || a.operation || a.type || '-')}</td>
|
|
84
|
+
<td style="padding:4px 10px;border-bottom:1px solid #f3f4f6;font-size:12px;color:#64748b">${esc(a.user_id || 'System')}</td>
|
|
85
|
+
</tr>`;
|
|
86
|
+
}).join('') || `<tr><td colspan="3" style="padding:12px;text-align:center;color:#9ca3af;font-size:12px">No recent activity</td></tr>`;
|
|
87
|
+
|
|
88
|
+
const html = `<!DOCTYPE html>
|
|
89
|
+
<html lang="en"><head>
|
|
90
|
+
<meta charset="utf-8"/>
|
|
91
|
+
<title>Engagement Report — ${esc(e.name || 'Engagement')}</title>
|
|
92
|
+
<style>
|
|
93
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; color: #111; background: #f9fafb; }
|
|
94
|
+
.wrap { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
|
|
95
|
+
.accent { height: 6px; background: ${esc(themeColor)}; border-radius: 3px; margin-bottom: 24px; }
|
|
96
|
+
.h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px 0; }
|
|
97
|
+
.muted { color: #64748b; font-size: 13px; }
|
|
98
|
+
.card { background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
99
|
+
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 32px; font-size: 14px; }
|
|
100
|
+
.grid .k { color: #64748b; }
|
|
101
|
+
.rings { display: flex; gap: 24px; justify-content: center; padding: 12px 0; }
|
|
102
|
+
.kstat { display: flex; flex-direction: column; align-items: center; }
|
|
103
|
+
.kstat .val { font-size: 28px; font-weight: 700; color: #111; }
|
|
104
|
+
.kstat .lbl { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
105
|
+
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; text-align: center; }
|
|
106
|
+
table { width: 100%; border-collapse: collapse; }
|
|
107
|
+
th { background: #f3f4f6; color: #374151; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; padding: 8px 10px; text-align: left; }
|
|
108
|
+
.no-print { display: block; }
|
|
109
|
+
.foot { text-align: center; font-size: 11px; color: #9ca3af; margin-top: 32px; padding-top: 16px; border-top: 1px solid #e5e7eb; }
|
|
110
|
+
.pbtn { background: ${esc(themeColor)}; color: white; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; }
|
|
111
|
+
@media print {
|
|
112
|
+
body { background: white; }
|
|
113
|
+
.no-print { display: none !important; }
|
|
114
|
+
.card { box-shadow: none; page-break-inside: avoid; }
|
|
115
|
+
.wrap { padding: 0; max-width: 100%; }
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
118
|
+
</head><body>
|
|
119
|
+
<div class="wrap">
|
|
120
|
+
<div class="no-print" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
|
121
|
+
<a href="/engagement/${esc(e.id)}" style="color:#64748b;text-decoration:none;font-size:13px">← Back</a>
|
|
122
|
+
<button class="pbtn" onclick="window.print()">Print / Save as PDF</button>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="accent"></div>
|
|
125
|
+
<div class="card">
|
|
126
|
+
<h1 class="h1">${esc(e.name || e.title || 'Engagement')}</h1>
|
|
127
|
+
<div class="muted">${esc(client?.name || e.client_name || '-')} · ${esc(cfg?.label || e.stage || '-')} · FY ${esc(e.year || '-')}</div>
|
|
128
|
+
</div>
|
|
129
|
+
<div class="card">
|
|
130
|
+
<h2 style="font-size:14px;margin:0 0 16px 0;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#374151">Progress</h2>
|
|
131
|
+
<div class="rings">
|
|
132
|
+
${progressRing(engProgress, 'Engagement', '#3b82f6')}
|
|
133
|
+
${progressRing(resolvedPct, 'Resolved', '#10b981')}
|
|
134
|
+
${progressRing(partnerPct, 'Partner', '#8b5cf6')}
|
|
135
|
+
${progressRing(managerPct, 'Manager', '#f59e0b')}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="card">
|
|
139
|
+
<h2 style="font-size:14px;margin:0 0 16px 0;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#374151">Engagement Details</h2>
|
|
140
|
+
<div class="grid">
|
|
141
|
+
<div><div class="k">Team</div><div>${esc(team?.name || e.team_name || '-')}</div></div>
|
|
142
|
+
<div><div class="k">Status</div><div>${esc(e.status || '-')}</div></div>
|
|
143
|
+
<div><div class="k">Type</div><div>${esc(e.type || e.engagement_type || e.repeat_interval || '-')}</div></div>
|
|
144
|
+
<div><div class="k">Fee</div><div>${fmtCurrency(e.fee)}</div></div>
|
|
145
|
+
<div><div class="k">Commenced</div><div>${fmtDate(e.commencement_date)}</div></div>
|
|
146
|
+
<div><div class="k">Deadline</div><div>${fmtDate(e.deadline_date || e.deadline)}</div></div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
<div class="card">
|
|
150
|
+
<h2 style="font-size:14px;margin:0 0 16px 0;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#374151">RFIs</h2>
|
|
151
|
+
<div class="stats" style="margin-bottom:16px">
|
|
152
|
+
<div class="kstat"><div class="val">${rfiStats.total}</div><div class="lbl">Total</div></div>
|
|
153
|
+
<div class="kstat"><div class="val" style="color:#3b82f6">${rfiStats.open}</div><div class="lbl">Open</div></div>
|
|
154
|
+
<div class="kstat"><div class="val" style="color:#10b981">${rfiStats.closed}</div><div class="lbl">Closed</div></div>
|
|
155
|
+
<div class="kstat"><div class="val" style="color:#dc2626">${rfiStats.overdue}</div><div class="lbl">Overdue</div></div>
|
|
156
|
+
</div>
|
|
157
|
+
<table><thead><tr><th>Title</th><th>Status</th><th>Due</th></tr></thead><tbody>${rfiRows}</tbody></table>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="card">
|
|
160
|
+
<h2 style="font-size:14px;margin:0 0 16px 0;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#374151">Reviews (${reviews.length})</h2>
|
|
161
|
+
<table><thead><tr><th>Name</th><th>Status</th><th>Highlights</th><th>Resolved</th></tr></thead><tbody>${reviewRows}</tbody></table>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="card">
|
|
164
|
+
<h2 style="font-size:14px;margin:0 0 16px 0;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#374151">Recent Activity</h2>
|
|
165
|
+
<table><thead><tr><th>When</th><th>Action</th><th>By</th></tr></thead><tbody>${activityRows}</tbody></table>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="foot">
|
|
168
|
+
Generated ${new Date().toLocaleString('en-ZA')} by ${esc(user?.email || user?.name || 'system')} · Moonlanding
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</body></html>`;
|
|
172
|
+
return html;
|
|
173
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export function formatDate(ts, opts) {
|
|
2
|
+
if (!ts) return '—';
|
|
3
|
+
const d = typeof ts === 'number' ? new Date(ts < 1e10 ? ts * 1000 : ts) : new Date(ts);
|
|
4
|
+
if (isNaN(d)) return '—';
|
|
5
|
+
return d.toLocaleDateString('en-ZA', opts || { day: '2-digit', month: 'short', year: 'numeric' });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function timeAgo(ts) {
|
|
9
|
+
if (!ts) return '—';
|
|
10
|
+
const d = typeof ts === 'number' ? new Date(ts < 1e10 ? ts * 1000 : ts) : new Date(ts);
|
|
11
|
+
const diff = Date.now() - d;
|
|
12
|
+
if (diff < 60000) return 'just now';
|
|
13
|
+
if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
|
|
14
|
+
if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
|
|
15
|
+
if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago';
|
|
16
|
+
return formatDate(ts);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatCurrency(amount, currency) {
|
|
20
|
+
if (amount == null || amount === '') return '—';
|
|
21
|
+
const n = Number(amount);
|
|
22
|
+
if (isNaN(n)) return String(amount);
|
|
23
|
+
const sym = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : 'R';
|
|
24
|
+
return sym + ' ' + n.toLocaleString('en-ZA', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatPercent(val) {
|
|
28
|
+
if (val == null || val === '') return '—';
|
|
29
|
+
const n = Number(val);
|
|
30
|
+
if (isNaN(n)) return val;
|
|
31
|
+
return (n > 1 ? n : Math.round(n * 100)) + '%';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function stagePill(stage, stageConfig) {
|
|
35
|
+
const STAGES = stageConfig || {
|
|
36
|
+
info_gathering: { label: 'Info Gathering', color: '#e53935', bg: '#ffebee' },
|
|
37
|
+
commencement: { label: 'Commencement', color: '#e65100', bg: '#fff3e0' },
|
|
38
|
+
team_execution: { label: 'Team Execution', color: '#1565c0', bg: '#e3f2fd' },
|
|
39
|
+
partner_review: { label: 'Partner Review', color: '#283593', bg: '#e8eaf6' },
|
|
40
|
+
finalization: { label: 'Finalization', color: '#2e7d32', bg: '#e8f5e9' },
|
|
41
|
+
closeout: { label: 'Close Out', color: '#33691e', bg: '#f1f8e9' },
|
|
42
|
+
};
|
|
43
|
+
const cfg = STAGES[stage];
|
|
44
|
+
if (!cfg) return stage ? `<span style="background:var(--color-bg,#f5f5f5);color:var(--color-text-muted,#555);padding:2px 8px;border-radius:10px;font-size:0.7rem;font-weight:700">${stage}</span>` : '—';
|
|
45
|
+
return `<span style="background:${cfg.bg};color:${cfg.color};padding:2px 8px;border-radius:10px;font-size:0.7rem;font-weight:700;white-space:nowrap;border:1px solid ${cfg.color}44">${cfg.label}</span>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const STATUS_MAP = {
|
|
49
|
+
active: ['#2e7d32', '#e8f5e9'],
|
|
50
|
+
inactive: ['#555', '#f5f5f5'],
|
|
51
|
+
pending: ['#e65100', '#fff3e0'],
|
|
52
|
+
draft: ['#888', '#f5f5f5'],
|
|
53
|
+
sent: ['#e65100', '#fff3e0'],
|
|
54
|
+
responded: ['#2e7d32', '#e8f5e9'],
|
|
55
|
+
closed: ['#555', '#eeeeee'],
|
|
56
|
+
overdue: ['#c62828', '#ffebee'],
|
|
57
|
+
deleted: ['#c62828', '#fdecea'],
|
|
58
|
+
admin: ['#6a1b9a', '#f3e5f5'],
|
|
59
|
+
partner: ['#1565c0', '#e3f2fd'],
|
|
60
|
+
manager: ['#2e7d32', '#e8f5e9'],
|
|
61
|
+
clerk: ['#e65100', '#fff3e0'],
|
|
62
|
+
user: ['#555', '#f5f5f5'],
|
|
63
|
+
auditor: ['#283593', '#e8eaf6'],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export function statusBadge(status, labelOverride) {
|
|
67
|
+
const s = (status || '').toLowerCase();
|
|
68
|
+
const [color, bg] = STATUS_MAP[s] || ['#888', '#f5f5f5'];
|
|
69
|
+
const label = labelOverride || (s ? s.charAt(0).toUpperCase() + s.slice(1) : '—');
|
|
70
|
+
return `<span style="background:${bg};color:${color};padding:2px 9px;border-radius:10px;font-size:0.7rem;font-weight:700;text-transform:uppercase;letter-spacing:0.3px;white-space:nowrap">${label}</span>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function roleBadge(role) {
|
|
74
|
+
const known = ['admin','partner','manager','clerk','user','auditor'];
|
|
75
|
+
if (!role) return statusBadge('', '—');
|
|
76
|
+
if (!known.includes(role.toLowerCase())) return statusBadge('', 'Unknown');
|
|
77
|
+
return statusBadge(role);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatName(user) {
|
|
81
|
+
if (!user) return '—';
|
|
82
|
+
return user.name || user.display_name || user.email || '—';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function truncate(str, n) {
|
|
86
|
+
const max = n || 50;
|
|
87
|
+
if (!str) return '—';
|
|
88
|
+
const s = String(str);
|
|
89
|
+
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function emptyState(message, icon) {
|
|
93
|
+
const ico = icon || '📭';
|
|
94
|
+
return `<div style="text-align:center;padding:48px 24px;color:var(--color-text-muted,#aaa)">
|
|
95
|
+
<div style="font-size:2rem;margin-bottom:12px">${ico}</div>
|
|
96
|
+
<div style="font-size:0.88rem;font-weight:500">${message || 'No items found'}</div>
|
|
97
|
+
</div>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function esc(s) {
|
|
101
|
+
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
102
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
let tagsStore = {};
|
|
2
|
+
|
|
3
|
+
async function apiCall(endpoint, options = {}) {
|
|
4
|
+
try {
|
|
5
|
+
const res = await fetch(`/api/tag${endpoint}`, { headers: { 'Content-Type': 'application/json' }, ...options });
|
|
6
|
+
if (!res.ok) throw new Error('API error');
|
|
7
|
+
return await res.json();
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw e;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function createTag(name, color = '#808080') {
|
|
14
|
+
const data = await apiCall('', { method: 'POST', body: JSON.stringify({ name: name.trim(), color }) });
|
|
15
|
+
tagsStore[data.id] = { id: data.id, name: data.name, color: data.color, usage: 0 };
|
|
16
|
+
showToast('Tag created', 'success');
|
|
17
|
+
return data;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function updateTag(id, name, color) {
|
|
21
|
+
const data = await apiCall(`/${id}`, { method: 'PUT', body: JSON.stringify({ name: name.trim(), color }) });
|
|
22
|
+
tagsStore[id] = { ...tagsStore[id], name: data.name, color: data.color };
|
|
23
|
+
showToast('Tag updated', 'success');
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function deleteTag(id) {
|
|
28
|
+
await apiCall(`/${id}`, { method: 'DELETE' });
|
|
29
|
+
delete tagsStore[id];
|
|
30
|
+
showToast('Tag deleted', 'success');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function listTags() {
|
|
34
|
+
try {
|
|
35
|
+
const data = await apiCall('');
|
|
36
|
+
const tags = Array.isArray(data) ? data : data.data || [];
|
|
37
|
+
tagsStore = tags.reduce((acc, tag) => { acc[tag.id] = tag; return acc; }, {});
|
|
38
|
+
return tags;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.error('Error loading tags:', e);
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function esc(text) {
|
|
46
|
+
const m = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
47
|
+
return text.replace(/[&<>"']/g, c => m[c]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function renderTagsList() {
|
|
51
|
+
const tags = Object.values(tagsStore).sort((a, b) => (b.usage || 0) - (a.usage || 0));
|
|
52
|
+
return `<div class="tags-list">${tags.map(t => `
|
|
53
|
+
<div class="tag-item" data-tag-id="${t.id}">
|
|
54
|
+
<div class="tag-dot" style="background-color: ${t.color}"></div>
|
|
55
|
+
<div class="tag-info"><span class="tag-name">${esc(t.name)}</span><span class="tag-usage">${t.usage || 0} items</span></div>
|
|
56
|
+
<div class="tag-actions">
|
|
57
|
+
<button class="btn btn-xs btn-ghost" data-action="gtsEditTag" data-args='["${t.id}"]'>Edit</button>
|
|
58
|
+
<button class="btn btn-xs btn-ghost text-error" data-action="gtsDeleteTag" data-args='["${t.id}"]'>Delete</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
`).join('') || '<p class="text-xs text-gray-500">No tags created</p>'}</div>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function showDialog(mode = 'create', tagId = null) {
|
|
65
|
+
const tag = mode === 'edit' ? tagsStore[tagId] : null;
|
|
66
|
+
const html = `
|
|
67
|
+
<div class="dialog-overlay" id="tag-dialog-overlay" data-action="gtsCloseDialog" onkeydown="if(event.key==='Escape')gtsCloseDialog()" role="dialog" aria-modal="true" aria-labelledby="tag-dialog-heading" aria-hidden="true">
|
|
68
|
+
<div class="dialog-content" data-stop-propagation="true">
|
|
69
|
+
<div class="dialog-header"><h2 id="tag-dialog-heading">${mode === 'create' ? 'Create New Tag' : 'Edit Tag'}</h2><button class="btn btn-sm btn-ghost" data-action="gtsCloseDialog" aria-label="Close dialog">×</button></div>
|
|
70
|
+
<div class="dialog-body">
|
|
71
|
+
<div class="form-group"><label class="label" for="tag-name-input">Tag Name</label><input type="text" class="input input-bordered w-full" id="tag-name-input" placeholder="e.g., Priority" value="${tag ? esc(tag.name) : ''}" maxlength="50" /></div>
|
|
72
|
+
<div class="form-group"><label class="label" for="tag-color-input">Color</label><div class="flex gap-2"><input type="color" id="tag-color-input" value="${tag ? tag.color : '#808080'}" aria-label="Tag color" /><span id="color-preview" class="w-8 h-8 rounded border" style="background-color: ${tag ? tag.color : '#808080'}" aria-hidden="true"></span></div></div>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="dialog-footer"><button class="btn btn-ghost" data-action="gtsCloseDialog">Cancel</button><button class="btn btn-primary" data-action="gtsSaveTag" data-args='["${mode}","${tagId || ''}"]'>${mode === 'create' ? 'Create' : 'Update'}</button></div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>`;
|
|
77
|
+
const container = document.getElementById('tag-dialog-container') || (c => (document.body.appendChild(c), c))(Object.assign(document.createElement('div'), { id: 'tag-dialog-container' }));
|
|
78
|
+
container.innerHTML = html;
|
|
79
|
+
document.getElementById('tag-color-input').addEventListener('input', (e) => {
|
|
80
|
+
document.getElementById('color-preview').style.backgroundColor = e.target.value;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function showToast(msg, type = 'info') {
|
|
85
|
+
let c = document.getElementById('toast-container');
|
|
86
|
+
if (!c) {
|
|
87
|
+
c = document.createElement('div');
|
|
88
|
+
c.id = 'toast-container';
|
|
89
|
+
c.className = 'fixed bottom-4 right-4 z-50 flex flex-col gap-2';
|
|
90
|
+
document.body.appendChild(c);
|
|
91
|
+
}
|
|
92
|
+
const t = document.createElement('div');
|
|
93
|
+
t.className = `alert alert-${type === 'error' ? 'error' : type === 'success' ? 'success' : 'info'} shadow-lg max-w-sm`;
|
|
94
|
+
t.textContent = msg;
|
|
95
|
+
c.appendChild(t);
|
|
96
|
+
setTimeout(() => t.remove(), 3000);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
window.gtsOpenDialog = (mode = 'create', tagId = null) => showDialog(mode, tagId);
|
|
100
|
+
window.gtsEditTag = (tagId) => gtsOpenDialog('edit', tagId);
|
|
101
|
+
window.gtsDeleteTag = async (tagId) => {
|
|
102
|
+
if (confirm(`Delete "${esc(tagsStore[tagId].name)}"?`)) {
|
|
103
|
+
await deleteTag(tagId);
|
|
104
|
+
refreshTagsList();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
window.gtsSaveTag = async (mode, tagId) => {
|
|
108
|
+
const name = document.getElementById('tag-name-input').value.trim();
|
|
109
|
+
const color = document.getElementById('tag-color-input').value;
|
|
110
|
+
if (!name) { showToast('Tag name required', 'error'); return; }
|
|
111
|
+
try {
|
|
112
|
+
mode === 'create' ? await createTag(name, color) : await updateTag(tagId, name, color);
|
|
113
|
+
gtsCloseDialog();
|
|
114
|
+
refreshTagsList();
|
|
115
|
+
} catch (e) {
|
|
116
|
+
showToast('Error: ' + e.message, 'error');
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
window.gtsCloseDialog = () => {
|
|
120
|
+
const c = document.getElementById('tag-dialog-container');
|
|
121
|
+
if (c) c.innerHTML = '';
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
async function refreshTagsList() {
|
|
125
|
+
await listTags();
|
|
126
|
+
const c = document.getElementById('tags-list-container');
|
|
127
|
+
if (c) c.innerHTML = renderTagsList();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function initGlobalTags() {
|
|
131
|
+
await listTags();
|
|
132
|
+
return { createTag, updateTag, deleteTag, listTags, renderTagsList, refreshTagsList };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function renderTagsUI() {
|
|
136
|
+
return `
|
|
137
|
+
<div class="card-clean"><div class="card-clean-body">
|
|
138
|
+
<div class="flex justify-between items-center mb-4"><h2 class="card-title text-lg">Global Tags</h2><button class="btn btn-primary btn-sm" data-action="gtsOpenDialog" data-args='["create"]'>New Tag</button></div>
|
|
139
|
+
<div id="tags-list-container">${renderTagsList()}</div>
|
|
140
|
+
</div></div>
|
|
141
|
+
<div id="tag-dialog-container"></div>
|
|
142
|
+
<style>.tags-list{display:flex;flex-direction:column;gap:0.75rem}.tag-item{display:flex;align-items:center;gap:1rem;padding:0.75rem;border:1px solid var(--color-border,#e5e7eb);border-radius:0.5rem}.tag-dot{width:20px;height:20px;border-radius:50%;flex-shrink:0}.tag-info{flex:1}.tag-name{font-weight:500;display:block}.tag-usage{font-size:0.75rem;color:var(--color-text-muted,#6b7280);display:block}.tag-actions{display:flex;gap:0.5rem}.dialog-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000}.dialog-content{background:var(--color-surface,white);border-radius:0.5rem;box-shadow:0 10px 25px rgba(0,0,0,0.2);max-width:500px;width:90%;color:var(--color-text,#111)}.dialog-header{padding:1.5rem;border-bottom:1px solid var(--color-border,#e5e7eb);display:flex;justify-content:space-between;align-items:center}.dialog-body{padding:1.5rem}.dialog-footer{padding:1rem 1.5rem;border-top:1px solid var(--color-border,#e5e7eb);display:flex;justify-content:flex-end;gap:0.75rem}.form-group{margin-bottom:1rem}.label{display:block;margin-bottom:0.5rem;font-weight:500}</style>
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { canEdit } from '@/ui/permissions-ui.js';
|
|
3
|
+
import { reviewZoneNav } from '@/ui/review-zone-nav.js';
|
|
4
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
5
|
+
import { SPACING, renderStatsRow, renderPageHeader, renderButton, renderEmptyState } from '@/ui/spacing-system.js';
|
|
6
|
+
|
|
7
|
+
function fmtDate(ts) {
|
|
8
|
+
if (!ts) return '';
|
|
9
|
+
const n = Number(ts);
|
|
10
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleString();
|
|
11
|
+
return String(ts);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const RESOLUTION_STATES = {
|
|
15
|
+
unresolved: { label: 'Unresolved', color: '#ef4444', pill: 'pill pill-danger' },
|
|
16
|
+
partial_resolved: { label: 'Partial', color: '#f59e0b', pill: 'pill pill-warning' },
|
|
17
|
+
manager_resolved: { label: 'Manager', color: '#3b82f6', pill: 'pill pill-info' },
|
|
18
|
+
partner_resolved: { label: 'Partner', color: '#8b5cf6', pill: 'pill pill-primary' },
|
|
19
|
+
resolved: { label: 'Resolved', color: '#22c55e', pill: 'pill pill-success' },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function resolutionBadge(status) {
|
|
23
|
+
const s = RESOLUTION_STATES[status] || RESOLUTION_STATES.unresolved;
|
|
24
|
+
return `<span class="${s.pill}">${s.label}</span>`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function responseItem(response) {
|
|
28
|
+
const name = response.user?.name || response.user_name || 'Unknown';
|
|
29
|
+
const initial = name.charAt(0).toUpperCase();
|
|
30
|
+
const date = fmtDate(response.created_at);
|
|
31
|
+
return `<div class="response-item">
|
|
32
|
+
<div class="response-avatar">${initial}</div>
|
|
33
|
+
<div class="response-content">
|
|
34
|
+
<div class="response-meta">
|
|
35
|
+
<span class="response-name">${esc(name)}</span>
|
|
36
|
+
<span class="response-date">${date}</span>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="response-text">${esc(response.text || response.content || '')}</div>
|
|
39
|
+
${response.attachment ? `<div class="response-attachment">
|
|
40
|
+
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
|
|
41
|
+
<a href="${response.attachment.url || '#'}" target="_blank" rel="noopener">${esc(response.attachment.name || 'Attachment')}</a>
|
|
42
|
+
</div>` : ''}
|
|
43
|
+
</div>
|
|
44
|
+
</div>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function highlightThread(highlight, responses, canResolve, idx) {
|
|
48
|
+
const status = highlight.status || 'unresolved';
|
|
49
|
+
const badge = resolutionBadge(status);
|
|
50
|
+
const color = RESOLUTION_STATES[status]?.color || '#ef4444';
|
|
51
|
+
|
|
52
|
+
const threadItems = responses.map(r => responseItem(r)).join('');
|
|
53
|
+
const sectionTag = highlight.section_name ? `<span class="review-meta-tag">${esc(highlight.section_name)}</span>` : '';
|
|
54
|
+
|
|
55
|
+
const resolveSelect = canResolve ? `<div class="thread-card-actions">
|
|
56
|
+
<label style="font-size:12px;color:var(--color-text-muted)">Mark as:</label>
|
|
57
|
+
<select id="resolve-status-${highlight.id}" class="form-input" style="width:auto;padding:4px 8px;font-size:13px;min-height:auto" aria-label="Resolution status" onchange="updateResolution('${highlight.id}')">
|
|
58
|
+
<option value="unresolved" ${status === 'unresolved' ? 'selected' : ''}>Unresolved</option>
|
|
59
|
+
<option value="partial_resolved" ${status === 'partial_resolved' ? 'selected' : ''}>Partial</option>
|
|
60
|
+
<option value="manager_resolved" ${status === 'manager_resolved' ? 'selected' : ''}>Manager</option>
|
|
61
|
+
<option value="partner_resolved" ${status === 'partner_resolved' ? 'selected' : ''}>Partner</option>
|
|
62
|
+
<option value="resolved" ${status === 'resolved' ? 'selected' : ''}>Resolved</option>
|
|
63
|
+
</select>
|
|
64
|
+
</div>` : '';
|
|
65
|
+
|
|
66
|
+
const replyForm = `<div class="thread-reply">
|
|
67
|
+
<textarea id="reply-${highlight.id}" class="form-input" style="resize:vertical;min-height:60px" rows="2" placeholder="Add a response..."></textarea>
|
|
68
|
+
<button class="btn-primary-clean" style="align-self:flex-start;padding:8px 16px" data-action="submitResponse" data-args='["${highlight.id}"]'>Reply</button>
|
|
69
|
+
</div>`;
|
|
70
|
+
|
|
71
|
+
return `<div class="thread-card" id="thread-${highlight.id}" data-thread-status="${status}">
|
|
72
|
+
<div class="thread-card-header">
|
|
73
|
+
<div class="thread-card-meta">
|
|
74
|
+
<span class="thread-card-idx" style="background:${color}">${idx + 1}</span>
|
|
75
|
+
<span class="thread-card-text">${esc(highlight.text || highlight.content || 'Area highlight')}</span>
|
|
76
|
+
${sectionTag}
|
|
77
|
+
</div>
|
|
78
|
+
${badge}
|
|
79
|
+
</div>
|
|
80
|
+
<div class="thread-card-page">Page ${highlight.page_number || '?'}</div>
|
|
81
|
+
<div class="thread-card-body">
|
|
82
|
+
${threadItems || `<div class="thread-card-empty">No responses yet. Be the first to reply.</div>`}
|
|
83
|
+
</div>
|
|
84
|
+
${resolveSelect}
|
|
85
|
+
${replyForm}
|
|
86
|
+
</div>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function renderHighlightThreading(user, review, highlights, responseMap) {
|
|
90
|
+
const canResolve = canEdit(user, 'review');
|
|
91
|
+
const indexed = highlights.map((h, i) => ({ ...h, _index: i }));
|
|
92
|
+
|
|
93
|
+
const stats = {
|
|
94
|
+
total: highlights.length,
|
|
95
|
+
resolved: highlights.filter(h => h.status === 'resolved').length,
|
|
96
|
+
partial: highlights.filter(h => h.status === 'partial_resolved').length,
|
|
97
|
+
open: highlights.filter(h => !h.status || h.status === 'unresolved').length,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const statsHtml = renderStatsRow([
|
|
101
|
+
{ value: stats.total, label: 'Total' },
|
|
102
|
+
{ value: stats.open, label: 'Open', color: 'var(--color-danger)' },
|
|
103
|
+
{ value: stats.partial, label: 'Partial', color: 'var(--color-warning)' },
|
|
104
|
+
{ value: stats.resolved, label: 'Resolved', color: 'var(--color-success)' },
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const filterBar = `<div class="review-filter-bar">
|
|
108
|
+
<button class="pill pill-primary" data-thread-filter="all" data-action="filterThreads" data-args='["all"]'>All (${stats.total})</button>
|
|
109
|
+
<button class="pill pill-neutral" data-thread-filter="open" data-action="filterThreads" data-args='["open"]'>Open (${stats.open})</button>
|
|
110
|
+
<button class="pill pill-neutral" data-thread-filter="resolved" data-action="filterThreads" data-args='["resolved"]'>Resolved (${stats.resolved})</button>
|
|
111
|
+
</div>`;
|
|
112
|
+
|
|
113
|
+
const threads = indexed.map(h => {
|
|
114
|
+
const responses = responseMap?.[h.id] || [];
|
|
115
|
+
return highlightThread(h, responses, canResolve, h._index);
|
|
116
|
+
}).join('');
|
|
117
|
+
|
|
118
|
+
const pageHeader = renderPageHeader(
|
|
119
|
+
'Highlight Discussions',
|
|
120
|
+
`${review.name || 'Review'}`,
|
|
121
|
+
`${renderButton('Back to Review', { variant: 'ghost', size: 'sm', href: `/review/${review.id}` })}
|
|
122
|
+
${canResolve ? renderButton('Resolve All', { variant: 'primary', size: 'sm', action: "bulkResolve" }) : ''}`
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const content = `
|
|
126
|
+
${pageHeader}
|
|
127
|
+
${statsHtml}
|
|
128
|
+
${filterBar}
|
|
129
|
+
<div id="threads-container">
|
|
130
|
+
${threads || renderEmptyState('No highlights to discuss. Add highlights from the PDF viewer.')}
|
|
131
|
+
</div>`;
|
|
132
|
+
|
|
133
|
+
const threadScript = `
|
|
134
|
+
window.showToast=window.showToast||function(m,t){alert(m)};
|
|
135
|
+
window.filterThreads=function(f){
|
|
136
|
+
document.querySelectorAll('[data-thread-filter]').forEach(b=>{
|
|
137
|
+
b.className=b.dataset.threadFilter===f?'pill pill-primary':'pill pill-neutral';
|
|
138
|
+
});
|
|
139
|
+
document.querySelectorAll('#threads-container>.thread-card').forEach(c=>{
|
|
140
|
+
const status=c.dataset.threadStatus;
|
|
141
|
+
const isResolved=status==='resolved';
|
|
142
|
+
if(f==='all')c.style.display='';
|
|
143
|
+
else if(f==='resolved')c.style.display=isResolved?'':'none';
|
|
144
|
+
else c.style.display=isResolved?'none':'';
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
window.submitResponse=async function(hid){
|
|
148
|
+
const ta=document.getElementById('reply-'+hid);
|
|
149
|
+
if(!ta||!ta.value.trim()){alert('Please enter a response');return}
|
|
150
|
+
try{
|
|
151
|
+
const res=await fetch('/api/mwr/review/highlight/'+hid+'/responses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:ta.value.trim()})});
|
|
152
|
+
if(res.ok){ta.value='';showToast('Response submitted','success');setTimeout(()=>location.reload(),500)}
|
|
153
|
+
else{const err=await res.json().then(d=>d.error||d.message||'Failed').catch(()=>'Failed');alert('Error: '+err)}
|
|
154
|
+
}catch(e){alert('Error: '+e.message)}
|
|
155
|
+
};
|
|
156
|
+
window.updateResolution=async function(hid){
|
|
157
|
+
const sel=document.getElementById('resolve-status-'+hid);
|
|
158
|
+
if(!sel)return;
|
|
159
|
+
try{
|
|
160
|
+
const res=await fetch('/api/mwr/review/highlight/'+hid,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:sel.value})});
|
|
161
|
+
if(res.ok){showToast('Status updated','success');setTimeout(()=>location.reload(),500)}
|
|
162
|
+
else{alert('Failed to update')}
|
|
163
|
+
}catch(e){alert('Error: '+e.message)}
|
|
164
|
+
};
|
|
165
|
+
window.bulkResolve=async function(){
|
|
166
|
+
if(!confirm('Resolve all highlights in this review?'))return;
|
|
167
|
+
try{
|
|
168
|
+
const res=await fetch('/api/mwr/review/${review.id}?action=resolve-all',{method:'POST'});
|
|
169
|
+
if(res.ok){showToast('All highlights resolved','success');setTimeout(()=>location.reload(),500)}
|
|
170
|
+
else{alert('Failed to resolve all')}
|
|
171
|
+
}catch(e){alert('Error: '+e.message)}
|
|
172
|
+
};`;
|
|
173
|
+
|
|
174
|
+
const bc = [{ href: '/', label: 'Home' }, { href: '/review', label: 'Reviews' }, { href: `/review/${review.id}`, label: review.name || 'Review' }, { label: 'Highlights' }];
|
|
175
|
+
return page(user, `${review.name || 'Review'} | Highlights`, bc, reviewZoneNav(review.id, 'highlights') + content, [threadScript]);
|
|
176
|
+
}
|