Files
2026-04-26 16:00:43 +02:00

2384 lines
115 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Syncing…';
}
try {
await loadFromServer();
renderImagePolicy();
renderTemplateSettings();
renderTaskSettings();
renderGeoSettings();
renderUserList();
renderSiteList();
refreshRecordDropdowns(); renderClRecordList();
renderClTemplateRecordSelection(); renderClTemplateList();
refreshTaskDropdowns(); renderTaskList();
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>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',
`<div class="mb-3">
<label class="form-label">Category name</label>
<input id="sltModalVal" class="form-control" type="text" placeholder="Category name…" />
</div>`,
(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) =>
`<option value="${c.id}">${esc(c.value)}</option>`).join('');
openSettingsModal('Add Sub Category',
`<div class="mb-3">
<label class="form-label">Parent Category</label>
<select id="sltModalParent" class="form-select">
<option value="">— Select parent —</option>${catOpts}
</select>
</div>
<div class="mb-3">
<label class="form-label">Sub Category name</label>
<input id="sltModalVal" class="form-control" type="text" placeholder="Sub category name…" />
</div>`,
(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',
`<div class="mb-3">
<label class="form-label">Severity name</label>
<input id="sltModalVal" class="form-control" type="text" placeholder="Severity name…" />
</div>
<div class="mb-3">
<label class="form-label">Background color <span class="text-muted small">(optional)</span></label>
<div class="d-flex align-items-center gap-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="sltModalColorEn" />
<label class="form-check-label small" for="sltModalColorEn">Set a color</label>
</div>
<input id="sltModalColor" type="color" class="form-control form-control-color" value="#4dabf7" title="Pick a color" style="display:none;width:2.5rem" />
</div>
</div>`,
(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',
`<div class="mb-3">
<label class="form-label">Status name</label>
<input id="sltModalVal" class="form-control" type="text" placeholder="Status name…" />
</div>
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="sltModalReqHB" />
<label class="form-check-label" for="sltModalReqHB">Handled By required</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="sltModalReqCmt" />
<label class="form-check-label" for="sltModalReqCmt">Comment required</label>
</div>
</div>
<div class="mb-3">
<label class="form-label">Background color <span class="text-muted small">(optional)</span></label>
<div class="d-flex align-items-center gap-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="sltModalColorEn" />
<label class="form-check-label small" for="sltModalColorEn">Set a color</label>
</div>
<input id="sltModalColor" type="color" class="form-control form-control-color" value="#4dabf7" title="Pick a color" style="display:none;width:2.5rem" />
</div>
</div>`,
(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',
`<div class="mb-3">
<label class="form-label">Handler name</label>
<input id="sltModalVal" class="form-control" type="text" placeholder="Handler name…" />
</div>`,
(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',
`<div class="mb-3">
<label class="form-label">Project name</label>
<input id="sltModalVal" class="form-control" type="text" placeholder="Project name…" />
</div>`,
(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) =>
`<option value="${p.id}">${esc(p.value)}</option>`).join('');
openSettingsModal('Add Process',
`<div class="mb-3">
<label class="form-label">Parent Project</label>
<select id="sltModalParent" class="form-select">
<option value="">— Select project —</option>${projOpts}
</select>
</div>
<div class="mb-3">
<label class="form-label">Process name</label>
<input id="sltModalVal" class="form-control" type="text" placeholder="Process name…" />
</div>`,
(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) =>
`<option value="${c.id}" ${c.id === item.categoryId ? 'selected' : ''}>${esc(c.value)}</option>`
).join('');
return `<select class="form-select form-select-sm" style="max-width:180px" data-slt-subcat-par="${item.id}">
<option value="">Parent…</option>${opts}
</select>`;
},
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
? `<span class="d-inline-block border rounded px-2 py-1 small" style="background:${escAttr(item.color)};color:${colorContrast(item.color)}">${esc(item.color)}</span>`
: '<span class="text-muted">—</span>',
editHtml: (item) => {
const hasColor = !!item.color;
return `<div class="d-flex align-items-center gap-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="slt-color-en-${item.id}" data-slt-color-en="${item.id}" ${hasColor ? 'checked' : ''} />
<label class="form-check-label small" for="slt-color-en-${item.id}">Set a color</label>
</div>
<input type="color" data-slt-color="${item.id}" value="${escAttr(item.color || '#4dabf7')}" class="form-control form-control-color" style="width:2.5rem;${hasColor ? '' : 'display:none'}" />
</div>`;
},
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
? '<span class="badge bg-info text-dark">Yes</span>'
: '<span class="text-muted">—</span>',
editHtml: (item) => `<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="slt-hb-${item.id}" data-slt-hb="${item.id}" ${item.requireHandledBy ? 'checked' : ''} />
<label class="form-check-label small" for="slt-hb-${item.id}">Handled By req.</label>
</div>`,
collect: (id) => document.querySelector(`[data-slt-hb="${id}"]`)?.checked || false
},
{ header: 'Comment req.',
cell: (item) => item.requireComment
? '<span class="badge bg-info text-dark">Yes</span>'
: '<span class="text-muted">—</span>',
editHtml: (item) => `<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="slt-cmt-${item.id}" data-slt-cmt="${item.id}" ${item.requireComment ? 'checked' : ''} />
<label class="form-check-label small" for="slt-cmt-${item.id}">Comment req.</label>
</div>`,
collect: (id) => document.querySelector(`[data-slt-cmt="${id}"]`)?.checked || false
},
{ header: 'Color',
cell: (item) => item.color
? `<span class="d-inline-block border rounded px-2 py-1 small" style="background:${escAttr(item.color)};color:${colorContrast(item.color)}">${esc(item.color)}</span>`
: '<span class="text-muted">—</span>',
editHtml: (item) => {
const hasColor = !!item.color;
return `<div class="d-flex align-items-center gap-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="slt-color-en-${item.id}" data-slt-status-color-en="${item.id}" ${hasColor ? 'checked' : ''} />
<label class="form-check-label small" for="slt-color-en-${item.id}">Set a color</label>
</div>
<input type="color" data-slt-status-color="${item.id}" value="${escAttr(item.color || '#4dabf7')}" class="form-control form-control-color" style="width:2.5rem;${hasColor ? '' : 'display:none'}" />
</div>`;
},
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) =>
`<option value="${p.id}" ${p.id === item.projectId ? 'selected' : ''}>${esc(p.value)}</option>`
).join('');
return `<select class="form-select form-select-sm" style="max-width:180px" data-slt-proc-par="${item.id}">
<option value="">Parent project…</option>${opts}
</select>`;
},
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 = '<div class="empty-state"><h3>No users</h3><p>Click "Add User" to create one.</p></div>';
return;
}
const rows = admin.users.map((u) => {
const taskCount = admin.tasks.filter((t) => t.userId === u.id).length;
const taskBadge = taskCount > 0
? `<span class="badge bg-primary">${taskCount}</span>`
: `<span class="badge bg-secondary">0</span>`;
return `<tr>
<td>${esc(u.email)}</td><td>${esc(u.name)}</td><td>${esc(u.familyName)}</td>
<td>${esc(u.company || '-')}</td><td>${esc(u.role || '-')}</td>
<td class="text-center">${taskBadge}</td>
<td class="admin-table-actions">
<button class="button button-small button-secondary" data-edit-user="${u.id}">Edit</button>
<button class="button button-small button-ghost" data-delete-user="${u.id}">Delete</button>
</td></tr>`;
}).join('');
container.innerHTML = `<table class="admin-table"><thead><tr>
<th>Email</th><th>Name</th><th>Family Name</th><th>Company</th><th>Role</th><th>Tasks</th><th>Actions</th>
</tr></thead><tbody>${rows}</tbody></table>`;
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 = '<div class="empty-state"><h3>No sites</h3><p>Click "Add Site" to create one.</p></div>';
return;
}
const rows = admin.sites.map((s) => `<tr>
<td>${esc(s.siteCode)}</td><td>${esc(s.host || '-')}</td>
<td>${esc(s.obeSiteCode || '-')}</td><td>${esc(s.pxsSiteCode || '-')}</td>
<td>${s.latitude != null ? parseFloat(s.latitude).toFixed(5) : '-'}</td>\n <td>${s.longitude != null ? parseFloat(s.longitude).toFixed(5) : '-'}</td>
<td class="admin-table-actions">
<button class="button button-small button-secondary" data-edit-site="${s.id}">Edit</button>
<button class="button button-small button-ghost" data-delete-site="${s.id}">Delete</button>
</td></tr>`).join('');
container.innerHTML = `<table class="admin-table"><thead><tr>
<th>Site Code</th><th>Host</th><th>OBE Site Code</th><th>PXS Site Code</th><th>Latitude</th><th>Longitude</th><th>Actions</th>
</tr></thead><tbody>${rows}</tbody></table>`;
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 = '<div class="empty-state"><h3>No records</h3><p>Click "Add Record" to create one.</p></div>';
return;
}
const rows = admin.clRecords.map((r) => `<tr>
<td>${r.sort}</td><td>${esc(r.category || '-')}</td><td>${esc(r.subCategory || '-')}</td>
<td>${esc(r.descriptionEN || '-')}</td><td>${esc(r.severity || '-')}</td>
<td>${r.imageRequired ? '✓' : '-'}</td>
<td class="admin-table-actions">
<button class="button button-small button-secondary" data-edit-rec="${r.id}">Edit</button>
<button class="button button-small button-ghost" data-delete-rec="${r.id}">Delete</button>
</td></tr>`).join('');
container.innerHTML = `<table class="admin-table admin-table-compact"><thead><tr>
<th>Sort</th><th>Category</th><th>Sub Cat.</th><th>Description EN</th><th>Severity</th><th>Img</th><th>Actions</th>
</tr></thead><tbody>${rows}</tbody></table>`;
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 = '<p class="field-help">No records available. Add records first.</p>';
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 `<tr><td><input type="checkbox" class="tpl-rec-check" value="${r.id}" ${checked}/></td>
<td>${r.sort}</td><td>${esc(r.category || '-')}</td><td>${esc(r.descriptionEN || '-')}</td></tr>`;
}).join('');
container.innerHTML = `<table class="admin-table admin-table-compact"><thead><tr>
<th style="width:50px">Include</th><th>Sort</th><th>Category</th><th>Description EN</th>
</tr></thead><tbody>${rows}</tbody></table>`;
}
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 = '<div class="empty-state"><h3>No templates</h3><p>Click "Add Template" to create one.</p></div>';
return;
}
const fmtDate = (d) => d ? formatDateDisplay(d) : '-';
const rows = admin.clTemplates.map((t) => `<tr>
<td>${esc(t.name)}</td><td>${esc(t.scope || '-')}</td><td>${esc(t.version || '-')}</td>
<td>${fmtDate(t.validFrom)}</td><td>${fmtDate(t.validTill)}</td><td>${(t.recordIds || []).length}</td>
<td class="admin-table-actions">
<button class="button button-small button-secondary" data-edit-tpl="${t.id}">Edit</button>
<button class="button button-small button-ghost" data-delete-tpl="${t.id}">Delete</button>
</td></tr>`).join('');
container.innerHTML = `<table class="admin-table"><thead><tr>
<th>Name</th><th>Scope</th><th>Version</th><th>Valid From</th><th>Valid Till</th><th>Records</th><th>Actions</th>
</tr></thead><tbody>${rows}</tbody></table>`;
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 = '<div class="empty-state"><h3>No tasks</h3><p>Click "Add Task" to assign one.</p></div>';
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 `<span class="badge ${cls}">${esc(s || 'pending')}</span>`;
};
const rows = admin.tasks.map((t) => `<tr>
<td>${esc(siteName(t.siteId))}</td><td>${esc(userName(t.userId))}</td>
<td>${esc(tplName(t.templateId))}</td><td>${esc(t.project || '-')}</td>
<td>${esc(t.process || '-')}</td><td>${statusBadge(t.status)}</td>
<td class="admin-table-actions">
<button class="button button-small button-primary" data-view-task="${t.id}">View</button>
<button class="button button-small" data-map-task="${t.id}" title="Show images on map" style="background:#0dcaf0;color:#000;border:none;"><i class="bi bi-geo-alt"></i> Map</button>
${t.status === 'final' ? `<button class="button button-small button-warning" data-reopen-task="${t.id}">Reopen</button>` : ''}
<button class="button button-small button-secondary" data-edit-task="${t.id}">Edit</button>
<button class="button button-small button-ghost" data-delete-task="${t.id}">Delete</button>
</td></tr>`).join('');
container.innerHTML = `<table class="admin-table"><thead><tr>
<th>Site</th><th>User</th><th>Template</th><th>Project</th><th>Process</th><th>Status</th><th>Actions</th>
</tr></thead><tbody>${rows}</tbody></table>`;
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 `<span class="badge ${fallbackClass || 'bg-secondary'}">${esc(val)}</span>`;
const color = statColor(val);
if (color) {
return `<span class="badge" style="background:${color};color:${colorContrast(color)}">${esc(val)}</span>`;
}
/* 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 `<span class="badge ${cls}">${esc(val)}</span>`;
};
const severityBadge = (val) => {
if (!val || val === '-') return `<span class="text-muted">${esc(val)}</span>`;
const color = sevColor(val);
if (color) {
return `<span class="badge" style="background:${color};color:${colorContrast(color)}">${esc(val)}</span>`;
}
return `<span>${esc(val)}</span>`;
};
let recordsHtml = '';
if (!records.length) {
recordsHtml = '<p class="text-muted">No records in template.</p>';
} 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
? `<span class="text-muted small"><i class="bi bi-hourglass-split me-1"></i>Loading ${images.length} image(s)...</span>`
: '<span class="text-muted small">No images</span>';
const serverImgPlaceholder = `<div class="server-images-slot" data-rec-id="${rec.id}">${imgPlaceholder}</div>`;
return `<div style="border:1px solid #dee2e6;border-radius:8px;padding:12px;margin-bottom:10px;">
<div class="d-flex align-items-center gap-2 mb-2">
<span class="badge bg-primary">#${rec.sort}</span>
<strong>${esc(desc)}</strong>
${rec.imageRequired ? '<span class="badge bg-warning text-dark">IMG REQ</span>' : ''}
</div>
<div class="d-flex gap-3 small text-muted mb-2">
<span><strong>Category:</strong> ${esc(rec.category || '-')}</span>
<span><strong>Sub:</strong> ${esc(rec.subCategory || '-')}</span>
<span><strong>Severity:</strong> ${severityBadge(rec.severity || '-')}</span>
</div>
<div class="row g-2 small">
<div class="col-md-3"><strong>Status:</strong> ${coloredBadge(status)}</div>
<div class="col-md-3"><strong>Handled By:</strong> ${esc(handledBy)}</div>
<div class="col-md-6"><strong>Comment:</strong> ${esc(comment)}</div>
</div>
<div class="mt-2">${serverImgPlaceholder}</div>
</div>`;
}).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 = `<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="adminReportModalTitle">Report Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="adminReportModalBody"></div>
<div class="modal-footer" id="adminReportModalFooter">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>`;
document.body.appendChild(modal);
}
const taskId = task.id;
document.getElementById('adminReportModalTitle').textContent = `Report — ${site?.siteCode || 'Unknown'} / ${tpl?.name || 'Unknown'}`;
document.getElementById('adminReportModalBody').innerHTML = `
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card border-primary"><div class="card-body py-2 px-3"><small class="text-muted">Site</small><div class="fw-semibold">${esc(site?.siteCode || '-')}</div></div></div></div>
<div class="col-md-2"><div class="card"><div class="card-body py-2 px-3"><small class="text-muted">Project</small><div class="fw-semibold">${esc(task.project || '-')}</div></div></div></div>
<div class="col-md-2"><div class="card"><div class="card-body py-2 px-3"><small class="text-muted">Process</small><div class="fw-semibold">${esc(task.process || '-')}</div></div></div></div>
<div class="col-md-2"><div class="card"><div class="card-body py-2 px-3"><small class="text-muted">Status</small><div><span class="badge ${taskStatusBadge}">${esc(task.status || 'pending')}</span></div></div></div></div>
<div class="col-md-3"><div class="card"><div class="card-body py-2 px-3"><small class="text-muted">User</small><div class="fw-semibold">${esc(user ? `${user.name} ${user.familyName}` : '-')}</div></div></div></div>
</div>
<div class="mb-3"><strong>Visit Date:</strong> ${esc(data.visitDate || 'Not set')}</div>
<h6 class="fw-semibold mb-3">Records (${records.length})</h6>
${recordsHtml}
`;
/* Reopen button (only for final tasks) */
const footer = document.getElementById('adminReportModalFooter');
footer.innerHTML = task.status === 'final'
? `<button type="button" class="btn btn-warning btn-sm" id="adminReopenTaskBtn">Reopen Task</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>`
: `<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>`;
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 '<span class="badge bg-secondary ms-1" title="Site has no geo coordinates configured">GPS: N/A</span>';
}
const lat = img.exif?.latitude;
const lon = img.exif?.longitude;
if (lat == null || lon == null) {
return '<span class="badge bg-secondary ms-1" title="No GPS data in image EXIF">No GPS</span>';
}
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 `<span class="badge bg-success ms-1" title="Image taken ${distanceM}\u00a0m from site (allowed: ${radiusM}\u00a0m)">&#10003; GPS ${distanceM}\u00a0m</span>`;
}
return `<span class="badge bg-warning text-dark ms-1" title="Image taken ${distanceM}\u00a0m from site (allowed: ${radiusM}\u00a0m)">&#9888; GPS ${distanceM}\u00a0m</span>`;
}
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 = '<span class="text-muted small">Could not load images.</span>';
}
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 = '<span class="text-muted small">No images</span>';
continue;
}
slot.innerHTML = imgs.map((img, i) =>
`<div class="d-inline-block text-center me-2 mb-2" style="max-width:90px;">
<div class="position-relative d-inline-block">
<img src="${img.dataUrl}" alt="${esc(img.name || `img-${i}`)}" data-admin-lightbox data-img-name="${escAttr(img.name || '')}" data-img-size="${img.size || 0}" data-img-width="${img.width || ''}" data-img-height="${img.height || ''}" data-img-exif="${escAttr(JSON.stringify(img.exif || {}))}" style="width:60px;height:60px;object-fit:cover;border-radius:4px;border:1px solid #dee2e6;cursor:pointer" />
</div>
<div class="small text-muted text-truncate" style="max-width:90px" title="${esc(img.name || '')}">${esc(img.name || `img-${i}`)}</div>
<div class="small text-muted">${formatFileSize(img.size)}</div>
<div>${buildGeoFenceBadge(img, site)}</div>
</div>`
).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 = `<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content" style="resize:both;overflow:auto;">
<div class="modal-header">
<h6 class="modal-title" id="adminLightboxTitle">Image</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-2">
<div class="row">
<div class="col-md-8 text-center">
<div class="mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" id="adminLightboxZoomOut" title="Zoom out"><i class="bi bi-zoom-out"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm me-2" id="adminLightboxZoomIn" title="Zoom in"><i class="bi bi-zoom-in"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm me-1" id="adminLightboxRotateLeft" title="Rotate left"><i class="bi bi-arrow-counterclockwise"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="adminLightboxRotateRight" title="Rotate right"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<div style="overflow:auto;max-height:72vh;">
<img id="adminLightboxImg" src="" alt="" style="max-width:100%;max-height:70vh;object-fit:contain;transition:transform 0.3s;" />
</div>
</div>
<div class="col-md-4" id="adminLightboxInfo" style="font-size:0.85rem;"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>`;
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 = '<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-1"></i>Image Details</h6>';
infoHtml += '<table class="table table-sm table-borderless mb-3">';
if (meta?.name) infoHtml += `<tr><td class="fw-semibold text-nowrap">File Name</td><td class="text-break">${esc(meta.name)}</td></tr>`;
if (meta?.size) infoHtml += `<tr><td class="fw-semibold">Size</td><td>${formatFileSize(meta.size)}</td></tr>`;
if (meta?.width && meta?.height) infoHtml += `<tr><td class="fw-semibold">Dimensions</td><td>${meta.width} × ${meta.height} px</td></tr>`;
infoHtml += '</table>';
/* EXIF data */
const exif = meta?.exif;
if (exif && typeof exif === 'object' && Object.keys(exif).length) {
infoHtml += '<h6 class="fw-semibold mb-2"><i class="bi bi-camera me-1"></i>EXIF Data</h6>';
infoHtml += '<table class="table table-sm table-borderless">';
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 += `<tr><td class="fw-semibold text-nowrap">${label}</td><td>${esc(String(val))}</td></tr>`;
}
}
infoHtml += '</table>';
/* Show on map button if GPS coords present */
if (exif.latitude != null && exif.longitude != null) {
infoHtml += `<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-2" id="adminLightboxMapBtn"><i class="bi bi-geo-alt me-1"></i>Show on Map</button>`;
}
} else {
infoHtml += '<p class="text-muted small">No EXIF data available.</p>';
}
/* Geo-fence result */
if (meta?.site) {
const geoBadge = buildGeoFenceBadge({ exif: meta.exif }, meta.site);
infoHtml += `<div class="mt-2"><span class="fw-semibold">Geo-fence:</span> ${geoBadge}</div>`;
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 += `<p class="text-muted small mb-0">Distance to site: <strong>${dist}\u00a0m</strong> &mdash; allowed radius: <strong>${radiusM}\u00a0m</strong></p>`;
}
}
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 = `<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content" style="resize:both;overflow:auto;">
<div class="modal-header">
<h6 class="modal-title" id="adminMapTitle"><i class="bi bi-geo-alt me-1"></i>Image Locations</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="adminMapContainer" style="height:500px;width:100%;"></div>
</div>
<div class="modal-footer">
<div id="adminMapLegend" class="me-auto d-flex gap-3 flex-wrap small"></div>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>`;
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: '&copy; 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: `<div style="width:16px;height:16px;background:#0d6efd;border:3px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,.4);"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
L.marker([siteLat, siteLng], { icon: siteIcon })
.addTo(map)
.bindPopup(`<strong>📍 Site: ${esc(opts.site.siteCode || 'Unknown')}</strong><br><small>${siteLat.toFixed(6)}, ${siteLng.toFixed(6)}</small><br><small>Geo-fence radius: ${radiusM} m</small>`, { 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: `<div style="width:14px;height:14px;background:${dotColor};border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,.4);"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7]
});
const marker = L.marker([pt.lat, pt.lng], { icon: imgIcon }).addTo(map);
let popupHtml = '';
if (pt.dataUrl) {
popupHtml += `<img src="${pt.dataUrl}" style="width:120px;height:90px;object-fit:cover;border-radius:4px;display:block;margin-bottom:6px;" />`;
}
popupHtml += `<strong>${esc(pt.name)}</strong><br><small>${pt.lat.toFixed(6)}, ${pt.lng.toFixed(6)}</small>`;
if (inZone !== null) {
const dist = Math.round(adminHaversineDistanceM(pt.lat, pt.lng, opts.site.latitude, opts.site.longitude));
popupHtml += `<br><small>${inZone ? '✅' : '⚠️'} ${dist} m from site</small>`;
}
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 += `<span><span style="display:inline-block;width:12px;height:12px;background:#0d6efd;border-radius:50%;border:2px solid #fff;box-shadow:0 0 3px rgba(0,0,0,.3);vertical-align:middle;"></span> Site location</span>`;
legendHtml += `<span style="border:1px dashed #0d6efd;padding:1px 6px;border-radius:3px;color:#0d6efd;">Geo-fence zone</span>`;
}
if (hasImages) {
legendHtml += `<span><span style="display:inline-block;width:12px;height:12px;background:#198754;border-radius:50%;border:2px solid #fff;vertical-align:middle;"></span> Image within zone</span>`;
legendHtml += `<span><span style="display:inline-block;width:12px;height:12px;background:#ffc107;border-radius:50%;border:2px solid #fff;vertical-align:middle;"></span> Image outside zone</span>`;
}
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 = '<option value="">— Select —</option>';
(options || []).forEach((v) => { sel.innerHTML += `<option value="${esc(v)}">${esc(v)}</option>`; });
sel.value = cur;
}
function populateSelectObjects(selectId, items, placeholder) {
const sel = document.getElementById(selectId);
if (!sel) return;
const cur = sel.value;
sel.innerHTML = `<option value="">${placeholder || '— Select —'}</option>`;
items.forEach(({ value, label }) => { sel.innerHTML += `<option value="${esc(value)}">${esc(label)}</option>`; });
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) => `<th>${c.header}</th>`).join('') + '<th>Actions</th>';
container.innerHTML = `<table class="admin-table"><thead><tr>${hdrs}</tr></thead>
<tbody><tr><td colspan="${colSpan}"><div class="empty-state p-3"><p class="mb-0 text-muted">No items yet.</p></div></td></tr></tbody></table>`;
return;
}
const tbodyRows = items.map((item) => {
const dataCells = columns.map((c) => `<td>${c.cell(item)}</td>`).join('');
return `<tr>
${dataCells}
<td class="admin-table-actions">
<button class="button button-small button-secondary" data-slt-edit="${item.id}">Edit</button>
<button class="button button-small button-ghost" data-slt-del="${item.id}">Delete</button>
</td>
</tr>`;
}).join('');
const hdrRow = columns.map((c) => `<th>${c.header}</th>`).join('') + '<th>Actions</th>';
container.innerHTML = `<table class="admin-table"><thead><tr>${hdrRow}</tr></thead>
<tbody>${tbodyRows}</tbody></table>`;
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) => `
<div class="mb-3">${c.editHtml(item)}</div>`).join('');
document.getElementById('settingsItemModalLabel').textContent = 'Edit Item';
document.getElementById('settingsItemModalBody').innerHTML = `
<div class="mb-3">
<label class="form-label fw-semibold">${esc(columns[0].header)}</label>
<input type="text" class="form-control" id="sltModalVal" value="${escAttr(item.value)}" />
</div>
${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, '&quot;').replace(/'/g, '&#39;'); }
/** 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(); } });
}