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.
Files changed (62) hide show
  1. package/package.json +18 -1
  2. package/src/app/api/debug/[[...path]]/route.js +217 -0
  3. package/src/app/api/formula/[[...path]]/route.js +142 -0
  4. package/src/cli.js +0 -1
  5. package/src/config/spec-helpers.js +6 -0
  6. package/src/engine.server.js +2 -2
  7. package/src/index.js +21 -11
  8. package/src/lib/auth-middleware.js +4 -2
  9. package/src/lib/busybase-adapter.js +162 -0
  10. package/src/lib/config-field-helpers.js +1 -1
  11. package/src/lib/config-generator-engine.js +24 -0
  12. package/src/lib/crud-handlers.js +21 -1
  13. package/src/lib/database-core.js +4 -7
  14. package/src/lib/database-migrations.js +254 -0
  15. package/src/lib/date-utils.js +164 -0
  16. package/src/lib/debug-registry.js +165 -0
  17. package/src/lib/error-handler.js +78 -0
  18. package/src/lib/export-sink.js +226 -0
  19. package/src/lib/field-iterator.js +126 -0
  20. package/src/lib/hyperformula-service.js +304 -0
  21. package/src/lib/metrics-collector.js +196 -0
  22. package/src/lib/observability-bootstrap.js +121 -0
  23. package/src/lib/perf-profiler.js +238 -0
  24. package/src/lib/query-engine.js +47 -0
  25. package/src/lib/query-string-adapter.js +97 -2
  26. package/src/lib/request-tracing.js +216 -0
  27. package/src/lib/response-formatter.js +0 -2
  28. package/src/lib/route-resolver.js +10 -0
  29. package/src/lib/status-helpers.js +128 -0
  30. package/src/lib/tracing.js +287 -0
  31. package/src/lib/utils.js +81 -0
  32. package/src/lib/validate.js +124 -1
  33. package/src/lib/xstate-workflow-engine.js +478 -0
  34. package/src/plugins/index.js +27 -0
  35. package/src/server/server.js +23 -4
  36. package/src/services/permission.service.js +6 -2
  37. package/src/ui/advanced-search-renderer.js +3 -3
  38. package/src/ui/advanced-widgets.js +4 -4
  39. package/src/ui/common-handlers.js +4 -2
  40. package/src/ui/dashboard-renderer.js +4 -4
  41. package/src/ui/engagement-cards.js +10 -10
  42. package/src/ui/engagement-grid-renderer.js +11 -8
  43. package/src/ui/entity-renderer.js +4 -2
  44. package/src/ui/event-delegation.js +348 -8
  45. package/src/ui/file-dialogs.js +9 -9
  46. package/src/ui/format-helpers.js +37 -3
  47. package/src/ui/highlight-threading-renderer.js +9 -9
  48. package/src/ui/job-management-renderer.js +6 -6
  49. package/src/ui/monitoring-dashboard-client.js +17 -3
  50. package/src/ui/notifications-renderer.js +6 -7
  51. package/src/ui/page-handler.js +9 -0
  52. package/src/ui/render-helpers.js +8 -1
  53. package/src/ui/review-comparison-renderer.js +1 -1
  54. package/src/ui/review-detail-renderer.js +6 -6
  55. package/src/ui/review-mwr-renderer.js +13 -10
  56. package/src/ui/review-widgets.js +5 -5
  57. package/src/ui/rfi-detail-renderer.js +9 -9
  58. package/src/ui/settings-renderer-advanced.js +13 -13
  59. package/src/ui/settings-renderer-teams.js +10 -9
  60. package/src/ui/settings-renderer.js +25 -24
  61. package/src/ui/spacing-system.js +8 -4
  62. 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" data-dialog-close-overlay="true"><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>`;
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" data-dialog-close-overlay="true"><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 onclick="saveAssignQuestion()" class="btn btn-primary">Assign</button><button data-dialog-close="assign-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>`;
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" data-dialog-close-overlay="true"><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>`;
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">&#8592; ${esc(engagement.name||'Engagement')}</a>`
56
- : `<a href="/rfi" class="text-primary text-sm">&#8592; RFI List</a>`;
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" data-dialog-close-overlay="true"><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" data-dialog-close-overlay="true"><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 onclick="submitResponse()" class="btn btn-primary">Submit</button><button data-dialog-close="respond-q-dialog" class="btn btn-ghost">Cancel</button></div></div></div>` : ''}
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(!confirm('Delete this question?'))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')}}
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: '&#128194;', name: 'Google Drive', desc: 'Document storage and collaboration' },
58
- { id: 'gmail', icon: '&#9993;', name: 'Gmail', desc: 'Email integration for notifications' },
59
- { id: 'firebase', icon: '&#128293;', name: 'Firebase (Legacy)', desc: 'Legacy data source for migration' },
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" onclick="copyPrivateKey()">Copy</button>
91
- <button class="btn btn-primary btn-sm" onclick="generatePrivateKey()">Generate New Key</button>
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" onclick="saveApiKeys()">Save API Keys</button>
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" data-dialog-close-overlay="true">
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(!confirm('Delete this trigger?'))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')}}
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||'').replace(/"/g,'&quot;')}">Edit</button>
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(!confirm('Delete?'))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')}}
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" onclick="saveType()">Save</button><button class="btn btn-ghost btn-sm" onclick="cancelForm()">Cancel</button></div></div></div>`;
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(!confirm('Delete this team?'))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
+ 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' : ''}>&#9650;</button>
74
- <button class="btn btn-ghost btn-xs rfi-move-btn" data-id="${s.id}" data-direction="down" ${i === sections.length - 1 ? 'disabled' : ''}>&#9660;</button>
75
- <button class="btn btn-ghost btn-xs rfi-edit-btn" data-id="${s.id}" data-name="${(s.name || '').replace(/"/g,'&quot;')}" data-color="${s.color || '#B0B0B0'}">Edit</button>
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(!confirm('Delete this section?'))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
+ 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">&#8592; Back to Settings</a>`;
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: '&#9881;', title: 'System Info', desc: 'Database, server, cache configuration', href: '/admin/settings/system' },
35
- { key: 'users', icon: '&#128100;', title: 'Users', desc: 'Manage user accounts and roles', href: '/admin/settings/users', countKey: 'users' },
36
- { key: 'teams', icon: '&#128101;', title: 'Teams', desc: 'Manage audit teams', href: '/admin/settings/teams', countKey: 'teams' },
37
- { key: 'rfi-sections', icon: '&#128193;', title: 'RFI Sections', desc: 'Manage RFI section categories and colors', href: '/admin/settings/rfi-sections', countKey: 'rfiSections' },
38
- { key: 'templates', icon: '&#128196;', title: 'Templates', desc: 'Review templates and sections', href: '/admin/settings/templates', countKey: 'templates' },
39
- { key: 'notifications', icon: '&#128276;', title: 'Notifications', desc: 'Configure alerts, reminders, and reports', href: '/admin/settings/notifications' },
40
- { key: 'integrations', icon: '&#128279;', title: 'Integrations', desc: 'Google Drive, Gmail, and external services', href: '/admin/settings/integrations' },
41
- { key: 'checklists', icon: '&#9745;', title: 'Checklists', desc: 'Manage checklist definitions', href: '/admin/settings/checklists', countKey: 'checklists' },
42
- { key: 'rfi-templates', icon: '&#128203;', title: 'RFI Templates', desc: 'Manage RFI template definitions', href: '/admin/settings/rfi-templates', countKey: 'rfiTemplates' },
43
- { key: 'recreation', icon: '&#128203;', title: 'Audit Logs', desc: 'Audit trail, recreation history and event log', href: '/admin/settings/recreation' },
44
- { key: 'entity-types', icon: '&#127970;', title: 'Entity Types', desc: 'Manage client entity type definitions', href: '/admin/settings/entity-types', countKey: 'entityTypes' },
45
- { key: 'engagement-types', icon: '&#128196;', title: 'Engagement Types', desc: 'Manage engagement type definitions', href: '/admin/settings/engagement-types', countKey: 'engagementTypes' },
46
- { key: 'permissions', icon: '&#128274;', title: 'Permissions', desc: 'MWR permissions and access control', href: '/admin/settings/permissions' },
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="font-size:1.25rem;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">${c.icon}</div>
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: '🗄️', 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]] },
79
- { icon: '📋', 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(', ')]] },
80
- { icon: '✉️', 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']] },
81
- { icon: '⚙️', 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']] },
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>
@@ -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
- return `<button onclick="${onclick}" class="${btnClass}" ${disabled ? 'disabled' : ''} style="${extraStyle}">${label}</button>`;
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 ? `onclick="${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">&#9776;</span><span>${typeof item === 'string' ? item : item.label || item.name || ''}</span></li>`
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 ? '&#128444;' : isPdf ? '&#128196;' : '&#128206;'
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
  }