/* * 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}] }, 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 || []; /* 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 || []; } 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 }; 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(); bindUserForm(); bindSiteForm(); bindClRecordForm(); bindClTemplateForm(); bindTaskForm(); renderImagePolicy(); renderTemplateSettings(); renderTaskSettings(); renderUserList(); renderSiteList(); renderClRecordList(); renderClTemplateList(); renderTaskList(); updateConnectionBadge(); window.addEventListener('online', updateConnectionBadge); window.addEventListener('offline', updateConnectionBadge); 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; } } 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"]', addCategory); on('click', '[data-ts-action="add-subcat"]', addSubCategory); on('click', '[data-ts-action="add-sev"]', () => addSimpleSetting('severities', 'tsSevInput', 'severities')); on('click', '[data-ts-action="add-status"]', addStatus); on('click', '[data-ts-action="add-handled"]', () => addSimpleSetting('handledBy', 'tsHandledInput', 'handled-by')); /* Enter key support */ bindEnter('tsCatInput', addCategory); bindEnter('tsSubCatInput', addSubCategory); bindEnter('tsSevInput', () => addSimpleSetting('severities', 'tsSevInput', 'severities')); bindEnter('tsStatusInput', addStatus); bindEnter('tsHandledInput', () => addSimpleSetting('handledBy', 'tsHandledInput', 'handled-by')); } function addCategory() { const input = document.getElementById('tsCatInput'); const val = input.value.trim(); if (!val) return; if (admin.templateSettings.categories.some((c) => c.value === val)) { showToast('Category already exists.', 'warning'); return; } fetchJson(`${API}/categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) }) .then((created) => { admin.templateSettings.categories.push(created); cacheState(); input.value = ''; renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add category.', 'error')); } function addSubCategory() { const input = document.getElementById('tsSubCatInput'); const parentSel = document.getElementById('tsSubCatParent'); const val = input.value.trim(); const categoryId = Number(parentSel.value); if (!val) return; if (!categoryId) { showToast('Select a parent category first.', 'warning'); return; } 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(); input.value = ''; renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add sub-category.', 'error')); } function addStatus() { const input = document.getElementById('tsStatusInput'); const val = input.value.trim(); if (!val) return; if (admin.templateSettings.statuses.some((i) => i.value === val)) { showToast('Status already exists.', 'warning'); return; } const requireHandledBy = document.getElementById('tsStatusReqHandled')?.checked || false; const requireComment = document.getElementById('tsStatusReqComment')?.checked || false; fetchJson(`${API}/statuses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, requireHandledBy, requireComment }) }) .then((created) => { admin.templateSettings.statuses.push(created); cacheState(); input.value = ''; document.getElementById('tsStatusReqHandled').checked = false; document.getElementById('tsStatusReqComment').checked = false; renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add status.', 'error')); } function addSimpleSetting(key, inputId, endpoint) { const input = document.getElementById(inputId); const val = input.value.trim(); if (!val) return; if (admin.templateSettings[key].some((i) => i.value === val)) { showToast('Value already exists.', 'warning'); return; } fetchJson(`${API}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) }) .then((created) => { admin.templateSettings[key].push(created); cacheState(); input.value = ''; renderTemplateSettings(); }) .catch((err) => showToast(err.message || 'Failed to add item.', 'error')); } function renderTemplateSettings() { /* Categories list */ renderSettingList('tsCatList', admin.templateSettings.categories, (item) => { 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')); }); /* SubCategories list */ const catName = (categoryId) => admin.templateSettings.categories.find((c) => c.id === categoryId)?.value || '?'; renderSettingListFn('tsSubCatList', admin.templateSettings.subCategories, (item) => `${item.value} (${esc(catName(item.categoryId))})`, (item) => { 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) => { fetchJson(`${API}/sub-categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, categoryId: item.categoryId }) }).then(() => { item.value = newVal; cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); }); /* Parent dropdown for sub categories */ const parentSel = document.getElementById('tsSubCatParent'); if (parentSel) { const current = parentSel.value; parentSel.innerHTML = ''; admin.templateSettings.categories.forEach((c) => { parentSel.innerHTML += ``; }); parentSel.value = current; } /* Severities */ renderSettingList('tsSevList', admin.templateSettings.severities, (item) => { 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) => { fetchJson(`${API}/severities/${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')); }); /* Statuses — custom rendering with requirement indicators */ renderStatusList('tsStatusList', admin.templateSettings.statuses); /* Handled By */ renderSettingList('tsHandledList', admin.templateSettings.handledBy, (item) => { 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"]', addProject); on('click', '[data-tk-action="add-proc"]', addProcess); bindEnter('tkProjInput', addProject); bindEnter('tkProcInput', addProcess); } function addProject() { const input = document.getElementById('tkProjInput'); const val = input.value.trim(); if (!val) return; if (admin.taskSettings.projects.some((p) => p.value === val)) { showToast('Project already exists.', 'warning'); return; } fetchJson(`${API}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) }) .then((created) => { admin.taskSettings.projects.push(created); cacheState(); input.value = ''; renderTaskSettings(); }) .catch((err) => showToast(err.message || 'Failed to add project.', 'error')); } function addProcess() { const input = document.getElementById('tkProcInput'); const parentSel = document.getElementById('tkProcParent'); const val = input.value.trim(); const projectId = Number(parentSel.value); if (!val) return; if (!projectId) { showToast('Select a parent project first.', 'warning'); return; } fetchJson(`${API}/processes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, projectId }) }) .then((created) => { admin.taskSettings.processes.push(created); cacheState(); input.value = ''; renderTaskSettings(); }) .catch((err) => showToast(err.message || 'Failed to add process.', 'error')); } function renderTaskSettings() { renderSettingList('tkProjList', admin.taskSettings.projects, (item) => { 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')); }); const projName = (id) => admin.taskSettings.projects.find((p) => p.id === id)?.value || '?'; renderSettingListFn('tkProcList', admin.taskSettings.processes, (item) => `${item.value} (${esc(projName(item.projectId))})`, (item) => { 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) => { fetchJson(`${API}/processes/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, projectId: item.projectId }) }).then(() => { item.value = newVal; cacheState(); renderTaskSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); }); /* Parent dropdown */ const parentSel = document.getElementById('tkProcParent'); if (parentSel) { const current = parentSel.value; parentSel.innerHTML = ''; admin.taskSettings.projects.forEach((p) => { parentSel.innerHTML += ``; }); parentSel.value = current; } } /* ═══════════════════════════════════════════════════════════════════════════ * 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('cancelUserBtn')?.addEventListener('click', hideUserForm); document.getElementById('showUserFormBtn')?.addEventListener('click', () => { clearUserForm(); showSection('userFormSection'); }); } 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 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(); hideSection('userFormSection'); } 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'; showSection('userFormSection'); } 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)))); } /* ═══════════════════════════════════════════════════════════════════════════ * SITES — list-first with inline form; Host is dropdown (OBE, PXS) * ═══════════════════════════════════════════════════════════════════════════ */ function bindSiteForm() { const form = document.getElementById('siteForm'); if (!form) return; form.addEventListener('submit', (e) => { e.preventDefault(); saveSite(); }); document.getElementById('cancelSiteBtn')?.addEventListener('click', hideSiteForm); document.getElementById('showSiteFormBtn')?.addEventListener('click', () => { clearSiteForm(); showSection('siteFormSection'); }); } function saveSite() { const el = (id) => document.getElementById(id); const data = { siteCode: el('siteSiteCode').value.trim(), host: el('siteHost').value, obeSiteCode: el('siteObe').value.trim(), pxsSiteCode: el('sitePxs').value.trim() }; 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'; resetEditing(); } function hideSiteForm() { clearSiteForm(); hideSection('siteFormSection'); } 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('siteFormHeading').textContent = 'Edit Site'; showSection('siteFormSection'); } 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 || '-')} `).join(''); container.innerHTML = `${rows}
Site CodeHostOBE Site CodePXS Site CodeActions
`; 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('cancelClRecBtn')?.addEventListener('click', hideClRecForm); document.getElementById('showClRecFormBtn')?.addEventListener('click', () => { clearClRecordForm(); refreshRecordDropdowns(); showSection('clRecFormSection'); }); } function refreshRecordDropdowns() { populateSelect('clRecCategory', admin.templateSettings.categories.map((c) => c.value)); /* Sub-category is filtered by selected category */ updateRecordSubCategoryDropdown(); populateSelect('clRecSeverity', admin.templateSettings.severities.map((s) => s.value)); 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 selectedCatValue = catSel?.value || ''; if (!selectedCatValue) { /* No category selected — show empty sub-category dropdown */ populateSelect('clRecSubCategory', []); return; } /* Find the category object to get its id */ const cat = admin.templateSettings.categories.find((c) => c.value === selectedCatValue); if (!cat) { populateSelect('clRecSubCategory', []); return; } /* Only show sub-categories that belong to the selected category */ const filtered = admin.templateSettings.subCategories .filter((s) => s.categoryId === cat.id) .map((s) => s.value); populateSelect('clRecSubCategory', filtered); } 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, category: el('clRecCategory').value, subCategory: el('clRecSubCategory').value, severity: el('clRecSeverity').value, 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(); hideSection('clRecFormSection'); } 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.category || ''; /* Re-filter sub-categories after setting the category value */ updateRecordSubCategoryDropdown(); el('clRecSubCategory').value = rec.subCategory || ''; el('clRecSeverity').value = rec.severity || ''; 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'; showSection('clRecFormSection'); } 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('cancelClTplBtn')?.addEventListener('click', hideClTplForm); document.getElementById('showClTplFormBtn')?.addEventListener('click', () => { clearClTemplateForm(); renderClTemplateRecordSelection(); showSection('clTplFormSection'); }); } 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(); hideSection('clTplFormSection'); } 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(); showSection('clTplFormSection'); } 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('cancelTaskBtn')?.addEventListener('click', hideTaskForm); document.getElementById('showTaskFormBtn')?.addEventListener('click', () => { clearTaskForm(); refreshTaskDropdowns(); showSection('taskFormSection'); }); } 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 —'); populateSelect('taskProject', admin.taskSettings.projects.map((p) => p.value)); /* 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 selectedProject = projSel?.value || ''; if (!selectedProject) { populateSelect('taskProcess', admin.taskSettings.processes.map((p) => p.value)); } else { const parentProj = admin.taskSettings.projects.find((p) => p.value === selectedProject); const filtered = admin.taskSettings.processes .filter((p) => p.projectId === (parentProj?.id || 0)) .map((p) => p.value); populateSelect('taskProcess', filtered); } } 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), project: el('taskProject').value, process: el('taskProcess').value }; 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(); hideSection('taskFormSection'); } 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.project || ''; el('taskProcess').value = task.process || ''; el('taskFormHeading').textContent = 'Edit Task Assignment'; showSection('taskFormSection'); } 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); } }); } function buildAndShowReportModal(task, site, user, tpl, data, records) { 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}
`; const statusBadge = status === 'NOK' ? 'bg-danger' : status === 'OK' ? 'bg-success' : status === 'TBC' ? 'bg-warning text-dark' : 'bg-secondary'; return `
#${rec.sort} ${esc(desc)} ${rec.imageRequired ? 'IMG REQ' : ''}
Category: ${esc(rec.category || '-')} Sub: ${esc(rec.subCategory || '-')} Severity: ${esc(rec.severity || '-')}
Status: ${esc(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 })); }); 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. */ async function fetchReportImagesForAdmin(taskId, records) { 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)}
` ).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 })); }); } } 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.

'; } 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 }]); }); } 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 } */ function openAdminMapModal(points) { if (!points?.length) { showToast('No geo-tagged images found.', '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 */ const map = L.map(container).setView([points[0].lat, points[0].lng], 14); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19 }).addTo(map); const bounds = L.latLngBounds(); for (const pt of points) { const marker = L.marker([pt.lat, pt.lng]).addTo(map); let popupHtml = ''; if (pt.dataUrl) { popupHtml += ``; } popupHtml += `${esc(pt.name)}
${pt.lat.toFixed(6)}, ${pt.lng.toFixed(6)}`; marker.bindPopup(popupHtml, { maxWidth: 200 }); bounds.extend([pt.lat, pt.lng]); } if (points.length > 1) map.fitBounds(bounds, { padding: [30, 30] }); /* 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. */ async function openTaskImagesMap(taskId) { try { 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) { showToast('No images with GPS coordinates found for this task.', 'info'); return; } openAdminMapModal(points); } 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 status list with requirement indicators and edit support. */ function renderStatusList(containerId, items) { const container = document.getElementById(containerId); if (!container) return; if (!items.length) { container.innerHTML = '

No items yet.

'; return; } container.innerHTML = ''; items.forEach((item) => { const row = document.createElement('div'); row.className = 'setting-list-item'; const badges = []; if (item.requireHandledBy) badges.push('HB'); if (item.requireComment) badges.push('CMT'); row.innerHTML = `${esc(item.value)}${badges.join('')} `; row.querySelector('.setting-del-btn').addEventListener('click', () => { 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')); }); row.querySelector('.setting-edit-btn').addEventListener('click', () => { const newVal = prompt('Edit status value:', item.value); if (newVal === null || !newVal.trim()) return; const reqHB = confirm('Require "Handled By" for this status?'); const reqCmt = confirm('Require "Comment" for this status?'); fetchJson(`${API}/statuses/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal.trim(), requireHandledBy: reqHB, requireComment: reqCmt }) }).then(() => { item.value = newVal.trim(); item.requireHandledBy = reqHB; item.requireComment = reqCmt; cacheState(); renderTemplateSettings(); }).catch((err) => showToast(err.message || 'Update failed.', 'error')); }); container.appendChild(row); }); } /** Render an editable/deletable list of {id, value} items. */ function renderSettingList(containerId, items, onDelete, onEdit) { renderSettingListFn(containerId, items, (item) => esc(item.value), onDelete, onEdit); } /** Render list with a custom label function. */ function renderSettingListFn(containerId, items, labelFn, onDelete, onEdit) { const container = document.getElementById(containerId); if (!container) return; if (!items.length) { container.innerHTML = '

No items yet.

'; return; } container.innerHTML = ''; items.forEach((item) => { const row = document.createElement('div'); row.className = 'setting-list-item'; row.innerHTML = `${labelFn(item)} `; row.querySelector('.setting-del-btn').addEventListener('click', () => onDelete(item)); row.querySelector('.setting-edit-btn').addEventListener('click', () => { const newVal = prompt('Edit value:', item.value); if (newVal !== null && newVal.trim()) onEdit(item, newVal.trim()); }); container.appendChild(row); }); } 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`; } 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(); } }); }