thatcher 1.0.41 → 1.0.43

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 (122) hide show
  1. package/README.md +27 -0
  2. package/package.json +2 -5
  3. package/src/app/api/audit/dashboard/route.js +2 -2
  4. package/src/app/api/audit/logs/route.js +2 -2
  5. package/src/app/api/audit/permissions/[id]/route.js +2 -2
  6. package/src/app/api/audit/permissions/route.js +3 -3
  7. package/src/app/api/audit/permissions/stats/route.js +2 -2
  8. package/src/app/api/audit/route.js +2 -3
  9. package/src/app/api/audit/stats/route.js +2 -2
  10. package/src/app/api/auth/google/callback/route.js +1 -1
  11. package/src/app/api/auth/google/route.js +1 -1
  12. package/src/app/api/auth/login/route.js +1 -1
  13. package/src/app/api/auth/logout/route.js +1 -1
  14. package/src/app/api/auth/me/route.js +1 -1
  15. package/src/app/api/auth/mwr-bridge/route.js +1 -1
  16. package/src/app/api/auth/password-reset/route.js +1 -1
  17. package/src/app/api/csrf-token/route.js +1 -1
  18. package/src/app/api/debug/[[...path]]/route.js +2 -2
  19. package/src/app/api/debug/config/route.js +1 -1
  20. package/src/app/api/debug/hooks/route.js +1 -1
  21. package/src/app/api/debug/plugins/route.js +1 -1
  22. package/src/app/api/debug/sqlite/route.js +1 -1
  23. package/src/app/api/debug/sync/route.js +1 -1
  24. package/src/app/api/debug/workflow/route.js +1 -1
  25. package/src/app/api/domains/[domain]/route.js +1 -2
  26. package/src/app/api/domains/route.js +1 -1
  27. package/src/app/api/email/allocate/batch/route.js +1 -1
  28. package/src/app/api/email/allocate/route.js +1 -1
  29. package/src/app/api/email/receive/route.js +1 -1
  30. package/src/app/api/email/send/route.js +1 -1
  31. package/src/app/api/email/unallocated/route.js +1 -1
  32. package/src/app/api/files/[id]/route.js +1 -1
  33. package/src/app/api/health/route.js +1 -2
  34. package/src/app/api/metrics/route.js +1 -2
  35. package/src/engine.js +3 -3
  36. package/src/engine.server.js +3 -3
  37. package/src/index.js +35 -10
  38. package/src/lib/action-factory.js +1 -1
  39. package/src/lib/api-helpers.js +1 -1
  40. package/src/lib/api.js +3 -4
  41. package/src/lib/audit-logger-enhanced.js +1 -1
  42. package/src/lib/auth-middleware.js +1 -1
  43. package/src/lib/auth-route-helpers.js +1 -1
  44. package/src/lib/{busybase-adapter.js → busybase/adapter.js} +3 -3
  45. package/src/lib/{busybase-audit-reads.js → busybase/audit-reads.js} +1 -1
  46. package/src/lib/{busybase-audit.js → busybase/audit.js} +2 -2
  47. package/src/lib/{busybase-lucia-adapter.js → busybase/lucia-adapter.js} +1 -1
  48. package/src/lib/{busybase-store.js → busybase/store.js} +3 -3
  49. package/src/lib/config-generator-engine.js +13 -1
  50. package/src/lib/crud-action-helpers.js +1 -1
  51. package/src/lib/crud-handlers.js +4 -4
  52. package/src/lib/email-sender.js +1 -1
  53. package/src/lib/errors/index.js +3 -0
  54. package/src/lib/{error-recovery.js → errors/recovery.js} +120 -4
  55. package/src/lib/{error-handler.js → errors/types.js} +1 -1
  56. package/src/lib/errors/wrap.js +225 -0
  57. package/src/lib/events-engine.js +1 -1
  58. package/src/lib/hot-reload/index.js +34 -0
  59. package/src/lib/index.js +5 -5
  60. package/src/lib/keyed-cache.js +112 -0
  61. package/src/lib/monitor.js +285 -0
  62. package/src/lib/next-shim.js +294 -0
  63. package/src/lib/observability-init.js +213 -0
  64. package/src/lib/perf.js +468 -0
  65. package/src/lib/query-cache.js +40 -46
  66. package/src/lib/realtime-server.js +18 -0
  67. package/src/lib/render-cache.js +14 -28
  68. package/src/lib/{request-tracing.js → request-trace.js} +57 -2
  69. package/src/lib/response-formatter.js +18 -0
  70. package/src/lib/route-helpers.js +1 -1
  71. package/src/lib/state-protocol.js +13 -0
  72. package/src/lib/state-transport-client.js +6 -0
  73. package/src/lib/state-transport-reconnect.js +5 -0
  74. package/src/lib/state-transport-server.js +8 -0
  75. package/src/lib/utils.js +1 -1
  76. package/src/lib/{validate.js → validation/entity-validators.js} +9 -8
  77. package/src/lib/validation/index.js +1 -1
  78. package/src/lib/validation/security-validators.js +1 -1
  79. package/src/lib/validation-middleware.js +2 -2
  80. package/src/lib/workflow-engine.js +23 -3
  81. package/src/lib/xstate-workflow-engine.js +18 -2
  82. package/src/services/collaborator-role.service.js +2 -2
  83. package/src/services/notification-engine.js +5 -5
  84. package/src/services/permission.service.js +1 -1
  85. package/src/ui/format-helpers.js +1 -4
  86. package/src/ui/highlight-threading-renderer.js +1 -1
  87. package/src/ui/page-handler-admin.js +6 -5
  88. package/src/ui/page-handler-helpers.js +2 -2
  89. package/src/ui/page-handler-reviews.js +4 -4
  90. package/src/ui/page-handler-rfi.js +1 -1
  91. package/src/ui/page-handler.js +2 -2
  92. package/src/ui/render-helpers.js +1 -1
  93. package/src/ui/renderer.js +1 -1
  94. package/src/ui/{review-comparison-renderer.js → review/comparison.js} +1 -1
  95. package/src/ui/{review-detail-renderer.js → review/detail-renderer.js} +3 -3
  96. package/src/ui/review/index.js +32 -0
  97. package/src/ui/{review-mwr-renderer.js → review/mwr.js} +1 -1
  98. package/src/ui/{review-renderer.js → review/renderer.js} +1 -1
  99. package/src/ui/{settings-renderer.js → settings/home.js} +7 -34
  100. package/src/ui/{settings-renderer-advanced.js → settings/review.js} +117 -56
  101. package/src/ui/settings/shared.js +45 -0
  102. package/src/ui/{settings-renderer-teams.js → settings/teams.js} +1 -2
  103. package/src/ui/settings/templates.js +116 -0
  104. package/src/lib/api-error-wrapper.js +0 -125
  105. package/src/lib/db-monitor.js +0 -127
  106. package/src/lib/error-resilience.js +0 -132
  107. package/src/lib/monitoring-init.js +0 -67
  108. package/src/lib/next-compat.js +0 -94
  109. package/src/lib/next-polyfills.js +0 -135
  110. package/src/lib/observability-bootstrap.js +0 -116
  111. package/src/lib/perf-monitor.js +0 -94
  112. package/src/lib/perf-profiler.js +0 -233
  113. package/src/lib/query-perf.js +0 -117
  114. package/src/lib/request-tracker.js +0 -43
  115. package/src/lib/resource-monitor.js +0 -120
  116. package/src/lib/validators.js +0 -67
  117. package/src/lib/with-error-handler.js +0 -31
  118. package/src/ui/settings-renderer-advanced2.js +0 -159
  119. /package/src/ui/{review-detail-panels.js → review/detail-panels.js} +0 -0
  120. /package/src/ui/{review-detail-script.js → review/detail-script.js} +0 -0
  121. /package/src/ui/{review-widgets.js → review/widgets.js} +0 -0
  122. /package/src/ui/{review-zone-nav.js → review/zone-nav.js} +0 -0
@@ -1,43 +0,0 @@
1
- import { recordRequest, recordError } from '@/lib/metrics-collector.js'
2
-
3
- function wrapHandler(handler, metadata = {}) {
4
- return async (request, context) => {
5
- const start = process.hrtime.bigint()
6
- const method = request.method
7
- const path = new URL(request.url).pathname
8
-
9
- try {
10
- const response = await handler(request, context)
11
- const duration = Number(process.hrtime.bigint() - start) / 1000000
12
- const status = response.status || 200
13
-
14
- recordRequest(path, method, duration, status)
15
-
16
- return response
17
- } catch (err) {
18
- const duration = Number(process.hrtime.bigint() - start) / 1000000
19
-
20
- recordRequest(path, method, duration, 500)
21
- recordError(path, method, err.message, err.stack)
22
-
23
- throw err
24
- }
25
- }
26
- }
27
-
28
- function trackRequest(path, method, duration, status) {
29
- recordRequest(path, method, duration, status)
30
- }
31
-
32
- function trackError(path, method, error, stack) {
33
- recordError(path, method, error, stack)
34
- }
35
-
36
- export { wrapHandler, trackRequest, trackError }
37
-
38
- if (typeof globalThis !== 'undefined') {
39
- globalThis.__requestTracker = {
40
- trackRequest,
41
- trackError
42
- }
43
- }
@@ -1,120 +0,0 @@
1
- import os from 'os'
2
- import fs from 'fs'
3
- import { recordResource } from '@/lib/metrics-collector.js'
4
- import { createLogger } from './logger.js';
5
-
6
- const log = createLogger('[ResourceMonitor]');
7
-
8
- let monitoringInterval = null
9
- let dbPath = null
10
-
11
- function getCPUUsage() {
12
- const cpus = os.cpus()
13
- let totalIdle = 0
14
- let totalTick = 0
15
-
16
- cpus.forEach(cpu => {
17
- for (const type in cpu.times) {
18
- totalTick += cpu.times[type]
19
- }
20
- totalIdle += cpu.times.idle
21
- })
22
-
23
- const idle = totalIdle / cpus.length
24
- const total = totalTick / cpus.length
25
- const usage = 1 - (idle / total)
26
-
27
- return Math.max(0, Math.min(1, usage))
28
- }
29
-
30
- function getMemoryUsage() {
31
- const totalMem = os.totalmem()
32
- const freeMem = os.freemem()
33
- const usedMem = totalMem - freeMem
34
-
35
- return {
36
- total: totalMem,
37
- used: usedMem,
38
- free: freeMem,
39
- percentage: usedMem / totalMem
40
- }
41
- }
42
-
43
- function getDiskUsage() {
44
- if (!dbPath) return null
45
-
46
- try {
47
- const stats = fs.statSync(dbPath)
48
- const totalSpace = 1024 * 1024 * 1024 * 100
49
- const usedSpace = stats.size
50
-
51
- return {
52
- total: totalSpace,
53
- used: usedSpace,
54
- percentage: usedSpace / totalSpace
55
- }
56
- } catch (err) {
57
- return null
58
- }
59
- }
60
-
61
- function collectResources() {
62
- try {
63
- const cpu = getCPUUsage()
64
- const memory = getMemoryUsage()
65
- const disk = getDiskUsage()
66
-
67
- recordResource(
68
- cpu,
69
- memory.percentage,
70
- disk?.percentage
71
- )
72
-
73
- return { cpu, memory, disk }
74
- } catch (err) {
75
- log.error('collection error:', { message: err?.message || String(err) })
76
- return null
77
- }
78
- }
79
-
80
- function startMonitoring(interval = 5000, databasePath = null) {
81
- if (monitoringInterval) {
82
- stopMonitoring()
83
- }
84
-
85
- dbPath = databasePath
86
-
87
- monitoringInterval = setInterval(() => {
88
- collectResources()
89
- }, interval)
90
-
91
- }
92
-
93
- function stopMonitoring() {
94
- if (monitoringInterval) {
95
- clearInterval(monitoringInterval)
96
- monitoringInterval = null
97
- }
98
- }
99
-
100
- function getCurrentResources() {
101
- return collectResources()
102
- }
103
-
104
- export {
105
- getCPUUsage,
106
- getMemoryUsage,
107
- getDiskUsage,
108
- collectResources,
109
- startMonitoring,
110
- stopMonitoring,
111
- getCurrentResources
112
- }
113
-
114
- if (typeof globalThis !== 'undefined') {
115
- globalThis.__resources = {
116
- getCurrentResources,
117
- startMonitoring,
118
- stopMonitoring
119
- }
120
- }
@@ -1,67 +0,0 @@
1
- export function isValidEmail(email) {
2
- if (!email || typeof email !== 'string') {
3
- return { valid: false, reason: 'Email is required' };
4
- }
5
-
6
- const trimmed = email.trim().toLowerCase();
7
- const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
8
-
9
- if (!EMAIL_REGEX.test(trimmed)) {
10
- return { valid: false, reason: 'Invalid email format' };
11
- }
12
-
13
- const domain = trimmed.split('@')[1];
14
- if (!domain || domain.length < 3 || !domain.includes('.')) {
15
- return { valid: false, reason: 'Invalid email domain' };
16
- }
17
-
18
- // Disposable email domains
19
- const DISPOSABLE_DOMAINS = new Set([
20
- 'mailinator.com', 'guerrillamail.com', 'tempmail.com',
21
- 'throwaway.email', 'yopmail.com', '10minutemail.com',
22
- ]);
23
-
24
- if (DISPOSABLE_DOMAINS.has(domain)) {
25
- return { valid: false, reason: 'Disposable email addresses are not allowed' };
26
- }
27
-
28
- return { valid: true, email: trimmed, domain };
29
- }
30
-
31
- export function isValidUrl(url) {
32
- try {
33
- new URL(url);
34
- return true;
35
- } catch {
36
- return false;
37
- }
38
- }
39
-
40
- export function isWithinYears(timestamp, minYearsAgo = 10, maxYearsAhead = 5) {
41
- const now = Date.now() / 1000;
42
- const secondsInYear = 365.25 * 24 * 60 * 60;
43
- return (
44
- timestamp > now - (minYearsAgo * secondsInYear) &&
45
- timestamp < now + (maxYearsAhead * secondsInYear)
46
- );
47
- }
48
-
49
- export function isBeforeDate(date1, date2) {
50
- return date1 < date2;
51
- }
52
-
53
- export function slugify(str) {
54
- return str
55
- .toLowerCase()
56
- .trim()
57
- .replace(/[^\w\s-]/g, '')
58
- .replace(/[\s_-]+/g, '-')
59
- .replace(/^-+|-+$/g, '');
60
- }
61
-
62
- export function getMissingFields(data, requiredFields) {
63
- return requiredFields.filter(field => {
64
- const val = data[field];
65
- return val === undefined || val === null || val === '';
66
- });
67
- }
@@ -1,31 +0,0 @@
1
- import { AppError, normalizeError, createErrorLogger } from '@/lib/error-handler';
2
- import { apiError } from '@/lib/response-formatter';
3
- import { HTTP } from '@/config/constants';
4
-
5
- export const withErrorHandler = (handler, operation = 'Operation') => {
6
- const logger = createErrorLogger(operation);
7
-
8
- return async (...args) => {
9
- try {
10
- return await handler(...args);
11
- } catch (e) {
12
- const error = normalizeError(e);
13
- logger.error(error.code, error.context);
14
- return apiError(error);
15
- }
16
- };
17
- };
18
-
19
- export const withAsyncErrorHandler = (handler, context = '') => {
20
- const logger = createErrorLogger(context);
21
-
22
- return async (...args) => {
23
- try {
24
- return await handler(...args);
25
- } catch (e) {
26
- const error = e instanceof AppError ? e : new AppError(e.message, 'INTERNAL_ERROR', HTTP.INTERNAL_ERROR, { originalMessage: e.message });
27
- logger.error(error.code || 'ERROR', error.context || {});
28
- throw error;
29
- }
30
- };
31
- };
@@ -1,159 +0,0 @@
1
- import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable } from '@/ui/settings-renderer.js';
2
- import { esc, icon } from '@/ui/render-helpers.js';
3
-
4
- const bc = (label) => [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { label }];
5
- const togRow = tg => `<div class="flex justify-between items-center py-3 border-b border-base-200"><div><div class="text-sm font-semibold">${tg.label}</div><div class="text-xs text-base-content/80">${tg.desc}</div></div><input type="checkbox" name="${tg.id}" ${tg.checked ? 'checked' : ''} class="checkbox checkbox-primary"/></div>`;
6
-
7
- export function renderSettingsReviewSettings(user, config = {}) {
8
- const review = config.review || {};
9
- const toggles = [
10
- { id: 'auto_save', label: 'Auto-save', desc: 'Automatically save review changes', checked: review.auto_save !== false },
11
- { id: 'highlight_notifications', label: 'Highlight Notifications', desc: 'Notify on new highlights', checked: review.highlight_notifications !== false },
12
- { id: 'require_resolution', label: 'Require Resolution', desc: 'All highlights resolved before closing', checked: !!review.require_resolution },
13
- { id: 'allow_private', label: 'Allow Private Reviews', desc: 'Enable private review visibility', checked: review.allow_private !== false },
14
- { id: 'enable_sections', label: 'Enable Sections', desc: 'Allow reviews organized into sections', checked: review.enable_sections !== false },
15
- { id: 'enable_wip_value', label: 'Enable WIP Value', desc: 'Track work-in-progress value', checked: !!review.enable_wip_value },
16
- ];
17
- const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">Review Settings</h1>
18
- <form id="review-settings-form"><div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
19
- <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-2">Review Options</h2>${toggles.map(togRow).join('')}</div></div>
20
- <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-4">Defaults</h2>
21
- <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Default Status</span></label><select name="default_status" class="select select-solid max-w-full"><option value="active" ${review.default_status==='active'?'selected':''}>Active</option><option value="draft" ${review.default_status==='draft'?'selected':''}>Draft</option></select></div>
22
- <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Max Highlights Per Review</span></label><input type="number" name="max_highlights" class="input input-solid max-w-full" value="${review.max_highlights || 500}" min="1"/></div>
23
- <div class="form-group"><label class="label"><span class="label-text font-semibold">Default Currency</span></label><select name="default_currency" class="select select-solid max-w-full"><option value="ZAR" ${!review.default_currency||review.default_currency==='ZAR'?'selected':''}>ZAR</option><option value="USD" ${review.default_currency==='USD'?'selected':''}>USD</option><option value="EUR" ${review.default_currency==='EUR'?'selected':''}>EUR</option><option value="GBP" ${review.default_currency==='GBP'?'selected':''}>GBP</option></select></div>
24
- </div></div>
25
- </div><button type="submit" class="btn btn-primary">Save Review Settings</button></form>`;
26
- const script = `${TOAST_SCRIPT}document.getElementById('review-settings-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#review-settings-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});document.querySelectorAll('#review-settings-form input[type=number]').forEach(n=>{if(data[n.name])data[n.name]=Number(data[n.name])});try{const res=await fetch('/api/admin/settings/review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(e){showToast('Error: '+e.message,'error')}})`;
27
- return settingsPage(user, 'Review Settings', bc('Review Settings'), content, [script]);
28
- }
29
-
30
- export function renderSettingsFileReview(user, config = {}, frSettings = {}) {
31
- const fr = config.fileReview || {};
32
- const reviewFlags = frSettings.review_flags || [];
33
- const tenderFlags = frSettings.tender_flags || [];
34
- const flagManagers = frSettings.flags_managers || [];
35
- const tempAccess = frSettings.temp_review_access_period || 0;
36
- const toggles = [
37
- { id: 'auto_pdf_cache', label: 'Auto-cache PDFs', desc: 'Cache PDF files for faster loading', checked: fr.auto_pdf_cache !== false },
38
- { id: 'allow_annotations', label: 'Allow Annotations', desc: 'Enable PDF annotation tools', checked: fr.allow_annotations !== false },
39
- { id: 'mobile_resize', label: 'Mobile Resize', desc: 'Enable mobile-friendly resizable highlights', checked: fr.mobile_resize !== false },
40
- { id: 'coordinate_snap', label: 'Coordinate Snap', desc: 'Snap highlight coordinates to text boundaries', checked: !!fr.coordinate_snap },
41
- ];
42
- const flagChips = (flags, type) => flags.map(f => `<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">${esc(f)}<button type="button" data-action="removeFlag" data-args='${esc(JSON.stringify([type, f]))}' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;line-height:1;color:inherit;display:inline-flex">${icon('close',16)}</button></span>`).join('');
43
- const content = `${settingsBack()}<h1 class="text-2xl font-bold mb-6">File Review Settings</h1>
44
- <div id="fr-review-flags" data-flags='${JSON.stringify(reviewFlags)}'></div>
45
- <div id="fr-tender-flags" data-flags='${JSON.stringify(tenderFlags)}'></div>
46
- <form id="file-review-settings-form">
47
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
48
- <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-2">File Review Options</h2>${toggles.map(togRow).join('')}</div></div>
49
- <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-4">Access &amp; Limits</h2>
50
- <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Temp Access Period (days)</span></label><input type="number" name="temp_review_access_period" class="input input-solid max-w-full" value="${tempAccess}" min="0"/></div>
51
- <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Max File Size (MB)</span></label><input type="number" name="max_file_size_mb" class="input input-solid max-w-full" value="${fr.max_file_size_mb || 50}" min="1"/></div>
52
- <div class="form-group"><label class="label"><span class="label-text font-semibold">Allowed File Types</span></label><input type="text" name="allowed_types" class="input input-solid max-w-full" value="${fr.allowed_types || 'pdf,doc,docx,xls,xlsx,png,jpg'}"/></div>
53
- </div></div>
54
- </div>
55
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
56
- <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Review Flags</h2>
57
- <div id="review-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(reviewFlags, 'review')}</div>
58
- <div class="flex gap-2"><input type="text" id="new-review-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["review"]' class="btn btn-primary btn-sm">Add</button></div>
59
- </div></div>
60
- <div class="card-clean"><div class="card-clean-body"><h2 class="card-title text-base mb-3">Tender Flags</h2>
61
- <div id="tender-flags-list" class="flex flex-wrap gap-2 mb-3">${flagChips(tenderFlags, 'tender')}</div>
62
- <div class="flex gap-2"><input type="text" id="new-tender-flag" class="input input-solid" placeholder="New flag label..." style="flex:1"/><button type="button" data-action="addFlag" data-args='["tender"]' class="btn btn-primary btn-sm">Add</button></div>
63
- </div></div>
64
- </div>
65
- <button type="submit" class="btn btn-primary">Save File Review Settings</button></form>`;
66
- const script = `${TOAST_SCRIPT}
67
- var _rvFlags=${JSON.stringify(reviewFlags)};
68
- var _tdFlags=${JSON.stringify(tenderFlags)};
69
- function _escFlag(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
70
- function renderFlags(type){const list=type==='review'?_rvFlags:_tdFlags;const el=document.getElementById(type+'-flags-list');if(!el)return;el.innerHTML=list.map(f=>'<span class="pill pill-neutral" style="display:inline-flex;align-items:center;gap:4px">'+_escFlag(f)+'<button type="button" data-action="removeFlag" data-args=\\''+_escFlag(JSON.stringify([type,f]))+'\\' aria-label="Remove flag" style="background:none;border:none;cursor:pointer;font-size:1rem;line-height:1;color:inherit">&times;</button></span>').join('')}
71
- window.addFlag=function(type){const inp=document.getElementById('new-'+type+'-flag');const val=(inp?.value||'').trim();if(!val)return;if(type==='review'){_rvFlags=[..._rvFlags,val]}else{_tdFlags=[..._tdFlags,val]};inp.value='';renderFlags(type)};
72
- window.removeFlag=function(type,flag){if(type==='review'){_rvFlags=_rvFlags.filter(f=>f!==flag)}else{_tdFlags=_tdFlags.filter(f=>f!==flag)};renderFlags(type)};
73
- document.getElementById('file-review-settings-form').addEventListener('submit',async(e)=>{e.preventDefault();const fd=new FormData(e.target);const data={};for(const[k,v]of fd.entries())data[k]=v;document.querySelectorAll('#file-review-settings-form input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});document.querySelectorAll('#file-review-settings-form input[type=number]').forEach(n=>{if(data[n.name])data[n.name]=Number(data[n.name])});data.review_flags=_rvFlags;data.tender_flags=_tdFlags;try{const res=await fetch('/api/admin/settings/file-review',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});if(res.ok){showToast('Settings saved','success')}else{showToast('Save failed','error')}}catch(err){showToast('Error: '+err.message,'error')}})`;
74
- return settingsPage(user, 'File Review Settings', bc('File Review Settings'), content, [script]);
75
- }
76
-
77
- export function renderSettingsMwrPermissions(user, permissions = []) {
78
- const rows = permissions.map(p => `<tr>
79
- <td class="text-sm">${esc(p.entity_type||'-')}</td>
80
- <td class="text-sm">${esc(p.entity_id||'-')}</td>
81
- <td class="text-sm">${esc(p.user_id||'-')}</td>
82
- <td class="text-sm">${esc(p.permission_type||'-')}</td>
83
- <td class="text-sm">${p.granted_at ? new Date(p.granted_at * 1000).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
84
- <td><button type="button" class="btn btn-error btn-xs btn-outline" data-action="revokePermission" data-args='${esc(JSON.stringify([p.id]))}'>Revoke</button></td>
85
- </tr>`).join('');
86
- const content = `${settingsBack()}<div class="flex justify-between items-center mb-6">
87
- <h1 class="text-2xl font-bold">MWR Permissions</h1>
88
- <button class="btn btn-primary btn-sm" data-action="openDialog" data-args='["grant-perm-dialog"]'>+ Grant Permission</button>
89
- </div>
90
- ${inlineTable(['Entity Type','Entity ID','User ID','Permission','Granted','Actions'], rows, 'No permissions configured')}
91
- <div id="grant-perm-dialog" class="dialog-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="gp-dialog-title" data-dialog-close="grant-perm-dialog">
92
- <div class="dialog-panel" style="max-width:min(460px,90vw)">
93
- <div class="dialog-header"><span class="dialog-title" id="gp-dialog-title">Grant Permission</span><button class="dialog-close" data-dialog-close="grant-perm-dialog" aria-label="Close">&times;</button></div>
94
- <div class="dialog-body">
95
- <div class="modal-form-group"><label>Entity Type</label><input id="gp-entity-type" class="form-input" placeholder="e.g. review"/></div>
96
- <div class="modal-form-group"><label>Entity ID</label><input id="gp-entity-id" class="form-input" placeholder="e.g. 1a2b3c"/></div>
97
- <div class="modal-form-group"><label>User ID</label><input id="gp-user-id" class="form-input" placeholder="e.g. user_42"/></div>
98
- <div class="modal-form-group"><label>Permission Type</label><select id="gp-perm-type" class="form-input"><option value="view">view</option><option value="edit">edit</option><option value="admin">admin</option></select></div>
99
- <div id="gp-result" style="display:none;margin-top:8px"></div>
100
- </div>
101
- <div class="dialog-footer">
102
- <button class="btn btn-ghost btn-sm" data-dialog-close="grant-perm-dialog">Cancel</button>
103
- <button class="btn btn-primary btn-sm" data-action="grantPermission">Grant</button>
104
- </div>
105
- </div>
106
- </div>`;
107
- const script = `${TOAST_SCRIPT}window.grantPermission=async function(){var body={entity_type:document.getElementById('gp-entity-type').value.trim(),entity_id:document.getElementById('gp-entity-id').value.trim(),user_id:document.getElementById('gp-user-id').value.trim(),permission_type:document.getElementById('gp-perm-type').value};var res=document.getElementById('gp-result');if(!body.entity_type||!body.entity_id||!body.user_id){res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">All fields required.</div>';return}try{var r=await fetch('/api/mwr/permissions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});var d=await r.json();if(r.ok&&d.success){showToast('Permission granted','success');(window.closeDialog?window.closeDialog('grant-perm-dialog'):document.getElementById('grant-perm-dialog').style.display='none');setTimeout(function(){location.reload()},500)}else{res.style.display='block';res.innerHTML='<div style="color:var(--color-danger);font-size:13px">'+(d.error||'Failed')+'</div>'}}catch(e){showToast('Error: '+e.message,'error')}};window.revokePermission=async function(id){if(!(await window.gmConfirm({title:'Revoke permission',message:'Revoke this permission?',confirmLabel:'Revoke',danger:true})))return;try{var r=await fetch('/api/permission/'+id,{method:'DELETE'});if(r.ok){showToast('Revoked','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error: '+e.message,'error')}}`;
108
- return settingsPage(user, 'MWR Permissions', bc('MWR Permissions'), content, [script]);
109
- }
110
-
111
- export function renderSettingsTemplateManage(user, template = {}, sections = []) {
112
- const sectionRows = sections.map((s, i) => `<tr data-id="${esc(s.id)}">
113
- <td><span class="inline-block w-5 h-5 rounded" style="background:${s.color || '#B0B0B0'}"></span></td>
114
- <td class="text-sm font-medium">${esc(s.name || '-')}</td>
115
- <td class="text-sm text-base-content/50">${s.order ?? i}</td>
116
- <td><div class="flex gap-1">
117
- <button data-action="editTplSection" data-args='["${esc(s.id)}","${esc((s.name||'').replace(/"/g,'&quot;'))}","${esc(s.color||'#B0B0B0')}"]' class="btn btn-ghost btn-xs">Edit</button>
118
- <button data-action="deleteTplSection" data-args='["${esc(s.id)}"]' class="btn btn-error btn-xs btn-outline">Delete</button>
119
- </div></td>
120
- </tr>`).join('');
121
- const tplBc = [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { href: '/admin/settings/templates', label: 'Templates' }, { label: template.name || 'Template' }];
122
- const content = `${settingsBack()}<div class="mb-6">
123
- <h1 class="text-2xl font-bold">${esc(template.name || 'Template')}</h1>
124
- <p class="text-sm text-base-content/50 mt-1">Manage template sections and configuration</p>
125
- </div>
126
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
127
- <div class="card-clean"><div class="card-clean-body">
128
- <h2 class="card-title text-base mb-4">Template Info</h2>
129
- <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Name</span></label><input type="text" id="tpl-name" class="input input-solid max-w-full" value="${esc(template.name || '')}"/></div>
130
- <div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Type</span></label><select id="tpl-type" class="select select-solid max-w-full"><option value="standard" ${template.type==='standard'?'selected':''}>Standard</option><option value="checklist" ${template.type==='checklist'?'selected':''}>Checklist</option><option value="audit" ${template.type==='audit'?'selected':''}>Audit</option></select></div>
131
- <div class="form-group mb-4"><label class="label cursor-pointer justify-start gap-3"><input type="checkbox" id="tpl-active" class="checkbox checkbox-primary" ${template.is_active?'checked':''}/><span class="label-text">Active</span></label></div>
132
- <button data-action="saveTplInfo" class="btn btn-primary btn-sm">Save Template Info</button>
133
- </div></div>
134
- <div class="card-clean"><div class="card-clean-body">
135
- <div class="flex justify-between items-center mb-4">
136
- <h2 class="card-title text-base">Sections</h2>
137
- <button data-action="addTplSection" class="btn btn-primary btn-sm">+ Add Section</button>
138
- </div>
139
- ${inlineTable(['Color', 'Name', 'Order', 'Actions'], sectionRows, 'No sections defined')}
140
- </div></div>
141
- </div>
142
- <div id="tpl-section-form" class="hidden card-clean" style="margin-top:1rem"><div class="card-clean-body">
143
- <div class="flex flex-wrap gap-4 items-end">
144
- <div class="form-group flex-1 min-w-40"><label class="label"><span class="label-text font-semibold">Name</span></label><input type="text" id="tpl-sec-name" class="input input-solid max-w-full" placeholder="Section name"/></div>
145
- <div class="form-group"><label class="label"><span class="label-text font-semibold">Color</span></label><input type="color" id="tpl-sec-color" value="#B0B0B0" class="input input-solid" style="height:42px;width:60px;padding:4px"/></div>
146
- <div class="flex gap-2"><button data-action="saveTplSection" class="btn btn-primary btn-sm">Save</button><button data-action="cancelTplSection" class="btn btn-ghost btn-sm">Cancel</button></div>
147
- </div>
148
- <input type="hidden" id="tpl-sec-id" value=""/>
149
- </div></div>`;
150
- const script = `${TOAST_SCRIPT}
151
- var tplId='${esc(template.id || '')}';
152
- window.saveTplInfo=async function(){var body={name:document.getElementById('tpl-name').value,type:document.getElementById('tpl-type').value,is_active:document.getElementById('tpl-active').checked?1:0};try{var res=await fetch('/api/review_template/'+tplId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast('Template updated','success')}else{showToast('Update failed','error')}}catch(e){showToast('Error','error')}};
153
- window.addTplSection=function(){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value='';document.getElementById('tpl-sec-name').value=''};
154
- window.editTplSection=function(id,name,color){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value=id;document.getElementById('tpl-sec-name').value=name;document.getElementById('tpl-sec-color').value=color};
155
- window.cancelTplSection=function(){document.getElementById('tpl-section-form').classList.add('hidden')};
156
- window.saveTplSection=async function(){var id=document.getElementById('tpl-sec-id').value;var body={name:document.getElementById('tpl-sec-name').value,color:document.getElementById('tpl-sec-color').value,review_template_id:tplId};if(!body.name){showToast('Name required','error');return}var url=id?'/api/review_template_section/'+id:'/api/review_template_section';var method=id?'PUT':'POST';try{var res=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};
157
- window.deleteTplSection=async function(id){if(!(await window.gmConfirm({title:'Delete Section',message:'Delete this section? This cannot be undone.',confirmLabel:'Delete',danger:true})))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
158
- return settingsPage(user, `Manage Template - ${esc(template.name || 'Template')}`, tplBc, content, [script]);
159
- }
File without changes