thatcher 1.0.4 → 1.0.6
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/package.json +18 -1
- package/src/app/api/debug/[[...path]]/route.js +217 -0
- package/src/app/api/formula/[[...path]]/route.js +142 -0
- package/src/cli.js +0 -1
- package/src/config/spec-helpers.js +6 -0
- package/src/engine.server.js +2 -2
- package/src/index.js +21 -11
- package/src/lib/auth-middleware.js +4 -2
- package/src/lib/busybase-adapter.js +162 -0
- package/src/lib/config-field-helpers.js +1 -1
- package/src/lib/config-generator-engine.js +24 -0
- package/src/lib/crud-handlers.js +21 -1
- package/src/lib/database-core.js +4 -7
- package/src/lib/database-migrations.js +254 -0
- package/src/lib/date-utils.js +164 -0
- package/src/lib/debug-registry.js +165 -0
- package/src/lib/error-handler.js +78 -0
- package/src/lib/export-sink.js +226 -0
- package/src/lib/field-iterator.js +126 -0
- package/src/lib/hyperformula-service.js +304 -0
- package/src/lib/metrics-collector.js +196 -0
- package/src/lib/observability-bootstrap.js +121 -0
- package/src/lib/perf-profiler.js +238 -0
- package/src/lib/query-engine.js +47 -0
- package/src/lib/query-string-adapter.js +97 -2
- package/src/lib/request-tracing.js +216 -0
- package/src/lib/response-formatter.js +0 -2
- package/src/lib/route-resolver.js +10 -0
- package/src/lib/status-helpers.js +128 -0
- package/src/lib/tracing.js +287 -0
- package/src/lib/utils.js +81 -0
- package/src/lib/validate.js +124 -1
- package/src/lib/xstate-workflow-engine.js +478 -0
- package/src/plugins/index.js +27 -0
- package/src/server/server.js +23 -4
- package/src/services/permission.service.js +6 -2
- package/src/ui/advanced-search-renderer.js +3 -3
- package/src/ui/advanced-widgets.js +4 -4
- package/src/ui/common-handlers.js +4 -2
- package/src/ui/dashboard-renderer.js +4 -4
- package/src/ui/engagement-cards.js +10 -10
- package/src/ui/engagement-grid-renderer.js +11 -8
- package/src/ui/entity-renderer.js +4 -2
- package/src/ui/event-delegation.js +348 -8
- package/src/ui/file-dialogs.js +9 -9
- package/src/ui/format-helpers.js +37 -3
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +6 -6
- package/src/ui/monitoring-dashboard-client.js +17 -3
- package/src/ui/notifications-renderer.js +6 -7
- package/src/ui/page-handler.js +9 -0
- package/src/ui/render-helpers.js +8 -1
- package/src/ui/review-comparison-renderer.js +1 -1
- package/src/ui/review-detail-renderer.js +6 -6
- package/src/ui/review-mwr-renderer.js +13 -10
- package/src/ui/review-widgets.js +5 -5
- package/src/ui/rfi-detail-renderer.js +9 -9
- package/src/ui/settings-renderer-advanced.js +13 -13
- package/src/ui/settings-renderer-teams.js +10 -9
- package/src/ui/settings-renderer.js +25 -24
- package/src/ui/spacing-system.js +8 -4
- package/src/ui/widgets.js +2 -2
package/src/ui/file-dialogs.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { statusLabel } from '@/ui/renderer.js';
|
|
2
2
|
|
|
3
3
|
export function zipFileCreationDialog(engagementId) {
|
|
4
|
-
return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
4
|
+
return `<div id="zip-create-dialog" class="dialog-overlay" style="display:none" data-dialog-close="zip-create-dialog" role="dialog" aria-modal="true" aria-labelledby="zip-create-dialog-title" aria-hidden="true">
|
|
5
5
|
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="zip-create-dialog-title">Create Zip Archive</span><button class="dialog-close" data-dialog-close="zip-create-dialog" aria-label="Close dialog">×</button></div>
|
|
6
6
|
<div class="dialog-body"><div id="zcd-files" class="flex flex-col gap-2" style="max-height:300px;overflow:auto"></div><div class="flex items-center gap-2 mt-3"><label class="flex items-center gap-2 text-sm"><input type="checkbox" id="zcd-all" class="checkbox checkbox-sm" checked/><span>Select All</span></label></div></div>
|
|
7
7
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="zip-create-dialog">Cancel</button><button class="btn btn-primary btn-sm" data-action="zcdCreate">Create Zip</button></div>
|
|
@@ -13,7 +13,7 @@ export function zipFileCreationDialog(engagementId) {
|
|
|
13
13
|
</script>`;
|
|
14
14
|
}
|
|
15
15
|
export function crossEngagementFilePicker(currentEngId) {
|
|
16
|
-
return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close
|
|
16
|
+
return `<div id="cross-file-picker" class="dialog-overlay" style="display:none" data-dialog-close="cross-file-picker" role="dialog" aria-modal="true" aria-labelledby="cross-file-picker-title" aria-hidden="true">
|
|
17
17
|
<div class="dialog-panel" style="max-width:640px"><div class="dialog-header"><span class="dialog-title" id="cross-file-picker-title">Pick File from Another Engagement</span><button class="dialog-close" data-dialog-close="cross-file-picker" aria-label="Close dialog">×</button></div>
|
|
18
18
|
<div class="dialog-body"><div class="modal-form-group"><label for="cfp-eng">Engagement</label><select id="cfp-eng" class="select select-bordered w-full" onchange="cfpLoadFiles()"></select></div><div id="cfp-files" class="flex flex-col gap-2 mt-3" style="max-height:300px;overflow:auto"></div></div>
|
|
19
19
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="cross-file-picker">Close</button></div>
|
|
@@ -37,13 +37,13 @@ export function userCvUpload(userId) {
|
|
|
37
37
|
</script>`;
|
|
38
38
|
}
|
|
39
39
|
export function quickViewAttachment() {
|
|
40
|
-
return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close
|
|
40
|
+
return `<div id="quick-view" class="dialog-overlay" style="display:none" data-dialog-close="quick-view" role="dialog" aria-modal="true" aria-labelledby="quick-view-title" aria-hidden="true">
|
|
41
41
|
<div class="dialog-panel" style="max-width:900px;max-height:90vh"><div class="dialog-header"><span class="dialog-title" id="quick-view-title">Preview</span><button class="dialog-close" data-dialog-close="quick-view" aria-label="Close dialog">×</button></div>
|
|
42
42
|
<div class="dialog-body" style="overflow:auto;max-height:75vh"><div id="qv-content" class="text-center"><div class="text-gray-500">Loading...</div></div></div>
|
|
43
43
|
<div class="dialog-footer"><a id="qv-download" href="#" download class="btn btn-primary btn-sm">Download</a><button class="btn btn-ghost btn-sm" data-dialog-close="quick-view">Close</button></div>
|
|
44
44
|
</div></div>
|
|
45
45
|
<script>
|
|
46
|
-
window.quickView=function(url,name,type){document.getElementById('quick-view').style.display='flex';document.getElementById('quick-view-title').textContent=name||'Preview';document.getElementById('qv-download').href=url;var c=document.getElementById('qv-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" alt="'+(name||'File preview')+'" style="max-width:100%;max-height:70vh"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:70vh;border:none"></iframe>'}else{c.innerHTML='<div class="py-8 text-gray-500"><div style="
|
|
46
|
+
window.quickView=function(url,name,type){document.getElementById('quick-view').style.display='flex';document.getElementById('quick-view-title').textContent=name||'Preview';document.getElementById('qv-download').href=url;var c=document.getElementById('qv-content');if(type&&type.startsWith('image/')){c.innerHTML='<img src="'+url+'" alt="'+(name||'File preview')+'" style="max-width:100%;max-height:70vh"/>'}else if(type==='application/pdf'){c.innerHTML='<iframe src="'+url+'" style="width:100%;height:70vh;border:none"></iframe>'}else{c.innerHTML='<div class="py-8 text-gray-500"><div style="display:flex;justify-content:center"><svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg></div><div class="mt-2">'+name+'</div><div class="text-xs mt-1">Preview not available</div></div>'}};
|
|
47
47
|
</script>`;
|
|
48
48
|
}
|
|
49
49
|
export function fetchCachedPdf(fileId) {
|
|
@@ -53,24 +53,24 @@ export function fetchCachedPdf(fileId) {
|
|
|
53
53
|
}
|
|
54
54
|
export function fileAttachmentBar(files = []) {
|
|
55
55
|
if (!files.length) return '';
|
|
56
|
-
const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${f.id}/download","${(f.name || '').replace(/"/g, '"')}","${f.mime_type || ''}"]'><span style="
|
|
56
|
+
const items = files.map(f => `<div class="flex items-center gap-2 p-1 rounded hover:bg-gray-100 cursor-pointer" data-action="quickView" data-args='["/api/file/${f.id}/download","${(f.name || '').replace(/"/g, '"')}","${f.mime_type || ''}"]'><span style="display:inline-flex"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span><span class="text-xs truncate" style="max-width:120px">${f.name || 'file'}</span></div>`).join('');
|
|
57
57
|
return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
|
|
58
58
|
}
|
|
59
59
|
export function fileLinksBar(links = []) {
|
|
60
60
|
if (!links.length) return '';
|
|
61
|
-
const items = links.map(l => `<a href="${l.url || '#'}" target="_blank" class="flex items-center gap-1 p-1 rounded hover:bg-gray-100 text-xs text-blue-600"><span
|
|
61
|
+
const items = links.map(l => `<a href="${l.url || '#'}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-1 p-1 rounded hover:bg-gray-100 text-xs text-blue-600"><span style="display:inline-flex"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span><span class="truncate" style="max-width:150px">${l.name || l.url || 'Link'}</span></a>`).join('');
|
|
62
62
|
return `<div class="flex flex-wrap gap-1 mt-2">${items}</div>`;
|
|
63
63
|
}
|
|
64
64
|
export function reviewAttachmentChoiceDialog(reviewId) {
|
|
65
|
-
return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close
|
|
65
|
+
return `<div id="rev-attach-choice" class="dialog-overlay" style="display:none" data-dialog-close="rev-attach-choice" role="dialog" aria-modal="true" aria-labelledby="rev-attach-choice-title" aria-hidden="true">
|
|
66
66
|
<div class="dialog-panel"><div class="dialog-header"><span class="dialog-title" id="rev-attach-choice-title">Add Attachment</span><button class="dialog-close" data-dialog-close="rev-attach-choice" aria-label="Close dialog">×</button></div>
|
|
67
|
-
<div class="dialog-body"><div class="flex flex-col gap-3"><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="triggerUpload"
|
|
67
|
+
<div class="dialog-body"><div class="flex flex-col gap-3"><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="triggerUpload"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>Upload New File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="openCrossFilePicker"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>Link Existing File</button><button class="btn btn-outline w-full text-left" data-dialog-close="rev-attach-choice" data-action="racUrl"><span style="display:inline-flex;vertical-align:middle;margin-right:6px"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>Add URL</button></div><input type="file" id="rac-upload" style="display:none" onchange="racUploadFile()"/></div>
|
|
68
68
|
<div class="dialog-footer"><button class="btn btn-ghost btn-sm" data-dialog-close="rev-attach-choice">Cancel</button></div>
|
|
69
69
|
</div></div>
|
|
70
70
|
<script>
|
|
71
71
|
window.openRevAttachChoice=function(){document.getElementById('rev-attach-choice').style.display='flex'};
|
|
72
72
|
window.racUploadFile=async function(){var file=document.getElementById('rac-upload').files[0];if(!file)return;var fd=new FormData();fd.append('file',file);fd.append('review_id','${reviewId}');try{var r=await fetch('/api/file/upload',{method:'POST',body:fd});if(r.ok)showToast('Attached','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}};
|
|
73
|
-
window.racUrl=function(){var url=
|
|
73
|
+
window.racUrl=async function(){var url=await window.gmPrompt({title:'Add Link','label':'Enter URL','placeholder':'https://...','type':'url',confirmLabel:'Add Link',validate:function(v){return /^https?:\\/\\//i.test(v)?null:'Enter a valid http(s) URL.'}});if(!url)return;fetch('/api/file',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({review_id:'${reviewId}',url:url,name:url,type:'link'})}).then(function(r){if(r.ok)showToast('Link added','success');else showToast('Failed','error')}).catch(function(){showToast('Error','error')})};
|
|
74
74
|
</script>`;
|
|
75
75
|
}
|
|
76
76
|
export function uploadFridayFilesCrossApp(engagementId) {
|
package/src/ui/format-helpers.js
CHANGED
|
@@ -89,10 +89,44 @@ export function truncate(str, n) {
|
|
|
89
89
|
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
// Shared stroke-based SVG icon set. Replaces emoji/raw-unicode glyphs with a
|
|
93
|
+
// consistent, theme-following (currentColor) icon system. Add new paths here
|
|
94
|
+
// rather than scattering inline <svg> across renderers.
|
|
95
|
+
const ICON_PATHS = {
|
|
96
|
+
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
|
97
|
+
list: '<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>',
|
|
98
|
+
user: '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
|
|
99
|
+
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
|
|
100
|
+
inbox: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
|
101
|
+
mail: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/>',
|
|
102
|
+
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
|
103
|
+
archive: '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/>',
|
|
104
|
+
database: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
|
|
105
|
+
check: '<path d="M20 6 9 17l-5-5"/>',
|
|
106
|
+
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
|
107
|
+
clipboard: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
|
|
108
|
+
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
|
|
109
|
+
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
|
110
|
+
tag: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
|
111
|
+
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>',
|
|
112
|
+
bell: '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
|
113
|
+
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
|
114
|
+
building: '<rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4M8 6h.01M16 6h.01M12 6h.01M8 10h.01M16 10h.01M12 10h.01M8 14h.01M16 14h.01M12 14h.01"/>',
|
|
115
|
+
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
|
116
|
+
undo: '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
|
|
117
|
+
external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export function icon(name, size = 18) {
|
|
121
|
+
const path = ICON_PATHS[name];
|
|
122
|
+
if (!path) return '';
|
|
123
|
+
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${path}</svg>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function emptyState(message, iconName) {
|
|
127
|
+
const ico = typeof iconName === 'string' && ICON_PATHS[iconName] ? icon(iconName, 32) : icon('inbox', 32);
|
|
94
128
|
return `<div style="text-align:center;padding:48px 24px;color:var(--color-text-muted,#aaa)">
|
|
95
|
-
<div style="
|
|
129
|
+
<div style="display:flex;justify-content:center;margin-bottom:12px">${ico}</div>
|
|
96
130
|
<div style="font-size:0.88rem;font-weight:500">${message || 'No items found'}</div>
|
|
97
131
|
</div>`;
|
|
98
132
|
}
|
|
@@ -131,7 +131,7 @@ export function renderHighlightThreading(user, review, highlights, responseMap)
|
|
|
131
131
|
</div>`;
|
|
132
132
|
|
|
133
133
|
const threadScript = `
|
|
134
|
-
window.showToast
|
|
134
|
+
if(!window.showToast)window.showToast=function(m,t){var 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)}var d=document.createElement('div');d.className='toast toast-'+(t||'info');d.textContent=m;c.appendChild(d);setTimeout(function(){d.style.opacity='0';setTimeout(function(){d.remove()},300)},3000)};
|
|
135
135
|
window.filterThreads=function(f){
|
|
136
136
|
document.querySelectorAll('[data-thread-filter]').forEach(b=>{
|
|
137
137
|
b.className=b.dataset.threadFilter===f?'pill pill-primary':'pill pill-neutral';
|
|
@@ -146,12 +146,12 @@ export function renderHighlightThreading(user, review, highlights, responseMap)
|
|
|
146
146
|
};
|
|
147
147
|
window.submitResponse=async function(hid){
|
|
148
148
|
const ta=document.getElementById('reply-'+hid);
|
|
149
|
-
if(!ta||!ta.value.trim()){
|
|
149
|
+
if(!ta||!ta.value.trim()){showToast('Please enter a response','error');return}
|
|
150
150
|
try{
|
|
151
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
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');
|
|
154
|
-
}catch(e){
|
|
153
|
+
else{const err=await res.json().then(d=>d.error||d.message||'Failed').catch(()=>'Failed');showToast('Error: '+err,'error')}
|
|
154
|
+
}catch(e){showToast('Error: '+e.message,'error')}
|
|
155
155
|
};
|
|
156
156
|
window.updateResolution=async function(hid){
|
|
157
157
|
const sel=document.getElementById('resolve-status-'+hid);
|
|
@@ -159,16 +159,16 @@ export function renderHighlightThreading(user, review, highlights, responseMap)
|
|
|
159
159
|
try{
|
|
160
160
|
const res=await fetch('/api/mwr/review/highlight/'+hid,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:sel.value})});
|
|
161
161
|
if(res.ok){showToast('Status updated','success');setTimeout(()=>location.reload(),500)}
|
|
162
|
-
else{
|
|
163
|
-
}catch(e){
|
|
162
|
+
else{showToast('Failed to update','error')}
|
|
163
|
+
}catch(e){showToast('Error: '+e.message,'error')}
|
|
164
164
|
};
|
|
165
165
|
window.bulkResolve=async function(){
|
|
166
|
-
if(!
|
|
166
|
+
if(!(await window.gmConfirm({title:'Please confirm',message:'Resolve all highlights in this review?',danger:false,confirmLabel:'OK'})))return;
|
|
167
167
|
try{
|
|
168
168
|
const res=await fetch('/api/mwr/review/${review.id}?action=resolve-all',{method:'POST'});
|
|
169
169
|
if(res.ok){showToast('All highlights resolved','success');setTimeout(()=>location.reload(),500)}
|
|
170
|
-
else{
|
|
171
|
-
}catch(e){
|
|
170
|
+
else{showToast('Failed to resolve all','error')}
|
|
171
|
+
}catch(e){showToast('Error: '+e.message,'error')}
|
|
172
172
|
};`;
|
|
173
173
|
|
|
174
174
|
const bc = [{ href: '/', label: 'Home' }, { href: '/review', label: 'Reviews' }, { href: `/review/${review.id}`, label: review.name || 'Review' }, { label: 'Highlights' }];
|
|
@@ -11,11 +11,11 @@ function fmtDate(ts) {
|
|
|
11
11
|
|
|
12
12
|
function jobStatusBadge(status) {
|
|
13
13
|
const colors = {
|
|
14
|
-
running: { bg: '#dbeafe', text: '#1e40af', icon:
|
|
15
|
-
success: { bg: '#d1fae5', text: '#065f46', icon: '
|
|
16
|
-
failed: { bg: '#fee2e2', text: '#991b1b', icon:
|
|
17
|
-
scheduled: { bg: '#fef3c7', text: '#92400e', icon:
|
|
18
|
-
disabled: { bg: '#f3f4f6', text: '#6b7280', icon:
|
|
14
|
+
running: { bg: '#dbeafe', text: '#1e40af', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="5 3 19 12 5 21 5 3"/></svg>` },
|
|
15
|
+
success: { bg: '#d1fae5', text: '#065f46', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>' },
|
|
16
|
+
failed: { bg: '#fee2e2', text: '#991b1b', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>` },
|
|
17
|
+
scheduled: { bg: '#fef3c7', text: '#92400e', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>` },
|
|
18
|
+
disabled: { bg: '#f3f4f6', text: '#6b7280', icon: `<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="12" cy="12" r="6"/></svg>` },
|
|
19
19
|
};
|
|
20
20
|
const c = colors[status] || colors.scheduled;
|
|
21
21
|
return `<span style="background:${c.bg};color:${c.text};padding:2px 10px;border-radius:9999px;font-size:0.75rem;font-weight:500">${c.icon} ${(status || 'unknown').replace(/_/g, ' ')}</span>`;
|
|
@@ -55,7 +55,7 @@ export function renderJobManagement(user, jobs, recentLogs = []) {
|
|
|
55
55
|
|
|
56
56
|
const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Scheduled Jobs</h1>${canManage ? '<button class="btn btn-primary btn-sm" data-action="triggerAll">Run All Now</button>' : ''}</div>${statCards}${table}${logTable}`;
|
|
57
57
|
|
|
58
|
-
const jobScript = `window.triggerJob=async function(name){try{const r=await fetch('/api/admin/jobs/'+name+'/trigger',{method:'POST'});if(r.ok){location.reload()}else{
|
|
58
|
+
const jobScript = `window.triggerJob=async function(name){try{const r=await fetch('/api/admin/jobs/'+name+'/trigger',{method:'POST'});if(r.ok){location.reload()}else{showToast('Trigger failed: '+(await r.text()),'error')}}catch(e){showToast(e.message,'error')}};window.toggleJob=async function(name,enabled){try{const r=await fetch('/api/admin/jobs/'+name,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});if(r.ok)location.reload();else showToast('Toggle failed','error')}catch(e){showToast(e.message,'error')}};window.triggerAll=async function(){if(!(await window.gmConfirm({title:'Please confirm',message:'Run all jobs now?',danger:false,confirmLabel:'OK'})))return;try{const r=await fetch('/api/admin/jobs/trigger-all',{method:'POST'});if(r.ok)location.reload();else showToast('Failed','error')}catch(e){showToast(e.message,'error')}}`;
|
|
59
59
|
|
|
60
60
|
return page(user, 'Scheduled Jobs | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label: 'Jobs' }], content, [jobScript]);
|
|
61
61
|
}
|
|
@@ -5,13 +5,27 @@ export function monitoringDashboardScript() {
|
|
|
5
5
|
|
|
6
6
|
function showTab(name) {
|
|
7
7
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'))
|
|
8
|
-
document.getElementById('tab-' + name)
|
|
8
|
+
var panel = document.getElementById('tab-' + name)
|
|
9
|
+
if (panel) panel.classList.remove('hidden')
|
|
9
10
|
|
|
10
|
-
document.querySelectorAll('.tab').forEach(el =>
|
|
11
|
-
|
|
11
|
+
document.querySelectorAll('.tab').forEach(el => {
|
|
12
|
+
el.classList.remove('tab-active')
|
|
13
|
+
var args = el.getAttribute('data-args')
|
|
14
|
+
if (args && args.indexOf('"' + name + '"') >= 0) el.classList.add('tab-active')
|
|
15
|
+
})
|
|
12
16
|
|
|
13
17
|
currentTab = name
|
|
14
18
|
}
|
|
19
|
+
window.showTab = showTab
|
|
20
|
+
|
|
21
|
+
// This standalone page does not load the shared event-delegation script,
|
|
22
|
+
// so wire the data-action="showTab" tab buttons directly.
|
|
23
|
+
document.addEventListener('click', function (e) {
|
|
24
|
+
var btn = e.target.closest('[data-action="showTab"]')
|
|
25
|
+
if (!btn) return
|
|
26
|
+
e.preventDefault()
|
|
27
|
+
try { showTab(JSON.parse(btn.getAttribute('data-args') || '[]')[0]) } catch (_) {}
|
|
28
|
+
})
|
|
15
29
|
|
|
16
30
|
async function fetchMetrics() {
|
|
17
31
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { page } from '@/ui/layout.js';
|
|
2
|
-
import { esc, TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
2
|
+
import { esc, TOAST_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
|
|
3
3
|
|
|
4
4
|
function fmtDate(ts) {
|
|
5
5
|
if (!ts) return '-';
|
|
@@ -18,18 +18,17 @@ function notifRow(n) {
|
|
|
18
18
|
<td class="text-xs"><span class="badge badge-flat-secondary">${esc(typeLabel)}</span></td>
|
|
19
19
|
<td class="text-sm max-w-md">${esc(n.message || '-')}</td>
|
|
20
20
|
<td>${entityLink}</td>
|
|
21
|
-
<td>${isRead ? '<span class="text-xs text-base-content/30">Read</span>' : `<button
|
|
21
|
+
<td>${isRead ? '<span class="text-xs text-base-content/30">Read</span>' : `<button data-action="markRead" data-args='["${esc(n.id)}"]' class="btn btn-ghost btn-xs">Mark read</button>`}</td>
|
|
22
22
|
</tr>`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function renderNotificationsPage(user, notifs = []) {
|
|
26
|
-
const rows = notifs.map(notifRow).join('') ||
|
|
27
|
-
`<tr><td colspan="5" class="text-center py-8 text-base-content/40 text-sm">No notifications</td></tr>`;
|
|
26
|
+
const rows = notifs.map(notifRow).join('') || emptyRow(5, 'No notifications');
|
|
28
27
|
|
|
29
28
|
const content = `
|
|
30
29
|
<div class="page-header">
|
|
31
30
|
<h1 class="page-title">Notifications</h1>
|
|
32
|
-
<button
|
|
31
|
+
<button data-action="markAllRead" class="btn btn-ghost btn-sm">Mark All Read</button>
|
|
33
32
|
</div>
|
|
34
33
|
<div class="card-clean">
|
|
35
34
|
<div class="card-clean-body">
|
|
@@ -44,8 +43,8 @@ export function renderNotificationsPage(user, notifs = []) {
|
|
|
44
43
|
`;
|
|
45
44
|
|
|
46
45
|
const script = `${TOAST_SCRIPT}
|
|
47
|
-
async function
|
|
48
|
-
async function
|
|
46
|
+
window.markRead=async function(id){try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({id})});var row=document.querySelector('[data-notif-id="'+id+'"]');if(row){row.classList.add('opacity-60');var btn=row.querySelector('button');if(btn)btn.replaceWith(document.createTextNode('Read'))}var count=document.getElementById('notif-count');if(count){var c=parseInt(count.textContent||'0',10)-1;if(c<=0)count.style.display='none';else count.textContent=c}}catch(e){showToast('Error','error')}};
|
|
47
|
+
window.markAllRead=async function(){try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({})});document.querySelectorAll('[data-notif-id]').forEach(function(row){row.classList.add('opacity-60');var btn=row.querySelector('button');if(btn)btn.replaceWith(document.createTextNode('Read'))});var count=document.getElementById('notif-count');if(count)count.style.display='none';showToast('All marked read','success')}catch(e){showToast('Error','error')}};`;
|
|
49
48
|
|
|
50
49
|
return page(user, 'Notifications | Moonlanding', null, content, [script]);
|
|
51
50
|
}
|
package/src/ui/page-handler.js
CHANGED
|
@@ -141,6 +141,15 @@ export async function handlePage(pathname, req, res) {
|
|
|
141
141
|
|
|
142
142
|
if (normalized === '/engagements') return handleEngagementList(user, req);
|
|
143
143
|
if (normalized === '/search') return handleSearch(user, req);
|
|
144
|
+
if (normalized === '/monitoring') {
|
|
145
|
+
if (!isPartner(user)) return renderAccessDenied(user, 'monitoring', 'view');
|
|
146
|
+
const { renderMonitoringDashboard } = await lazyRenderer('monitoring-dashboard.js');
|
|
147
|
+
return renderMonitoringDashboard();
|
|
148
|
+
}
|
|
149
|
+
if (normalized === '/flexup') {
|
|
150
|
+
const { renderFlexUpView } = await lazyRenderer('flexup-view-renderer.js');
|
|
151
|
+
return renderFlexUpView(user);
|
|
152
|
+
}
|
|
144
153
|
if (normalized === '/ml-console') {
|
|
145
154
|
const db = getDatabase();
|
|
146
155
|
let candidates = []; let reviewMap = {};
|
package/src/ui/render-helpers.js
CHANGED
|
@@ -47,12 +47,18 @@ export const AVATAR_SIZES = { sm: 24, md: 32, lg: 40, xl: 48 }
|
|
|
47
47
|
|
|
48
48
|
export 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)};`
|
|
49
49
|
|
|
50
|
-
export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col)th.classList.add(sortDir===1?'sort-asc':'sort-desc')});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>th.addEventListener('click',()=>sortTable(th.dataset.sort)))})})();`
|
|
50
|
+
export const TABLE_SCRIPT = `(function(){let sortCol=null,sortDir=1;function filterTable(){const search=(document.getElementById('search-input')?.value||'').toLowerCase();const filters={};document.querySelectorAll('[data-filter]').forEach(el=>{if(el.value)filters[el.dataset.filter]=el.value.toLowerCase()});let shown=0,total=0;document.querySelectorAll('tbody tr[data-row]').forEach(row=>{total++;const text=row.textContent.toLowerCase();const matchSearch=!search||text.includes(search);const matchFilters=Object.entries(filters).every(([key,val])=>{const cell=row.querySelector('[data-col="'+key+'"]');return !cell||cell.textContent.toLowerCase().includes(val)});const visible=matchSearch&&matchFilters;row.style.display=visible?'':'none';if(visible)shown++});const counter=document.getElementById('row-count');if(counter)counter.textContent=shown===total?total+' items':shown+' of '+total+' items'}function sortTable(col){if(sortCol===col)sortDir*=-1;else{sortCol=col;sortDir=1;}document.querySelectorAll('th[data-sort]').forEach(th=>{th.classList.remove('sort-asc','sort-desc');if(th.dataset.sort===col){th.classList.add(sortDir===1?'sort-asc':'sort-desc');th.setAttribute('aria-sort',sortDir===1?'ascending':'descending')}else{th.removeAttribute('aria-sort')}});const tbody=document.querySelector('tbody');if(!tbody)return;const rows=Array.from(tbody.querySelectorAll('tr[data-row]'));rows.sort((a,b)=>{const av=a.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';const bv=b.querySelector('[data-col="'+col+'"]')?.textContent?.trim()||'';return av.localeCompare(bv,undefined,{numeric:true})*sortDir});rows.forEach(r=>tbody.appendChild(r))}window.filterTable=filterTable;window.sortTable=sortTable;document.addEventListener('DOMContentLoaded',()=>{document.getElementById('search-input')?.addEventListener('input',filterTable);document.querySelectorAll('[data-filter]').forEach(el=>el.addEventListener('change',filterTable));document.querySelectorAll('th[data-sort]').forEach(th=>{th.setAttribute('tabindex','0');th.setAttribute('role','button');if(!th.getAttribute('aria-label'))th.setAttribute('aria-label','Sort by '+(th.textContent||th.dataset.sort).trim());th.addEventListener('click',()=>sortTable(th.dataset.sort));th.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '||e.key==='Spacebar'){e.preventDefault();sortTable(th.dataset.sort)}})})})})();`
|
|
51
51
|
|
|
52
52
|
export function esc(s) {
|
|
53
53
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Canonical empty-row for a <tbody>. One styling for every "no rows" message so
|
|
57
|
+
// empty tables read as intentional and consistent (was ~10 divergent inline styles).
|
|
58
|
+
export function emptyRow(colspan, message = 'No items found') {
|
|
59
|
+
return `<tr><td colspan="${colspan}" class="table-empty-row">${message}</td></tr>`
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
export function stagePill(stage) {
|
|
57
63
|
const cfg = STAGE_CONFIG.find(s => s.key === stage)
|
|
58
64
|
const lbl = cfg ? cfg.label : (stage || '-')
|
|
@@ -115,6 +121,7 @@ export function nameHash(name) {
|
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
export { getInitials } from '@/lib/utils.js'
|
|
124
|
+
export { icon } from '@/ui/format-helpers.js'
|
|
118
125
|
|
|
119
126
|
export function fmtDate(ts) {
|
|
120
127
|
if (!ts) return '-';
|
|
@@ -52,7 +52,7 @@ export function renderComparisonPicker(user, reviews) {
|
|
|
52
52
|
const reviewOptions = reviews.map(r => `<option value="${r.id}">${r.name || r.title || 'Untitled'} ${r.status ? '(' + r.status + ')' : ''}</option>`).join('');
|
|
53
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
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
|
|
55
|
+
const script = `window.startComparison=function(){const l=document.getElementById('left-review')?.value;const r=document.getElementById('right-review')?.value;if(!l||!r)return showToast('Select both reviews','error');if(l===r)return showToast('Select different reviews','error');window.location='/reviews/compare?left='+l+'&right='+r}`;
|
|
56
56
|
|
|
57
57
|
return page(user, 'Compare Reviews | Moonlanding', [{ href: '/', label: 'Dashboard' }, { href: '/reviews', label: 'Reviews' }, { label: 'Compare' }], content, [script]);
|
|
58
58
|
}
|
|
@@ -2,7 +2,7 @@ import { page } from '@/ui/layout.js';
|
|
|
2
2
|
import { reviewZoneNav } from '@/ui/review-zone-nav.js';
|
|
3
3
|
export { reviewZoneNav };
|
|
4
4
|
import { canEdit } from '@/ui/permissions-ui.js';
|
|
5
|
-
import { esc, statusBadge, TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
5
|
+
import { esc, statusBadge, TOAST_SCRIPT, icon } from '@/ui/render-helpers.js';
|
|
6
6
|
import { reviewDetailScript } from '@/ui/review-detail-script.js';
|
|
7
7
|
import { highlightRow, collaboratorRow, addCollaboratorDialog } from '@/ui/review-detail-panels.js';
|
|
8
8
|
import { tenderPanelHtml, tenderDialog, linksPanelHtml, linkDialog, sectionsPanelHtml, compareDialogHtml } from '@/ui/review-detail-panels-extra.js';
|
|
@@ -91,7 +91,7 @@ export function renderReviewDetail(user, review, highlights = [], collaborators
|
|
|
91
91
|
const flaggedCount = highlights.filter(h => { try { return (JSON.parse(h.flags||'[]')||[]).includes('flagged'); } catch { return false; } }).length;
|
|
92
92
|
const hRows = highlights.map(h => highlightRow(h)).join('');
|
|
93
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"
|
|
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">${icon('flag', 14)} Flagged${flaggedCount > 0 ? ` (${flaggedCount})` : ''}</button>${renderButton('Open PDF', { variant: 'primary', size: 'sm', href: `/review/${esc(r.id)}/pdf` })}`,
|
|
95
95
|
['Highlight', 'Page', 'Status', 'Tags', 'By', 'Actions'], hRows,
|
|
96
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
97
|
|
|
@@ -109,7 +109,7 @@ export function renderReviewDetail(user, review, highlights = [], collaborators
|
|
|
109
109
|
['Name', 'Items', 'Progress', ''], clRows, 'No checklists attached');
|
|
110
110
|
|
|
111
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"
|
|
112
|
+
<div class="dialog" style="max-width:520px">
|
|
113
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
114
|
<div class="dialog-body" style="padding:16px 20px">
|
|
115
115
|
<label style="font-size:13px;font-weight:500;color:var(--color-text-muted);display:block;margin-bottom:6px">Choose a template</label>
|
|
@@ -144,7 +144,7 @@ export function renderReviewDetail(user, review, highlights = [], collaborators
|
|
|
144
144
|
const overviewPanel = `<div id="rvpanel-overview" class="rv-panel">${detailsCard}</div>`;
|
|
145
145
|
const sectionsPanel = sectionsPanelHtml(r.id, sections, SPACING, renderTable, renderButton);
|
|
146
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"
|
|
147
|
+
<div class="dialog" style="max-width:480px">
|
|
148
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
149
|
<div class="dialog-body" style="padding:16px 20px">
|
|
150
150
|
<label style="font-size:13px;font-weight:500;color:var(--color-text-muted);display:block;margin-bottom:6px">Recipients</label>
|
|
@@ -154,13 +154,13 @@ export function renderReviewDetail(user, review, highlights = [], collaborators
|
|
|
154
154
|
</div>
|
|
155
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
156
|
<button class="btn-ghost-clean" data-dialog-close="notify-collab-dialog">Cancel</button>
|
|
157
|
-
<button class="btn-primary-clean" id="notify-send-btn"
|
|
157
|
+
<button class="btn-primary-clean" id="notify-send-btn" data-action="sendCollabNotify" data-args='["${esc(r.id)}"]'>Send Notification</button>
|
|
158
158
|
</div>
|
|
159
159
|
</div>
|
|
160
160
|
</div>`;
|
|
161
161
|
|
|
162
162
|
const notifyScript = `<script>
|
|
163
|
-
async function
|
|
163
|
+
window.sendCollabNotify=async function(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
164
|
</script>`;
|
|
165
165
|
|
|
166
166
|
const content = `${pageHeader}${reviewZoneNav(r.id, 'overview')}${tabBar}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { page } from '@/ui/layout.js';
|
|
2
2
|
import { reviewZoneNav } from '@/ui/review-zone-nav.js';
|
|
3
3
|
import { SPACING, renderCard, renderButton, renderProgress, renderEmptyState, renderStatsRow, renderPageHeader } from '@/ui/spacing-system.js';
|
|
4
|
+
import { icon, esc } from '@/ui/format-helpers.js';
|
|
5
|
+
import { TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
// Star icon with fill toggle for priority on/off state.
|
|
8
|
+
const starIcon = (on) => `<svg width="16" height="16" viewBox="0 0 24 24" fill="${on ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`;
|
|
6
9
|
|
|
7
10
|
function fmtDate(ts) {
|
|
8
11
|
if (!ts) return '-';
|
|
@@ -41,29 +44,29 @@ export function renderMwrHome(user, stats) {
|
|
|
41
44
|
</nav>`;
|
|
42
45
|
|
|
43
46
|
function listItem(r) {
|
|
44
|
-
const
|
|
47
|
+
const isPri = !!(r.priority || r.starred);
|
|
45
48
|
const archiveLabel = r.status === 'archived' ? 'Unarchive' : 'Archive';
|
|
46
49
|
const archiveAct = r.status === 'archived' ? 'unarchive' : 'archive';
|
|
47
50
|
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">${
|
|
49
|
-
<span class="list-item-name" data-action="openReview" data-args='["${r.id}"]' style="cursor:pointer;flex:1">${r.name || 'Untitled'}</span>
|
|
51
|
+
<button type="button" class="row-action row-action-star${isPri ? ' is-priority' : ''}" data-action="toggleReviewStar" data-args='["${esc(r.id)}"]' aria-label="Toggle priority" aria-pressed="${isPri}" title="Star/Priority">${starIcon(isPri)}</button>
|
|
52
|
+
<span class="list-item-name" data-action="openReview" data-args='["${esc(r.id)}"]' style="cursor:pointer;flex:1">${esc(r.name || 'Untitled')}</span>
|
|
50
53
|
<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"
|
|
52
|
-
<button class="row-action row-action-tag" data-action="openReviewTag" data-args='["${r.id}"]' aria-label="Tag" title="Tag"
|
|
53
|
-
<button class="row-action row-action-archive" data-action="toggleReviewArchive" data-args='["${r.id}","${archiveAct}"]' aria-label="${archiveLabel}" title="${archiveLabel}">${r.status === 'archived' ? '
|
|
54
|
-
<button class="row-action row-action-open" data-action="openReview" data-args='["${r.id}"]' aria-label="Open" title="Open"
|
|
54
|
+
<button class="row-action row-action-flag" data-action="toggleReviewFlag" data-args='["${r.id}"]' aria-label="Flag" title="Flag">${icon('flag', 16)}</button>
|
|
55
|
+
<button class="row-action row-action-tag" data-action="openReviewTag" data-args='["${r.id}"]' aria-label="Tag" title="Tag">${icon('tag', 16)}</button>
|
|
56
|
+
<button class="row-action row-action-archive" data-action="toggleReviewArchive" data-args='["${r.id}","${archiveAct}"]' aria-label="${archiveLabel}" title="${archiveLabel}">${r.status === 'archived' ? icon('undo', 16) : icon('archive', 16)}</button>
|
|
57
|
+
<button class="row-action row-action-open" data-action="openReview" data-args='["${r.id}"]' aria-label="Open" title="Open">${icon('external', 16)}</button>
|
|
55
58
|
</div>`;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
const activeList = active.length ? active.map(listItem).join('') : renderEmptyState('No active reviews');
|
|
59
62
|
const priorityList = priority.length ? priority.map(listItem).join('') : renderEmptyState('No priority reviews');
|
|
60
63
|
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');
|
|
64
|
+
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">${esc(a.description || a.action || '-')}</span></div>`).join('') : renderEmptyState('No recent activity');
|
|
62
65
|
|
|
63
66
|
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
67
|
|
|
65
68
|
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=
|
|
69
|
+
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=async(id)=>{const t=await window.gmPrompt({title:'Tag Review',label:'Tag',placeholder:'Enter a tag',confirmLabel:'Add 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
70
|
const bc = [{ href: '/', label: 'Dashboard' }, { label: 'MWR Home' }];
|
|
68
71
|
return page(user, 'MWR Home | Moonlanding', bc, content, [script]);
|
|
69
72
|
}
|
package/src/ui/review-widgets.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { h } from '@/ui/webjsx.js'
|
|
2
|
-
import { statusLabel, TOAST_SCRIPT } from '@/ui/render-helpers.js'
|
|
2
|
+
import { statusLabel, TOAST_SCRIPT, esc } from '@/ui/render-helpers.js'
|
|
3
3
|
import { teamAvatarGroup } from '@/ui/widgets.js'
|
|
4
4
|
|
|
5
5
|
export function mobileReviewCard(review) {
|
|
@@ -8,8 +8,8 @@ export function mobileReviewCard(review) {
|
|
|
8
8
|
const date = r.created_at ? `<span class="text-xs text-gray-500">${typeof r.created_at === 'number' ? new Date(r.created_at * 1000).toLocaleDateString() : r.created_at}</span>` : ''
|
|
9
9
|
return `<div class="mobile-card mobile-review-card card-clean">
|
|
10
10
|
<div class="card-clean-body">
|
|
11
|
-
<div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${r.name || r.title || 'Untitled Review'}</h3>${sts}</div>
|
|
12
|
-
<div class="flex items-center gap-2 text-xs text-gray-500 mb-2">${r.engagement_name ? `<span>${r.engagement_name}</span>` : ''}${date}</div>
|
|
11
|
+
<div class="flex justify-between items-center mb-2"><h3 class="font-medium text-sm">${esc(r.name || r.title || 'Untitled Review')}</h3>${sts}</div>
|
|
12
|
+
<div class="flex items-center gap-2 text-xs text-gray-500 mb-2">${r.engagement_name ? `<span>${esc(r.engagement_name)}</span>` : ''}${date}</div>
|
|
13
13
|
${r.highlights_count !== undefined ? `<div class="text-xs text-gray-500">${r.highlights_count} highlight${r.highlights_count !== 1 ? 's' : ''}</div>` : ''}
|
|
14
14
|
<div class="flex gap-1 mt-2"><a href="/review/${r.id}" class="btn btn-xs btn-primary">View</a><a href="/review/${r.id}/edit" class="btn btn-xs btn-outline">Edit</a></div>
|
|
15
15
|
</div></div>`
|
|
@@ -37,7 +37,7 @@ export function sidebarReviewDetails(review) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function archiveReviewDialog() {
|
|
40
|
-
return `<div id="archive-review-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
40
|
+
return `<div id="archive-review-dialog" class="dialog-overlay" style="display:none" data-dialog-close="archive-review-dialog" role="dialog" aria-modal="true" aria-labelledby="archive-review-dialog-title" aria-hidden="true">
|
|
41
41
|
<div class="dialog-panel">
|
|
42
42
|
<div class="dialog-header"><span class="dialog-title" id="archive-review-dialog-title">Archive Review</span><button class="dialog-close" data-dialog-close="archive-review-dialog" aria-label="Close dialog">×</button></div>
|
|
43
43
|
<div class="dialog-body">
|
|
@@ -73,7 +73,7 @@ export function reviewPrivateToggle(reviewId, isPrivate) {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export function markAllHighlightsResolved(reviewId, unresolvedCount = 0) {
|
|
76
|
-
return `<div id="bulk-resolve-dialog" class="dialog-overlay" style="display:none" data-dialog-close
|
|
76
|
+
return `<div id="bulk-resolve-dialog" class="dialog-overlay" style="display:none" data-dialog-close="bulk-resolve-dialog" role="dialog" aria-modal="true" aria-labelledby="bulk-resolve-dialog-title" aria-hidden="true">
|
|
77
77
|
<div class="dialog-panel" style="max-width:380px">
|
|
78
78
|
<div class="dialog-header"><span class="dialog-title" id="bulk-resolve-dialog-title">Resolve All Highlights</span><button class="dialog-close" data-dialog-close="bulk-resolve-dialog" aria-label="Close dialog">×</button></div>
|
|
79
79
|
<div class="dialog-body">
|