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,58 @@
|
|
|
1
|
+
import { statusLabel } from '@/ui/renderer.js';
|
|
2
|
+
import { page } from '@/ui/layout.js';
|
|
3
|
+
|
|
4
|
+
function reviewSummaryPanel(review, highlights, side) {
|
|
5
|
+
const total = highlights.length;
|
|
6
|
+
const resolved = highlights.filter(h => h.status === 'resolved').length;
|
|
7
|
+
const sts = review.status ? statusLabel(review.status) : '';
|
|
8
|
+
return `<div class="p-4 bg-gray-50 border-b border-gray-200"><div class="flex items-center justify-between mb-2"><span class="font-medium text-sm">${review.name || 'Untitled'}</span>${sts}</div><div class="grid grid-cols-3 gap-2 text-xs"><div class="text-center"><div class="font-bold text-lg">${total}</div><div class="text-gray-500">Highlights</div></div><div class="text-center"><div class="font-bold text-lg text-green-600">${resolved}</div><div class="text-gray-500">Resolved</div></div><div class="text-center"><div class="font-bold text-lg text-red-600">${total - resolved}</div><div class="text-gray-500">Open</div></div></div></div>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function highlightDiffRow(leftH, rightH, idx) {
|
|
12
|
+
const renderSide = (h) => {
|
|
13
|
+
if (!h) return '<td class="p-2 bg-gray-50 text-center text-xs text-gray-400">-</td>';
|
|
14
|
+
const colors = { resolved: '#22c55e', partial_resolved: '#f59e0b' };
|
|
15
|
+
const color = colors[h.status] || '#ef4444';
|
|
16
|
+
return `<td class="p-2 border-b border-gray-50"><div class="flex items-center gap-1"><span style="width:6px;height:6px;border-radius:50%;background:${color};display:inline-block"></span><span class="text-xs">${h.text || h.content || 'Area'}</span></div><div class="text-xs text-gray-400">p.${h.page_number || '?'}</div></td>`;
|
|
17
|
+
};
|
|
18
|
+
const match = leftH && rightH && leftH.text === rightH?.text ? 'bg-green-50' : leftH && rightH ? 'bg-yellow-50' : 'bg-red-50';
|
|
19
|
+
return `<tr class="${match}"><td class="p-2 text-xs text-gray-400 text-center border-b border-gray-100">${idx + 1}</td>${renderSide(leftH)}${renderSide(rightH)}<td class="p-2 text-xs text-center border-b border-gray-100">${leftH && rightH ? (leftH.status === rightH.status ? '<span class="text-green-600">Same</span>' : '<span class="text-yellow-600">Changed</span>') : '<span class="text-red-600">Missing</span>'}</td></tr>`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function diffStats(leftHighlights, rightHighlights) {
|
|
23
|
+
const leftTexts = new Set(leftHighlights.map(h => h.text || ''));
|
|
24
|
+
const rightTexts = new Set(rightHighlights.map(h => h.text || ''));
|
|
25
|
+
const common = [...leftTexts].filter(t => rightTexts.has(t)).length;
|
|
26
|
+
const onlyLeft = leftHighlights.length - common;
|
|
27
|
+
const onlyRight = rightHighlights.length - common;
|
|
28
|
+
return { common, onlyLeft, onlyRight };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderReviewComparison(user, leftReview, rightReview, leftHighlights, rightHighlights) {
|
|
32
|
+
const stats = diffStats(leftHighlights, rightHighlights);
|
|
33
|
+
const maxLen = Math.max(leftHighlights.length, rightHighlights.length);
|
|
34
|
+
const rows = Array.from({ length: maxLen }, (_, i) =>
|
|
35
|
+
highlightDiffRow(leftHighlights[i], rightHighlights[i], i)
|
|
36
|
+
).join('');
|
|
37
|
+
|
|
38
|
+
const diffSummary = `<div class="grid grid-cols-3 gap-4 mb-6"><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-green-600">${stats.common}</div><div class="text-xs text-gray-500">Matching</div></div></div><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-blue-600">${stats.onlyLeft}</div><div class="text-xs text-gray-500">Only in Left</div></div></div><div class="card-clean"><div class="card-clean-body" style=""><div class="text-lg font-bold text-purple-600">${stats.onlyRight}</div><div class="text-xs text-gray-500">Only in Right</div></div></div></div>`;
|
|
39
|
+
|
|
40
|
+
const comparisonTable = `<div class="card-clean" style="overflow-x:auto"><div class="card-clean-body" style="padding:0rem"><div class="grid grid-cols-2 gap-0"><div class="border-r border-gray-200">${reviewSummaryPanel(leftReview, leftHighlights, 'left')}</div><div>${reviewSummaryPanel(rightReview, rightHighlights, 'right')}</div></div><table class="data-table"><thead class="bg-gray-50"><tr><th class="text-center w-12">#</th><th>${leftReview.name || 'Left Review'}</th><th>${rightReview.name || 'Right Review'}</th><th class="text-center w-20">Status</th></tr></thead><tbody>${rows || '<tr><td colspan="4" class="text-center py-8 text-gray-400">No highlights to compare</td></tr>'}</tbody></table></div></div>`;
|
|
41
|
+
|
|
42
|
+
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Review Comparison</h1><a href="/reviews" class="btn btn-ghost btn-sm">Back to Reviews</a></div>${diffSummary}${comparisonTable}`;
|
|
43
|
+
|
|
44
|
+
return page(user, 'Review Comparison | Moonlanding', [
|
|
45
|
+
{ href: '/', label: 'Dashboard' },
|
|
46
|
+
{ href: '/reviews', label: 'Reviews' },
|
|
47
|
+
{ label: 'Comparison' }
|
|
48
|
+
], content);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderComparisonPicker(user, reviews) {
|
|
52
|
+
const reviewOptions = reviews.map(r => `<option value="${r.id}">${r.name || r.title || 'Untitled'} ${r.status ? '(' + r.status + ')' : ''}</option>`).join('');
|
|
53
|
+
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Compare Reviews</h1><a href="/reviews" class="btn btn-ghost btn-sm">Back to Reviews</a></div><div class="card-clean" style="margin:autorem"><div class="card-clean-body"><p class="text-sm text-gray-500 mb-4">Select two reviews to compare their highlights side by side.</p><div class="form-group mb-4"><label class="text-sm font-medium block mb-1" for="left-review">Left Review</label><select id="left-review" class="select select-bordered w-full">${reviewOptions}</select></div><div class="form-group mb-4"><label class="text-sm font-medium block mb-1" for="right-review">Right Review</label><select id="right-review" class="select select-bordered w-full">${reviewOptions}</select></div><button class="btn btn-primary w-full" data-action="startComparison">Compare</button></div></div>`;
|
|
54
|
+
|
|
55
|
+
const script = `window.startComparison=function(){const l=document.getElementById('left-review')?.value;const r=document.getElementById('right-review')?.value;if(!l||!r)return alert('Select both reviews');if(l===r)return alert('Select different reviews');window.location='/reviews/compare?left='+l+'&right='+r}`;
|
|
56
|
+
|
|
57
|
+
return page(user, 'Compare Reviews | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/reviews', label: 'Reviews' }, { label: 'Compare' }], content, [script]);
|
|
58
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
2
|
+
|
|
3
|
+
export function highlightRow(h) {
|
|
4
|
+
const resolved = h.resolved || h.status === 'resolved';
|
|
5
|
+
const badge = resolved
|
|
6
|
+
? '<span class="pill pill-success">Resolved</span>'
|
|
7
|
+
: '<span class="pill pill-warning">Open</span>';
|
|
8
|
+
const rowStyle = resolved ? 'opacity:0.6' : '';
|
|
9
|
+
const textStyle = resolved ? 'text-decoration:line-through;color:var(--color-text-muted)' : 'font-weight:500';
|
|
10
|
+
|
|
11
|
+
let flags = [];
|
|
12
|
+
let tags = [];
|
|
13
|
+
try { flags = JSON.parse(h.flags || '[]') || []; } catch {}
|
|
14
|
+
try { tags = JSON.parse(h.tags || '[]') || []; } catch {}
|
|
15
|
+
const isFlagged = flags.includes('flagged');
|
|
16
|
+
|
|
17
|
+
const flagBtn = `<button data-action="toggleFlag" data-args='["${esc(h.id)}","${isFlagged}"]' title="${isFlagged ? 'Unflag' : 'Flag'}" style="background:none;border:none;cursor:pointer;padding:2px 4px;font-size:15px;line-height:1;color:${isFlagged ? '#f59e0b' : 'var(--color-text-muted)'}">${isFlagged ? '⚑' : '⚐'}</button>`;
|
|
18
|
+
const tagPills = tags.map(t => `<span style="display:inline-flex;align-items:center;gap:3px;background:#eff6ff;color:#1d4ed8;font-size:11px;padding:1px 6px;border-radius:9999px;font-weight:500">${esc(t)}<button data-action="removeTag" data-args='["${esc(h.id)}","${esc(t)}"]' style="background:none;border:none;cursor:pointer;padding:0;margin-left:2px;font-size:11px;color:#6b7280;line-height:1">×</button></span>`).join(' ');
|
|
19
|
+
const addTagBtn = `<button data-action="openAddTag" data-args='["${esc(h.id)}"]' style="background:none;border:none;cursor:pointer;font-size:11px;color:var(--color-text-muted);padding:1px 5px;border:1px dashed var(--color-border,#e5e7eb);border-radius:9999px">+tag</button>`;
|
|
20
|
+
const tagsCell = `<div style="display:flex;flex-wrap:wrap;gap:3px;align-items:center;min-width:80px">${tagPills}${addTagBtn}</div>`;
|
|
21
|
+
|
|
22
|
+
return `<tr style="${rowStyle}" data-highlight-id="${esc(h.id)}" data-flagged="${isFlagged}" data-section-id="${esc(h.section_id||'')}">
|
|
23
|
+
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;${textStyle}">${flagBtn}${esc(h.text||h.content||h.comment||'Highlight')}</td>
|
|
24
|
+
<td style="font-size:13px;color:var(--color-text-muted);white-space:nowrap">p.${h.page||h.page_number||'-'}</td>
|
|
25
|
+
<td>${badge}</td>
|
|
26
|
+
<td>${tagsCell}</td>
|
|
27
|
+
<td style="font-size:13px;color:var(--color-text-muted)">${esc(h.created_by_name||h.user_id||'-')}</td>
|
|
28
|
+
<td style="text-align:right">${!resolved ? `<button data-action="resolveHighlight" data-args='["${esc(h.id)}"]' class="btn-primary-clean" style="font-size:12px;padding:4px 12px;min-height:28px">Resolve</button>` : '<span style="font-size:12px;color:var(--color-text-muted)">Done</span>'}</td>
|
|
29
|
+
</tr>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function collaboratorRow(c) {
|
|
33
|
+
const initials = (c.user_name || c.email || '?').charAt(0).toUpperCase();
|
|
34
|
+
const colors = ['#1565c0','#2e7d32','#6a1b9a','#c62828','#e65100','#00695c'];
|
|
35
|
+
const bg = colors[(c.user_name || c.email || '').charCodeAt(0) % colors.length] || colors[0];
|
|
36
|
+
return `<tr>
|
|
37
|
+
<td>
|
|
38
|
+
<div style="display:flex;align-items:center;gap:8px">
|
|
39
|
+
<div style="width:28px;height:28px;border-radius:50%;background:${bg};color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;flex-shrink:0">${initials}</div>
|
|
40
|
+
<span style="font-size:14px">${esc(c.user_name||c.email||c.user_id||'-')}</span>
|
|
41
|
+
</div>
|
|
42
|
+
</td>
|
|
43
|
+
<td><span class="pill pill-info" style="text-transform:capitalize">${esc(c.role||'viewer')}</span></td>
|
|
44
|
+
<td style="font-size:13px;color:var(--color-text-muted)">${c.expires_at?new Date(typeof c.expires_at==='number'?c.expires_at*1000:c.expires_at).toLocaleDateString():'-'}</td>
|
|
45
|
+
<td style="text-align:right"><button data-action="removeCollaborator" data-args='["${esc(c.id)}"]' class="btn-danger-clean" style="font-size:12px;padding:4px 10px;min-height:28px">Remove</button></td>
|
|
46
|
+
</tr>`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function addCollaboratorDialog(reviewId) {
|
|
50
|
+
return `<div id="collab-dialog" class="dialog-overlay" style="display:none" data-dialog-close-overlay="true">
|
|
51
|
+
<div class="dialog-panel" style="max-width:400px;width:100%">
|
|
52
|
+
<h3 style="font-size:16px;font-weight:600;color:var(--color-text);margin:0 0 16px">Add Collaborator</h3>
|
|
53
|
+
<div class="form-field" style="margin-bottom:12px">
|
|
54
|
+
<label class="form-label">Email</label>
|
|
55
|
+
<input type="email" id="collab-email" class="form-input" placeholder="collaborator@example.com"/>
|
|
56
|
+
</div>
|
|
57
|
+
<div class="form-field" style="margin-bottom:16px">
|
|
58
|
+
<label class="form-label">Role</label>
|
|
59
|
+
<select id="collab-role" class="form-input">
|
|
60
|
+
<option value="viewer">Viewer</option>
|
|
61
|
+
<option value="commenter">Commenter</option>
|
|
62
|
+
<option value="editor">Editor</option>
|
|
63
|
+
</select>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="form-actions" style="padding-top:0;margin-top:0">
|
|
66
|
+
<button data-dialog-close="collab-dialog" class="btn-ghost-clean" style="font-size:13px;padding:8px 16px">Cancel</button>
|
|
67
|
+
<button data-action="addCollaborator" data-args='["${esc(reviewId)}"]' class="btn-primary-clean" style="font-size:13px;padding:8px 16px">Add</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</div>`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { reviewZoneNav } from '@/ui/review-zone-nav.js';
|
|
3
|
+
export { reviewZoneNav };
|
|
4
|
+
import { canEdit } from '@/ui/permissions-ui.js';
|
|
5
|
+
import { esc, statusBadge, TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
6
|
+
import { reviewDetailScript } from '@/ui/review-detail-script.js';
|
|
7
|
+
import { highlightRow, collaboratorRow, addCollaboratorDialog } from '@/ui/review-detail-panels.js';
|
|
8
|
+
import { tenderPanelHtml, tenderDialog, linksPanelHtml, linkDialog, sectionsPanelHtml, compareDialogHtml } from '@/ui/review-detail-panels-extra.js';
|
|
9
|
+
import { retryHighlightDialog, switchTemplateButton } from '@/ui/engagement-dialogs.js';
|
|
10
|
+
import { reviewTemplateChoiceDialog } from '@/ui/review-dialogs.js';
|
|
11
|
+
import { SPACING, renderCard, renderTable, renderButton, renderInfoGrid } from '@/ui/spacing-system.js';
|
|
12
|
+
|
|
13
|
+
function fmtDate(ts) {
|
|
14
|
+
if (!ts) return '-';
|
|
15
|
+
const n = Number(ts);
|
|
16
|
+
if (!isNaN(n) && n > 1e9 && n < 3e9) return new Date(n * 1000).toLocaleDateString();
|
|
17
|
+
try { return new Date(ts).toLocaleDateString(); } catch { return String(ts); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function renderReviewDetail(user, review, highlights = [], collaborators = [], checklists = [], sections = [], tenders = []) {
|
|
21
|
+
const r = review || {};
|
|
22
|
+
const canEditReview = canEdit(user, 'review');
|
|
23
|
+
const totalH = highlights.length;
|
|
24
|
+
const resolvedH = highlights.filter(h => h.resolved || h.status === 'resolved').length;
|
|
25
|
+
const openH = totalH - resolvedH;
|
|
26
|
+
const pct = totalH > 0 ? Math.round((resolvedH / totalH) * 100) : 0;
|
|
27
|
+
|
|
28
|
+
let parsedLinks = [];
|
|
29
|
+
try { parsedLinks = JSON.parse(r.links || '[]') || []; } catch {}
|
|
30
|
+
|
|
31
|
+
const TABS = [
|
|
32
|
+
{ id: 'overview', label: 'Overview' },
|
|
33
|
+
{ id: 'highlights', label: 'Highlights', count: totalH },
|
|
34
|
+
{ id: 'collaborators', label: 'Collaborators', count: collaborators.length },
|
|
35
|
+
{ id: 'checklists', label: 'Checklists', count: checklists.length },
|
|
36
|
+
{ id: 'sections', label: 'Sections', count: sections.length },
|
|
37
|
+
{ id: 'tender', label: 'Tender', count: tenders.length > 0 ? tenders.length : undefined },
|
|
38
|
+
{ id: 'links', label: 'Links', count: parsedLinks.length > 0 ? parsedLinks.length : undefined },
|
|
39
|
+
{ id: 'history', label: 'History' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const tabBar = `<nav class="tab-bar" style="margin-bottom:${SPACING.lg}">
|
|
43
|
+
${TABS.map((t, i) => `<button data-action="switchTab" data-args='["${t.id}"]' id="rvtab-${t.id}" class="tab-btn${i === 0 ? ' active' : ''}" aria-selected="${i === 0}">${t.label}${t.count !== undefined ? `<span class="tab-count">${t.count}</span>` : ''}</button>`).join('')}
|
|
44
|
+
</nav>`;
|
|
45
|
+
|
|
46
|
+
const progressSection = totalH > 0
|
|
47
|
+
? `<div style="margin-top:${SPACING.md}">
|
|
48
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:${SPACING.sm}">
|
|
49
|
+
<span style="font-size:13px;font-weight:600;color:var(--color-text-muted)">Resolution</span>
|
|
50
|
+
<span style="font-size:13px;color:var(--color-text-light)">${resolvedH}/${totalH} resolved</span>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="resolution-bar"><div class="resolution-bar-segment resolution-bar-resolved" style="width:${pct}%"></div></div>
|
|
53
|
+
</div>`
|
|
54
|
+
: `<div style="margin-top:${SPACING.md};font-size:13px;color:var(--color-text-light)">No highlights yet</div>`;
|
|
55
|
+
|
|
56
|
+
const detailsCard = renderCard(`<div class="card-header">Review Details</div>${renderInfoGrid([
|
|
57
|
+
['Name', esc(r.name || r.title || '-')],
|
|
58
|
+
['Status', statusBadge(r.status)],
|
|
59
|
+
['Type', esc(r.review_type || r.type || '-')],
|
|
60
|
+
['Financial Year', esc(r.financial_year || '-')],
|
|
61
|
+
['Deadline', fmtDate(r.deadline)],
|
|
62
|
+
['Created', fmtDate(r.created_at)],
|
|
63
|
+
])}${progressSection}`, { padding: SPACING.md });
|
|
64
|
+
|
|
65
|
+
const actions = [
|
|
66
|
+
renderButton('Open PDF', { variant: 'primary', size: 'sm', href: `/review/${esc(r.id)}/pdf` }),
|
|
67
|
+
renderButton('Compare', { variant: 'ghost', size: 'sm', action: 'openDialog', args: ['pdf-compare-dialog'] }),
|
|
68
|
+
renderButton('Discussions', { variant: 'ghost', size: 'sm', href: `/review/${esc(r.id)}/highlights` }),
|
|
69
|
+
renderButton('Sections', { variant: 'ghost', size: 'sm', href: `/review/${esc(r.id)}/sections` }),
|
|
70
|
+
renderButton('Resolution', { variant: 'ghost', size: 'sm', href: `/review/${esc(r.id)}/resolution` }),
|
|
71
|
+
...(canEditReview ? [
|
|
72
|
+
renderButton('Edit', { variant: 'outline', size: 'sm', href: `/review/${esc(r.id)}/edit` }),
|
|
73
|
+
renderButton('Export PDF', { variant: 'ghost', size: 'sm', action: 'exportPdf', args: [`${esc(r.id)}`] }),
|
|
74
|
+
renderButton('Notify', { variant: 'ghost', size: 'sm', action: 'openDialog', args: ['notify-collab-dialog'] }),
|
|
75
|
+
] : []),
|
|
76
|
+
...(resolvedH < totalH && canEditReview ? [renderButton('Bulk Resolve', { variant: 'success', size: 'sm', action: 'bulkResolve', args: [`${esc(r.id)}`] })] : []),
|
|
77
|
+
...(canEditReview ? [renderButton('Retry Highlight', { variant: 'ghost', size: 'sm', action: 'openRetryHighlight' })] : []),
|
|
78
|
+
...(canEditReview ? [switchTemplateButton(esc(r.id))] : []),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
function tablePanel(id, heading, countBadge, extraBtn, cols, bodyRows, emptyMsg) {
|
|
82
|
+
const header = `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:${SPACING.md}">
|
|
83
|
+
<div><span style="font-size:15px;font-weight:600;color:var(--color-text)">${heading}</span>${countBadge ? `<span style="font-size:13px;color:var(--color-text-muted);margin-left:8px">(${countBadge})</span>` : ''}</div>
|
|
84
|
+
<div style="display:flex;align-items:center;gap:8px">${extraBtn || ''}</div>
|
|
85
|
+
</div>`;
|
|
86
|
+
return `<div id="rvpanel-${id}" class="rv-panel" style="display:none">${header}${renderTable(cols,
|
|
87
|
+
bodyRows || `<tr><td colspan="${cols.length}" style="text-align:center;padding:${SPACING.xl} 0;color:var(--color-text-muted)">${emptyMsg}</td></tr>`,
|
|
88
|
+
SPACING.md)}</div>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const flaggedCount = highlights.filter(h => { try { return (JSON.parse(h.flags||'[]')||[]).includes('flagged'); } catch { return false; } }).length;
|
|
92
|
+
const hRows = highlights.map(h => highlightRow(h)).join('');
|
|
93
|
+
const highlightsPanel = tablePanel('highlights', 'Highlights', totalH,
|
|
94
|
+
`<span style="font-size:13px;color:var(--color-text-muted)">${resolvedH} resolved, ${openH} open</span><button id="btn-filter-flagged" data-action="filterByFlag" style="font-size:12px;padding:4px 10px;border:1px solid ${flaggedCount > 0 ? '#f59e0b' : 'var(--color-border,#e5e7eb)'};background:${flaggedCount > 0 ? '#fffbeb' : 'transparent'};color:${flaggedCount > 0 ? '#92400e' : 'var(--color-text-muted)'};border-radius:6px;cursor:pointer;display:inline-flex;align-items:center;gap:4px">⚑ Flagged${flaggedCount > 0 ? ` (${flaggedCount})` : ''}</button>${renderButton('Open PDF', { variant: 'primary', size: 'sm', href: `/review/${esc(r.id)}/pdf` })}`,
|
|
95
|
+
['Highlight', 'Page', 'Status', 'Tags', 'By', 'Actions'], hRows,
|
|
96
|
+
`<a href="/review/${esc(r.id)}/pdf" style="color:var(--color-primary);font-weight:500;text-decoration:none">Open the PDF</a> to start adding highlights`);
|
|
97
|
+
|
|
98
|
+
const collabRows = collaborators.map(c => collaboratorRow(c)).join('');
|
|
99
|
+
const collabPanel = tablePanel('collaborators', 'Collaborators', collaborators.length,
|
|
100
|
+
canEditReview ? renderButton('+ Add Collaborator', { variant: 'primary', size: 'sm', action: 'openDialog', args: ['collab-dialog'] }) : '',
|
|
101
|
+
['User', 'Role', 'Expires', 'Actions'], collabRows, 'No collaborators added yet');
|
|
102
|
+
|
|
103
|
+
const clRows = checklists.map(cl => {
|
|
104
|
+
const clPct = cl.total_items > 0 ? Math.round((cl.completed_items || 0) / cl.total_items * 100) : 0;
|
|
105
|
+
return `<tr><td style="font-weight:500">${esc(cl.name || '-')}</td><td style="font-size:13px;color:var(--color-text-muted)">${cl.total_items || 0} items</td><td><div style="display:flex;align-items:center;gap:8px"><div class="resolution-bar" style="width:80px"><div class="resolution-bar-segment resolution-bar-resolved" style="width:${clPct}%"></div></div><span style="font-size:12px;color:var(--color-text-muted)">${cl.completed_items || 0}/${cl.total_items || 0}</span></div></td><td><a href="/checklist/${esc(cl.id)}" class="btn-ghost-clean" style="font-size:13px;padding:4px 10px">View</a></td></tr>`;
|
|
106
|
+
}).join('');
|
|
107
|
+
const checklistPanel = tablePanel('checklists', 'Checklists', checklists.length,
|
|
108
|
+
canEditReview ? renderButton('+ Add from Template', { variant: 'primary', size: 'sm', action: 'openChecklistPicker', args: [`${esc(r.id)}`] }) : '',
|
|
109
|
+
['Name', 'Items', 'Progress', ''], clRows, 'No checklists attached');
|
|
110
|
+
|
|
111
|
+
const checklistPickerDialog = `<div id="checklist-picker-dialog" class="dialog-backdrop" style="display:none" data-dialog-close="checklist-picker-dialog">
|
|
112
|
+
<div class="dialog" style="max-width:520px" onclick="event.stopPropagation()">
|
|
113
|
+
<div class="dialog-header"><h2 style="font-size:17px;font-weight:600;margin:0">Attach Checklist</h2><button class="dialog-close" data-dialog-close="checklist-picker-dialog" aria-label="Close">×</button></div>
|
|
114
|
+
<div class="dialog-body" style="padding:16px 20px">
|
|
115
|
+
<label style="font-size:13px;font-weight:500;color:var(--color-text-muted);display:block;margin-bottom:6px">Choose a template</label>
|
|
116
|
+
<select id="checklist-picker-template" class="input" style="width:100%;margin-bottom:12px"><option value="">Loading templates...</option></select>
|
|
117
|
+
<label style="font-size:13px;font-weight:500;color:var(--color-text-muted);display:block;margin-bottom:6px">Checklist name</label>
|
|
118
|
+
<input id="checklist-picker-name" type="text" class="input" style="width:100%" placeholder="Leave blank to use template name"/>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="dialog-footer" style="display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid var(--color-border,#e5e7eb)">
|
|
121
|
+
<button class="btn-ghost-clean" data-dialog-close="checklist-picker-dialog">Cancel</button>
|
|
122
|
+
<button class="btn-primary-clean" id="checklist-picker-save" data-action="saveChecklistFromTemplate" data-args='["${esc(r.id)}"]'>Attach</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
</div>`;
|
|
126
|
+
|
|
127
|
+
const historyPanel = `<div id="rvpanel-history" class="rv-panel" style="display:none">
|
|
128
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:${SPACING.md}">
|
|
129
|
+
<span style="font-size:15px;font-weight:600;color:var(--color-text)">History</span>
|
|
130
|
+
<button class="btn-ghost-clean" style="font-size:13px" data-action="loadHistory" data-args='["${esc(r.id)}"]'>Refresh</button>
|
|
131
|
+
</div>
|
|
132
|
+
<div id="history-list" style="display:flex;flex-direction:column;gap:8px"><div style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">Loading history...</div></div>
|
|
133
|
+
</div>`;
|
|
134
|
+
|
|
135
|
+
const metaTags = [statusBadge(r.status),
|
|
136
|
+
r.review_type ? `<span class="pill pill-neutral" style="text-transform:uppercase;font-size:11px;letter-spacing:0.5px">${esc(r.review_type)}</span>` : '',
|
|
137
|
+
r.financial_year ? `<span style="font-size:13px;color:var(--color-text-muted)">FY ${esc(r.financial_year)}</span>` : '',
|
|
138
|
+
pct === 100 ? `<span class="pill pill-success">Complete</span>` : '',
|
|
139
|
+
].filter(Boolean).join(' ');
|
|
140
|
+
|
|
141
|
+
const bc = [{ href: '/', label: 'Home' }, { href: '/review', label: 'Reviews' }, { href: `/review/${r.id}`, label: r.name || 'Review' }, { label: 'Overview' }];
|
|
142
|
+
const pageHeader = `<div class="page-header" style="align-items:flex-start"><div><h1 class="page-title">${esc(r.name || r.title || 'Review')}</h1><div class="review-meta-row">${metaTags}</div></div><div style="display:flex;flex-wrap:wrap;gap:8px">${actions.join('')}</div></div>`;
|
|
143
|
+
|
|
144
|
+
const overviewPanel = `<div id="rvpanel-overview" class="rv-panel">${detailsCard}</div>`;
|
|
145
|
+
const sectionsPanel = sectionsPanelHtml(r.id, sections, SPACING, renderTable, renderButton);
|
|
146
|
+
const notifyDialog = `<div id="notify-collab-dialog" class="dialog-backdrop" style="display:none" data-dialog-close="notify-collab-dialog">
|
|
147
|
+
<div class="dialog" style="max-width:480px" onclick="event.stopPropagation()">
|
|
148
|
+
<div class="dialog-header"><h2 style="font-size:17px;font-weight:600;margin:0">Notify Collaborators</h2><button class="dialog-close" data-dialog-close="notify-collab-dialog">×</button></div>
|
|
149
|
+
<div class="dialog-body" style="padding:16px 20px">
|
|
150
|
+
<label style="font-size:13px;font-weight:500;color:var(--color-text-muted);display:block;margin-bottom:6px">Recipients</label>
|
|
151
|
+
<div id="notify-recipients" style="display:flex;flex-direction:column;gap:6px;margin-bottom:12px;max-height:160px;overflow-y:auto">${collaborators.map(c => `<label style="display:flex;align-items:center;gap:8px;font-size:13px"><input type="checkbox" name="notify_user" value="${esc(c.user_id||c.id)}" checked/> ${esc(c.user_name||c.name||c.email||c.user_id||'-')}</label>`).join('') || '<span style="font-size:13px;color:var(--color-text-muted)">No collaborators added yet</span>'}</div>
|
|
152
|
+
<label style="font-size:13px;font-weight:500;color:var(--color-text-muted);display:block;margin-bottom:6px">Message</label>
|
|
153
|
+
<textarea id="notify-message" rows="4" class="input" style="width:100%;resize:vertical" placeholder="Enter your message..."></textarea>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="dialog-footer" style="display:flex;gap:8px;justify-content:flex-end;padding:12px 20px;border-top:1px solid var(--color-border,#e5e7eb)">
|
|
156
|
+
<button class="btn-ghost-clean" data-dialog-close="notify-collab-dialog">Cancel</button>
|
|
157
|
+
<button class="btn-primary-clean" id="notify-send-btn" onclick="sendCollabNotify('${esc(r.id)}')">Send Notification</button>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>`;
|
|
161
|
+
|
|
162
|
+
const notifyScript = `<script>
|
|
163
|
+
async function sendCollabNotify(reviewId){var msg=document.getElementById('notify-message').value.trim();if(!msg){showToast('Enter a message','error');return;}var checked=Array.from(document.querySelectorAll('#notify-recipients input[name="notify_user"]:checked')).map(function(i){return i.value;});if(!checked.length){showToast('Select at least one recipient','error');return;}var btn=document.getElementById('notify-send-btn');if(btn){btn.disabled=true;btn.textContent='Sending...';}try{var r=await fetch('/api/mwr/review/'+reviewId+'/notify',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({user_ids:checked,message:msg})});if(r.ok){showToast('Notification sent','success');document.getElementById('notify-collab-dialog').style.display='none';document.getElementById('notify-message').value='';}else{var d=await r.json();showToast(d.error||'Failed','error');}}catch(e){showToast('Error: '+e.message,'error');}finally{if(btn){btn.disabled=false;btn.textContent='Send Notification';}}}
|
|
164
|
+
</script>`;
|
|
165
|
+
|
|
166
|
+
const content = `${pageHeader}${reviewZoneNav(r.id, 'overview')}${tabBar}
|
|
167
|
+
<div>${overviewPanel}${highlightsPanel}${collabPanel}${checklistPanel}${sectionsPanel}${tenderPanelHtml(r.id, tenders, canEditReview, tablePanel)}${linksPanelHtml(r.id, parsedLinks, canEditReview, tablePanel)}${historyPanel}${addCollaboratorDialog(r.id)}${checklistPickerDialog}${tenderDialog(r.id)}${linkDialog(r.id)}${compareDialogHtml(r.id)}${notifyDialog}${notifyScript}${retryHighlightDialog(esc(r.id))}${reviewTemplateChoiceDialog()}</div>`;
|
|
168
|
+
|
|
169
|
+
return page(user, `${esc(r.name || 'Review')} | Moonlanding`, bc, content, [reviewDetailScript(r.id)]);
|
|
170
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
2
|
+
import { TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
3
|
+
import { reviewHighlightActionsScript } from '@/ui/review-highlight-actions-script.js';
|
|
4
|
+
|
|
5
|
+
export function reviewDetailScript(reviewId) {
|
|
6
|
+
const id = esc(reviewId || '');
|
|
7
|
+
return `${TOAST_SCRIPT}
|
|
8
|
+
function switchTab(tab){
|
|
9
|
+
document.querySelectorAll('.rv-panel').forEach(function(p){p.style.display='none'});
|
|
10
|
+
var panel=document.getElementById('rvpanel-'+tab);if(panel)panel.style.display='';
|
|
11
|
+
document.querySelectorAll('[id^="rvtab-"]').forEach(function(b){b.classList.remove('active')});
|
|
12
|
+
var btn=document.getElementById('rvtab-'+tab);if(btn)btn.classList.add('active');
|
|
13
|
+
if(tab==='history')loadHistory('${id}');
|
|
14
|
+
}
|
|
15
|
+
async function resolveHighlight(id){if(!confirm('Mark this highlight as resolved?'))return;try{var r=await fetch('/api/mwr/review/${id}/highlights/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolved:true,status:'resolved'})});if(r.ok){showToast('Resolved','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
16
|
+
async function bulkResolve(reviewId){if(!confirm('Resolve all highlights?'))return;try{var r=await fetch('/api/mwr/review/'+reviewId+'/highlights/bulk-resolve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({resolve_all:true})});if(r.ok){showToast('All resolved','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
17
|
+
async function exportPdf(reviewId){showToast('Generating PDF...','info');try{var r=await fetch('/api/mwr/review/'+reviewId+'/export-pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})});if(r.ok){var b=await r.blob();var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='review-'+reviewId+'.pdf';a.click();showToast('PDF downloaded','success')}else showToast('Export failed','error')}catch(e){showToast('Error','error')}}
|
|
18
|
+
async function addCollaborator(reviewId){var email=document.getElementById('collab-email').value.trim();var role=document.getElementById('collab-role').value;if(!email){showToast('Email required','error');return}try{var r=await fetch('/api/mwr/review/'+reviewId+'/collaborators',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email,role:role})});if(r.ok){showToast('Collaborator added','success');document.getElementById('collab-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
19
|
+
async function removeCollaborator(collabId){if(!confirm('Remove this collaborator?'))return;try{var r=await fetch('/api/mwr/review/${id}/collaborators/'+collabId,{method:'DELETE'});if(r.ok){showToast('Removed','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
20
|
+
async function openChecklistPicker(reviewId){
|
|
21
|
+
var dlg=document.getElementById('checklist-picker-dialog');if(!dlg)return;
|
|
22
|
+
dlg.style.display='flex';
|
|
23
|
+
var sel=document.getElementById('checklist-picker-template');sel.innerHTML='<option>Loading...</option>';
|
|
24
|
+
try{
|
|
25
|
+
var r=await fetch('/api/mwr/checklist-template',{credentials:'include'});var data=await r.json();
|
|
26
|
+
if(!data.success||!data.templates||!data.templates.length){sel.innerHTML='<option value="">No templates available</option>';return}
|
|
27
|
+
sel.innerHTML=data.templates.map(function(t){return '<option value="'+t.id+'">'+t.name.replace(/</g,'<')+' ('+t.item_count+' items)</option>'}).join('');
|
|
28
|
+
}catch(e){sel.innerHTML='<option value="">Failed to load templates</option>'}
|
|
29
|
+
}
|
|
30
|
+
async function saveChecklistFromTemplate(reviewId){
|
|
31
|
+
var sel=document.getElementById('checklist-picker-template');var nameInput=document.getElementById('checklist-picker-name');
|
|
32
|
+
var tplId=sel&&sel.value;if(!tplId){showToast('Pick a template','error');return}
|
|
33
|
+
var body={template_id:tplId};var name=(nameInput&&nameInput.value||'').trim();if(name)body.name=name;
|
|
34
|
+
var btn=document.getElementById('checklist-picker-save');if(btn)btn.disabled=true;
|
|
35
|
+
try{
|
|
36
|
+
var r=await fetch('/api/mwr/review/'+reviewId+'/checklists',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(body)});
|
|
37
|
+
var data=await r.json();
|
|
38
|
+
if(r.ok&&data.success){showToast('Checklist attached','success');document.getElementById('checklist-picker-dialog').style.display='none';setTimeout(function(){location.reload()},500)}
|
|
39
|
+
else showToast('Failed: '+(data.error||'unknown'),'error');
|
|
40
|
+
}catch(e){showToast('Network error','error')}
|
|
41
|
+
finally{if(btn)btn.disabled=false}
|
|
42
|
+
}
|
|
43
|
+
function openAddTender(){
|
|
44
|
+
var dlg=document.getElementById('tender-dialog');if(!dlg)return;
|
|
45
|
+
['tender-id','tender-deadline','tender-announcement','tender-contact-person','tender-contact-number','tender-contact-email','tender-price','tender-winning-price'].forEach(function(f){document.getElementById(f).value='';});
|
|
46
|
+
document.getElementById('tender-dialog-title').textContent='Add Tender';
|
|
47
|
+
document.getElementById('tender-status').value='open';
|
|
48
|
+
dlg.style.display='flex';
|
|
49
|
+
}
|
|
50
|
+
function openEditTender(tenderId,status,deadline,announcement,contactPerson,contactNumber,contactEmail,price){
|
|
51
|
+
var dlg=document.getElementById('tender-dialog');if(!dlg)return;
|
|
52
|
+
document.getElementById('tender-id').value=tenderId;
|
|
53
|
+
document.getElementById('tender-dialog-title').textContent='Edit Tender';
|
|
54
|
+
document.getElementById('tender-status').value=status||'open';
|
|
55
|
+
document.getElementById('tender-deadline').value=deadline||'';
|
|
56
|
+
document.getElementById('tender-announcement').value=announcement||'';
|
|
57
|
+
document.getElementById('tender-contact-person').value=contactPerson||'';
|
|
58
|
+
document.getElementById('tender-contact-number').value=contactNumber||'';
|
|
59
|
+
document.getElementById('tender-contact-email').value=contactEmail||'';
|
|
60
|
+
document.getElementById('tender-price').value=price||'';
|
|
61
|
+
dlg.style.display='flex';
|
|
62
|
+
}
|
|
63
|
+
async function saveTender(reviewId){
|
|
64
|
+
var tenderId=document.getElementById('tender-id').value.trim();
|
|
65
|
+
var deadline=document.getElementById('tender-deadline').value;
|
|
66
|
+
if(!deadline){showToast('Deadline required','error');return}
|
|
67
|
+
var body={status:document.getElementById('tender-status').value,deadline:deadline,announcement_date:document.getElementById('tender-announcement').value||null,contact_person:document.getElementById('tender-contact-person').value||null,contact_number:document.getElementById('tender-contact-number').value||null,contact_email:document.getElementById('tender-contact-email').value||null,price:document.getElementById('tender-price').value?Number(document.getElementById('tender-price').value):null,winning_price:document.getElementById('tender-winning-price').value?Number(document.getElementById('tender-winning-price').value):null};
|
|
68
|
+
var btn=document.getElementById('tender-save-btn');if(btn)btn.disabled=true;
|
|
69
|
+
try{
|
|
70
|
+
var url=tenderId?'/api/mwr/review/'+reviewId+'/tender/'+tenderId:'/api/mwr/review/'+reviewId+'/tender';
|
|
71
|
+
var r=await fetch(url,{method:tenderId?'PATCH':'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(body)});
|
|
72
|
+
var data=await r.json();
|
|
73
|
+
if(r.ok&&data.success){showToast(tenderId?'Tender updated':'Tender added','success');document.getElementById('tender-dialog').style.display='none';setTimeout(function(){location.reload()},500)}
|
|
74
|
+
else showToast('Failed: '+(data.error||'unknown'),'error');
|
|
75
|
+
}catch(e){showToast('Network error','error')}
|
|
76
|
+
finally{if(btn)btn.disabled=false}
|
|
77
|
+
}
|
|
78
|
+
async function deleteTender(tenderId){if(!confirm('Delete this tender?'))return;try{var r=await fetch('/api/mwr/review/${id}/tender/'+tenderId,{method:'DELETE',credentials:'include'});var data=await r.json();if(r.ok&&data.success){showToast('Tender deleted','success');setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Error','error')}}
|
|
79
|
+
function openAddLink(){document.getElementById('link-dialog').style.display='flex';document.getElementById('link-label').value='';document.getElementById('link-url').value='';}
|
|
80
|
+
async function saveLink(reviewId){var url=document.getElementById('link-url').value.trim();var label=document.getElementById('link-label').value.trim();if(!url){showToast('URL required','error');return}try{var r=await fetch('/api/mwr/review/'+reviewId+'/links',{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({url:url,label:label||url,type:'url'})});var data=await r.json();if(r.ok&&data.success){showToast('Link added','success');document.getElementById('link-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Network error','error')}}
|
|
81
|
+
async function removeLink(reviewId,idx){if(!confirm('Remove this link?'))return;try{var r=await fetch('/api/mwr/review/'+reviewId+'/links/'+idx,{method:'DELETE',credentials:'include'});var data=await r.json();if(r.ok&&data.success){showToast('Link removed','success');setTimeout(function(){location.reload()},500)}else showToast('Failed: '+(data.error||'unknown'),'error');}catch(e){showToast('Error','error')}}
|
|
82
|
+
async function loadHistory(reviewId){
|
|
83
|
+
var list=document.getElementById('history-list');
|
|
84
|
+
if(!list)return;
|
|
85
|
+
list.innerHTML='<div style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">Loading...</div>';
|
|
86
|
+
try{
|
|
87
|
+
var r=await fetch('/api/audit/logs?entity_type=review&entity_id='+reviewId,{credentials:'include'});
|
|
88
|
+
var data=await r.json();
|
|
89
|
+
var logs=(data.logs||data.data||[]);
|
|
90
|
+
if(!logs.length){list.innerHTML='<div style="font-size:13px;color:var(--color-text-muted);padding:24px 0;text-align:center">No history found</div>';return}
|
|
91
|
+
list.innerHTML=logs.map(function(log){var ts=log.timestamp?new Date(log.timestamp).toLocaleString():'-';var op=log.operation||log.action||'-';var uid=log.user_id?'User '+log.user_id:'System';var details='';if(log.details&&typeof log.details==='object'){var keys=Object.keys(log.details).slice(0,3);details=keys.map(function(k){return k+': '+String(log.details[k]).slice(0,40)}).join(', ');}return '<div style="display:flex;gap:12px;align-items:flex-start;padding:10px 0;border-bottom:1px solid var(--color-border,#e5e7eb)"><div style="width:8px;height:8px;border-radius:50%;background:var(--color-primary,#2563eb);margin-top:5px;flex-shrink:0"></div><div style="flex:1;min-width:0"><div style="display:flex;justify-content:space-between;gap:8px"><span style="font-size:13px;font-weight:500;color:var(--color-text)">'+op.replace(/_/g,' ')+'</span><span style="font-size:12px;color:var(--color-text-muted);white-space:nowrap">'+ts+'</span></div><div style="font-size:12px;color:var(--color-text-muted);margin-top:2px">'+uid+(details?' — '+details:'')+'</div></div></div>';}).join('');
|
|
92
|
+
}catch(e){list.innerHTML='<div style="font-size:13px;color:var(--color-danger,#dc2626);padding:24px 0;text-align:center">Failed to load history</div>'}
|
|
93
|
+
}
|
|
94
|
+
${reviewHighlightActionsScript(reviewId)}`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { reviewZoneNav } from '@/ui/review-zone-nav.js';
|
|
3
|
+
import { SPACING, renderCard, renderButton, renderProgress, renderEmptyState, renderStatsRow, renderPageHeader } from '@/ui/spacing-system.js';
|
|
4
|
+
|
|
5
|
+
const TOAST_SCRIPT = `window.showToast=(m,t='info')=>{let c=document.getElementById('toast-container');if(!c){c=document.createElement('div');c.id='toast-container';c.className='toast-container';c.setAttribute('role','status');c.setAttribute('aria-live','polite');c.setAttribute('aria-atomic','true');document.body.appendChild(c)}const d=document.createElement('div');d.className='toast toast-'+t;d.textContent=m;c.appendChild(d);setTimeout(()=>{d.style.opacity='0';setTimeout(()=>d.remove(),300)},3000)};`;
|
|
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).toLocaleDateString();
|
|
11
|
+
return String(ts);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function statusBadge(status) {
|
|
15
|
+
const s = status || 'open';
|
|
16
|
+
const map = { active: 'pill pill-info', open: 'pill pill-info', in_progress: 'pill pill-info', completed: 'pill pill-success', closed: 'pill pill-success', archived: 'pill pill-neutral' };
|
|
17
|
+
const cls = map[s] || 'pill pill-warning';
|
|
18
|
+
return `<span class="${cls}">${s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</span>`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function renderMwrHome(user, stats) {
|
|
22
|
+
const { myReviews = [], sharedReviews = [], recentActivity = [], totalReviews = 0, activeReviews = 0, flaggedReviews = 0, overdueReviews = 0 } = stats;
|
|
23
|
+
const all = [...myReviews, ...sharedReviews];
|
|
24
|
+
const active = all.filter(r => (r.status || 'open') !== 'archived');
|
|
25
|
+
const priority = all.filter(r => r.priority || r.starred || r.flagged);
|
|
26
|
+
const archive = all.filter(r => r.status === 'archived');
|
|
27
|
+
const history = recentActivity;
|
|
28
|
+
|
|
29
|
+
const statsHtml = renderStatsRow([
|
|
30
|
+
{ value: totalReviews, label: 'Total Reviews' },
|
|
31
|
+
{ value: activeReviews, label: 'Active', color: 'var(--color-primary)' },
|
|
32
|
+
{ value: flaggedReviews, label: 'Flagged', color: 'var(--color-warning)' },
|
|
33
|
+
{ value: overdueReviews, label: 'Overdue', color: overdueReviews > 0 ? 'var(--color-danger)' : undefined },
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const tabBar = `<nav class="tab-bar" style="margin-bottom:${SPACING.md}">
|
|
37
|
+
<button class="tab-btn active" data-tab="active" data-action="switchHomeTab" data-args='["active"]'>Active<span class="tab-count">${active.length}</span></button>
|
|
38
|
+
<button class="tab-btn" data-tab="priority" data-action="switchHomeTab" data-args='["priority"]'>Priority<span class="tab-count">${priority.length}</span></button>
|
|
39
|
+
<button class="tab-btn" data-tab="history" data-action="switchHomeTab" data-args='["history"]'>History<span class="tab-count">${history.length}</span></button>
|
|
40
|
+
<button class="tab-btn" data-tab="archive" data-action="switchHomeTab" data-args='["archive"]'>Archive<span class="tab-count">${archive.length}</span></button>
|
|
41
|
+
</nav>`;
|
|
42
|
+
|
|
43
|
+
function listItem(r) {
|
|
44
|
+
const star = r.priority || r.starred ? '★' : '☆';
|
|
45
|
+
const archiveLabel = r.status === 'archived' ? 'Unarchive' : 'Archive';
|
|
46
|
+
const archiveAct = r.status === 'archived' ? 'unarchive' : 'archive';
|
|
47
|
+
return `<div class="list-item-row" data-review-id="${r.id}">
|
|
48
|
+
<button class="row-action row-action-star" data-action="toggleReviewStar" data-args='["${r.id}"]' aria-label="Toggle priority" title="Star/Priority">${star}</button>
|
|
49
|
+
<span class="list-item-name" data-action="openReview" data-args='["${r.id}"]' style="cursor:pointer;flex:1">${r.name || 'Untitled'}</span>
|
|
50
|
+
<div class="list-item-meta">${statusBadge(r.status)}<span style="font-size:12px;color:var(--color-text-light)">${fmtDate(r.updated_at || r.created_at)}</span></div>
|
|
51
|
+
<button class="row-action row-action-flag" data-action="toggleReviewFlag" data-args='["${r.id}"]' aria-label="Flag" title="Flag">⚑</button>
|
|
52
|
+
<button class="row-action row-action-tag" data-action="openReviewTag" data-args='["${r.id}"]' aria-label="Tag" title="Tag">#</button>
|
|
53
|
+
<button class="row-action row-action-archive" data-action="toggleReviewArchive" data-args='["${r.id}","${archiveAct}"]' aria-label="${archiveLabel}" title="${archiveLabel}">${r.status === 'archived' ? '↶' : '🗄'}</button>
|
|
54
|
+
<button class="row-action row-action-open" data-action="openReview" data-args='["${r.id}"]' aria-label="Open" title="Open">↗</button>
|
|
55
|
+
</div>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const activeList = active.length ? active.map(listItem).join('') : renderEmptyState('No active reviews');
|
|
59
|
+
const priorityList = priority.length ? priority.map(listItem).join('') : renderEmptyState('No priority reviews');
|
|
60
|
+
const archiveList = archive.length ? archive.map(listItem).join('') : renderEmptyState('No archived reviews');
|
|
61
|
+
const historyList = history.length ? history.slice(0, 50).map(a => `<div class="activity-item-row"><span class="activity-item-date">${fmtDate(a.created_at)}</span><span class="activity-item-desc">${a.description || a.action || '-'}</span></div>`).join('') : renderEmptyState('No recent activity');
|
|
62
|
+
|
|
63
|
+
const panels = `<div id="home-panel-active">${activeList}</div><div id="home-panel-priority" style="display:none">${priorityList}</div><div id="home-panel-history" style="display:none">${historyList}</div><div id="home-panel-archive" style="display:none">${archiveList}</div>`;
|
|
64
|
+
|
|
65
|
+
const content = `${renderPageHeader('MWR Home', `Welcome back, ${user?.name || 'User'}`)}${statsHtml}${tabBar}${renderCard(panels, { padding: 0 })}`;
|
|
66
|
+
const script = `${TOAST_SCRIPT}window.switchHomeTab=(key)=>{document.querySelectorAll('.tab-btn[data-tab]').forEach(t=>t.classList.toggle('active',t.dataset.tab===key));document.querySelectorAll('[id^="home-panel-"]').forEach(p=>p.style.display='none');const el=document.getElementById('home-panel-'+key);if(el)el.style.display='block'};window.openReview=(id)=>{location.href='/review/'+id};window.toggleReviewStar=async(id)=>{try{const r=await fetch('/api/mwr/review/star',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Star failed','error')}catch(e){window.showToast('Star failed','error')}};window.toggleReviewFlag=async(id)=>{try{const r=await fetch('/api/mwr/review/flag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id})});if(r.ok)location.reload();else window.showToast('Flag failed','error')}catch(e){window.showToast('Flag failed','error')}};window.openReviewTag=(id)=>{const t=prompt('Tag:');if(t)fetch('/api/mwr/review/tag',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:id,tag:t})}).then(r=>{if(r.ok)window.showToast('Tagged');else window.showToast('Tag failed','error')})};window.toggleReviewArchive=async(id,act)=>{try{const r=await fetch('/api/mwr/review/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_ids:[id],action:act})});if(r.ok)location.reload();else window.showToast('Archive failed','error')}catch(e){window.showToast('Archive failed','error')}};`;
|
|
67
|
+
const bc = [{ href: '/', label: 'Dashboard' }, { label: 'MWR Home' }];
|
|
68
|
+
return page(user, 'MWR Home | Moonlanding', bc, content, [script]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderSectionReport(user, review, sections) {
|
|
72
|
+
const total = sections.reduce((s, sec) => s + (sec.highlights_count || 0), 0);
|
|
73
|
+
const resolved = sections.reduce((s, sec) => s + (sec.resolved_count || 0), 0);
|
|
74
|
+
const flagged = sections.reduce((s, sec) => s + (sec.flagged_count || 0), 0);
|
|
75
|
+
|
|
76
|
+
const tableRows = sections.map((sec, i) => {
|
|
77
|
+
const pct = sec.highlights_count > 0 ? Math.round((sec.resolved_count || 0) / sec.highlights_count * 100) : 0;
|
|
78
|
+
return `<tr>
|
|
79
|
+
<td>${sec.name || `Section ${i + 1}`}</td>
|
|
80
|
+
<td style="text-align:center">${sec.highlights_count || 0}</td>
|
|
81
|
+
<td style="text-align:center">${sec.resolved_count || 0}</td>
|
|
82
|
+
<td style="text-align:center">${sec.flagged_count || 0}</td>
|
|
83
|
+
<td style="text-align:center"><div class="resolution-bar" style="width:80px;display:inline-block"><div class="resolution-bar-segment resolution-bar-resolved" style="width:${pct}%"></div></div> <span style="font-size:12px;color:var(--color-text-muted)">${pct}%</span></td>
|
|
84
|
+
</tr>`;
|
|
85
|
+
}).join('');
|
|
86
|
+
|
|
87
|
+
const summaryRow = `<tr style="font-weight:700;background:var(--color-bg)">
|
|
88
|
+
<td>Total</td>
|
|
89
|
+
<td style="text-align:center">${total}</td>
|
|
90
|
+
<td style="text-align:center">${resolved}</td>
|
|
91
|
+
<td style="text-align:center">${flagged}</td>
|
|
92
|
+
<td style="text-align:center">${total > 0 ? Math.round(resolved / total * 100) : 0}%</td>
|
|
93
|
+
</tr>`;
|
|
94
|
+
|
|
95
|
+
const pageHeader = renderPageHeader(
|
|
96
|
+
`Section Report: ${review.name || 'Review'}`,
|
|
97
|
+
'',
|
|
98
|
+
`${renderButton('Print', { variant: 'ghost', size: 'sm', action: 'printPage' })}
|
|
99
|
+
${renderButton('Export CSV', { variant: 'primary', size: 'sm', action: 'exportSectionReport' })}`
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const content = `${pageHeader}
|
|
103
|
+
<div class="table-wrap">
|
|
104
|
+
<table class="data-table">
|
|
105
|
+
<thead><tr><th>Section</th><th style="text-align:center">Highlights</th><th style="text-align:center">Resolved</th><th style="text-align:center">Flagged</th><th style="text-align:center">Progress</th></tr></thead>
|
|
106
|
+
<tbody>${tableRows}${summaryRow}</tbody>
|
|
107
|
+
</table>
|
|
108
|
+
</div>`;
|
|
109
|
+
|
|
110
|
+
const bc = [{ href: '/', label: 'Home' }, { href: '/review', label: 'Reviews' }, { href: `/review/${review.id}`, label: review.name || 'Review' }, { label: 'Sections' }];
|
|
111
|
+
const exportScript = `window.exportSectionReport=function(){var rows=[['Section','Highlights','Resolved','Flagged','Progress']];document.querySelectorAll('tbody tr').forEach(function(r){var cells=[];r.querySelectorAll('td').forEach(function(c){cells.push(c.textContent.trim())});if(cells.length)rows.push(cells)});var csv=rows.map(function(r){return r.join(',')}).join('\\n');var b=new Blob([csv],{type:'text/csv'});var a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='section-report.csv';a.click()}`;
|
|
112
|
+
return page(user, `${review.name || 'Review'} | Sections`, bc, reviewZoneNav(review.id, 'sections') + content, [exportScript]);
|
|
113
|
+
}
|