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
|
@@ -27,15 +27,15 @@ function questionRow(q, i, sections = [], clientId = '', showRespond = false, sh
|
|
|
27
27
|
|
|
28
28
|
function editQuestionDialog(rfiId, sections) {
|
|
29
29
|
const sectionOpts = sections.map(s => `<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('');
|
|
30
|
-
return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none"
|
|
30
|
+
return `<div id="q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-4" id="q-dialog-title">Edit Question</h3><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Question Text</span></label><textarea id="q-text" rows="4" class="textarea textarea-solid max-w-full"></textarea></div><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Section</span></label><select id="q-section" class="select select-solid max-w-full"><option value="">No section</option>${sectionOpts}</select></div><div class="grid grid-cols-2 gap-3 mb-4"><div class="form-group"><label class="label"><span class="label-text font-medium">Deadline</span></label><input type="date" id="q-deadline" class="input input-solid max-w-full"/></div><div class="form-group"><label class="label"><span class="label-text font-medium">Category</span></label><input type="text" id="q-category" placeholder="Category" class="input input-solid max-w-full"/></div></div><input type="hidden" id="q-edit-id"/><div class="modal-action"><button data-action="saveQuestion" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Save</button><button data-dialog-close="q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function assignQuestionDialog(rfiId) {
|
|
34
|
-
return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none"
|
|
34
|
+
return `<div id="assign-q-dialog" data-rfi-id="${esc(rfiId)}" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="assign-q-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><h3 class="text-lg font-semibold mb-4">Assign to Client User</h3><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Client User</span></label><select id="assign-q-user" class="select select-solid max-w-full"><option value="">Loading...</option></select></div><input type="hidden" id="assign-q-id"/><div class="modal-action"><button data-action="saveAssignQuestion" class="btn btn-primary">Assign</button><button data-dialog-close="assign-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function addSectionDialog(rfiId) {
|
|
38
|
-
return `<div id="sec-dialog" class="modal" style="display:none"
|
|
38
|
+
return `<div id="sec-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="sec-dialog"></div><div class="modal-content rounded-box max-w-sm p-6"><h3 class="text-lg font-semibold mb-4">Add Section</h3><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Section Name</span></label><input type="text" id="sec-name" class="input input-solid max-w-full"/></div><div class="modal-action"><button data-action="saveSection" data-args='["${esc(rfiId)}"]' class="btn btn-primary">Add</button><button data-dialog-close="sec-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], engagement = null) {
|
|
@@ -52,10 +52,10 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
52
52
|
</div>` : '';
|
|
53
53
|
|
|
54
54
|
const engLink = engagement
|
|
55
|
-
? `<a href="/engagement/${esc(engagement.id)}" class="text-primary text-sm"
|
|
56
|
-
: `<a href="/rfi" class="text-primary text-sm"
|
|
55
|
+
? `<a href="/engagement/${esc(engagement.id)}" class="text-primary text-sm">${esc(engagement.name||'Engagement')}</a>`
|
|
56
|
+
: `<a href="/rfi" class="text-primary text-sm">RFI List</a>`;
|
|
57
57
|
|
|
58
|
-
const reportBtn = `<a href="/rfi/${esc(rfi.id)}/report" target="_blank" class="btn btn-ghost btn-sm">Report</a>`;
|
|
58
|
+
const reportBtn = `<a href="/rfi/${esc(rfi.id)}/report" target="_blank" rel="noopener noreferrer" class="btn btn-ghost btn-sm">Report</a>`;
|
|
59
59
|
const headerBtns = `<div class="flex gap-2 flex-wrap">${reportBtn}${canEditRfi ? `
|
|
60
60
|
<button data-action="openAddQuestion" class="btn btn-primary btn-sm">+ Question</button>
|
|
61
61
|
<button data-action="openDialog" data-params='{"dialogId":"sec-dialog"}' class="btn btn-ghost btn-sm">+ Section</button>
|
|
@@ -108,8 +108,8 @@ export function renderRfiDetail(user, rfi = {}, questions = [], sections = [], e
|
|
|
108
108
|
${editQuestionDialog(rfi.id, sections)}
|
|
109
109
|
${addSectionDialog(rfi.id)}
|
|
110
110
|
${clientId ? assignQuestionDialog(rfi.id) : ''}
|
|
111
|
-
${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none"
|
|
112
|
-
${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none"
|
|
111
|
+
${canViewResponses ? `<div id="view-resp-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="view-resp-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-1">Responses</h3><p id="view-resp-qtext" class="text-sm text-base-content/60 mb-3"></p><div id="view-resp-list" style="max-height:240px;overflow-y:auto"></div><div class="modal-action"><button data-dialog-close="view-resp-dialog" class="btn btn-ghost">Close</button></div></div></div>` : ''}
|
|
112
|
+
${isClient ? `<div id="respond-q-dialog" class="modal" style="display:none" ><div class="modal-overlay" data-dialog-close="respond-q-dialog"></div><div class="modal-content rounded-box max-w-lg p-6"><h3 class="text-lg font-semibold mb-1">Respond to Question</h3><p id="respond-q-text" class="text-sm text-base-content/60 mb-4"></p><div class="form-group mb-3"><label class="label"><span class="label-text font-medium">Your Response</span></label><textarea id="respond-q-answer" rows="5" class="textarea textarea-solid max-w-full" placeholder="Enter your response..."></textarea></div><div class="form-group mb-4"><label class="label"><span class="label-text font-medium">Attach File (optional)</span></label><input type="file" id="respond-q-file" class="input input-solid max-w-full"/></div><input type="hidden" id="respond-q-id"/><div class="modal-action"><button data-action="submitResponse" class="btn btn-primary">Submit</button><button data-dialog-close="respond-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>` : ''}
|
|
113
113
|
`;
|
|
114
114
|
|
|
115
115
|
const script = `${TOAST}
|
|
@@ -124,7 +124,7 @@ function filterQuestions(q){filterRows()}
|
|
|
124
124
|
function openAddQuestion(){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Add Question';document.getElementById('q-edit-id').value='';document.getElementById('q-text').value='';document.getElementById('q-section').value='';document.getElementById('q-deadline').value='';document.getElementById('q-category').value=''}
|
|
125
125
|
async function openEditQuestion(id){document.getElementById('q-dialog').style.display='flex';document.getElementById('q-dialog-title').textContent='Edit Question';document.getElementById('q-edit-id').value=id;try{var rfiId=document.getElementById('q-dialog').dataset.rfiId;var r=await fetch('/api/rfi/'+rfiId+'/questions/'+id);var d=await r.json();var q=d.data||d;document.getElementById('q-text').value=q.question_text||q.question||q.title||'';document.getElementById('q-section').value=q.section_id||q.rfi_section_id||'';document.getElementById('q-deadline').value=q.deadline?(new Date(typeof q.deadline==='number'?q.deadline*1000:q.deadline)).toISOString().split('T')[0]:'';document.getElementById('q-category').value=q.category||''}catch(e){showToast('Error loading','error')}}
|
|
126
126
|
async function saveQuestion(rfiId){var id=document.getElementById('q-edit-id').value;var questionText=document.getElementById('q-text').value.trim();if(!questionText){showToast('Question text required','error');return}var data={question:questionText,category:document.getElementById('q-category').value.trim()||null,section_id:document.getElementById('q-section').value||null};var dl=document.getElementById('q-deadline').value;if(dl)data.due_date=Math.floor(new Date(dl).getTime()/1000);var btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Saving...'}try{var url=id?'/api/rfi/'+rfiId+'/questions/'+id:'/api/rfi/'+rfiId+'/questions';var r=await fetch(url,{method:id?'PUT':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});var result=await r.json();if(r.ok){showToast(id?'Question updated':'Question added','success');document.getElementById('q-dialog').style.display='none';setTimeout(function(){location.reload()},500)}else{showToast(result.message||result.error||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}finally{if(btn){btn.disabled=false;btn.textContent='Save'}}}
|
|
127
|
-
async function deleteQuestion(id){if(!
|
|
127
|
+
async function deleteQuestion(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this question?',danger:true,confirmLabel:'OK'})))return;var rfiId=document.getElementById('q-dialog').dataset.rfiId;try{var r=await fetch('/api/rfi/'+rfiId+'/questions/'+id,{method:'DELETE'});if(r.ok){showToast('Question deleted','success');setTimeout(function(){location.reload()},500)}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
|
|
128
128
|
async function saveSection(rfiId){var name=document.getElementById('sec-name').value.trim();if(!name){showToast('Name required','error');return}try{var r=await fetch('/api/rfi_section',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,rfi_id:rfiId})});if(r.ok){showToast('Section added','success');setTimeout(function(){location.reload()},500)}else showToast('Failed','error')}catch(e){showToast('Error','error')}document.getElementById('sec-dialog').style.display='none'}
|
|
129
129
|
async function sendReminder(rfiId){try{var r=await fetch('/api/friday/rfi/reminder',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rfi_id:rfiId})});if(r.ok)showToast('Reminder sent','success');else showToast('Failed','error')}catch(e){showToast('Error','error')}}`;
|
|
130
130
|
|
|
@@ -54,9 +54,9 @@ loadAuditLogs();`;
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const INTEGRATIONS = [
|
|
57
|
-
{ id: 'google_drive', icon:
|
|
58
|
-
{ id: 'gmail', icon:
|
|
59
|
-
{ id: 'firebase', icon:
|
|
57
|
+
{ id: 'google_drive', 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"><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"/></svg>`, name: 'Google Drive', desc: 'Document storage and collaboration' },
|
|
58
|
+
{ id: 'gmail', 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"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg>`, name: 'Gmail', desc: 'Email integration for notifications' },
|
|
59
|
+
{ id: 'firebase', 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"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></svg>`, name: 'Firebase (Legacy)', desc: 'Legacy data source for migration' },
|
|
60
60
|
];
|
|
61
61
|
|
|
62
62
|
export function renderSettingsIntegrations(user, integrations = {}) {
|
|
@@ -87,8 +87,8 @@ export function renderSettingsIntegrations(user, integrations = {}) {
|
|
|
87
87
|
<label class="label"><span class="label-text font-semibold">My Review Private Key</span></label>
|
|
88
88
|
<div class="flex gap-2 items-center">
|
|
89
89
|
<input type="text" id="mwr-private-key" class="input input-solid flex-1" readonly value="${esc(private_key)}" placeholder="No key generated"/>
|
|
90
|
-
<button class="btn btn-ghost btn-sm"
|
|
91
|
-
<button class="btn btn-primary btn-sm"
|
|
90
|
+
<button class="btn btn-ghost btn-sm" data-action="copyPrivateKey">Copy</button>
|
|
91
|
+
<button class="btn btn-primary btn-sm" data-action="generatePrivateKey">Generate New Key</button>
|
|
92
92
|
</div>
|
|
93
93
|
<div class="text-xs text-base-content/50 mt-1">This key links Friday and My Review. Keep it confidential — anyone with access can import data.</div>
|
|
94
94
|
</div>
|
|
@@ -97,7 +97,7 @@ export function renderSettingsIntegrations(user, integrations = {}) {
|
|
|
97
97
|
<input type="text" id="mwr-api-key" class="input input-solid w-full" value="${esc(mwr_api_key)}" placeholder="Paste the key from My Review Integrations settings"/>
|
|
98
98
|
<div class="text-xs text-base-content/50 mt-1">Enter the private key generated in the My Review settings to enable cross-app access.</div>
|
|
99
99
|
</div>
|
|
100
|
-
<button class="btn btn-primary btn-sm"
|
|
100
|
+
<button class="btn btn-primary btn-sm" data-action="saveApiKeys">Save API Keys</button>
|
|
101
101
|
</div></div>`;
|
|
102
102
|
const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Integrations</h1>${apiKeysCard}${cards}`;
|
|
103
103
|
const script = `${TOAST_SCRIPT}
|
|
@@ -122,7 +122,7 @@ export function renderSettingsNotifications(user, config = {}) {
|
|
|
122
122
|
{ id: 'new_messages', label: 'New Messages', desc: 'Notify when new messages are received', checked: true },
|
|
123
123
|
{ id: 'weekly_reports', label: 'Weekly Reports', desc: 'Send weekly summary reports', checked: true },
|
|
124
124
|
];
|
|
125
|
-
const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none"
|
|
125
|
+
const triggerDialog = `<div id="notif-trigger-dialog" class="modal" style="display:none" >
|
|
126
126
|
<div class="modal-overlay" data-dialog-close="notif-trigger-dialog"></div>
|
|
127
127
|
<div class="modal-content rounded-box max-w-md p-6">
|
|
128
128
|
<h3 class="text-lg font-semibold mb-4">Add Notification Trigger</h3>
|
|
@@ -159,18 +159,18 @@ document.getElementById('notif-form').addEventListener('submit',async(e)=>{e.pre
|
|
|
159
159
|
async function loadTriggers(){const tbody=document.getElementById('triggers-tbody');try{const r=await fetch('/api/friday/engagement/notifications');const d=await r.json();const rows=d.data||[];tbody.innerHTML=rows.length?rows.map(function(t){return'<tr><td class="text-sm">'+t.trigger_type+'</td><td class="text-sm">'+t.trigger_days+' days</td><td class="text-sm">'+t.trigger_reference+'</td><td class="text-sm">'+t.recipient_type+'</td><td>'+(t.active?'<span class="badge badge-flat-success text-xs">Active</span>':'<span class="badge badge-flat-secondary text-xs">Inactive</span>')+'</td><td><button class="btn btn-ghost btn-xs" data-action="deleteNotifTrigger" data-args=\\'["\\'+t.id+\\'"]\\'>Delete</button></td></tr>'}).join(''):'<tr><td colspan="6" class="text-center py-8 text-base-content/40 text-sm">No triggers configured</td></tr>'}catch(e){tbody.innerHTML='<tr><td colspan="6" class="text-center py-4 text-error text-sm">Failed to load</td></tr>'}}
|
|
160
160
|
function openNotifTriggerDialog(){document.getElementById('notif-trigger-dialog').style.display='flex'}
|
|
161
161
|
async function saveNotifTrigger(){const type=document.getElementById('ntd-type').value;const days=Number(document.getElementById('ntd-days').value);const ref=document.getElementById('ntd-ref').value;const recipient=document.getElementById('ntd-recipient').value;const active=document.getElementById('ntd-active').checked;if(!days&&days!==0){showToast('Enter number of days','error');return}try{const r=await fetch('/api/friday/engagement/notifications',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({trigger_type:type,trigger_days:days,trigger_reference:ref,recipient_type:recipient,active})});if(r.ok){showToast('Trigger added','success');document.getElementById('notif-trigger-dialog').style.display='none';loadTriggers()}else{const d=await r.json();showToast(d.message||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
|
|
162
|
-
async function deleteNotifTrigger(id){if(!
|
|
162
|
+
async function deleteNotifTrigger(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this trigger?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/friday/engagement/notifications',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});if(r.ok){showToast('Deleted','success');loadTriggers()}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
|
|
163
163
|
loadTriggers();`;
|
|
164
164
|
return settingsPage(user, 'Notifications - Settings', bc('Notifications'), content, [script]);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
function renderTypeList(user, items, entityKey, title) {
|
|
168
168
|
const rows = items.map(t => `<tr class="hover">
|
|
169
|
-
<td class="font-medium text-sm">${t.name || '-'}</td>
|
|
169
|
+
<td class="font-medium text-sm">${esc(t.name || '-')}</td>
|
|
170
170
|
<td class="text-xs text-base-content/50">${t.created_at ? new Date(t.created_at).toLocaleDateString() : '-'}</td>
|
|
171
171
|
<td><div class="flex gap-2">
|
|
172
|
-
<button class="btn btn-ghost btn-xs type-edit-btn" data-id="${t.id}" data-name="${(t.name||'')
|
|
173
|
-
<button class="btn btn-error btn-xs type-del-btn" data-id="${t.id}">Delete</button>
|
|
172
|
+
<button type="button" class="btn btn-ghost btn-xs type-edit-btn" data-id="${esc(t.id)}" data-name="${esc(t.name||'')}">Edit</button>
|
|
173
|
+
<button type="button" class="btn btn-error btn-xs type-del-btn" data-id="${esc(t.id)}">Delete</button>
|
|
174
174
|
</div></td>
|
|
175
175
|
</tr>`).join('');
|
|
176
176
|
const script = `${TOAST_SCRIPT}
|
|
@@ -179,9 +179,9 @@ function openAdd(){_eid='';document.getElementById('type-name').value='';documen
|
|
|
179
179
|
function openEdit(id,name){_eid=id;document.getElementById('type-name').value=name;document.getElementById('type-form').style.display='block'}
|
|
180
180
|
function cancelForm(){document.getElementById('type-form').style.display='none'}
|
|
181
181
|
async function saveType(){const name=document.getElementById('type-name').value.trim();if(!name){showToast('Name required','error');return}const url=_eid?'/api/${entityKey}/'+_eid:'/api/${entityKey}';const method=_eid?'PUT':'POST';try{const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});if(r.ok){showToast(_eid?'Updated':'Created','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
182
|
-
async function delType(id){if(!
|
|
182
|
+
async function delType(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/${entityKey}/'+id,{method:'DELETE'});if(r.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
183
183
|
document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.type-edit-btn').forEach(b=>b.addEventListener('click',function(){openEdit(this.dataset.id,this.dataset.name)}));document.querySelectorAll('.type-del-btn').forEach(b=>b.addEventListener('click',function(){delType(this.dataset.id)}));document.querySelectorAll('.type-add-btn').forEach(b=>b.addEventListener('click',openAdd))});`;
|
|
184
|
-
const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex gap-2 items-end"><div class="form-group"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid" style="max-width:240px" placeholder="Name"/></div><button data-action="saveType" class="btn btn-primary btn-sm"
|
|
184
|
+
const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex gap-2 items-end"><div class="form-group"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid" style="max-width:240px" placeholder="Name"/></div><button data-action="saveType" class="btn btn-primary btn-sm">Save</button><button data-action="cancelForm" class="btn btn-ghost btn-sm">Cancel</button></div></div></div>`;
|
|
185
185
|
const content = `${settingsBack()}<div class="flex justify-between items-center mb-4"><h1 class="text-2xl font-bold">${title}</h1><button class="btn btn-primary btn-sm type-add-btn">Add</button></div>${formHtml}<div class="card-clean"><div class="card-clean-body" style="padding:0">${inlineTable(['Name','Created','Actions'],rows,'No items found.')}</div></div>`;
|
|
186
186
|
return settingsPage(user, `${title} - Settings`, bc(title), content, [script]);
|
|
187
187
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
2
3
|
|
|
3
4
|
const RFI_PALETTE = ['#B0B0B0', '#44BBA4', '#FF4141', '#7F7EFF', '#3b82f6', '#f59e0b', '#ec4899', '#8b5cf6'];
|
|
4
5
|
|
|
@@ -52,7 +53,7 @@ function openEditTeam(id,name){document.getElementById('team-edit-id').value=id;
|
|
|
52
53
|
function openAddTeam(){document.getElementById('team-edit-id').value='';document.getElementById('team-name-input').value='';document.getElementById('team-dialog-title').textContent='Add Team';document.getElementById('team-dialog').style.display='flex'}
|
|
53
54
|
function closeTeamDialog(){document.getElementById('team-dialog').style.display='none'}
|
|
54
55
|
async function saveTeam(){const id=document.getElementById('team-edit-id').value;const name=document.getElementById('team-name-input').value.trim();if(!name){showToast('Name is required','error');return}const url=id?'/api/team/'+id:'/api/team';const method=id?'PUT':'POST';try{const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});if(r.ok){showToast(id?'Team updated':'Team created','success');setTimeout(()=>location.reload(),500)}else{const d=await r.json();showToast(d.error||'Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
|
|
55
|
-
async function deleteTeam(id){if(!
|
|
56
|
+
async function deleteTeam(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this team?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/team/'+id,{method:'DELETE'});if(r.ok){showToast('Team deleted','success');setTimeout(()=>location.reload(),500)}else showToast('Delete failed','error')}catch(e){showToast('Error','error')}}
|
|
56
57
|
document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.team-edit-btn').forEach(b=>b.addEventListener('click',function(){const id=this.dataset.id;const name=this.dataset.name;openEditTeam(id,name)}));document.querySelectorAll('.team-delete-btn').forEach(b=>b.addEventListener('click',function(){const id=this.dataset.id;deleteTeam(id)}));document.querySelectorAll('.team-add-btn').forEach(b=>b.addEventListener('click',openAddTeam))});`;
|
|
57
58
|
|
|
58
59
|
const content = `${settingsBack()}${editDialog}
|
|
@@ -65,15 +66,15 @@ document.addEventListener('DOMContentLoaded',function(){document.querySelectorAl
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
export function renderSettingsRfiSections(user, sections = []) {
|
|
68
|
-
const rows = sections.map((s, i) => `<tr class="hover rfi-section-row" data-id="${s.id}">
|
|
69
|
-
<td><span class="inline-block w-5 h-5 rounded" style="background:${s.color || '#B0B0B0'}"></span></td>
|
|
70
|
-
<td class="text-sm">${s.name || '-'}</td>
|
|
69
|
+
const rows = sections.map((s, i) => `<tr class="hover rfi-section-row" data-id="${esc(s.id)}">
|
|
70
|
+
<td><span class="inline-block w-5 h-5 rounded" style="background:${esc(s.color || '#B0B0B0')}"></span></td>
|
|
71
|
+
<td class="text-sm">${esc(s.name || '-')}</td>
|
|
71
72
|
<td class="text-sm text-base-content/50">${s.order ?? i}</td>
|
|
72
73
|
<td><div class="flex gap-1">
|
|
73
|
-
<button class="btn btn-ghost btn-xs rfi-move-btn" data-id="${s.id}" data-direction="up" ${i === 0 ? 'disabled' : ''}
|
|
74
|
-
<button class="btn btn-ghost btn-xs rfi-move-btn" data-id="${s.id}" data-direction="down" ${i === sections.length - 1 ? 'disabled' : ''}
|
|
75
|
-
<button class="btn btn-ghost btn-xs rfi-edit-btn" data-id="${s.id}" data-name="${(s.name || '')
|
|
76
|
-
<button class="btn btn-error btn-xs rfi-delete-btn" data-id="${s.id}">Delete</button>
|
|
74
|
+
<button class="btn btn-ghost btn-xs rfi-move-btn" data-id="${esc(s.id)}" data-direction="up" aria-label="Move up" ${i === 0 ? 'disabled' : ''}><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"><polyline points="18 15 12 9 6 15"/></svg></button>
|
|
75
|
+
<button class="btn btn-ghost btn-xs rfi-move-btn" data-id="${esc(s.id)}" data-direction="down" aria-label="Move down" ${i === sections.length - 1 ? 'disabled' : ''}><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"><polyline points="6 9 12 15 18 9"/></svg></button>
|
|
76
|
+
<button class="btn btn-ghost btn-xs rfi-edit-btn" data-id="${esc(s.id)}" data-name="${esc(s.name || '')}" data-color="${esc(s.color || '#B0B0B0')}">Edit</button>
|
|
77
|
+
<button class="btn btn-error btn-xs rfi-delete-btn" data-id="${esc(s.id)}">Delete</button>
|
|
77
78
|
</div></td>
|
|
78
79
|
</tr>`).join('');
|
|
79
80
|
|
|
@@ -103,7 +104,7 @@ function cancelForm(){document.getElementById('section-form').style.display='non
|
|
|
103
104
|
function selectColor(c){document.getElementById('section-color').value=c;document.querySelectorAll('#color-palette div').forEach(el=>{el.style.borderColor=el.dataset.color===c?'#1a1a1a':'transparent'})}
|
|
104
105
|
function editSection(id,name,color){document.getElementById('section-form').style.display='block';document.getElementById('edit-id').value=id;document.getElementById('section-name').value=name;selectColor(color)}
|
|
105
106
|
async function saveSection(){const name=document.getElementById('section-name').value.trim();const color=document.getElementById('section-color').value;const id=document.getElementById('edit-id').value;if(!name){showToast('Name is required','error');return}const url=id?'/api/rfi_section/'+id:'/api/rfi_section';const method=id?'PUT':'POST';try{const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name,color})});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(()=>location.reload(),500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}
|
|
106
|
-
async function deleteSection(id){if(!
|
|
107
|
+
async function deleteSection(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete this section?',danger:true,confirmLabel:'OK'})))return;try{const res=await fetch('/api/rfi_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}}
|
|
107
108
|
async function moveSection(id,dir){try{const res=await fetch('/api/rfi_section/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({reorder:dir})});if(res.ok){location.reload()}else{showToast('Reorder failed','error')}}catch(e){showToast('Error','error')}}
|
|
108
109
|
selectColor('#B0B0B0');
|
|
109
110
|
document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.rfi-add-btn').forEach(b=>b.addEventListener('click',function(e){e.preventDefault();toggleAddForm()}));document.querySelectorAll('.rfi-edit-btn').forEach(b=>b.addEventListener('click',function(e){e.preventDefault();editSection(this.dataset.id,this.dataset.name,this.dataset.color)}));document.querySelectorAll('.rfi-delete-btn').forEach(b=>b.addEventListener('click',function(e){e.preventDefault();deleteSection(this.dataset.id)}));document.querySelectorAll('.rfi-move-btn').forEach(b=>b.addEventListener('click',function(e){e.preventDefault();moveSection(this.dataset.id,this.dataset.direction)}))});`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { page } from '@/ui/layout.js';
|
|
2
|
-
import { TOAST_SCRIPT, TABLE_SCRIPT, statusBadge as _statusBadge } from '@/ui/render-helpers.js';
|
|
2
|
+
import { TOAST_SCRIPT, TABLE_SCRIPT, statusBadge as _statusBadge, esc } from '@/ui/render-helpers.js';
|
|
3
|
+
import { icon } from '@/ui/format-helpers.js';
|
|
3
4
|
import { SPACING, renderCard } from '@/ui/spacing-system.js';
|
|
4
5
|
|
|
5
6
|
export { TOAST_SCRIPT } from '@/ui/render-helpers.js';
|
|
@@ -9,7 +10,7 @@ export function settingsPage(user, title, bc, content, scripts = []) {
|
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export function settingsBack() {
|
|
12
|
-
return `<a href="/admin/settings" class="btn btn-ghost btn-sm gap-1 mb-4"
|
|
13
|
+
return `<a href="/admin/settings" class="btn btn-ghost btn-sm gap-1 mb-4">Back to Settings</a>`;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function inlineTable(headers, rows, emptyMsg) {
|
|
@@ -31,19 +32,19 @@ export function roleBadge(role) {
|
|
|
31
32
|
function statusBadge(status) { return _statusBadge(status); }
|
|
32
33
|
|
|
33
34
|
const SETTINGS_CARDS = [
|
|
34
|
-
{ key: 'system', icon: '
|
|
35
|
-
{ key: 'users', icon: '
|
|
36
|
-
{ key: 'teams', icon: '
|
|
37
|
-
{ key: 'rfi-sections', icon: '
|
|
38
|
-
{ key: 'templates', icon: '
|
|
39
|
-
{ key: 'notifications', icon: '
|
|
40
|
-
{ key: 'integrations', icon: '
|
|
41
|
-
{ key: 'checklists', icon: '
|
|
42
|
-
{ key: 'rfi-templates', icon: '
|
|
43
|
-
{ key: 'recreation', icon: '
|
|
44
|
-
{ key: 'entity-types', icon: '
|
|
45
|
-
{ key: 'engagement-types', icon: '
|
|
46
|
-
{ key: 'permissions', icon: '
|
|
35
|
+
{ key: 'system', icon: 'gear', title: 'System Info', desc: 'Database, server, cache configuration', href: '/admin/settings/system' },
|
|
36
|
+
{ key: 'users', icon: 'user', title: 'Users', desc: 'Manage user accounts and roles', href: '/admin/settings/users', countKey: 'users' },
|
|
37
|
+
{ key: 'teams', icon: 'users', title: 'Teams', desc: 'Manage audit teams', href: '/admin/settings/teams', countKey: 'teams' },
|
|
38
|
+
{ key: 'rfi-sections', icon: 'folder', title: 'RFI Sections', desc: 'Manage RFI section categories and colors', href: '/admin/settings/rfi-sections', countKey: 'rfiSections' },
|
|
39
|
+
{ key: 'templates', icon: 'document', title: 'Templates', desc: 'Review templates and sections', href: '/admin/settings/templates', countKey: 'templates' },
|
|
40
|
+
{ key: 'notifications', icon: 'bell', title: 'Notifications', desc: 'Configure alerts, reminders, and reports', href: '/admin/settings/notifications' },
|
|
41
|
+
{ key: 'integrations', icon: 'link', title: 'Integrations', desc: 'Google Drive, Gmail, and external services', href: '/admin/settings/integrations' },
|
|
42
|
+
{ key: 'checklists', icon: 'clipboard', title: 'Checklists', desc: 'Manage checklist definitions', href: '/admin/settings/checklists', countKey: 'checklists' },
|
|
43
|
+
{ key: 'rfi-templates', icon: 'list', title: 'RFI Templates', desc: 'Manage RFI template definitions', href: '/admin/settings/rfi-templates', countKey: 'rfiTemplates' },
|
|
44
|
+
{ key: 'recreation', icon: 'list', title: 'Audit Logs', desc: 'Audit trail, recreation history and event log', href: '/admin/settings/recreation' },
|
|
45
|
+
{ key: 'entity-types', icon: 'building', title: 'Entity Types', desc: 'Manage client entity type definitions', href: '/admin/settings/entity-types', countKey: 'entityTypes' },
|
|
46
|
+
{ key: 'engagement-types', icon: 'document', title: 'Engagement Types', desc: 'Manage engagement type definitions', href: '/admin/settings/engagement-types', countKey: 'engagementTypes' },
|
|
47
|
+
{ key: 'permissions', icon: 'lock', title: 'Permissions', desc: 'MWR permissions and access control', href: '/admin/settings/permissions' },
|
|
47
48
|
];
|
|
48
49
|
|
|
49
50
|
export function renderSettingsHome(user, config = {}, counts = {}) {
|
|
@@ -53,7 +54,7 @@ export function renderSettingsHome(user, config = {}, counts = {}) {
|
|
|
53
54
|
return `<a href="${c.href}" class="card-clean" style="text-decoration:none;border:1px solid var(--color-border);transition:box-shadow 0.15s">
|
|
54
55
|
<div class="card-clean-body" style="padding:${SPACING.md}">
|
|
55
56
|
<div style="display:flex;align-items:flex-start;gap:${SPACING.md}">
|
|
56
|
-
<div style="
|
|
57
|
+
<div style="width:2.25rem;height:2.25rem;display:flex;align-items:center;justify-content:center;background:var(--color-base-200);border-radius:8px;flex-shrink:0;color:var(--ds-accent,#247420)">${icon(c.icon, 20)}</div>
|
|
57
58
|
<div style="flex:1;min-width:0">
|
|
58
59
|
<div style="font-weight:600;font-size:0.875rem;color:var(--color-base-content)">${c.title}</div>
|
|
59
60
|
<div style="font-size:0.75rem;color:var(--color-text-muted);margin-top:${SPACING.xs};line-height:1.4">${c.desc}</div>
|
|
@@ -75,14 +76,14 @@ export function renderSettingsHome(user, config = {}, counts = {}) {
|
|
|
75
76
|
export function renderSettingsSystem(user, config = {}) {
|
|
76
77
|
const t = config.thresholds || {};
|
|
77
78
|
const sections = [
|
|
78
|
-
{ icon: '
|
|
79
|
-
{ icon: '
|
|
80
|
-
{ icon: '
|
|
81
|
-
{ icon: '
|
|
79
|
+
{ icon: 'database', title: 'System Information', items: [['Database Type', config.database?.type || 'SQLite'], ['Server Port', config.server?.port || 3004], ['Session TTL', (t.cache?.session_ttl_seconds || 3600) + 's'], ['Page Size (Default)', t.system?.default_page_size || 50], ['Page Size (Max)', t.system?.max_page_size || 500]] },
|
|
80
|
+
{ icon: 'clipboard', title: 'RFI Configuration', items: [['Max Days Outstanding', (t.rfi?.max_days_outstanding || 90) + ' days'], ['Escalation Delay', (t.rfi?.escalation_delay_hours || 24) + ' hours'], ['Notification Days', (t.rfi?.notification_days || [7, 3, 1, 0]).join(', ')]] },
|
|
81
|
+
{ icon: 'mail', title: 'Email Configuration', items: [['Batch Size', t.email?.send_batch_size || 10], ['Max Retries', t.email?.send_max_retries || 3], ['Rate Limit Delay', (t.email?.rate_limit_delay_ms || 6000) + 'ms']] },
|
|
82
|
+
{ icon: 'gear', title: 'Workflow Configuration', items: [['Stage Transition Lockout', (t.workflow?.stage_transition_lockout_minutes || 5) + ' min'], ['Collaborator Default Expiry', (t.collaborator?.default_expiry_days || 7) + ' days'], ['Collaborator Max Expiry', (t.collaborator?.max_expiry_days || 30) + ' days']] },
|
|
82
83
|
];
|
|
83
84
|
const cards = sections.map(s => `<div class="card-clean">
|
|
84
85
|
<div class="card-clean-body" style="padding:1rem">
|
|
85
|
-
<h2 class="font-semibold text-sm flex items-center gap-2 mb-3"><span>${s.icon}</span> ${s.title}</h2>
|
|
86
|
+
<h2 class="font-semibold text-sm flex items-center gap-2 mb-3"><span style="color:var(--ds-accent,#247420);display:inline-flex">${icon(s.icon, 18)}</span> ${s.title}</h2>
|
|
86
87
|
${s.items.map(([l, v]) => `<div class="flex justify-between items-center py-2 border-b border-base-200 last:border-0">
|
|
87
88
|
<span class="text-sm text-base-content/60">${l}</span>
|
|
88
89
|
<span class="text-sm font-semibold font-mono bg-base-200 px-2 py-0.5 rounded">${v}</span>
|
|
@@ -103,10 +104,10 @@ export function renderSettingsUsers(user, users = []) {
|
|
|
103
104
|
const rows = users.map(u => `<tr class="hover cursor-pointer" data-row data-navigate="/user/${u.id}">
|
|
104
105
|
<td data-col="name">
|
|
105
106
|
<div class="flex items-center gap-2">
|
|
106
|
-
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary flex-shrink-0">${(u.name||'?').charAt(0).toUpperCase()}</div>
|
|
107
|
+
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary flex-shrink-0">${esc((u.name||'?').charAt(0).toUpperCase())}</div>
|
|
107
108
|
<div>
|
|
108
|
-
<div class="text-sm font-medium">${u.name || '-'}</div>
|
|
109
|
-
<div class="text-xs text-base-content/50">${u.email || ''}</div>
|
|
109
|
+
<div class="text-sm font-medium">${esc(u.name || '-')}</div>
|
|
110
|
+
<div class="text-xs text-base-content/50">${esc(u.email || '')}</div>
|
|
110
111
|
</div>
|
|
111
112
|
</div>
|
|
112
113
|
</td>
|
package/src/ui/spacing-system.js
CHANGED
|
@@ -83,10 +83,14 @@ export function renderButton(label, opts = {}) {
|
|
|
83
83
|
|
|
84
84
|
if (action) {
|
|
85
85
|
const argsAttr = args ? ` data-args='${JSON.stringify(args)}'` : '';
|
|
86
|
-
return `<button data-action="${action}"${argsAttr} class="${btnClass}" ${disabled ? 'disabled' : ''} style="${extraStyle}">${label}</button>`;
|
|
86
|
+
return `<button type="button" data-action="${action}"${argsAttr} class="${btnClass}" ${disabled ? 'disabled' : ''} style="${extraStyle}">${label}</button>`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// onclick is a legacy escape-hatch (no caller currently uses it). Prefer
|
|
90
|
+
// data-action; emit a plain type=button when neither is supplied so we never
|
|
91
|
+
// render onclick="undefined".
|
|
92
|
+
const onclickAttr = onclick ? ` data-action="${onclick}"` : '';
|
|
93
|
+
return `<button type="button"${onclickAttr} class="${btnClass}" ${disabled ? 'disabled' : ''} style="${extraStyle}">${label}</button>`;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
export function renderProgress(value, max = 100, variant = 'success', marginTop = SPACING.md) {
|
|
@@ -149,7 +153,7 @@ export function renderResolutionBar(resolved, partial, total) {
|
|
|
149
153
|
|
|
150
154
|
export function renderFilterBar(filters, activeKey = 'all') {
|
|
151
155
|
return `<div class="review-filter-bar">${filters.map(f => {
|
|
152
|
-
const actionAttr = f.action ? `data-action="${f.action}"${f.args ? ` data-args='${JSON.stringify(f.args)}'` : ''}` : (f.onclick ? `
|
|
153
|
-
return `<button class="pill${f.key === activeKey ? ' pill-primary' : ' pill-neutral'}" data-thread-filter="${f.key}" ${actionAttr}>${f.label}${f.count !== undefined ? ` (${f.count})` : ''}</button>`;
|
|
156
|
+
const actionAttr = f.action ? `data-action="${f.action}"${f.args ? ` data-args='${JSON.stringify(f.args)}'` : ''}` : (f.onclick ? `data-action="${f.onclick}"` : '');
|
|
157
|
+
return `<button type="button" class="pill${f.key === activeKey ? ' pill-primary' : ' pill-neutral'}" data-thread-filter="${f.key}" ${actionAttr}>${f.label}${f.count !== undefined ? ` (${f.count})` : ''}</button>`;
|
|
154
158
|
}).join('')}</div>`;
|
|
155
159
|
}
|
package/src/ui/widgets.js
CHANGED
|
@@ -87,7 +87,7 @@ export function infoBubble(text, position = 'top') {
|
|
|
87
87
|
|
|
88
88
|
export function sortableList(items = [], containerId = 'sortable') {
|
|
89
89
|
const lis = items.map((item, i) =>
|
|
90
|
-
`<li class="sortable-item" draggable="true" data-index="${i}"><span class="sortable-handle"
|
|
90
|
+
`<li class="sortable-item" draggable="true" data-index="${i}"><span class="sortable-handle"><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"><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg></span><span>${typeof item === 'string' ? item : item.label || item.name || ''}</span></li>`
|
|
91
91
|
).join('')
|
|
92
92
|
const script = `(function(){const c=document.getElementById('${containerId}');let dragged=null;c.addEventListener('dragstart',e=>{dragged=e.target.closest('.sortable-item');e.dataTransfer.effectAllowed='move'});c.addEventListener('dragover',e=>{e.preventDefault();const t=e.target.closest('.sortable-item');if(t&&t!==dragged)t.classList.add('drag-over')});c.addEventListener('dragleave',e=>{const t=e.target.closest('.sortable-item');if(t)t.classList.remove('drag-over')});c.addEventListener('drop',e=>{e.preventDefault();const t=e.target.closest('.sortable-item');if(t&&t!==dragged){t.classList.remove('drag-over');c.insertBefore(dragged,t.nextSibling);const order=[...c.querySelectorAll('.sortable-item')].map(el=>+el.dataset.index);c.dispatchEvent(new CustomEvent('sortable-reorder',{detail:{order}}))}});})();`
|
|
93
93
|
return `<ul id="${containerId}" class="sortable-list">${lis}</ul><script>${script}</script>`
|
|
@@ -111,7 +111,7 @@ export function responseAttachment(file = {}) {
|
|
|
111
111
|
const ext = name.split('.').pop().toLowerCase()
|
|
112
112
|
const isImg = ['png','jpg','jpeg','gif','webp','svg'].includes(ext)
|
|
113
113
|
const isPdf = ext === 'pdf'
|
|
114
|
-
const icon = isImg ?
|
|
114
|
+
const icon = isImg ? `<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"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>` : isPdf ? `<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="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>` : `<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>`
|
|
115
115
|
const preview = isImg ? `<img src="${href}" alt="${name}" class="attachment-preview"/>` : isPdf ? `<iframe src="${href}" class="attachment-preview" style="width:100%;height:200px;border:none"></iframe>` : ''
|
|
116
116
|
return `<div class="attachment-card"><span class="attachment-icon">${icon}</span><div class="attachment-info"><div class="attachment-name">${name}</div>${size ? `<div class="attachment-size">${size}</div>` : ''}${preview}</div><a href="${href}" download class="btn btn-ghost btn-xs">Download</a></div>`
|
|
117
117
|
}
|