Files
CLProject/public/js/admin.js
T
2026-04-21 23:26:13 +02:00

1796 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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}]
},
editingId: null,
editingType: null
};
/* ── Persistence helpers (MariaDB via REST API, IndexedDB as offline cache) ── */
/*
* Loads all admin entity data from the server (MariaDB) via the bulk endpoint,
* and caches in IndexedDB for offline use.
*/
async function loadFromServer() {
try {
const data = await fetchJson(`${API}/all`);
admin.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
admin.taskSettings = data.taskSettings || { projects: [], processes: [] };
admin.users = data.users || [];
admin.sites = data.sites || [];
admin.clRecords = data.clRecords || [];
admin.clTemplates = data.clTemplates || [];
admin.tasks = data.tasks || [];
/* Cache in IndexedDB for offline access */
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {});
return true;
} catch (err) {
console.warn('Failed to load admin data from server, using IndexedDB cache', err);
return false;
}
}
/*
* Loads from IndexedDB cache (offline fallback).
*/
async function loadFromCache() {
try {
const row = await dbGet(STORE_CONFIG, 'admin_all');
const data = row?.value;
if (!data) return;
admin.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
admin.taskSettings = data.taskSettings || { projects: [], processes: [] };
admin.users = data.users || [];
admin.sites = data.sites || [];
admin.clRecords = data.clRecords || [];
admin.clTemplates = data.clTemplates || [];
admin.tasks = data.tasks || [];
} catch { /* ignore */ }
}
/*
* Caches current admin state to IndexedDB.
*/
function cacheState() {
const data = {
templateSettings: admin.templateSettings,
taskSettings: admin.taskSettings,
users: admin.users,
sites: admin.sites,
clRecords: admin.clRecords,
clTemplates: admin.clTemplates,
tasks: admin.tasks
};
dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Initialization
* ═══════════════════════════════════════════════════════════════════════════ */
export async function initAdmin({ imageRules }) {
admin.imageRules = imageRules;
/* Load from server (MariaDB) first; data is also cached in IndexedDB. */
if (navigator.onLine) {
await loadFromServer();
} else {
await loadFromCache();
}
initNavigation();
bindImagePolicyForm();
bindTemplateSettingsForm();
bindTaskSettingsForm();
bindUserForm();
bindSiteForm();
bindClRecordForm();
bindClTemplateForm();
bindTaskForm();
renderImagePolicy();
renderTemplateSettings();
renderTaskSettings();
renderUserList();
renderSiteList();
renderClRecordList();
renderClTemplateList();
renderTaskList();
updateConnectionBadge();
window.addEventListener('online', updateConnectionBadge);
window.addEventListener('offline', updateConnectionBadge);
if (navigator.onLine) syncImageRules();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Navigation
* ═══════════════════════════════════════════════════════════════════════════ */
function initNavigation() {
const nav = document.getElementById('adminNav');
if (!nav) return;
nav.querySelectorAll('.admin-nav-cat-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const cat = btn.closest('.admin-nav-cat');
cat.classList.toggle('is-open');
const arrow = btn.querySelector('.nav-arrow');
if (arrow) arrow.textContent = cat.classList.contains('is-open') ? '▾' : '▸';
});
});
nav.querySelectorAll('.admin-nav-item').forEach((item) => {
item.addEventListener('click', () => {
activateNavItem(item);
showPanel(item.dataset.panel);
});
});
}
function activateNavItem(item) {
const nav = document.getElementById('adminNav');
nav.querySelectorAll('.admin-nav-item').forEach((i) => i.classList.remove('is-active'));
item.classList.add('is-active');
const cat = item.closest('.admin-nav-cat');
if (cat && !cat.classList.contains('is-open')) {
cat.classList.add('is-open');
const arrow = cat.querySelector('.nav-arrow');
if (arrow) arrow.textContent = '▾';
}
}
function showPanel(panelId) {
document.querySelectorAll('.admin-panel').forEach((p) => p.classList.remove('admin-panel-active'));
const target = document.getElementById('panel-' + panelId);
if (target) target.classList.add('admin-panel-active');
switch (panelId) {
case 'users': renderUserList(); break;
case 'sites': renderSiteList(); break;
case 'cl-records': refreshRecordDropdowns(); renderClRecordList(); break;
case 'cl-templates': renderClTemplateRecordSelection(); renderClTemplateList(); break;
case 'reports': refreshTaskDropdowns(); renderTaskList(); break;
case 'settings-template': renderTemplateSettings(); break;
case 'settings-task': renderTaskSettings(); break;
}
}
function navigateToPanel(panelId) {
const nav = document.getElementById('adminNav');
const navItem = nav.querySelector(`[data-panel="${panelId}"]`);
if (navItem) activateNavItem(navItem);
showPanel(panelId);
}
/* ═══════════════════════════════════════════════════════════════════════════
* Connection badge
* ═══════════════════════════════════════════════════════════════════════════ */
function updateConnectionBadge() {
const badge = document.getElementById('connectionBadge');
if (!badge) return;
badge.textContent = navigator.onLine ? 'Online' : 'Offline';
badge.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
}
/* ═══════════════════════════════════════════════════════════════════════════
* SETTINGS IMAGE POLICY
* ═══════════════════════════════════════════════════════════════════════════ */
async function syncImageRules() {
try {
const rules = await fetchJson('/config/image-rules');
admin.imageRules = rules;
await dbPut(STORE_CONFIG, { key: 'imageRules', value: rules });
renderImagePolicy();
} catch (err) { console.error('Image-rules sync failed', err); }
}
function bindImagePolicyForm() {
const form = document.getElementById('adminImageRulesForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); void saveImagePolicy(); });
document.getElementById('resetImageRulesButton')?.addEventListener('click', () => populateImagePolicyForm(admin.imageRules));
}
function renderImagePolicy() {
populateImagePolicyForm(admin.imageRules);
const syncState = document.getElementById('adminSyncState');
const mimeTypes = document.getElementById('adminPolicyMimeTypes');
const fileSize = document.getElementById('adminPolicyFileSize');
const opt = document.getElementById('adminPolicyOptimization');
const limits = document.getElementById('adminPolicyLimits');
if (!admin.imageRules) {
if (syncState) { syncState.textContent = 'No image rules loaded'; syncState.className = 'badge badge-offline'; }
[mimeTypes, fileSize, opt, limits].forEach((el) => { if (el) el.textContent = '-'; });
return;
}
if (syncState) {
syncState.textContent = navigator.onLine ? 'Live configuration' : 'Offline (cached)';
syncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
}
if (mimeTypes) mimeTypes.textContent = (admin.imageRules.allowedMimeTypes || []).join(', ');
if (fileSize) fileSize.textContent = fmtKb(admin.imageRules.maxFileSizeBytes);
if (opt) opt.textContent = `${admin.imageRules.oversizeBehavior}, quality ${admin.imageRules.jpegQuality || admin.imageRules.imageQuality || '-'}%`;
if (limits) limits.textContent = `${admin.imageRules.maxWidthPx}×${admin.imageRules.maxHeightPx}px`;
}
function populateImagePolicyForm(rules) {
if (!rules) return;
/* Checkboxes for MIME types */
const checkboxes = document.querySelectorAll('#adminMimeCheckboxes input[name="mimeType"]');
const allowed = new Set(rules.allowedMimeTypes || []);
checkboxes.forEach((cb) => { cb.checked = allowed.has(cb.value); });
const el = (id) => document.getElementById(id);
if (el('adminMaxFileSizeKb')) el('adminMaxFileSizeKb').value = String(Math.round((rules.maxFileSizeBytes || 0) / 1024));
if (el('adminMaxWidthPx')) el('adminMaxWidthPx').value = String(rules.maxWidthPx || '');
if (el('adminMaxHeightPx')) el('adminMaxHeightPx').value = String(rules.maxHeightPx || '');
if (el('adminImageQuality')) el('adminImageQuality').value = String(rules.jpegQuality || rules.imageQuality || '');
if (el('adminOversizeBehavior')) el('adminOversizeBehavior').value = rules.oversizeBehavior || 'auto_optimize';
}
async function saveImagePolicy() {
if (!navigator.onLine) { showToast('Go online to save.', 'warning'); return; }
const el = (id) => document.getElementById(id);
const mimeTypes = [...document.querySelectorAll('#adminMimeCheckboxes input[name="mimeType"]:checked')].map((cb) => cb.value);
const payload = {
name: 'default',
allowedMimeTypes: mimeTypes,
maxFileSizeBytes: Number(el('adminMaxFileSizeKb').value) * 1024,
maxWidthPx: Number(el('adminMaxWidthPx').value),
maxHeightPx: Number(el('adminMaxHeightPx').value),
jpegQuality: Number(el('adminImageQuality').value),
oversizeBehavior: el('adminOversizeBehavior').value,
maxAttachmentsPerField: 5
};
const msg = validateImageRulesPayload(payload);
if (msg) { showToast(msg, 'error'); return; }
const btn = document.getElementById('saveImageRulesButton');
btn.disabled = true;
try {
const result = await fetchJson('/config/image-rules', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
admin.imageRules = result;
await dbPut(STORE_CONFIG, { key: 'imageRules', value: result });
renderImagePolicy();
showToast('Image policy saved.', 'success');
} catch (err) { console.error(err); showToast(err.message || 'Save failed.', 'error'); }
finally { btn.disabled = false; }
}
/* ═══════════════════════════════════════════════════════════════════════════
* SETTINGS TEMPLATE — list-based editing with parent for SubCategories
* ═══════════════════════════════════════════════════════════════════════════ */
function bindTemplateSettingsForm() {
on('click', '[data-ts-action="add-cat"]', addCategory);
on('click', '[data-ts-action="add-subcat"]', addSubCategory);
on('click', '[data-ts-action="add-sev"]', () => addSimpleSetting('severities', 'tsSevInput', 'severities'));
on('click', '[data-ts-action="add-status"]', addStatus);
on('click', '[data-ts-action="add-handled"]', () => addSimpleSetting('handledBy', 'tsHandledInput', 'handled-by'));
/* Enter key support */
bindEnter('tsCatInput', addCategory);
bindEnter('tsSubCatInput', addSubCategory);
bindEnter('tsSevInput', () => addSimpleSetting('severities', 'tsSevInput', 'severities'));
bindEnter('tsStatusInput', addStatus);
bindEnter('tsHandledInput', () => addSimpleSetting('handledBy', 'tsHandledInput', 'handled-by'));
}
function addCategory() {
const input = document.getElementById('tsCatInput');
const val = input.value.trim();
if (!val) return;
if (admin.templateSettings.categories.some((c) => c.value === val)) { showToast('Category already exists.', 'warning'); return; }
fetchJson(`${API}/categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
.then((created) => {
admin.templateSettings.categories.push(created);
cacheState();
input.value = '';
renderTemplateSettings();
})
.catch((err) => showToast(err.message || 'Failed to add category.', 'error'));
}
function addSubCategory() {
const input = document.getElementById('tsSubCatInput');
const parentSel = document.getElementById('tsSubCatParent');
const val = input.value.trim();
const categoryId = Number(parentSel.value);
if (!val) return;
if (!categoryId) { showToast('Select a parent category first.', 'warning'); return; }
fetchJson(`${API}/sub-categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, categoryId }) })
.then((created) => {
admin.templateSettings.subCategories.push(created);
cacheState();
input.value = '';
renderTemplateSettings();
})
.catch((err) => showToast(err.message || 'Failed to add sub-category.', 'error'));
}
function addStatus() {
const input = document.getElementById('tsStatusInput');
const val = input.value.trim();
if (!val) return;
if (admin.templateSettings.statuses.some((i) => i.value === val)) { showToast('Status already exists.', 'warning'); return; }
const requireHandledBy = document.getElementById('tsStatusReqHandled')?.checked || false;
const requireComment = document.getElementById('tsStatusReqComment')?.checked || false;
fetchJson(`${API}/statuses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, requireHandledBy, requireComment }) })
.then((created) => {
admin.templateSettings.statuses.push(created);
cacheState();
input.value = '';
document.getElementById('tsStatusReqHandled').checked = false;
document.getElementById('tsStatusReqComment').checked = false;
renderTemplateSettings();
})
.catch((err) => showToast(err.message || 'Failed to add status.', 'error'));
}
function addSimpleSetting(key, inputId, endpoint) {
const input = document.getElementById(inputId);
const val = input.value.trim();
if (!val) return;
if (admin.templateSettings[key].some((i) => i.value === val)) { showToast('Value already exists.', 'warning'); return; }
fetchJson(`${API}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
.then((created) => {
admin.templateSettings[key].push(created);
cacheState();
input.value = '';
renderTemplateSettings();
})
.catch((err) => showToast(err.message || 'Failed to add item.', 'error'));
}
function renderTemplateSettings() {
/* Categories list */
renderSettingList('tsCatList', admin.templateSettings.categories, (item) => {
fetchJson(`${API}/categories/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.categories = admin.templateSettings.categories.filter((c) => c.id !== item.id);
admin.templateSettings.subCategories = admin.templateSettings.subCategories.filter((s) => s.categoryId !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
item.value = newVal;
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
/* SubCategories list */
const catName = (categoryId) => admin.templateSettings.categories.find((c) => c.id === categoryId)?.value || '?';
renderSettingListFn('tsSubCatList', admin.templateSettings.subCategories, (item) => `${item.value} <span class="muted-count">(${esc(catName(item.categoryId))})</span>`, (item) => {
fetchJson(`${API}/sub-categories/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.subCategories = admin.templateSettings.subCategories.filter((s) => s.id !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/sub-categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, categoryId: item.categoryId }) }).then(() => {
item.value = newVal;
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
/* Parent dropdown for sub categories */
const parentSel = document.getElementById('tsSubCatParent');
if (parentSel) {
const current = parentSel.value;
parentSel.innerHTML = '<option value="">Parent category…</option>';
admin.templateSettings.categories.forEach((c) => {
parentSel.innerHTML += `<option value="${c.id}">${esc(c.value)}</option>`;
});
parentSel.value = current;
}
/* Severities */
renderSettingList('tsSevList', admin.templateSettings.severities, (item) => {
fetchJson(`${API}/severities/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.severities = admin.templateSettings.severities.filter((i) => i.id !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/severities/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
item.value = newVal; cacheState(); renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
/* Statuses — custom rendering with requirement indicators */
renderStatusList('tsStatusList', admin.templateSettings.statuses);
/* Handled By */
renderSettingList('tsHandledList', admin.templateSettings.handledBy, (item) => {
fetchJson(`${API}/handled-by/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.handledBy = admin.templateSettings.handledBy.filter((i) => i.id !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/handled-by/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
item.value = newVal; cacheState(); renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* SETTINGS TASK — Projects + Processes (parent required)
* ═══════════════════════════════════════════════════════════════════════════ */
function bindTaskSettingsForm() {
on('click', '[data-tk-action="add-proj"]', addProject);
on('click', '[data-tk-action="add-proc"]', addProcess);
bindEnter('tkProjInput', addProject);
bindEnter('tkProcInput', addProcess);
}
function addProject() {
const input = document.getElementById('tkProjInput');
const val = input.value.trim();
if (!val) return;
if (admin.taskSettings.projects.some((p) => p.value === val)) { showToast('Project already exists.', 'warning'); return; }
fetchJson(`${API}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
.then((created) => {
admin.taskSettings.projects.push(created);
cacheState();
input.value = '';
renderTaskSettings();
})
.catch((err) => showToast(err.message || 'Failed to add project.', 'error'));
}
function addProcess() {
const input = document.getElementById('tkProcInput');
const parentSel = document.getElementById('tkProcParent');
const val = input.value.trim();
const projectId = Number(parentSel.value);
if (!val) return;
if (!projectId) { showToast('Select a parent project first.', 'warning'); return; }
fetchJson(`${API}/processes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, projectId }) })
.then((created) => {
admin.taskSettings.processes.push(created);
cacheState();
input.value = '';
renderTaskSettings();
})
.catch((err) => showToast(err.message || 'Failed to add process.', 'error'));
}
function renderTaskSettings() {
renderSettingList('tkProjList', admin.taskSettings.projects, (item) => {
fetchJson(`${API}/projects/${item.id}`, { method: 'DELETE' }).then(() => {
admin.taskSettings.projects = admin.taskSettings.projects.filter((p) => p.id !== item.id);
admin.taskSettings.processes = admin.taskSettings.processes.filter((p) => p.projectId !== item.id);
cacheState();
renderTaskSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/projects/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
item.value = newVal; cacheState(); renderTaskSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
const projName = (id) => admin.taskSettings.projects.find((p) => p.id === id)?.value || '?';
renderSettingListFn('tkProcList', admin.taskSettings.processes, (item) => `${item.value} <span class="muted-count">(${esc(projName(item.projectId))})</span>`, (item) => {
fetchJson(`${API}/processes/${item.id}`, { method: 'DELETE' }).then(() => {
admin.taskSettings.processes = admin.taskSettings.processes.filter((p) => p.id !== item.id);
cacheState();
renderTaskSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/processes/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, projectId: item.projectId }) }).then(() => {
item.value = newVal; cacheState(); renderTaskSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
/* Parent dropdown */
const parentSel = document.getElementById('tkProcParent');
if (parentSel) {
const current = parentSel.value;
parentSel.innerHTML = '<option value="">Parent project…</option>';
admin.taskSettings.projects.forEach((p) => {
parentSel.innerHTML += `<option value="${p.id}">${esc(p.value)}</option>`;
});
parentSel.value = current;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* USERS — list-first with inline form
* ═══════════════════════════════════════════════════════════════════════════ */
function bindUserForm() {
const form = document.getElementById('userForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); saveUser(); });
document.getElementById('cancelUserBtn')?.addEventListener('click', hideUserForm);
document.getElementById('showUserFormBtn')?.addEventListener('click', () => {
clearUserForm(); showSection('userFormSection');
});
}
function showSection(id) { const s = document.getElementById(id); if (s) s.style.display = ''; }
function hideSection(id) { const s = document.getElementById(id); if (s) s.style.display = 'none'; }
function saveUser() {
const el = (id) => document.getElementById(id);
const data = {
email: el('userEmail').value.trim(),
password: el('userPassword').value,
name: el('userName').value.trim(),
familyName: el('userFamilyName').value.trim(),
company: el('userCompany').value.trim(),
role: el('userRole').value
};
if (!data.email || !data.name || !data.familyName || !data.role) {
showToast('Email, Name, Family Name, and Role are required.', 'warning'); return;
}
if (admin.editingType === 'user' && admin.editingId) {
fetchJson(`${API}/users/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((updated) => {
const idx = admin.users.findIndex((u) => u.id === admin.editingId);
if (idx >= 0) admin.users[idx] = updated;
resetEditing();
cacheState();
hideUserForm();
renderUserList();
showToast('User saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
fetchJson(`${API}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.users.push(created);
cacheState();
hideUserForm();
renderUserList();
showToast('User saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearUserForm() {
document.getElementById('userForm')?.reset();
document.getElementById('userFormHeading').textContent = 'Add User';
resetEditing();
}
function hideUserForm() { clearUserForm(); hideSection('userFormSection'); }
function editUser(id) {
const u = admin.users.find((x) => x.id === id);
if (!u) return;
admin.editingId = id; admin.editingType = 'user';
const el = (eid) => document.getElementById(eid);
el('userEmail').value = u.email || '';
el('userPassword').value = u.password || '';
el('userName').value = u.name || '';
el('userFamilyName').value = u.familyName || '';
el('userCompany').value = u.company || '';
el('userRole').value = u.role || '';
el('userFormHeading').textContent = 'Edit User';
showSection('userFormSection');
}
function deleteUser(id) {
if (!confirm('Delete this user?')) return;
fetchJson(`${API}/users/${id}`, { method: 'DELETE' }).then(() => {
admin.users = admin.users.filter((u) => u.id !== id);
admin.tasks = admin.tasks.filter((t) => t.userId !== id);
cacheState();
renderUserList();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderUserList() {
const container = document.getElementById('userListContainer');
if (!container) return;
if (!admin.users.length) {
container.innerHTML = '<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))));
}
/* ═══════════════════════════════════════════════════════════════════════════
* SITES — list-first with inline form; Host is dropdown (OBE, PXS)
* ═══════════════════════════════════════════════════════════════════════════ */
function bindSiteForm() {
const form = document.getElementById('siteForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); saveSite(); });
document.getElementById('cancelSiteBtn')?.addEventListener('click', hideSiteForm);
document.getElementById('showSiteFormBtn')?.addEventListener('click', () => {
clearSiteForm(); showSection('siteFormSection');
});
}
function saveSite() {
const el = (id) => document.getElementById(id);
const data = {
siteCode: el('siteSiteCode').value.trim(),
host: el('siteHost').value,
obeSiteCode: el('siteObe').value.trim(),
pxsSiteCode: el('sitePxs').value.trim()
};
if (!data.siteCode) { showToast('Site Code is required.', 'warning'); return; }
if (admin.editingType === 'site' && admin.editingId) {
fetchJson(`${API}/sites/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((updated) => {
const idx = admin.sites.findIndex((s) => s.id === admin.editingId);
if (idx >= 0) admin.sites[idx] = updated;
resetEditing();
cacheState();
hideSiteForm();
renderSiteList();
showToast('Site saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
fetchJson(`${API}/sites`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.sites.push(created);
cacheState();
hideSiteForm();
renderSiteList();
showToast('Site saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearSiteForm() {
document.getElementById('siteForm')?.reset();
document.getElementById('siteFormHeading').textContent = 'Add Site';
resetEditing();
}
function hideSiteForm() { clearSiteForm(); hideSection('siteFormSection'); }
function editSite(id) {
const s = admin.sites.find((x) => x.id === id);
if (!s) return;
admin.editingId = id; admin.editingType = 'site';
const el = (eid) => document.getElementById(eid);
el('siteSiteCode').value = s.siteCode;
el('siteHost').value = s.host || '';
el('siteObe').value = s.obeSiteCode || '';
el('sitePxs').value = s.pxsSiteCode || '';
el('siteFormHeading').textContent = 'Edit Site';
showSection('siteFormSection');
}
function deleteSite(id) {
if (!confirm('Delete this site?')) return;
fetchJson(`${API}/sites/${id}`, { method: 'DELETE' }).then(() => {
admin.sites = admin.sites.filter((s) => s.id !== id);
admin.tasks = admin.tasks.filter((t) => t.siteId !== id);
cacheState();
renderSiteList();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderSiteList() {
const container = document.getElementById('siteListContainer');
if (!container) return;
if (!admin.sites.length) {
container.innerHTML = '<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 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>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('cancelClRecBtn')?.addEventListener('click', hideClRecForm);
document.getElementById('showClRecFormBtn')?.addEventListener('click', () => {
clearClRecordForm(); refreshRecordDropdowns(); showSection('clRecFormSection');
});
}
function refreshRecordDropdowns() {
populateSelect('clRecCategory', admin.templateSettings.categories.map((c) => c.value));
/* Sub-category is filtered by selected category */
updateRecordSubCategoryDropdown();
populateSelect('clRecSeverity', admin.templateSettings.severities.map((s) => s.value));
populateSelect('clRecStatus', admin.templateSettings.statuses.map((s) => s.value));
populateSelect('clRecHandledBy', admin.templateSettings.handledBy.map((h) => h.value));
/* Bind category change to filter sub-categories */
const catSel = document.getElementById('clRecCategory');
if (catSel && !catSel._boundSubCatFilter) {
catSel.addEventListener('change', updateRecordSubCategoryDropdown);
catSel._boundSubCatFilter = true;
}
}
function updateRecordSubCategoryDropdown() {
const catSel = document.getElementById('clRecCategory');
const selectedCatValue = catSel?.value || '';
if (!selectedCatValue) {
/* No category selected — show empty sub-category dropdown */
populateSelect('clRecSubCategory', []);
return;
}
/* Find the category object to get its id */
const cat = admin.templateSettings.categories.find((c) => c.value === selectedCatValue);
if (!cat) { populateSelect('clRecSubCategory', []); return; }
/* Only show sub-categories that belong to the selected category */
const filtered = admin.templateSettings.subCategories
.filter((s) => s.categoryId === cat.id)
.map((s) => s.value);
populateSelect('clRecSubCategory', filtered);
}
function saveClRecord() {
const el = (id) => document.getElementById(id);
const sortVal = Number(el('clRecSort').value);
if (!sortVal) { showToast('Sort number is required.', 'warning'); return; }
const existing = admin.clRecords.find((r) => r.sort === sortVal && r.id !== admin.editingId);
if (existing) { showToast(`Sort value ${sortVal} already used.`, 'warning'); return; }
const data = {
sort: sortVal,
category: el('clRecCategory').value,
subCategory: el('clRecSubCategory').value,
severity: el('clRecSeverity').value,
imageRequired: el('clRecImageRequired').checked,
descriptionEN: el('clRecDescEN').value.trim(),
descriptionFR: el('clRecDescFR').value.trim(),
descriptionNL: el('clRecDescNL').value.trim()
};
if (admin.editingType === 'clRecord' && admin.editingId) {
fetchJson(`${API}/cl-records/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((updated) => {
const idx = admin.clRecords.findIndex((r) => r.id === admin.editingId);
if (idx >= 0) admin.clRecords[idx] = updated;
admin.clRecords.sort((a, b) => a.sort - b.sort);
resetEditing();
cacheState();
hideClRecForm();
renderClRecordList();
showToast('Record saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
fetchJson(`${API}/cl-records`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.clRecords.push(created);
admin.clRecords.sort((a, b) => a.sort - b.sort);
cacheState();
hideClRecForm();
renderClRecordList();
showToast('Record saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearClRecordForm() {
document.getElementById('clRecordForm')?.reset();
document.getElementById('clRecFormHeading').textContent = 'Add Record';
resetEditing();
}
function hideClRecForm() { clearClRecordForm(); hideSection('clRecFormSection'); }
function editClRecord(id) {
const rec = admin.clRecords.find((r) => r.id === id);
if (!rec) return;
admin.editingId = id; admin.editingType = 'clRecord';
refreshRecordDropdowns();
const el = (eid) => document.getElementById(eid);
el('clRecSort').value = rec.sort;
el('clRecCategory').value = rec.category || '';
/* Re-filter sub-categories after setting the category value */
updateRecordSubCategoryDropdown();
el('clRecSubCategory').value = rec.subCategory || '';
el('clRecSeverity').value = rec.severity || '';
el('clRecImageRequired').checked = !!rec.imageRequired;
el('clRecDescEN').value = rec.descriptionEN || '';
el('clRecDescFR').value = rec.descriptionFR || '';
el('clRecDescNL').value = rec.descriptionNL || '';
el('clRecFormHeading').textContent = 'Edit Record';
showSection('clRecFormSection');
}
function deleteClRecord(id) {
if (!confirm('Delete this record?')) return;
fetchJson(`${API}/cl-records/${id}`, { method: 'DELETE' }).then(() => {
admin.clRecords = admin.clRecords.filter((r) => r.id !== id);
admin.clTemplates.forEach((tpl) => { tpl.recordIds = (tpl.recordIds || []).filter((rid) => rid !== id); });
cacheState();
renderClRecordList();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderClRecordList() {
const container = document.getElementById('clRecordListContainer');
const countEl = document.getElementById('clRecCount');
if (!container) return;
if (countEl) countEl.textContent = `${admin.clRecords.length} record(s)`;
if (!admin.clRecords.length) {
container.innerHTML = '<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('cancelClTplBtn')?.addEventListener('click', hideClTplForm);
document.getElementById('showClTplFormBtn')?.addEventListener('click', () => {
clearClTemplateForm(); renderClTemplateRecordSelection(); showSection('clTplFormSection');
});
}
function renderClTemplateRecordSelection() {
const container = document.getElementById('clTplRecordSelection');
if (!container) return;
if (!admin.clRecords.length) {
container.innerHTML = '<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(); hideSection('clTplFormSection'); }
function editClTemplate(id) {
const tpl = admin.clTemplates.find((t) => t.id === id);
if (!tpl) return;
admin.editingId = id; admin.editingType = 'clTemplate';
const el = (eid) => document.getElementById(eid);
el('clTplName').value = tpl.name;
el('clTplScope').value = tpl.scope || '';
el('clTplVersion').value = tpl.version || '';
el('clTplValidFrom').value = tpl.validFrom || '';
el('clTplValidTill').value = tpl.validTill || '';
el('clTplFormHeading').textContent = 'Edit Template';
renderClTemplateRecordSelection();
showSection('clTplFormSection');
}
function deleteClTemplate(id) {
if (!confirm('Delete this template?')) return;
fetchJson(`${API}/cl-templates/${id}`, { method: 'DELETE' }).then(() => {
admin.clTemplates = admin.clTemplates.filter((t) => t.id !== id);
admin.tasks = admin.tasks.filter((t) => t.templateId !== id);
cacheState();
renderClTemplateList();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderClTemplateList() {
const container = document.getElementById('clTemplateListContainer');
const countEl = document.getElementById('clTplCount');
if (!container) return;
if (countEl) countEl.textContent = `${admin.clTemplates.length} template(s)`;
if (!admin.clTemplates.length) {
container.innerHTML = '<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('cancelTaskBtn')?.addEventListener('click', hideTaskForm);
document.getElementById('showTaskFormBtn')?.addEventListener('click', () => {
clearTaskForm(); refreshTaskDropdowns(); showSection('taskFormSection');
});
}
function refreshTaskDropdowns() {
const siteOpts = admin.sites.map((s) => ({ value: s.id, label: s.siteCode }));
populateSelectObjects('taskSite', siteOpts, '— Select site —');
const userOpts = admin.users.map((u) => ({ value: u.id, label: `${u.name} ${u.familyName} (${u.email})` }));
populateSelectObjects('taskUser', userOpts, '— Select user —');
const tplOpts = admin.clTemplates.map((t) => ({ value: t.id, label: `${t.name} (v${t.version || '?'})` }));
populateSelectObjects('taskTemplate', tplOpts, '— Select template —');
populateSelect('taskProject', admin.taskSettings.projects.map((p) => p.value));
/* Processes filtered by selected project — bind change listener */
updateTaskProcessDropdown();
const projSel = document.getElementById('taskProject');
if (projSel && !projSel._boundTaskProc) {
projSel.addEventListener('change', updateTaskProcessDropdown);
projSel._boundTaskProc = true;
}
}
function updateTaskProcessDropdown() {
const projSel = document.getElementById('taskProject');
const selectedProject = projSel?.value || '';
if (!selectedProject) {
populateSelect('taskProcess', admin.taskSettings.processes.map((p) => p.value));
} else {
const parentProj = admin.taskSettings.projects.find((p) => p.value === selectedProject);
const filtered = admin.taskSettings.processes
.filter((p) => p.projectId === (parentProj?.id || 0))
.map((p) => p.value);
populateSelect('taskProcess', filtered);
}
}
function saveTask() {
const el = (id) => document.getElementById(id);
const data = {
siteId: Number(el('taskSite').value),
userId: Number(el('taskUser').value),
templateId: Number(el('taskTemplate').value),
project: el('taskProject').value,
process: el('taskProcess').value
};
if (!data.siteId || !data.userId || !data.templateId) {
showToast('User, Site, and Template are required.', 'warning'); return;
}
if (admin.editingType === 'task' && admin.editingId) {
const existing = admin.tasks.find((t) => t.id === admin.editingId);
const payload = { ...data, status: existing?.status || 'pending' };
fetchJson(`${API}/tasks/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
.then((updated) => {
const idx = admin.tasks.findIndex((t) => t.id === admin.editingId);
if (idx >= 0) admin.tasks[idx] = updated;
resetEditing();
cacheState();
hideTaskForm();
renderTaskList();
showToast('Task assigned.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
data.status = 'pending';
fetchJson(`${API}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.tasks.push(created);
cacheState();
hideTaskForm();
renderTaskList();
showToast('Task assigned.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearTaskForm() {
document.getElementById('taskForm')?.reset();
document.getElementById('taskFormHeading').textContent = 'Create Task Assignment';
resetEditing();
}
function hideTaskForm() { clearTaskForm(); hideSection('taskFormSection'); }
function editTask(id) {
const task = admin.tasks.find((t) => t.id === id);
if (!task) return;
admin.editingId = id; admin.editingType = 'task';
refreshTaskDropdowns();
const el = (eid) => document.getElementById(eid);
el('taskSite').value = task.siteId || '';
el('taskUser').value = task.userId || '';
el('taskTemplate').value = task.templateId || '';
el('taskProject').value = task.project || '';
el('taskProcess').value = task.process || '';
el('taskFormHeading').textContent = 'Edit Task Assignment';
showSection('taskFormSection');
}
function deleteTask(id) {
if (!confirm('Delete this task? This will also permanently remove all uploaded images.')) return;
fetchJson(`${API}/tasks/${id}`, { method: 'DELETE' }).then(() => {
admin.tasks = admin.tasks.filter((t) => t.id !== id);
cacheState();
renderTaskList();
/* Also delete the report and image files from the server */
if (navigator.onLine) {
fetch(`/api/v1/reports/${id}`, { method: 'DELETE' }).catch(() => {});
}
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderTaskList() {
const container = document.getElementById('taskListContainer');
if (!container) return;
if (!admin.tasks.length) {
container.innerHTML = '<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);
}
});
}
function buildAndShowReportModal(task, site, user, tpl, data, records) {
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>`;
const statusBadge = status === 'NOK' ? 'bg-danger' : status === 'OK' ? 'bg-success' : status === 'TBC' ? 'bg-warning text-dark' : 'bg-secondary';
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> ${esc(rec.severity || '-')}</span>
</div>
<div class="row g-2 small">
<div class="col-md-3"><strong>Status:</strong> <span class="badge ${statusBadge}">${esc(status)}</span></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
}));
});
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
}
/**
* Fetches report answers from the server to get record data (status, handledBy, comments).
*/
async function fetchReportDataForAdmin(taskId) {
if (!navigator.onLine) return null;
try {
const resp = await fetch(`/api/v1/reports/${taskId}`, { headers: { Accept: 'application/json' } });
if (!resp.ok) return null;
const report = await resp.json();
return report.answers || null;
} catch {
return null;
}
}
/**
* Fetches images from the server for a report and renders them
* into the already-open modal under each record's placeholder slot.
* Images are served as URLs pointing to files on disk.
*/
async function fetchReportImagesForAdmin(taskId, records) {
try {
const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } });
if (!resp.ok) {
/* Clear loading placeholders on failure */
for (const rec of records) {
const slot = document.querySelector(`.server-images-slot[data-rec-id="${rec.id}"]`);
if (slot) slot.innerHTML = '<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">
<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 class="small text-muted text-truncate" style="max-width:70px" title="${esc(img.name || '')}">${esc(img.name || `img-${i}`)}</div>
<div class="small text-muted">${formatFileSize(img.size)}</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
}));
});
}
} 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>';
}
infoPanel.innerHTML = infoHtml;
/* Bind map button if present */
const mapBtn = document.getElementById('adminLightboxMapBtn');
if (mapBtn && exif?.latitude != null && exif?.longitude != null) {
mapBtn.addEventListener('click', () => {
openAdminMapModal([{ lat: exif.latitude, lng: exif.longitude, name: meta?.name || 'Image', dataUrl: src }]);
});
}
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Admin: Map modal (Leaflet + OpenStreetMap)
* ═══════════════════════════════════════════════════════════════════════════ */
/**
* Open a modal with a Leaflet map showing markers for the given points.
* Each point: { lat, lng, name }
*/
function openAdminMapModal(points) {
if (!points?.length) { showToast('No geo-tagged images found.', 'info'); return; }
let modal = document.getElementById('adminMapModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'adminMapModal';
modal.className = 'modal fade';
modal.tabIndex = -1;
modal.style.zIndex = '1070'; /* above lightbox modal (1055) */
modal.innerHTML = `<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">
<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 */
const map = L.map(container).setView([points[0].lat, points[0].lng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
const bounds = L.latLngBounds();
for (const pt of points) {
const marker = L.marker([pt.lat, pt.lng]).addTo(map);
let popupHtml = '';
if (pt.dataUrl) {
popupHtml += `<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>`;
marker.bindPopup(popupHtml, { maxWidth: 200 });
bounds.extend([pt.lat, pt.lng]);
}
if (points.length > 1) map.fitBounds(bounds, { padding: [30, 30] });
/* Fix map tiles when modal is resized */
setTimeout(() => map.invalidateSize(), 200);
const observer = new ResizeObserver(() => map.invalidateSize());
observer.observe(container);
modal.addEventListener('hidden.bs.modal', () => { observer.disconnect(); map.remove(); }, { once: true });
}, { once: true });
}
/**
* Fetch images for a task and open the map with all geo-tagged images.
*/
async function openTaskImagesMap(taskId) {
try {
const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } });
if (!resp.ok) { showToast('Could not load images for map.', 'error'); return; }
const imagesByRecord = await resp.json();
const points = [];
for (const recId of Object.keys(imagesByRecord)) {
for (const img of imagesByRecord[recId]) {
if (img.exif?.latitude != null && img.exif?.longitude != null) {
points.push({ lat: img.exif.latitude, lng: img.exif.longitude, name: img.name || 'Image', dataUrl: img.dataUrl || null });
}
}
}
if (!points.length) { showToast('No images with GPS coordinates found for this task.', 'info'); return; }
openAdminMapModal(points);
} catch (err) {
showToast('Failed to load images: ' + err.message, 'error');
}
}
function formatFileSize(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`;
}
function safeJsonParse(str) {
try { return JSON.parse(str); } catch { return null; }
}
/* ═══════════════════════════════════════════════════════════════════════════
* Shared helpers
* ═══════════════════════════════════════════════════════════════════════════ */
function resetEditing() { admin.editingId = null; admin.editingType = null; }
function populateSelect(selectId, options) {
const sel = document.getElementById(selectId);
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<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 status list with requirement indicators and edit support. */
function renderStatusList(containerId, items) {
const container = document.getElementById(containerId);
if (!container) return;
if (!items.length) { container.innerHTML = '<p class="field-help">No items yet.</p>'; return; }
container.innerHTML = '';
items.forEach((item) => {
const row = document.createElement('div');
row.className = 'setting-list-item';
const badges = [];
if (item.requireHandledBy) badges.push('<span class="badge bg-info text-dark ms-1">HB</span>');
if (item.requireComment) badges.push('<span class="badge bg-info text-dark ms-1">CMT</span>');
row.innerHTML = `<span class="setting-list-label">${esc(item.value)}${badges.join('')}</span>
<button type="button" class="button button-small button-secondary setting-edit-btn">Edit</button>
<button type="button" class="button button-small button-ghost setting-del-btn">×</button>`;
row.querySelector('.setting-del-btn').addEventListener('click', () => {
fetchJson(`${API}/statuses/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.statuses = admin.templateSettings.statuses.filter((i) => i.id !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
});
row.querySelector('.setting-edit-btn').addEventListener('click', () => {
const newVal = prompt('Edit status value:', item.value);
if (newVal === null || !newVal.trim()) return;
const reqHB = confirm('Require "Handled By" for this status?');
const reqCmt = confirm('Require "Comment" for this status?');
fetchJson(`${API}/statuses/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal.trim(), requireHandledBy: reqHB, requireComment: reqCmt }) }).then(() => {
item.value = newVal.trim();
item.requireHandledBy = reqHB;
item.requireComment = reqCmt;
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
container.appendChild(row);
});
}
/** Render an editable/deletable list of {id, value} items. */
function renderSettingList(containerId, items, onDelete, onEdit) {
renderSettingListFn(containerId, items, (item) => esc(item.value), onDelete, onEdit);
}
/** Render list with a custom label function. */
function renderSettingListFn(containerId, items, labelFn, onDelete, onEdit) {
const container = document.getElementById(containerId);
if (!container) return;
if (!items.length) { container.innerHTML = '<p class="field-help">No items yet.</p>'; return; }
container.innerHTML = '';
items.forEach((item) => {
const row = document.createElement('div');
row.className = 'setting-list-item';
row.innerHTML = `<span class="setting-list-label">${labelFn(item)}</span>
<button type="button" class="button button-small button-secondary setting-edit-btn">Edit</button>
<button type="button" class="button button-small button-ghost setting-del-btn">×</button>`;
row.querySelector('.setting-del-btn').addEventListener('click', () => onDelete(item));
row.querySelector('.setting-edit-btn').addEventListener('click', () => {
const newVal = prompt('Edit value:', item.value);
if (newVal !== null && newVal.trim()) onEdit(item, newVal.trim());
});
container.appendChild(row);
});
}
function showToast(message, tone) {
let toast = document.getElementById('adminToast');
if (!toast) { toast = document.createElement('div'); toast.id = 'adminToast'; toast.className = 'admin-toast'; document.body.appendChild(toast); }
const cls = tone === 'success' ? 'badge-online' : tone === 'error' ? 'badge-error' : 'badge-offline';
toast.textContent = message;
toast.className = `admin-toast badge ${cls} admin-toast-visible`;
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.classList.remove('admin-toast-visible'); }, 3000);
}
function fmtKb(bytes) {
if (!bytes) return '-';
return `${Math.round(bytes / 1024)} KB`;
}
function formatDateDisplay(dateStr) {
if (!dateStr) return '-';
/* HTML date inputs give yyyy-mm-dd — display as dd/mm/yyyy */
const parts = dateStr.split('-');
if (parts.length === 3) return `${parts[2]}/${parts[1]}/${parts[0]}`;
return dateStr;
}
function esc(text) { const d = document.createElement('div'); d.textContent = text ?? ''; return d.innerHTML; }
function escAttr(text) { return esc(text).replace(/"/g, '&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(); } });
}