/* * Admin module — self-contained admin console logic for the Check List PoC. * * Manages: * • Sidebar navigation (category expand/collapse, panel switching) * • Image policy editor (Settings › Image Policy) — server-backed via API * • Template settings (Settings › Template) — server-backed via API with list view (edit/remove) * • Task settings (Settings › Task) — server-backed via API with list view (edit/remove) * • Users CRUD (Users — single panel list-first with add/edit form) * • Sites CRUD (Sites — single panel list-first with add/edit form) * • CL Records CRUD (Check Lists › Records — list-first with add/edit form) * • CL Templates CRUD (Check Lists › Templates — list-first with add/edit form) * • Tasks CRUD (Reports — list-first with add/edit form) * * Data notes: * • Sub Categories require a parent Category * • Processes require a parent Project * • Records: Status, Handled By, Comment are disabled placeholders * • Records: "Image Required" checkbox determines if user must add images * • Sites: Host is dropdown (OBE, PXS) * • Users: field is Email (not Username), Role options: CW, ANT, CW/ANT * • Templates: Scope dropdown (CW, ANT, ANT_CPsite), dates use date picker */ import { fetchJson } from './api.js'; import { dbPut, dbGet } from './db.js'; import { STORE_CONFIG } from './constants.js'; import { validateImageRulesPayload } from './validation.js'; /* ── API base path for admin entity CRUD ────────────────────────────────── */ const API = '/admin'; /* ── Admin state ────────────────────────────────────────────────────────── */ const admin = { imageRules: null, users: [], sites: [], clRecords: [], clTemplates: [], tasks: [], templateSettings: { categories: [], // [{id, value}] subCategories: [], // [{id, value, categoryId}] severities: [], // [{id, value}] statuses: [], // [{id, value}] handledBy: [] // [{id, value}] }, taskSettings: { projects: [], // [{id, value}] processes: [] // [{id, value, projectId}] }, appConfig: {}, editingId: null, editingType: null }; /* ── Persistence helpers (MariaDB via REST API, IndexedDB as offline cache) ── */ /* * Loads all admin entity data from the server (MariaDB) via the bulk endpoint, * and caches in IndexedDB for offline use. */ async function loadFromServer() { try { const data = await fetchJson(`${API}/all`); admin.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] }; admin.taskSettings = data.taskSettings || { projects: [], processes: [] }; admin.users = data.users || []; admin.sites = data.sites || []; admin.clRecords = data.clRecords || []; admin.clTemplates = data.clTemplates || []; admin.tasks = data.tasks || []; admin.appConfig = data.appConfig || {}; /* Cache in IndexedDB for offline access */ await dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {}); return true; } catch (err) { console.warn('Failed to load admin data from server, using IndexedDB cache', err); return false; } } /* * Loads from IndexedDB cache (offline fallback). */ async function loadFromCache() { try { const row = await dbGet(STORE_CONFIG, 'admin_all'); const data = row?.value; if (!data) return; admin.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] }; admin.taskSettings = data.taskSettings || { projects: [], processes: [] }; admin.users = data.users || []; admin.sites = data.sites || []; admin.clRecords = data.clRecords || []; admin.clTemplates = data.clTemplates || []; admin.tasks = data.tasks || []; admin.appConfig = data.appConfig || {}; } catch { /* ignore */ } } /* * Caches current admin state to IndexedDB. */ function cacheState() { const data = { templateSettings: admin.templateSettings, taskSettings: admin.taskSettings, users: admin.users, sites: admin.sites, clRecords: admin.clRecords, clTemplates: admin.clTemplates, tasks: admin.tasks, appConfig: admin.appConfig }; dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {}); } /* ═══════════════════════════════════════════════════════════════════════════ * Initialization * ═══════════════════════════════════════════════════════════════════════════ */ export async function initAdmin({ imageRules }) { admin.imageRules = imageRules; /* Load from server (MariaDB) first; data is also cached in IndexedDB. */ if (navigator.onLine) { await loadFromServer(); } else { await loadFromCache(); } initNavigation(); bindImagePolicyForm(); bindTemplateSettingsForm(); bindTaskSettingsForm(); bindGeoSettingsForm(); bindUserForm(); bindSiteForm(); bindClRecordForm(); bindClTemplateForm(); bindTaskForm(); renderImagePolicy(); renderTemplateSettings(); renderTaskSettings(); renderGeoSettings(); renderUserList(); renderSiteList(); renderClRecordList(); renderClTemplateList(); renderTaskList(); updateConnectionBadge(); window.addEventListener('online', updateConnectionBadge); window.addEventListener('offline', updateConnectionBadge); /* Sync */ document.getElementById('adminSyncBtn')?.addEventListener('click', async () => { const btn = document.getElementById('adminSyncBtn'); if (btn) { btn.disabled = true; btn.innerHTML = 'Syncing…'; } try { await loadFromServer(); renderImagePolicy(); renderTemplateSettings(); renderTaskSettings(); renderGeoSettings(); renderUserList(); renderSiteList(); refreshRecordDropdowns(); renderClRecordList(); renderClTemplateRecordSelection(); renderClTemplateList(); refreshTaskDropdowns(); renderTaskList(); } finally { if (btn) { btn.disabled = false; btn.innerHTML = 'Sync'; } } }); /* Logout */ document.getElementById('adminLogoutBtn')?.addEventListener('click', async () => { try { await fetch('/api/v1/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ } window.location.href = '/'; }); if (navigator.onLine) syncImageRules(); } /* ═══════════════════════════════════════════════════════════════════════════ * Navigation * ═══════════════════════════════════════════════════════════════════════════ */ function initNavigation() { const nav = document.getElementById('adminNav'); if (!nav) return; nav.querySelectorAll('.admin-nav-cat-btn').forEach((btn) => { btn.addEventListener('click', () => { const cat = btn.closest('.admin-nav-cat'); cat.classList.toggle('is-open'); const arrow = btn.querySelector('.nav-arrow'); if (arrow) arrow.textContent = cat.classList.contains('is-open') ? '▾' : '▸'; }); }); nav.querySelectorAll('.admin-nav-item').forEach((item) => { item.addEventListener('click', () => { activateNavItem(item); showPanel(item.dataset.panel); }); }); } function activateNavItem(item) { const nav = document.getElementById('adminNav'); nav.querySelectorAll('.admin-nav-item').forEach((i) => i.classList.remove('is-active')); item.classList.add('is-active'); const cat = item.closest('.admin-nav-cat'); if (cat && !cat.classList.contains('is-open')) { cat.classList.add('is-open'); const arrow = cat.querySelector('.nav-arrow'); if (arrow) arrow.textContent = '▾'; } } function showPanel(panelId) { document.querySelectorAll('.admin-panel').forEach((p) => p.classList.remove('admin-panel-active')); const target = document.getElementById('panel-' + panelId); if (target) target.classList.add('admin-panel-active'); switch (panelId) { case 'users': renderUserList(); break; case 'sites': renderSiteList(); break; case 'cl-records': refreshRecordDropdowns(); renderClRecordList(); break; case 'cl-templates': renderClTemplateRecordSelection(); renderClTemplateList(); break; case 'reports': refreshTaskDropdowns(); renderTaskList(); break; case 'settings-template': renderTemplateSettings(); break; case 'settings-task': renderTaskSettings(); break; case 'settings-geo': renderGeoSettings(); break; } } function navigateToPanel(panelId) { const nav = document.getElementById('adminNav'); const navItem = nav.querySelector(`[data-panel="${panelId}"]`); if (navItem) activateNavItem(navItem); showPanel(panelId); } /* ═══════════════════════════════════════════════════════════════════════════ * Connection badge * ═══════════════════════════════════════════════════════════════════════════ */ function updateConnectionBadge() { const badge = document.getElementById('connectionBadge'); if (!badge) return; badge.textContent = navigator.onLine ? 'Online' : 'Offline'; badge.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`; } /* ═══════════════════════════════════════════════════════════════════════════ * SETTINGS › IMAGE POLICY * ═══════════════════════════════════════════════════════════════════════════ */ async function syncImageRules() { try { const rules = await fetchJson('/config/image-rules'); admin.imageRules = rules; await dbPut(STORE_CONFIG, { key: 'imageRules', value: rules }); renderImagePolicy(); } catch (err) { console.error('Image-rules sync failed', err); } } function bindImagePolicyForm() { const form = document.getElementById('adminImageRulesForm'); if (!form) return; form.addEventListener('submit', (e) => { e.preventDefault(); void saveImagePolicy(); }); document.getElementById('resetImageRulesButton')?.addEventListener('click', () => populateImagePolicyForm(admin.imageRules)); } function renderImagePolicy() { populateImagePolicyForm(admin.imageRules); const syncState = document.getElementById('adminSyncState'); const mimeTypes = document.getElementById('adminPolicyMimeTypes'); const fileSize = document.getElementById('adminPolicyFileSize'); const opt = document.getElementById('adminPolicyOptimization'); const limits = document.getElementById('adminPolicyLimits'); if (!admin.imageRules) { if (syncState) { syncState.textContent = 'No image rules loaded'; syncState.className = 'badge badge-offline'; } [mimeTypes, fileSize, opt, limits].forEach((el) => { if (el) el.textContent = '-'; }); return; } if (syncState) { syncState.textContent = navigator.onLine ? 'Live configuration' : 'Offline (cached)'; syncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`; } if (mimeTypes) mimeTypes.textContent = (admin.imageRules.allowedMimeTypes || []).join(', '); if (fileSize) fileSize.textContent = fmtKb(admin.imageRules.maxFileSizeBytes); if (opt) opt.textContent = `${admin.imageRules.oversizeBehavior}, quality ${admin.imageRules.jpegQuality || admin.imageRules.imageQuality || '-'}%`; if (limits) limits.textContent = `${admin.imageRules.maxWidthPx}×${admin.imageRules.maxHeightPx}px`; } function populateImagePolicyForm(rules) { if (!rules) return; /* Checkboxes for MIME types */ const checkboxes = document.querySelectorAll('#adminMimeCheckboxes input[name="mimeType"]'); const allowed = new Set(rules.allowedMimeTypes || []); checkboxes.forEach((cb) => { cb.checked = allowed.has(cb.value); }); const el = (id) => document.getElementById(id); if (el('adminMaxFileSizeKb')) el('adminMaxFileSizeKb').value = String(Math.round((rules.maxFileSizeBytes || 0) / 1024)); if (el('adminMaxWidthPx')) el('adminMaxWidthPx').value = String(rules.maxWidthPx || ''); if (el('adminMaxHeightPx')) el('adminMaxHeightPx').value = String(rules.maxHeightPx || ''); if (el('adminImageQuality')) el('adminImageQuality').value = String(rules.jpegQuality || rules.imageQuality || ''); if (el('adminOversizeBehavior')) el('adminOversizeBehavior').value = rules.oversizeBehavior || 'auto_optimize'; } async function saveImagePolicy() { if (!navigator.onLine) { showToast('Go online to save.', 'warning'); return; } const el = (id) => document.getElementById(id); const mimeTypes = [...document.querySelectorAll('#adminMimeCheckboxes input[name="mimeType"]:checked')].map((cb) => cb.value); const payload = { name: 'default', allowedMimeTypes: mimeTypes, maxFileSizeBytes: Number(el('adminMaxFileSizeKb').value) * 1024, maxWidthPx: Number(el('adminMaxWidthPx').value), maxHeightPx: Number(el('adminMaxHeightPx').value), jpegQuality: Number(el('adminImageQuality').value), oversizeBehavior: el('adminOversizeBehavior').value, maxAttachmentsPerField: 5 }; const msg = validateImageRulesPayload(payload); if (msg) { showToast(msg, 'error'); return; } const btn = document.getElementById('saveImageRulesButton'); btn.disabled = true; try { const result = await fetchJson('/config/image-rules', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); admin.imageRules = result; await dbPut(STORE_CONFIG, { key: 'imageRules', value: result }); renderImagePolicy(); showToast('Image policy saved.', 'success'); } catch (err) { console.error(err); showToast(err.message || 'Save failed.', 'error'); } finally { btn.disabled = false; } } /* ═══════════════════════════════════════════════════════════════════════════ * SETTINGS › TEMPLATE — list-based editing with parent for SubCategories * ═══════════════════════════════════════════════════════════════════════════ */ function bindTemplateSettingsForm() { on('click', '[data-ts-action="add-cat"]', openAddCategoryModal); on('click', '[data-ts-action="add-subcat"]', openAddSubCategoryModal); on('click', '[data-ts-action="add-sev"]', openAddSeverityModal); on('click', '[data-ts-action="add-status"]', openAddStatusModal); on('click', '[data-ts-action="add-handled"]', openAddHandledByModal); } /* ── Settings modal helpers ─────────────────────────────────────────────── */ /** * Generic helper: open the shared settings modal with custom body HTML. * onSave(bodyEl) is called with the modal body element when Save is clicked. */ function openSettingsModal(title, bodyHtml, onSave) { document.getElementById('settingsItemModalLabel').textContent = title; document.getElementById('settingsItemModalBody').innerHTML = bodyHtml; const saveBtn = document.getElementById('settingsItemModalSave'); const newSaveBtn = saveBtn.cloneNode(true); saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('settingsItemModal')); newSaveBtn.addEventListener('click', () => { const handled = onSave(document.getElementById('settingsItemModalBody')); if (handled) modal.hide(); }); modal.show(); } function openAddCategoryModal() { openSettingsModal('Add Category', `
`, (body) => { const val = body.querySelector('#sltModalVal').value.trim(); if (!val) { showToast('Value is required.', 'warning'); return false; } if (admin.templateSettings.categories.some((c) => c.value === val)) { showToast('Category already exists.', 'warning'); return false; } fetchJson(`${API}/categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) }) .then((created) => { admin.templateSettings.categories.push(created); cacheState(); renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add category.', 'error')); return true; } ); } function openAddSubCategoryModal() { const catOpts = admin.templateSettings.categories.map((c) => ``).join(''); openSettingsModal('Add Sub Category', `
`, (body) => { const val = body.querySelector('#sltModalVal').value.trim(); const categoryId = Number(body.querySelector('#sltModalParent').value); if (!val) { showToast('Value is required.', 'warning'); return false; } if (!categoryId) { showToast('Select a parent category.', 'warning'); return false; } fetchJson(`${API}/sub-categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, categoryId }) }) .then((created) => { admin.templateSettings.subCategories.push(created); cacheState(); renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add sub-category.', 'error')); return true; } ); } function openAddSeverityModal() { openSettingsModal('Add Severity', `
`, (body) => { const val = body.querySelector('#sltModalVal').value.trim(); if (!val) { showToast('Value is required.', 'warning'); return false; } if (admin.templateSettings.severities.some((i) => i.value === val)) { showToast('Already exists.', 'warning'); return false; } const colorEn = body.querySelector('#sltModalColorEn')?.checked; const color = colorEn ? body.querySelector('#sltModalColor')?.value : null; fetchJson(`${API}/severities`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, color }) }) .then((created) => { admin.templateSettings.severities.push(created); cacheState(); renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add severity.', 'error')); return true; } ); /* Show/hide color picker when checkbox is toggled */ const cb = document.getElementById('sltModalColorEn'); const cp = document.getElementById('sltModalColor'); if (cb && cp) cb.addEventListener('change', () => { cp.style.display = cb.checked ? 'inline-block' : 'none'; }); } function openAddStatusModal() { openSettingsModal('Add Status', `
`, (body) => { const val = body.querySelector('#sltModalVal').value.trim(); if (!val) { showToast('Value is required.', 'warning'); return false; } if (admin.templateSettings.statuses.some((i) => i.value === val)) { showToast('Already exists.', 'warning'); return false; } const requireHandledBy = body.querySelector('#sltModalReqHB').checked; const requireComment = body.querySelector('#sltModalReqCmt').checked; const colorEn = body.querySelector('#sltModalColorEn')?.checked; const color = colorEn ? body.querySelector('#sltModalColor')?.value : null; fetchJson(`${API}/statuses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, requireHandledBy, requireComment, color }) }) .then((created) => { admin.templateSettings.statuses.push(created); cacheState(); renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add status.', 'error')); return true; } ); /* Show/hide color picker when checkbox is toggled */ const cb = document.getElementById('sltModalColorEn'); const cp = document.getElementById('sltModalColor'); if (cb && cp) cb.addEventListener('change', () => { cp.style.display = cb.checked ? 'inline-block' : 'none'; }); } function openAddHandledByModal() { openSettingsModal('Add Handled By', `
`, (body) => { const val = body.querySelector('#sltModalVal').value.trim(); if (!val) { showToast('Value is required.', 'warning'); return false; } if (admin.templateSettings.handledBy.some((i) => i.value === val)) { showToast('Already exists.', 'warning'); return false; } fetchJson(`${API}/handled-by`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) }) .then((created) => { admin.templateSettings.handledBy.push(created); cacheState(); renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add handler.', 'error')); return true; } ); } function openAddProjectModal() { openSettingsModal('Add Project', `
`, (body) => { const val = body.querySelector('#sltModalVal').value.trim(); if (!val) { showToast('Value is required.', 'warning'); return false; } if (admin.taskSettings.projects.some((p) => p.value === val)) { showToast('Already exists.', 'warning'); return false; } fetchJson(`${API}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) }) .then((created) => { admin.taskSettings.projects.push(created); cacheState(); renderTaskSettings(); }) .catch((err) => showToast(err.message || 'Failed to add project.', 'error')); return true; } ); } function openAddProcessModal() { const projOpts = admin.taskSettings.projects.map((p) => ``).join(''); openSettingsModal('Add Process', `
`, (body) => { const val = body.querySelector('#sltModalVal').value.trim(); const projectId = Number(body.querySelector('#sltModalParent').value); if (!val) { showToast('Value is required.', 'warning'); return false; } if (!projectId) { showToast('Select a parent project.', 'warning'); return false; } fetchJson(`${API}/processes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, projectId }) }) .then((created) => { admin.taskSettings.processes.push(created); cacheState(); renderTaskSettings(); }) .catch((err) => showToast(err.message || 'Failed to add process.', 'error')); return true; } ); } function renderTemplateSettings() { /* ── Categories ─────────────────────────────────────────────────────── */ renderSettingList('tsCatList', admin.templateSettings.categories, (item) => { if (!confirm('Delete this category? All sub-categories under it will also be removed.')) return; fetchJson(`${API}/categories/${item.id}`, { method: 'DELETE' }).then(() => { admin.templateSettings.categories = admin.templateSettings.categories.filter((c) => c.id !== item.id); admin.templateSettings.subCategories = admin.templateSettings.subCategories.filter((s) => s.categoryId !== item.id); cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); }, (item, newVal) => { fetchJson(`${API}/categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => { item.value = newVal; cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); } ); /* ── Sub Categories — Value + Parent Category columns ───────────────── */ const catName = (categoryId) => admin.templateSettings.categories.find((c) => c.id === categoryId)?.value || '?'; renderSettingTable('tsSubCatList', admin.templateSettings.subCategories, [ { header: 'Value', cell: (item) => esc(item.value) }, { header: 'Parent Category', cell: (item) => esc(catName(item.categoryId)), editHtml: (item) => { const opts = admin.templateSettings.categories.map((c) => `` ).join(''); return ``; }, collect: (id) => Number(document.querySelector(`[data-slt-subcat-par="${id}"]`)?.value || 0) } ], (item) => { if (!confirm('Delete this sub-category?')) return; fetchJson(`${API}/sub-categories/${item.id}`, { method: 'DELETE' }).then(() => { admin.templateSettings.subCategories = admin.templateSettings.subCategories.filter((s) => s.id !== item.id); cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); }, (item, newVal, [categoryId]) => { const resolvedCatId = categoryId || item.categoryId; if (!resolvedCatId) { showToast('Parent category is required.', 'warning'); return; } fetchJson(`${API}/sub-categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, categoryId: resolvedCatId }) }).then(() => { item.value = newVal; item.categoryId = resolvedCatId; cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); } ); /* ── Severities — Value + Color columns ────────────────────────────── */ renderSettingTable('tsSevList', admin.templateSettings.severities, [ { header: 'Value', cell: (item) => esc(item.value) }, { header: 'Color', cell: (item) => item.color ? `${esc(item.color)}` : '', editHtml: (item) => { const hasColor = !!item.color; return `
`; }, collect: (id) => { const cb = document.querySelector(`[data-slt-color-en="${id}"]`); const cp = document.querySelector(`[data-slt-color="${id}"]`); return (cb?.checked && cp?.value) ? cp.value : null; } } ], (item) => { if (!confirm('Delete this severity?')) return; fetchJson(`${API}/severities/${item.id}`, { method: 'DELETE' }).then(() => { admin.templateSettings.severities = admin.templateSettings.severities.filter((i) => i.id !== item.id); cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); }, (item, newVal, [color]) => { fetchJson(`${API}/severities/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, color }) }).then(() => { item.value = newVal; item.color = color; cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); } ); /* Bind color-enable checkboxes inside the rendered severity list */ document.querySelectorAll('[data-slt-color-en]').forEach((cb) => { const id = cb.dataset.sltColorEn; const cp = document.querySelector(`[data-slt-color="${id}"]`); if (cp) cb.addEventListener('change', () => { cp.style.display = cb.checked ? 'inline-block' : 'none'; }); }); /* ── Statuses — Value + Handled By req. + Comment req. + Color columns ─ */ renderSettingTable('tsStatusList', admin.templateSettings.statuses, [ { header: 'Value', cell: (item) => esc(item.value) }, { header: 'Handled By req.', cell: (item) => item.requireHandledBy ? 'Yes' : '', editHtml: (item) => `
`, collect: (id) => document.querySelector(`[data-slt-hb="${id}"]`)?.checked || false }, { header: 'Comment req.', cell: (item) => item.requireComment ? 'Yes' : '', editHtml: (item) => `
`, collect: (id) => document.querySelector(`[data-slt-cmt="${id}"]`)?.checked || false }, { header: 'Color', cell: (item) => item.color ? `${esc(item.color)}` : '', editHtml: (item) => { const hasColor = !!item.color; return `
`; }, collect: (id) => { const cb = document.querySelector(`[data-slt-status-color-en="${id}"]`); const cp = document.querySelector(`[data-slt-status-color="${id}"]`); return (cb?.checked && cp?.value) ? cp.value : null; } } ], (item) => { if (!confirm('Delete this status?')) return; fetchJson(`${API}/statuses/${item.id}`, { method: 'DELETE' }).then(() => { admin.templateSettings.statuses = admin.templateSettings.statuses.filter((i) => i.id !== item.id); cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); }, (item, newVal, [reqHB, reqCmt, color]) => { fetchJson(`${API}/statuses/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, requireHandledBy: reqHB, requireComment: reqCmt, color }) }).then(() => { item.value = newVal; item.requireHandledBy = reqHB; item.requireComment = reqCmt; item.color = color; cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); } ); /* ── Handled By ─────────────────────────────────────────────────────── */ renderSettingList('tsHandledList', admin.templateSettings.handledBy, (item) => { if (!confirm('Delete this handler?')) return; fetchJson(`${API}/handled-by/${item.id}`, { method: 'DELETE' }).then(() => { admin.templateSettings.handledBy = admin.templateSettings.handledBy.filter((i) => i.id !== item.id); cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); }, (item, newVal) => { fetchJson(`${API}/handled-by/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => { item.value = newVal; cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); } ); } /* ═══════════════════════════════════════════════════════════════════════════ * SETTINGS › TASK — Projects + Processes (parent required) * ═══════════════════════════════════════════════════════════════════════════ */ function bindTaskSettingsForm() { on('click', '[data-tk-action="add-proj"]', openAddProjectModal); on('click', '[data-tk-action="add-proc"]', openAddProcessModal); } function renderTaskSettings() { /* ── Projects ───────────────────────────────────────────────────────── */ renderSettingList('tkProjList', admin.taskSettings.projects, (item) => { if (!confirm('Delete this project? All processes under it will also be removed.')) return; fetchJson(`${API}/projects/${item.id}`, { method: 'DELETE' }).then(() => { admin.taskSettings.projects = admin.taskSettings.projects.filter((p) => p.id !== item.id); admin.taskSettings.processes = admin.taskSettings.processes.filter((p) => p.projectId !== item.id); cacheState(); renderTaskSettings(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); }, (item, newVal) => { fetchJson(`${API}/projects/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => { item.value = newVal; cacheState(); renderTaskSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); } ); /* ── Processes — Value + Parent Project columns ─────────────────────── */ const projName = (id) => admin.taskSettings.projects.find((p) => p.id === id)?.value || '?'; renderSettingTable('tkProcList', admin.taskSettings.processes, [ { header: 'Value', cell: (item) => esc(item.value) }, { header: 'Parent Project', cell: (item) => esc(projName(item.projectId)), editHtml: (item) => { const opts = admin.taskSettings.projects.map((p) => `` ).join(''); return ``; }, collect: (id) => Number(document.querySelector(`[data-slt-proc-par="${id}"]`)?.value || 0) } ], (item) => { if (!confirm('Delete this process?')) return; fetchJson(`${API}/processes/${item.id}`, { method: 'DELETE' }).then(() => { admin.taskSettings.processes = admin.taskSettings.processes.filter((p) => p.id !== item.id); cacheState(); renderTaskSettings(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); }, (item, newVal, [projectId]) => { const resolvedProjId = projectId || item.projectId; if (!resolvedProjId) { showToast('Parent project is required.', 'warning'); return; } fetchJson(`${API}/processes/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, projectId: resolvedProjId }) }).then(() => { item.value = newVal; item.projectId = resolvedProjId; cacheState(); renderTaskSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); } ); } /* ═══════════════════════════════════════════════════════════════════════════ * USERS — list-first with inline form * ═══════════════════════════════════════════════════════════════════════════ */ function bindUserForm() { const form = document.getElementById('userForm'); if (!form) return; form.addEventListener('submit', (e) => { e.preventDefault(); saveUser(); }); document.getElementById('showUserFormBtn')?.addEventListener('click', () => { clearUserForm(); showModal('userFormModal'); }); /* Reset editing state when modal is closed without saving */ document.getElementById('userFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing()); } function showSection(id) { const s = document.getElementById(id); if (s) s.style.display = ''; } function hideSection(id) { const s = document.getElementById(id); if (s) s.style.display = 'none'; } function showModal(id) { bootstrap.Modal.getOrCreateInstance(document.getElementById(id)).show(); } function hideModal(id) { bootstrap.Modal.getOrCreateInstance(document.getElementById(id)).hide(); } function saveUser() { const el = (id) => document.getElementById(id); const data = { email: el('userEmail').value.trim(), password: el('userPassword').value, name: el('userName').value.trim(), familyName: el('userFamilyName').value.trim(), company: el('userCompany').value.trim(), role: el('userRole').value }; if (!data.email || !data.name || !data.familyName || !data.role) { showToast('Email, Name, Family Name, and Role are required.', 'warning'); return; } if (admin.editingType === 'user' && admin.editingId) { fetchJson(`${API}/users/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((updated) => { const idx = admin.users.findIndex((u) => u.id === admin.editingId); if (idx >= 0) admin.users[idx] = updated; resetEditing(); cacheState(); hideUserForm(); renderUserList(); showToast('User saved.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } else { fetchJson(`${API}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((created) => { admin.users.push(created); cacheState(); hideUserForm(); renderUserList(); showToast('User saved.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } } function clearUserForm() { document.getElementById('userForm')?.reset(); document.getElementById('userFormHeading').textContent = 'Add User'; resetEditing(); } function hideUserForm() { clearUserForm(); hideModal('userFormModal'); } function editUser(id) { const u = admin.users.find((x) => x.id === id); if (!u) return; admin.editingId = id; admin.editingType = 'user'; const el = (eid) => document.getElementById(eid); el('userEmail').value = u.email || ''; el('userPassword').value = u.password || ''; el('userName').value = u.name || ''; el('userFamilyName').value = u.familyName || ''; el('userCompany').value = u.company || ''; el('userRole').value = u.role || ''; el('userFormHeading').textContent = 'Edit User'; showModal('userFormModal'); } function deleteUser(id) { if (!confirm('Delete this user?')) return; fetchJson(`${API}/users/${id}`, { method: 'DELETE' }).then(() => { admin.users = admin.users.filter((u) => u.id !== id); admin.tasks = admin.tasks.filter((t) => t.userId !== id); cacheState(); renderUserList(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); } function renderUserList() { const container = document.getElementById('userListContainer'); if (!container) return; if (!admin.users.length) { container.innerHTML = '

No users

Click "Add User" to create one.

'; return; } const rows = admin.users.map((u) => { const taskCount = admin.tasks.filter((t) => t.userId === u.id).length; const taskBadge = taskCount > 0 ? `${taskCount}` : `0`; return ` ${esc(u.email)}${esc(u.name)}${esc(u.familyName)} ${esc(u.company || '-')}${esc(u.role || '-')} ${taskBadge} `; }).join(''); container.innerHTML = `${rows}
EmailNameFamily NameCompanyRoleTasksActions
`; container.querySelectorAll('[data-edit-user]').forEach((b) => b.addEventListener('click', () => editUser(Number(b.dataset.editUser)))); container.querySelectorAll('[data-delete-user]').forEach((b) => b.addEventListener('click', () => deleteUser(Number(b.dataset.deleteUser)))); } /* ═══════════════════════════════════════════════════════════════════════════ * SETTINGS › GEO LOCATION — configurable geo-fence radius * ═══════════════════════════════════════════════════════════════════════════ */ function bindGeoSettingsForm() { const form = document.getElementById('geoSettingsForm'); if (!form) return; form.addEventListener('submit', async (e) => { e.preventDefault(); const val = parseInt(document.getElementById('geoFenceRadius').value, 10); if (isNaN(val) || val < 1) { showToast('Enter a valid radius (≥ 1 m).', 'warning'); return; } try { await fetchJson(`${API}/app-config/geo_fence_radius_m`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: String(val) }) }); admin.appConfig.geo_fence_radius_m = String(val); cacheState(); showToast('Geo fence radius saved.', 'success'); } catch (err) { showToast(err.message || 'Save failed.', 'error'); } }); } function renderGeoSettings() { const el = document.getElementById('geoFenceRadius'); if (!el) return; el.value = admin.appConfig?.geo_fence_radius_m ?? '50'; } /* ═══════════════════════════════════════════════════════════════════════════ * SITES — list-first with inline form; Host is dropdown (OBE, PXS) * ═══════════════════════════════════════════════════════════════════════════ */ /* Leaflet map instance for the site modal picker (one instance, re-used). */ let _siteMap = null; let _siteMarker = null; function bindSiteForm() { const form = document.getElementById('siteForm'); if (!form) return; form.addEventListener('submit', (e) => { e.preventDefault(); saveSite(); }); document.getElementById('showSiteFormBtn')?.addEventListener('click', () => { clearSiteForm(); showModal('siteFormModal'); }); /* Init Leaflet map once the modal is fully visible */ document.getElementById('siteFormModal')?.addEventListener('shown.bs.modal', () => { const mapEl = document.getElementById('siteMapPicker'); if (!mapEl) return; if (!_siteMap) { /* Default view: centre of Europe */ _siteMap = L.map('siteMapPicker').setView([51.5, 10], 4); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(_siteMap); _siteMap.on('click', (ev) => { const { lat, lng } = ev.latlng; _setSiteMapMarker(lat, lng); document.getElementById('siteLat').value = lat.toFixed(7); document.getElementById('siteLng').value = lng.toFixed(7); }); /* Sync typed lat/lng inputs → marker */ ['siteLat', 'siteLng'].forEach((id) => { document.getElementById(id)?.addEventListener('change', _syncMapFromInputs); }); } else { _siteMap.invalidateSize(); } /* If editing a site that already has coords, position the map there */ const lat = parseFloat(document.getElementById('siteLat').value); const lng = parseFloat(document.getElementById('siteLng').value); if (!isNaN(lat) && !isNaN(lng)) { _setSiteMapMarker(lat, lng); _siteMap.setView([lat, lng], 14); } }); document.getElementById('siteFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing()); } function _setSiteMapMarker(lat, lng) { if (!_siteMap) return; if (_siteMarker) { _siteMarker.setLatLng([lat, lng]); } else { _siteMarker = L.marker([lat, lng], { draggable: true }).addTo(_siteMap); _siteMarker.on('dragend', () => { const pos = _siteMarker.getLatLng(); document.getElementById('siteLat').value = pos.lat.toFixed(7); document.getElementById('siteLng').value = pos.lng.toFixed(7); }); } } function _syncMapFromInputs() { if (!_siteMap) return; const lat = parseFloat(document.getElementById('siteLat').value); const lng = parseFloat(document.getElementById('siteLng').value); if (!isNaN(lat) && !isNaN(lng)) { _setSiteMapMarker(lat, lng); _siteMap.setView([lat, lng], Math.max(_siteMap.getZoom(), 12)); } } function saveSite() { const el = (id) => document.getElementById(id); const latVal = el('siteLat').value.trim(); const lngVal = el('siteLng').value.trim(); const data = { siteCode: el('siteSiteCode').value.trim(), host: el('siteHost').value, obeSiteCode: el('siteObe').value.trim(), pxsSiteCode: el('sitePxs').value.trim(), latitude: latVal !== '' ? parseFloat(latVal) : null, longitude: lngVal !== '' ? parseFloat(lngVal) : null }; if (!data.siteCode) { showToast('Site Code is required.', 'warning'); return; } if (admin.editingType === 'site' && admin.editingId) { fetchJson(`${API}/sites/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((updated) => { const idx = admin.sites.findIndex((s) => s.id === admin.editingId); if (idx >= 0) admin.sites[idx] = updated; resetEditing(); cacheState(); hideSiteForm(); renderSiteList(); showToast('Site saved.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } else { fetchJson(`${API}/sites`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((created) => { admin.sites.push(created); cacheState(); hideSiteForm(); renderSiteList(); showToast('Site saved.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } } function clearSiteForm() { document.getElementById('siteForm')?.reset(); document.getElementById('siteFormHeading').textContent = 'Add Site'; /* Remove the map marker when clearing so it doesn't carry over to new-site form */ if (_siteMarker && _siteMap) { _siteMap.removeLayer(_siteMarker); _siteMarker = null; } resetEditing(); } function hideSiteForm() { clearSiteForm(); hideModal('siteFormModal'); } function editSite(id) { const s = admin.sites.find((x) => x.id === id); if (!s) return; admin.editingId = id; admin.editingType = 'site'; const el = (eid) => document.getElementById(eid); el('siteSiteCode').value = s.siteCode; el('siteHost').value = s.host || ''; el('siteObe').value = s.obeSiteCode || ''; el('sitePxs').value = s.pxsSiteCode || ''; el('siteLat').value = s.latitude != null ? s.latitude : ''; el('siteLng').value = s.longitude != null ? s.longitude : ''; el('siteFormHeading').textContent = 'Edit Site'; showModal('siteFormModal'); } function deleteSite(id) { if (!confirm('Delete this site?')) return; fetchJson(`${API}/sites/${id}`, { method: 'DELETE' }).then(() => { admin.sites = admin.sites.filter((s) => s.id !== id); admin.tasks = admin.tasks.filter((t) => t.siteId !== id); cacheState(); renderSiteList(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); } function renderSiteList() { const container = document.getElementById('siteListContainer'); if (!container) return; if (!admin.sites.length) { container.innerHTML = '

No sites

Click "Add Site" to create one.

'; return; } const rows = admin.sites.map((s) => ` ${esc(s.siteCode)}${esc(s.host || '-')} ${esc(s.obeSiteCode || '-')}${esc(s.pxsSiteCode || '-')} ${s.latitude != null ? parseFloat(s.latitude).toFixed(5) : '-'}\n ${s.longitude != null ? parseFloat(s.longitude).toFixed(5) : '-'} `).join(''); container.innerHTML = `${rows}
Site CodeHostOBE Site CodePXS Site CodeLatitudeLongitudeActions
`; container.querySelectorAll('[data-edit-site]').forEach((b) => b.addEventListener('click', () => editSite(Number(b.dataset.editSite)))); container.querySelectorAll('[data-delete-site]').forEach((b) => b.addEventListener('click', () => deleteSite(Number(b.dataset.deleteSite)))); } /* ═══════════════════════════════════════════════════════════════════════════ * CHECK LISTS › RECORDS * ═══════════════════════════════════════════════════════════════════════════ */ function bindClRecordForm() { const form = document.getElementById('clRecordForm'); if (!form) return; form.addEventListener('submit', (e) => { e.preventDefault(); saveClRecord(); }); document.getElementById('showClRecFormBtn')?.addEventListener('click', () => { clearClRecordForm(); refreshRecordDropdowns(); showModal('clRecFormModal'); }); document.getElementById('clRecFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing()); } function refreshRecordDropdowns() { populateSelectObjects('clRecCategory', admin.templateSettings.categories.map((c) => ({ value: c.id, label: c.value })), '— Select —'); /* Sub-category is filtered by selected category */ updateRecordSubCategoryDropdown(); populateSelectObjects('clRecSeverity', admin.templateSettings.severities.map((s) => ({ value: s.id, label: s.value })), '— Select —'); populateSelect('clRecStatus', admin.templateSettings.statuses.map((s) => s.value)); populateSelect('clRecHandledBy', admin.templateSettings.handledBy.map((h) => h.value)); /* Bind category change to filter sub-categories */ const catSel = document.getElementById('clRecCategory'); if (catSel && !catSel._boundSubCatFilter) { catSel.addEventListener('change', updateRecordSubCategoryDropdown); catSel._boundSubCatFilter = true; } } function updateRecordSubCategoryDropdown() { const catSel = document.getElementById('clRecCategory'); const selectedCatId = Number(catSel?.value || 0); if (!selectedCatId) { populateSelectObjects('clRecSubCategory', [], '— Select —'); return; } const filtered = admin.templateSettings.subCategories .filter((s) => s.categoryId === selectedCatId) .map((s) => ({ value: s.id, label: s.value })); populateSelectObjects('clRecSubCategory', filtered, '— Select —'); } function saveClRecord() { const el = (id) => document.getElementById(id); const sortVal = Number(el('clRecSort').value); if (!sortVal) { showToast('Sort number is required.', 'warning'); return; } const existing = admin.clRecords.find((r) => r.sort === sortVal && r.id !== admin.editingId); if (existing) { showToast(`Sort value ${sortVal} already used.`, 'warning'); return; } const data = { sort: sortVal, categoryId: Number(el('clRecCategory').value) || null, subCategoryId: Number(el('clRecSubCategory').value) || null, severityId: Number(el('clRecSeverity').value) || null, imageRequired: el('clRecImageRequired').checked, descriptionEN: el('clRecDescEN').value.trim(), descriptionFR: el('clRecDescFR').value.trim(), descriptionNL: el('clRecDescNL').value.trim() }; if (admin.editingType === 'clRecord' && admin.editingId) { fetchJson(`${API}/cl-records/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((updated) => { const idx = admin.clRecords.findIndex((r) => r.id === admin.editingId); if (idx >= 0) admin.clRecords[idx] = updated; admin.clRecords.sort((a, b) => a.sort - b.sort); resetEditing(); cacheState(); hideClRecForm(); renderClRecordList(); showToast('Record saved.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } else { fetchJson(`${API}/cl-records`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((created) => { admin.clRecords.push(created); admin.clRecords.sort((a, b) => a.sort - b.sort); cacheState(); hideClRecForm(); renderClRecordList(); showToast('Record saved.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } } function clearClRecordForm() { document.getElementById('clRecordForm')?.reset(); document.getElementById('clRecFormHeading').textContent = 'Add Record'; resetEditing(); } function hideClRecForm() { clearClRecordForm(); hideModal('clRecFormModal'); } function editClRecord(id) { const rec = admin.clRecords.find((r) => r.id === id); if (!rec) return; admin.editingId = id; admin.editingType = 'clRecord'; refreshRecordDropdowns(); const el = (eid) => document.getElementById(eid); el('clRecSort').value = rec.sort; el('clRecCategory').value = rec.categoryId || ''; /* Re-filter sub-categories after setting the category value */ updateRecordSubCategoryDropdown(); el('clRecSubCategory').value = rec.subCategoryId || ''; el('clRecSeverity').value = rec.severityId || ''; el('clRecImageRequired').checked = !!rec.imageRequired; el('clRecDescEN').value = rec.descriptionEN || ''; el('clRecDescFR').value = rec.descriptionFR || ''; el('clRecDescNL').value = rec.descriptionNL || ''; el('clRecFormHeading').textContent = 'Edit Record'; showModal('clRecFormModal'); } function deleteClRecord(id) { if (!confirm('Delete this record?')) return; fetchJson(`${API}/cl-records/${id}`, { method: 'DELETE' }).then(() => { admin.clRecords = admin.clRecords.filter((r) => r.id !== id); admin.clTemplates.forEach((tpl) => { tpl.recordIds = (tpl.recordIds || []).filter((rid) => rid !== id); }); cacheState(); renderClRecordList(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); } function renderClRecordList() { const container = document.getElementById('clRecordListContainer'); const countEl = document.getElementById('clRecCount'); if (!container) return; if (countEl) countEl.textContent = `${admin.clRecords.length} record(s)`; if (!admin.clRecords.length) { container.innerHTML = '

No records

Click "Add Record" to create one.

'; return; } const rows = admin.clRecords.map((r) => ` ${r.sort}${esc(r.category || '-')}${esc(r.subCategory || '-')} ${esc(r.descriptionEN || '-')}${esc(r.severity || '-')} ${r.imageRequired ? '✓' : '-'} `).join(''); container.innerHTML = `${rows}
SortCategorySub Cat.Description ENSeverityImgActions
`; container.querySelectorAll('[data-edit-rec]').forEach((b) => b.addEventListener('click', () => editClRecord(Number(b.dataset.editRec)))); container.querySelectorAll('[data-delete-rec]').forEach((b) => b.addEventListener('click', () => deleteClRecord(Number(b.dataset.deleteRec)))); } /* ═══════════════════════════════════════════════════════════════════════════ * CHECK LISTS › TEMPLATES * ═══════════════════════════════════════════════════════════════════════════ */ function bindClTemplateForm() { const form = document.getElementById('clTemplateForm'); if (!form) return; form.addEventListener('submit', (e) => { e.preventDefault(); saveClTemplate(); }); document.getElementById('showClTplFormBtn')?.addEventListener('click', () => { clearClTemplateForm(); renderClTemplateRecordSelection(); showModal('clTplFormModal'); }); document.getElementById('clTplFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing()); } function renderClTemplateRecordSelection() { const container = document.getElementById('clTplRecordSelection'); if (!container) return; if (!admin.clRecords.length) { container.innerHTML = '

No records available. Add records first.

'; return; } const selectedIds = new Set(); if (admin.editingType === 'clTemplate' && admin.editingId) { const tpl = admin.clTemplates.find((t) => t.id === admin.editingId); if (tpl) (tpl.recordIds || []).forEach((rid) => selectedIds.add(rid)); } const rows = admin.clRecords.map((r) => { const checked = selectedIds.has(r.id) ? 'checked' : ''; return ` ${r.sort}${esc(r.category || '-')}${esc(r.descriptionEN || '-')}`; }).join(''); container.innerHTML = `${rows}
IncludeSortCategoryDescription EN
`; } function saveClTemplate() { const el = (id) => document.getElementById(id); const data = { name: el('clTplName').value.trim(), scope: el('clTplScope').value, version: el('clTplVersion').value.trim(), validFrom: el('clTplValidFrom').value || null, validTill: el('clTplValidTill').value || null, recordIds: [...document.querySelectorAll('.tpl-rec-check:checked')].map((cb) => Number(cb.value)) }; if (!data.name) { showToast('Template name required.', 'warning'); return; } if (admin.editingType === 'clTemplate' && admin.editingId) { fetchJson(`${API}/cl-templates/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((updated) => { const idx = admin.clTemplates.findIndex((t) => t.id === admin.editingId); if (idx >= 0) admin.clTemplates[idx] = updated; resetEditing(); cacheState(); hideClTplForm(); renderClTemplateList(); showToast('Template saved.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } else { fetchJson(`${API}/cl-templates`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((created) => { admin.clTemplates.push(created); cacheState(); hideClTplForm(); renderClTemplateList(); showToast('Template saved.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } } function clearClTemplateForm() { document.getElementById('clTemplateForm')?.reset(); document.getElementById('clTplFormHeading').textContent = 'Add Template'; resetEditing(); } function hideClTplForm() { clearClTemplateForm(); hideModal('clTplFormModal'); } function editClTemplate(id) { const tpl = admin.clTemplates.find((t) => t.id === id); if (!tpl) return; admin.editingId = id; admin.editingType = 'clTemplate'; const el = (eid) => document.getElementById(eid); el('clTplName').value = tpl.name; el('clTplScope').value = tpl.scope || ''; el('clTplVersion').value = tpl.version || ''; el('clTplValidFrom').value = tpl.validFrom || ''; el('clTplValidTill').value = tpl.validTill || ''; el('clTplFormHeading').textContent = 'Edit Template'; renderClTemplateRecordSelection(); showModal('clTplFormModal'); } function deleteClTemplate(id) { if (!confirm('Delete this template?')) return; fetchJson(`${API}/cl-templates/${id}`, { method: 'DELETE' }).then(() => { admin.clTemplates = admin.clTemplates.filter((t) => t.id !== id); admin.tasks = admin.tasks.filter((t) => t.templateId !== id); cacheState(); renderClTemplateList(); }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); } function renderClTemplateList() { const container = document.getElementById('clTemplateListContainer'); const countEl = document.getElementById('clTplCount'); if (!container) return; if (countEl) countEl.textContent = `${admin.clTemplates.length} template(s)`; if (!admin.clTemplates.length) { container.innerHTML = '

No templates

Click "Add Template" to create one.

'; return; } const fmtDate = (d) => d ? formatDateDisplay(d) : '-'; const rows = admin.clTemplates.map((t) => ` ${esc(t.name)}${esc(t.scope || '-')}${esc(t.version || '-')} ${fmtDate(t.validFrom)}${fmtDate(t.validTill)}${(t.recordIds || []).length} `).join(''); container.innerHTML = `${rows}
NameScopeVersionValid FromValid TillRecordsActions
`; container.querySelectorAll('[data-edit-tpl]').forEach((b) => b.addEventListener('click', () => editClTemplate(Number(b.dataset.editTpl)))); container.querySelectorAll('[data-delete-tpl]').forEach((b) => b.addEventListener('click', () => deleteClTemplate(Number(b.dataset.deleteTpl)))); } /* ═══════════════════════════════════════════════════════════════════════════ * REPORTS — Task assignment (list-first) * ═══════════════════════════════════════════════════════════════════════════ */ function bindTaskForm() { const form = document.getElementById('taskForm'); if (!form) return; form.addEventListener('submit', (e) => { e.preventDefault(); saveTask(); }); document.getElementById('showTaskFormBtn')?.addEventListener('click', () => { clearTaskForm(); refreshTaskDropdowns(); showModal('taskFormModal'); }); document.getElementById('taskFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing()); } function refreshTaskDropdowns() { const siteOpts = admin.sites.map((s) => ({ value: s.id, label: s.siteCode })); populateSelectObjects('taskSite', siteOpts, '— Select site —'); const userOpts = admin.users.map((u) => ({ value: u.id, label: `${u.name} ${u.familyName} (${u.email})` })); populateSelectObjects('taskUser', userOpts, '— Select user —'); const tplOpts = admin.clTemplates.map((t) => ({ value: t.id, label: `${t.name} (v${t.version || '?'})` })); populateSelectObjects('taskTemplate', tplOpts, '— Select template —'); populateSelectObjects('taskProject', admin.taskSettings.projects.map((p) => ({ value: p.id, label: p.value })), '— Select project —'); /* Processes filtered by selected project — bind change listener */ updateTaskProcessDropdown(); const projSel = document.getElementById('taskProject'); if (projSel && !projSel._boundTaskProc) { projSel.addEventListener('change', updateTaskProcessDropdown); projSel._boundTaskProc = true; } } function updateTaskProcessDropdown() { const projSel = document.getElementById('taskProject'); const selectedProjectId = Number(projSel?.value || 0); if (!selectedProjectId) { populateSelectObjects('taskProcess', admin.taskSettings.processes.map((p) => ({ value: p.id, label: p.value })), '— Select process —'); } else { const filtered = admin.taskSettings.processes .filter((p) => p.projectId === selectedProjectId) .map((p) => ({ value: p.id, label: p.value })); populateSelectObjects('taskProcess', filtered, '— Select process —'); } } function saveTask() { const el = (id) => document.getElementById(id); const data = { siteId: Number(el('taskSite').value), userId: Number(el('taskUser').value), templateId: Number(el('taskTemplate').value), projectId: Number(el('taskProject').value) || null, processId: Number(el('taskProcess').value) || null }; if (!data.siteId || !data.userId || !data.templateId) { showToast('User, Site, and Template are required.', 'warning'); return; } if (admin.editingType === 'task' && admin.editingId) { const existing = admin.tasks.find((t) => t.id === admin.editingId); const payload = { ...data, status: existing?.status || 'pending' }; fetchJson(`${API}/tasks/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then((updated) => { const idx = admin.tasks.findIndex((t) => t.id === admin.editingId); if (idx >= 0) admin.tasks[idx] = updated; resetEditing(); cacheState(); hideTaskForm(); renderTaskList(); showToast('Task assigned.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } else { data.status = 'pending'; fetchJson(`${API}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }) .then((created) => { admin.tasks.push(created); cacheState(); hideTaskForm(); renderTaskList(); showToast('Task assigned.', 'success'); }) .catch((err) => showToast(err.message || 'Save failed.', 'error')); } } function clearTaskForm() { document.getElementById('taskForm')?.reset(); document.getElementById('taskFormHeading').textContent = 'Create Task Assignment'; resetEditing(); } function hideTaskForm() { clearTaskForm(); hideModal('taskFormModal'); } function editTask(id) { const task = admin.tasks.find((t) => t.id === id); if (!task) return; admin.editingId = id; admin.editingType = 'task'; refreshTaskDropdowns(); const el = (eid) => document.getElementById(eid); el('taskSite').value = task.siteId || ''; el('taskUser').value = task.userId || ''; el('taskTemplate').value = task.templateId || ''; el('taskProject').value = task.projectId || ''; updateTaskProcessDropdown(); el('taskProcess').value = task.processId || ''; el('taskFormHeading').textContent = 'Edit Task Assignment'; showModal('taskFormModal'); } function deleteTask(id) { if (!confirm('Delete this task? This will also permanently remove all uploaded images.')) return; fetchJson(`${API}/tasks/${id}`, { method: 'DELETE' }).then(() => { admin.tasks = admin.tasks.filter((t) => t.id !== id); cacheState(); renderTaskList(); /* Also delete the report and image files from the server */ if (navigator.onLine) { fetch(`/api/v1/reports/${id}`, { method: 'DELETE' }).catch(() => {}); } }).catch((err) => showToast(err.message || 'Delete failed.', 'error')); } function renderTaskList() { const container = document.getElementById('taskListContainer'); if (!container) return; if (!admin.tasks.length) { container.innerHTML = '

No tasks

Click "Add Task" to assign one.

'; return; } const siteName = (id) => admin.sites.find((s) => s.id === id)?.siteCode || '?'; const userName = (id) => { const u = admin.users.find((x) => x.id === id); return u ? `${u.name} ${u.familyName}` : '?'; }; const tplName = (id) => admin.clTemplates.find((t) => t.id === id)?.name || '?'; const statusBadge = (s) => { const cls = s === 'final' ? 'badge-online' : s === 'draft' ? 'badge-offline' : 'badge-neutral'; return `${esc(s || 'pending')}`; }; const rows = admin.tasks.map((t) => ` ${esc(siteName(t.siteId))}${esc(userName(t.userId))} ${esc(tplName(t.templateId))}${esc(t.project || '-')} ${esc(t.process || '-')}${statusBadge(t.status)} ${t.status === 'final' ? `` : ''} `).join(''); container.innerHTML = `${rows}
SiteUserTemplateProjectProcessStatusActions
`; container.querySelectorAll('[data-view-task]').forEach((b) => b.addEventListener('click', () => viewTaskReport(Number(b.dataset.viewTask)))); container.querySelectorAll('[data-map-task]').forEach((b) => b.addEventListener('click', () => openTaskImagesMap(Number(b.dataset.mapTask)))); container.querySelectorAll('[data-reopen-task]').forEach((b) => b.addEventListener('click', () => reopenTask(Number(b.dataset.reopenTask)))); container.querySelectorAll('[data-edit-task]').forEach((b) => b.addEventListener('click', () => editTask(Number(b.dataset.editTask)))); container.querySelectorAll('[data-delete-task]').forEach((b) => b.addEventListener('click', () => deleteTask(Number(b.dataset.deleteTask)))); } /* ═══════════════════════════════════════════════════════════════════════════ * Task Report Viewer (admin preview — like user portal display) * ═══════════════════════════════════════════════════════════════════════════ */ function viewTaskReport(taskId) { const task = admin.tasks.find(t => t.id === taskId); if (!task) { showToast('Task not found.', 'error'); return; } const site = admin.sites.find(s => s.id === task.siteId); const user = admin.users.find(u => u.id === task.userId); const tpl = admin.clTemplates.find(t => t.id === task.templateId); const recordIds = tpl?.recordIds || []; const records = recordIds.map(rid => admin.clRecords.find(r => r.id === rid)).filter(Boolean).sort((a, b) => a.sort - b.sort); /* Try to get data from server first, fallback to empty */ fetchReportDataForAdmin(taskId).then(serverData => { const data = serverData || { visitDate: '', records: {} }; buildAndShowReportModal(task, site, user, tpl, data, records); /* Always fetch images from DB for submitted reports */ if (navigator.onLine) { fetchReportImagesForAdmin(taskId, records, site); } }); } function buildAndShowReportModal(task, site, user, tpl, data, records) { /* Look up configured color for a severity or status value */ const sevColor = (val) => admin.templateSettings.severities.find((s) => s.value === val)?.color || null; const statColor = (val) => admin.templateSettings.statuses.find((s) => s.value === val)?.color || null; /* Build a colored badge using configured color or fall back to Bootstrap class */ const coloredBadge = (val, fallbackClass) => { if (!val || val === '-') return `${esc(val)}`; const color = statColor(val); if (color) { return `${esc(val)}`; } /* Legacy fallback for NOK/OK/TBC if no color configured */ const cls = val === 'NOK' ? 'bg-danger' : val === 'OK' ? 'bg-success' : val === 'TBC' ? 'bg-warning text-dark' : 'bg-secondary'; return `${esc(val)}`; }; const severityBadge = (val) => { if (!val || val === '-') return `${esc(val)}`; const color = sevColor(val); if (color) { return `${esc(val)}`; } return `${esc(val)}`; }; let recordsHtml = ''; if (!records.length) { recordsHtml = '

No records in template.

'; } else { recordsHtml = records.map(rec => { const rd = data.records[rec.id] || {}; const desc = rec.descriptionEN || rec.descriptionFR || rec.descriptionNL || 'No description'; const status = rd.status || '-'; const handledBy = rd.handledBy || '-'; const comment = rd.comment || '-'; const images = rd.images || []; /* Show a loading placeholder — real images will be fetched from server files */ const imgPlaceholder = images.length ? `Loading ${images.length} image(s)...` : 'No images'; const serverImgPlaceholder = `
${imgPlaceholder}
`; return `
#${rec.sort} ${esc(desc)} ${rec.imageRequired ? 'IMG REQ' : ''}
Category: ${esc(rec.category || '-')} Sub: ${esc(rec.subCategory || '-')} Severity: ${severityBadge(rec.severity || '-')}
Status: ${coloredBadge(status)}
Handled By: ${esc(handledBy)}
Comment: ${esc(comment)}
${serverImgPlaceholder}
`; }).join(''); } const taskStatusBadge = task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary'; /* Build & show modal */ let modal = document.getElementById('adminReportViewModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'adminReportViewModal'; modal.className = 'modal fade'; modal.tabIndex = -1; modal.innerHTML = ``; document.body.appendChild(modal); } const taskId = task.id; document.getElementById('adminReportModalTitle').textContent = `Report — ${site?.siteCode || 'Unknown'} / ${tpl?.name || 'Unknown'}`; document.getElementById('adminReportModalBody').innerHTML = `
Site
${esc(site?.siteCode || '-')}
Project
${esc(task.project || '-')}
Process
${esc(task.process || '-')}
Status
${esc(task.status || 'pending')}
User
${esc(user ? `${user.name} ${user.familyName}` : '-')}
Visit Date: ${esc(data.visitDate || 'Not set')}
Records (${records.length})
${recordsHtml} `; /* Reopen button (only for final tasks) */ const footer = document.getElementById('adminReportModalFooter'); footer.innerHTML = task.status === 'final' ? ` ` : ``; document.getElementById('adminReopenTaskBtn')?.addEventListener('click', () => { reopenTask(taskId); bootstrap.Modal.getInstance(modal)?.hide(); }); /* Bind image lightbox clicks (with EXIF info) */ modal.querySelectorAll('[data-admin-lightbox]').forEach(img => { img.addEventListener('click', () => openAdminLightbox(img.src, img.alt, { name: img.dataset.imgName, size: Number(img.dataset.imgSize) || 0, width: img.dataset.imgWidth, height: img.dataset.imgHeight, exif: img.dataset.imgExif ? safeJsonParse(img.dataset.imgExif) : null, site })); }); const bsModal = bootstrap.Modal.getOrCreateInstance(modal); bsModal.show(); } /** * Fetches report answers from the server to get record data (status, handledBy, comments). */ async function fetchReportDataForAdmin(taskId) { if (!navigator.onLine) return null; try { const resp = await fetch(`/api/v1/reports/${taskId}`, { headers: { Accept: 'application/json' } }); if (!resp.ok) return null; const report = await resp.json(); return report.answers || null; } catch { return null; } } /** * Fetches images from the server for a report and renders them * into the already-open modal under each record's placeholder slot. * Images are served as URLs pointing to files on disk. */ /** * Returns distance in metres between two WGS-84 coordinates (Haversine formula). */ function adminHaversineDistanceM(lat1, lon1, lat2, lon2) { const R = 6_371_000; const toRad = (d) => (d * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } /** * Returns an HTML badge showing the geo-fence result for one image. * - Green : GPS present, within radius * - Amber : GPS present, outside radius * - Grey : no GPS data in EXIF */ function buildGeoFenceBadge(img, site) { /* No site coordinates configured — can't evaluate */ if (!site?.latitude || !site?.longitude) { return 'GPS: N/A'; } const lat = img.exif?.latitude; const lon = img.exif?.longitude; if (lat == null || lon == null) { return 'No GPS'; } const radiusM = parseInt(admin.appConfig?.geo_fence_radius_m ?? '50', 10) || 50; const distanceM = Math.round(adminHaversineDistanceM(lat, lon, site.latitude, site.longitude)); if (distanceM <= radiusM) { return `✓ GPS ${distanceM}\u00a0m`; } return `⚠ GPS ${distanceM}\u00a0m`; } async function fetchReportImagesForAdmin(taskId, records, site) { try { const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } }); if (!resp.ok) { /* Clear loading placeholders on failure */ for (const rec of records) { const slot = document.querySelector(`.server-images-slot[data-rec-id="${rec.id}"]`); if (slot) slot.innerHTML = 'Could not load images.'; } return; } const imagesByRecord = await resp.json(); for (const rec of records) { const slot = document.querySelector(`.server-images-slot[data-rec-id="${rec.id}"]`); if (!slot) continue; const imgs = imagesByRecord[rec.id]; if (!imgs?.length) { slot.innerHTML = 'No images'; continue; } slot.innerHTML = imgs.map((img, i) => `
${esc(img.name || `img-${i}`)}
${esc(img.name || `img-${i}`)}
${formatFileSize(img.size)}
${buildGeoFenceBadge(img, site)}
` ).join(''); /* Bind lightbox clicks for newly added images */ slot.querySelectorAll('[data-admin-lightbox]').forEach(imgEl => { imgEl.addEventListener('click', () => openAdminLightbox(imgEl.src, imgEl.alt, { name: imgEl.dataset.imgName, size: Number(imgEl.dataset.imgSize) || 0, width: imgEl.dataset.imgWidth, height: imgEl.dataset.imgHeight, exif: imgEl.dataset.imgExif ? safeJsonParse(imgEl.dataset.imgExif) : null, site })); }); } } catch (err) { console.warn('Failed to fetch report images:', err.message); } } /* ═══════════════════════════════════════════════════════════════════════════ * Admin: Reopen a final task * ═══════════════════════════════════════════════════════════════════════════ */ function reopenTask(taskId) { const task = admin.tasks.find(t => t.id === taskId); if (!task) return; const payload = { siteId: task.siteId, userId: task.userId, templateId: task.templateId, project: task.project, process: task.process, status: 'draft' }; fetchJson(`${API}/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(() => { task.status = 'draft'; cacheState(); renderTaskList(); showToast('Task reopened — user can now edit it again.', 'success'); }) .catch((err) => showToast(err.message || 'Reopen failed.', 'error')); } /* ═══════════════════════════════════════════════════════════════════════════ * Admin: Image lightbox (with EXIF, name, size info) * ═══════════════════════════════════════════════════════════════════════════ */ function openAdminLightbox(src, alt, meta) { let modal = document.getElementById('adminImageLightbox'); if (!modal) { modal = document.createElement('div'); modal.id = 'adminImageLightbox'; modal.className = 'modal fade'; modal.tabIndex = -1; modal.innerHTML = ``; document.body.appendChild(modal); let rotation = 0; let scale = 1; const imgEl = document.getElementById('adminLightboxImg'); function applyAdminTransform() { imgEl.style.transform = `rotate(${rotation}deg) scale(${scale})`; } document.getElementById('adminLightboxRotateLeft').addEventListener('click', () => { rotation = (rotation - 90) % 360; applyAdminTransform(); }); document.getElementById('adminLightboxRotateRight').addEventListener('click', () => { rotation = (rotation + 90) % 360; applyAdminTransform(); }); document.getElementById('adminLightboxZoomIn').addEventListener('click', () => { scale = Math.min(scale + 0.25, 5); applyAdminTransform(); }); document.getElementById('adminLightboxZoomOut').addEventListener('click', () => { scale = Math.max(scale - 0.25, 0.25); applyAdminTransform(); }); modal.addEventListener('hidden.bs.modal', () => { rotation = 0; scale = 1; imgEl.style.transform = ''; }); } document.getElementById('adminLightboxImg').src = src; document.getElementById('adminLightboxImg').style.transform = ''; document.getElementById('adminLightboxTitle').textContent = meta?.name || alt || 'Image Preview'; /* Build info panel with file metadata and EXIF */ const infoPanel = document.getElementById('adminLightboxInfo'); let infoHtml = '
Image Details
'; infoHtml += ''; if (meta?.name) infoHtml += ``; if (meta?.size) infoHtml += ``; if (meta?.width && meta?.height) infoHtml += ``; infoHtml += '
File Name${esc(meta.name)}
Size${formatFileSize(meta.size)}
Dimensions${meta.width} × ${meta.height} px
'; /* EXIF data */ const exif = meta?.exif; if (exif && typeof exif === 'object' && Object.keys(exif).length) { infoHtml += '
EXIF Data
'; infoHtml += ''; const exifLabels = { make: 'Camera Make', model: 'Camera Model', dateTimeOriginal: 'Date Taken', dateTime: 'Date/Time', focalLength: 'Focal Length', exposureTime: 'Exposure', fNumber: 'F-Number', isoSpeed: 'ISO', orientation: 'Orientation', pixelXDimension: 'Width (EXIF)', pixelYDimension: 'Height (EXIF)', latitude: 'Latitude', longitude: 'Longitude', altitude: 'Altitude' }; for (const [key, label] of Object.entries(exifLabels)) { if (exif[key] != null && exif[key] !== '') { let val = exif[key]; if (key === 'focalLength') val = `${Number(val).toFixed(1)} mm`; else if (key === 'exposureTime') val = val < 1 ? `1/${Math.round(1 / val)}s` : `${val}s`; else if (key === 'fNumber') val = `f/${Number(val).toFixed(1)}`; else if (key === 'latitude' || key === 'longitude') val = `${Number(val).toFixed(6)}°`; else if (key === 'altitude') val = `${Number(val).toFixed(1)} m`; infoHtml += ``; } } infoHtml += '
${label}${esc(String(val))}
'; /* Show on map button if GPS coords present */ if (exif.latitude != null && exif.longitude != null) { infoHtml += ``; } } else { infoHtml += '

No EXIF data available.

'; } /* Geo-fence result */ if (meta?.site) { const geoBadge = buildGeoFenceBadge({ exif: meta.exif }, meta.site); infoHtml += `
Geo-fence: ${geoBadge}
`; if (meta.exif?.latitude != null && meta.exif?.longitude != null && meta.site?.latitude != null && meta.site?.longitude != null) { const radiusM = parseInt(admin.appConfig?.geo_fence_radius_m ?? '50', 10) || 50; const dist = Math.round(adminHaversineDistanceM(meta.exif.latitude, meta.exif.longitude, meta.site.latitude, meta.site.longitude)); infoHtml += `

Distance to site: ${dist}\u00a0m — allowed radius: ${radiusM}\u00a0m

`; } } infoPanel.innerHTML = infoHtml; /* Bind map button if present */ const mapBtn = document.getElementById('adminLightboxMapBtn'); if (mapBtn && exif?.latitude != null && exif?.longitude != null) { mapBtn.addEventListener('click', () => { openAdminMapModal([{ lat: exif.latitude, lng: exif.longitude, name: meta?.name || 'Image', dataUrl: src }], meta?.site ? { site: meta.site, radiusM: parseInt(admin.appConfig?.geo_fence_radius_m ?? '50', 10) || 50 } : {}); }); } const bsModal = bootstrap.Modal.getOrCreateInstance(modal); bsModal.show(); } /* ═══════════════════════════════════════════════════════════════════════════ * Admin: Map modal (Leaflet + OpenStreetMap) * ═══════════════════════════════════════════════════════════════════════════ */ /** * Open a modal with a Leaflet map showing markers for the given points. * Each point: { lat, lng, name } */ /** * Opens the image-locations map modal. * @param {Array} points – [{lat, lng, name, dataUrl}] * @param {object} [opts] – { site: {siteCode, latitude, longitude}, radiusM: number } */ function openAdminMapModal(points, opts = {}) { const hasSite = opts.site?.latitude != null && opts.site?.longitude != null; const hasImages = points?.length > 0; if (!hasSite && !hasImages) { showToast('No geo-tagged images and no site location available.', 'info'); return; } let modal = document.getElementById('adminMapModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'adminMapModal'; modal.className = 'modal fade'; modal.tabIndex = -1; modal.style.zIndex = '1070'; /* above lightbox modal (1055) */ modal.innerHTML = ``; document.body.appendChild(modal); } /* Ensure backdrop also appears above lightbox */ modal.addEventListener('shown.bs.modal', () => { const backdrop = document.querySelector('.modal-backdrop:last-child'); if (backdrop) backdrop.style.zIndex = '1065'; }, { once: true }); const bsModal = bootstrap.Modal.getOrCreateInstance(modal); bsModal.show(); /* Initialize map after modal is shown (Leaflet needs visible container) */ modal.addEventListener('shown.bs.modal', function initMap() { modal.removeEventListener('shown.bs.modal', initMap); const container = document.getElementById('adminMapContainer'); container.innerHTML = ''; /* clear any previous map instance */ container._leaflet_id = null; /* allow re-init */ /* Decide initial centre: prefer site, then first image */ const centre = hasSite ? [opts.site.latitude, opts.site.longitude] : [points[0].lat, points[0].lng]; const map = L.map(container).setView(centre, 15); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); const bounds = L.latLngBounds(); /* ─ Site marker + geo-fence circle ──────────────────────────── */ if (hasSite) { const siteLat = opts.site.latitude; const siteLng = opts.site.longitude; const radiusM = opts.radiusM || 50; /* Geo-fence circle */ L.circle([siteLat, siteLng], { radius: radiusM, color: '#0d6efd', fillColor: '#0d6efd', fillOpacity: 0.10, weight: 2, dashArray: '6 4' }).addTo(map).bindTooltip(`Geo-fence: ${radiusM} m radius`, { permanent: false }); /* Site pin — distinct blue icon */ const siteIcon = L.divIcon({ className: '', html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); L.marker([siteLat, siteLng], { icon: siteIcon }) .addTo(map) .bindPopup(`📍 Site: ${esc(opts.site.siteCode || 'Unknown')}
${siteLat.toFixed(6)}, ${siteLng.toFixed(6)}
Geo-fence radius: ${radiusM} m`, { maxWidth: 220 }); bounds.extend([siteLat, siteLng]); /* Also include the circle edge in bounds so it's fully visible */ const edgeOffset = radiusM / 111_320; bounds.extend([siteLat + edgeOffset, siteLng]); bounds.extend([siteLat - edgeOffset, siteLng]); } /* ─ Image markers ─────────────────────────────────────── */ for (const pt of points) { const inZone = hasSite && opts.radiusM ? adminHaversineDistanceM(pt.lat, pt.lng, opts.site.latitude, opts.site.longitude) <= opts.radiusM : null; /* Colour the marker dot: green = within zone, amber = outside, grey = unknown */ const dotColor = inZone === true ? '#198754' : inZone === false ? '#ffc107' : '#6c757d'; const imgIcon = L.divIcon({ className: '', html: `
`, iconSize: [14, 14], iconAnchor: [7, 7] }); const marker = L.marker([pt.lat, pt.lng], { icon: imgIcon }).addTo(map); let popupHtml = ''; if (pt.dataUrl) { popupHtml += ``; } popupHtml += `${esc(pt.name)}
${pt.lat.toFixed(6)}, ${pt.lng.toFixed(6)}`; if (inZone !== null) { const dist = Math.round(adminHaversineDistanceM(pt.lat, pt.lng, opts.site.latitude, opts.site.longitude)); popupHtml += `
${inZone ? '✅' : '⚠️'} ${dist} m from site`; } marker.bindPopup(popupHtml, { maxWidth: 200 }); bounds.extend([pt.lat, pt.lng]); } if (bounds.isValid()) { map.fitBounds(bounds, { padding: [40, 40] }); } /* Build legend */ const legendEl = document.getElementById('adminMapLegend'); if (legendEl) { let legendHtml = ''; if (hasSite) { legendHtml += ` Site location`; legendHtml += `Geo-fence zone`; } if (hasImages) { legendHtml += ` Image within zone`; legendHtml += ` Image outside zone`; } legendEl.innerHTML = legendHtml; } /* Fix map tiles when modal is resized */ setTimeout(() => map.invalidateSize(), 200); const observer = new ResizeObserver(() => map.invalidateSize()); observer.observe(container); modal.addEventListener('hidden.bs.modal', () => { observer.disconnect(); map.remove(); }, { once: true }); }, { once: true }); } /** * Fetch images for a task and open the map with all geo-tagged images, * the site marker, and the geo-fence zone circle. */ async function openTaskImagesMap(taskId) { try { const task = admin.tasks.find(t => t.id === taskId); const site = task ? admin.sites.find(s => s.id === task.siteId) : null; const radiusM = parseInt(admin.appConfig?.geo_fence_radius_m ?? '50', 10) || 50; const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } }); if (!resp.ok) { showToast('Could not load images for map.', 'error'); return; } const imagesByRecord = await resp.json(); const points = []; for (const recId of Object.keys(imagesByRecord)) { for (const img of imagesByRecord[recId]) { if (img.exif?.latitude != null && img.exif?.longitude != null) { points.push({ lat: img.exif.latitude, lng: img.exif.longitude, name: img.name || 'Image', dataUrl: img.dataUrl || null }); } } } if (!points.length && !site?.latitude) { showToast('No GPS images and no site coordinates available.', 'info'); return; } openAdminMapModal(points, { site, radiusM }); } catch (err) { showToast('Failed to load images: ' + err.message, 'error'); } } function formatFileSize(bytes) { if (!bytes) return '-'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(2)} MB`; } function safeJsonParse(str) { try { return JSON.parse(str); } catch { return null; } } /* ═══════════════════════════════════════════════════════════════════════════ * Shared helpers * ═══════════════════════════════════════════════════════════════════════════ */ function resetEditing() { admin.editingId = null; admin.editingType = null; } function populateSelect(selectId, options) { const sel = document.getElementById(selectId); if (!sel) return; const cur = sel.value; sel.innerHTML = ''; (options || []).forEach((v) => { sel.innerHTML += ``; }); sel.value = cur; } function populateSelectObjects(selectId, items, placeholder) { const sel = document.getElementById(selectId); if (!sel) return; const cur = sel.value; sel.innerHTML = ``; items.forEach(({ value, label }) => { sel.innerHTML += ``; }); sel.value = cur; } /** Render an editable/deletable list of {id, value} items as a table. */ function renderSettingList(containerId, items, onDelete, onEdit) { renderSettingTable(containerId, items, [{ header: 'Value', cell: (item) => esc(item.value) }], onDelete, (item, newVal) => onEdit(item, newVal) ); } /** * Render a settings list as an admin table with proper named columns and an * inline edit row that expands under each row when "Edit" is clicked. * * columns — array of column definition objects: * { * header : string — column heading * cell : (item) => html — display content for that cell * editHtml : (item) => html — edit control injected into the inline edit row * (omit for the primary Value column; it always gets a text input) * collect : (itemId) => any — reads the edit control and returns the extra value * (omit for the primary Value column) * } * The first column is always the primary text value; its editHtml/collect are * auto-generated (a text input whose value is passed as newVal to onEdit). * Extra columns supply additional display + edit controls. * * onDelete(item) — called when Delete is confirmed * onEdit(item, newVal, extras) — newVal = primary text; extras = array of * collect() results for columns[1], [2], … */ function renderSettingTable(containerId, items, columns, onDelete, onEdit) { const container = document.getElementById(containerId); if (!container) return; const colSpan = columns.length + 1; // +1 for Actions if (!items.length) { const hdrs = columns.map((c) => `${c.header}`).join('') + 'Actions'; container.innerHTML = `${hdrs}

No items yet.

`; return; } const tbodyRows = items.map((item) => { const dataCells = columns.map((c) => `${c.cell(item)}`).join(''); return ` ${dataCells} `; }).join(''); const hdrRow = columns.map((c) => `${c.header}`).join('') + 'Actions'; container.innerHTML = `${hdrRow}${tbodyRows}
`; container.querySelectorAll('[data-slt-edit]').forEach((btn) => { btn.addEventListener('click', () => { const id = Number(btn.dataset.sltEdit); const item = items.find((i) => i.id === id); if (!item) return; /* Build modal body — primary text input + extra controls from columns[1..n] */ const extraInputsHtml = columns.slice(1).map((c) => `
${c.editHtml(item)}
`).join(''); document.getElementById('settingsItemModalLabel').textContent = 'Edit Item'; document.getElementById('settingsItemModalBody').innerHTML = `
${extraInputsHtml}`; const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('settingsItemModal')); const saveBtn = document.getElementById('settingsItemModalSave'); /* Remove previous listener by cloning */ const newSaveBtn = saveBtn.cloneNode(true); saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); newSaveBtn.addEventListener('click', () => { const newVal = document.getElementById('sltModalVal')?.value.trim(); if (!newVal) { showToast('Value is required.', 'warning'); return; } const extras = columns.slice(1).map((c) => c.collect(id)); modal.hide(); onEdit(item, newVal, extras); }); modal.show(); }); }); container.querySelectorAll('[data-slt-del]').forEach((btn) => { btn.addEventListener('click', () => { const id = Number(btn.dataset.sltDel); const item = items.find((i) => i.id === id); if (!item) return; onDelete(item); }); }); } function showToast(message, tone) { let toast = document.getElementById('adminToast'); if (!toast) { toast = document.createElement('div'); toast.id = 'adminToast'; toast.className = 'admin-toast'; document.body.appendChild(toast); } const cls = tone === 'success' ? 'badge-online' : tone === 'error' ? 'badge-error' : 'badge-offline'; toast.textContent = message; toast.className = `admin-toast badge ${cls} admin-toast-visible`; clearTimeout(toast._timer); toast._timer = setTimeout(() => { toast.classList.remove('admin-toast-visible'); }, 3000); } function fmtKb(bytes) { if (!bytes) return '-'; return `${Math.round(bytes / 1024)} KB`; } /* Returns '#000000' or '#ffffff' depending on which gives better contrast on hexColor. */ function colorContrast(hexColor) { if (!hexColor || hexColor.length < 7) return '#000000'; const r = parseInt(hexColor.slice(1, 3), 16); const g = parseInt(hexColor.slice(3, 5), 16); const b = parseInt(hexColor.slice(5, 7), 16); /* Perceived luminance formula (WCAG) */ return (r * 0.299 + g * 0.587 + b * 0.114) > 128 ? '#000000' : '#ffffff'; } function formatDateDisplay(dateStr) { if (!dateStr) return '-'; /* HTML date inputs give yyyy-mm-dd — display as dd/mm/yyyy */ const parts = dateStr.split('-'); if (parts.length === 3) return `${parts[2]}/${parts[1]}/${parts[0]}`; return dateStr; } function esc(text) { const d = document.createElement('div'); d.textContent = text ?? ''; return d.innerHTML; } function escAttr(text) { return esc(text).replace(/"/g, '"').replace(/'/g, '''); } /** Delegate event to document for dynamically created elements. */ function on(event, selector, handler) { document.addEventListener(event, (e) => { const target = e.target.closest(selector); if (target) handler(e, target); }); } function bindEnter(inputId, handler) { const el = document.getElementById(inputId); if (el) el.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); handler(); } }); }