/*
* Admin module — self-contained admin console logic for the Check List PoC.
*
* Manages:
* • Sidebar navigation (category expand/collapse, panel switching)
* • Image policy editor (Settings › Image Policy) — server-backed via API
* • Template settings (Settings › Template) — server-backed via API with list view (edit/remove)
* • Task settings (Settings › Task) — server-backed via API with list view (edit/remove)
* • Users CRUD (Users — single panel list-first with add/edit form)
* • Sites CRUD (Sites — single panel list-first with add/edit form)
* • CL Records CRUD (Check Lists › Records — list-first with add/edit form)
* • CL Templates CRUD (Check Lists › Templates — list-first with add/edit form)
* • Tasks CRUD (Reports — list-first with add/edit form)
*
* Data notes:
* • Sub Categories require a parent Category
* • Processes require a parent Project
* • Records: Status, Handled By, Comment are disabled placeholders
* • Records: "Image Required" checkbox determines if user must add images
* • Sites: Host is dropdown (OBE, PXS)
* • Users: field is Email (not Username), Role options: CW, ANT, CW/ANT
* • Templates: Scope dropdown (CW, ANT, ANT_CPsite), dates use date picker
*/
import { fetchJson } from './api.js';
import { dbPut, dbGet } from './db.js';
import { STORE_CONFIG } from './constants.js';
import { validateImageRulesPayload } from './validation.js';
/* ── API base path for admin entity CRUD ────────────────────────────────── */
const API = '/admin';
/* ── Admin state ────────────────────────────────────────────────────────── */
const admin = {
imageRules: null,
users: [],
sites: [],
clRecords: [],
clTemplates: [],
tasks: [],
templateSettings: {
categories: [], // [{id, value}]
subCategories: [], // [{id, value, categoryId}]
severities: [], // [{id, value}]
statuses: [], // [{id, value}]
handledBy: [] // [{id, value}]
},
taskSettings: {
projects: [], // [{id, value}]
processes: [] // [{id, value, projectId}]
},
editingId: null,
editingType: null
};
/* ── Persistence helpers (MariaDB via REST API, IndexedDB as offline cache) ── */
/*
* Loads all admin entity data from the server (MariaDB) via the bulk endpoint,
* and caches in IndexedDB for offline use.
*/
async function loadFromServer() {
try {
const data = await fetchJson(`${API}/all`);
admin.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
admin.taskSettings = data.taskSettings || { projects: [], processes: [] };
admin.users = data.users || [];
admin.sites = data.sites || [];
admin.clRecords = data.clRecords || [];
admin.clTemplates = data.clTemplates || [];
admin.tasks = data.tasks || [];
/* Cache in IndexedDB for offline access */
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {});
return true;
} catch (err) {
console.warn('Failed to load admin data from server, using IndexedDB cache', err);
return false;
}
}
/*
* Loads from IndexedDB cache (offline fallback).
*/
async function loadFromCache() {
try {
const row = await dbGet(STORE_CONFIG, 'admin_all');
const data = row?.value;
if (!data) return;
admin.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
admin.taskSettings = data.taskSettings || { projects: [], processes: [] };
admin.users = data.users || [];
admin.sites = data.sites || [];
admin.clRecords = data.clRecords || [];
admin.clTemplates = data.clTemplates || [];
admin.tasks = data.tasks || [];
} catch { /* ignore */ }
}
/*
* Caches current admin state to IndexedDB.
*/
function cacheState() {
const data = {
templateSettings: admin.templateSettings,
taskSettings: admin.taskSettings,
users: admin.users,
sites: admin.sites,
clRecords: admin.clRecords,
clTemplates: admin.clTemplates,
tasks: admin.tasks
};
dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Initialization
* ═══════════════════════════════════════════════════════════════════════════ */
export async function initAdmin({ imageRules }) {
admin.imageRules = imageRules;
/* Load from server (MariaDB) first; data is also cached in IndexedDB. */
if (navigator.onLine) {
await loadFromServer();
} else {
await loadFromCache();
}
initNavigation();
bindImagePolicyForm();
bindTemplateSettingsForm();
bindTaskSettingsForm();
bindUserForm();
bindSiteForm();
bindClRecordForm();
bindClTemplateForm();
bindTaskForm();
renderImagePolicy();
renderTemplateSettings();
renderTaskSettings();
renderUserList();
renderSiteList();
renderClRecordList();
renderClTemplateList();
renderTaskList();
updateConnectionBadge();
window.addEventListener('online', updateConnectionBadge);
window.addEventListener('offline', updateConnectionBadge);
if (navigator.onLine) syncImageRules();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Navigation
* ═══════════════════════════════════════════════════════════════════════════ */
function initNavigation() {
const nav = document.getElementById('adminNav');
if (!nav) return;
nav.querySelectorAll('.admin-nav-cat-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const cat = btn.closest('.admin-nav-cat');
cat.classList.toggle('is-open');
const arrow = btn.querySelector('.nav-arrow');
if (arrow) arrow.textContent = cat.classList.contains('is-open') ? '▾' : '▸';
});
});
nav.querySelectorAll('.admin-nav-item').forEach((item) => {
item.addEventListener('click', () => {
activateNavItem(item);
showPanel(item.dataset.panel);
});
});
}
function activateNavItem(item) {
const nav = document.getElementById('adminNav');
nav.querySelectorAll('.admin-nav-item').forEach((i) => i.classList.remove('is-active'));
item.classList.add('is-active');
const cat = item.closest('.admin-nav-cat');
if (cat && !cat.classList.contains('is-open')) {
cat.classList.add('is-open');
const arrow = cat.querySelector('.nav-arrow');
if (arrow) arrow.textContent = '▾';
}
}
function showPanel(panelId) {
document.querySelectorAll('.admin-panel').forEach((p) => p.classList.remove('admin-panel-active'));
const target = document.getElementById('panel-' + panelId);
if (target) target.classList.add('admin-panel-active');
switch (panelId) {
case 'users': renderUserList(); break;
case 'sites': renderSiteList(); break;
case 'cl-records': refreshRecordDropdowns(); renderClRecordList(); break;
case 'cl-templates': renderClTemplateRecordSelection(); renderClTemplateList(); break;
case 'reports': refreshTaskDropdowns(); renderTaskList(); break;
case 'settings-template': renderTemplateSettings(); break;
case 'settings-task': renderTaskSettings(); break;
}
}
function navigateToPanel(panelId) {
const nav = document.getElementById('adminNav');
const navItem = nav.querySelector(`[data-panel="${panelId}"]`);
if (navItem) activateNavItem(navItem);
showPanel(panelId);
}
/* ═══════════════════════════════════════════════════════════════════════════
* Connection badge
* ═══════════════════════════════════════════════════════════════════════════ */
function updateConnectionBadge() {
const badge = document.getElementById('connectionBadge');
if (!badge) return;
badge.textContent = navigator.onLine ? 'Online' : 'Offline';
badge.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
}
/* ═══════════════════════════════════════════════════════════════════════════
* SETTINGS › IMAGE POLICY
* ═══════════════════════════════════════════════════════════════════════════ */
async function syncImageRules() {
try {
const rules = await fetchJson('/config/image-rules');
admin.imageRules = rules;
await dbPut(STORE_CONFIG, { key: 'imageRules', value: rules });
renderImagePolicy();
} catch (err) { console.error('Image-rules sync failed', err); }
}
function bindImagePolicyForm() {
const form = document.getElementById('adminImageRulesForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); void saveImagePolicy(); });
document.getElementById('resetImageRulesButton')?.addEventListener('click', () => populateImagePolicyForm(admin.imageRules));
}
function renderImagePolicy() {
populateImagePolicyForm(admin.imageRules);
const syncState = document.getElementById('adminSyncState');
const mimeTypes = document.getElementById('adminPolicyMimeTypes');
const fileSize = document.getElementById('adminPolicyFileSize');
const opt = document.getElementById('adminPolicyOptimization');
const limits = document.getElementById('adminPolicyLimits');
if (!admin.imageRules) {
if (syncState) { syncState.textContent = 'No image rules loaded'; syncState.className = 'badge badge-offline'; }
[mimeTypes, fileSize, opt, limits].forEach((el) => { if (el) el.textContent = '-'; });
return;
}
if (syncState) {
syncState.textContent = navigator.onLine ? 'Live configuration' : 'Offline (cached)';
syncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
}
if (mimeTypes) mimeTypes.textContent = (admin.imageRules.allowedMimeTypes || []).join(', ');
if (fileSize) fileSize.textContent = fmtKb(admin.imageRules.maxFileSizeBytes);
if (opt) opt.textContent = `${admin.imageRules.oversizeBehavior}, quality ${admin.imageRules.jpegQuality || admin.imageRules.imageQuality || '-'}%`;
if (limits) limits.textContent = `${admin.imageRules.maxWidthPx}×${admin.imageRules.maxHeightPx}px`;
}
function populateImagePolicyForm(rules) {
if (!rules) return;
/* Checkboxes for MIME types */
const checkboxes = document.querySelectorAll('#adminMimeCheckboxes input[name="mimeType"]');
const allowed = new Set(rules.allowedMimeTypes || []);
checkboxes.forEach((cb) => { cb.checked = allowed.has(cb.value); });
const el = (id) => document.getElementById(id);
if (el('adminMaxFileSizeKb')) el('adminMaxFileSizeKb').value = String(Math.round((rules.maxFileSizeBytes || 0) / 1024));
if (el('adminMaxWidthPx')) el('adminMaxWidthPx').value = String(rules.maxWidthPx || '');
if (el('adminMaxHeightPx')) el('adminMaxHeightPx').value = String(rules.maxHeightPx || '');
if (el('adminImageQuality')) el('adminImageQuality').value = String(rules.jpegQuality || rules.imageQuality || '');
if (el('adminOversizeBehavior')) el('adminOversizeBehavior').value = rules.oversizeBehavior || 'auto_optimize';
}
async function saveImagePolicy() {
if (!navigator.onLine) { showToast('Go online to save.', 'warning'); return; }
const el = (id) => document.getElementById(id);
const mimeTypes = [...document.querySelectorAll('#adminMimeCheckboxes input[name="mimeType"]:checked')].map((cb) => cb.value);
const payload = {
name: 'default',
allowedMimeTypes: mimeTypes,
maxFileSizeBytes: Number(el('adminMaxFileSizeKb').value) * 1024,
maxWidthPx: Number(el('adminMaxWidthPx').value),
maxHeightPx: Number(el('adminMaxHeightPx').value),
jpegQuality: Number(el('adminImageQuality').value),
oversizeBehavior: el('adminOversizeBehavior').value,
maxAttachmentsPerField: 5
};
const msg = validateImageRulesPayload(payload);
if (msg) { showToast(msg, 'error'); return; }
const btn = document.getElementById('saveImageRulesButton');
btn.disabled = true;
try {
const result = await fetchJson('/config/image-rules', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
admin.imageRules = result;
await dbPut(STORE_CONFIG, { key: 'imageRules', value: result });
renderImagePolicy();
showToast('Image policy saved.', 'success');
} catch (err) { console.error(err); showToast(err.message || 'Save failed.', 'error'); }
finally { btn.disabled = false; }
}
/* ═══════════════════════════════════════════════════════════════════════════
* SETTINGS › TEMPLATE — list-based editing with parent for SubCategories
* ═══════════════════════════════════════════════════════════════════════════ */
function bindTemplateSettingsForm() {
on('click', '[data-ts-action="add-cat"]', addCategory);
on('click', '[data-ts-action="add-subcat"]', addSubCategory);
on('click', '[data-ts-action="add-sev"]', () => addSimpleSetting('severities', 'tsSevInput', 'severities'));
on('click', '[data-ts-action="add-status"]', addStatus);
on('click', '[data-ts-action="add-handled"]', () => addSimpleSetting('handledBy', 'tsHandledInput', 'handled-by'));
/* Enter key support */
bindEnter('tsCatInput', addCategory);
bindEnter('tsSubCatInput', addSubCategory);
bindEnter('tsSevInput', () => addSimpleSetting('severities', 'tsSevInput', 'severities'));
bindEnter('tsStatusInput', addStatus);
bindEnter('tsHandledInput', () => addSimpleSetting('handledBy', 'tsHandledInput', 'handled-by'));
}
function addCategory() {
const input = document.getElementById('tsCatInput');
const val = input.value.trim();
if (!val) return;
if (admin.templateSettings.categories.some((c) => c.value === val)) { showToast('Category already exists.', 'warning'); return; }
fetchJson(`${API}/categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
.then((created) => {
admin.templateSettings.categories.push(created);
cacheState();
input.value = '';
renderTemplateSettings();
})
.catch((err) => showToast(err.message || 'Failed to add category.', 'error'));
}
function addSubCategory() {
const input = document.getElementById('tsSubCatInput');
const parentSel = document.getElementById('tsSubCatParent');
const val = input.value.trim();
const categoryId = Number(parentSel.value);
if (!val) return;
if (!categoryId) { showToast('Select a parent category first.', 'warning'); return; }
fetchJson(`${API}/sub-categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, categoryId }) })
.then((created) => {
admin.templateSettings.subCategories.push(created);
cacheState();
input.value = '';
renderTemplateSettings();
})
.catch((err) => showToast(err.message || 'Failed to add sub-category.', 'error'));
}
function addStatus() {
const input = document.getElementById('tsStatusInput');
const val = input.value.trim();
if (!val) return;
if (admin.templateSettings.statuses.some((i) => i.value === val)) { showToast('Status already exists.', 'warning'); return; }
const requireHandledBy = document.getElementById('tsStatusReqHandled')?.checked || false;
const requireComment = document.getElementById('tsStatusReqComment')?.checked || false;
fetchJson(`${API}/statuses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, requireHandledBy, requireComment }) })
.then((created) => {
admin.templateSettings.statuses.push(created);
cacheState();
input.value = '';
document.getElementById('tsStatusReqHandled').checked = false;
document.getElementById('tsStatusReqComment').checked = false;
renderTemplateSettings();
})
.catch((err) => showToast(err.message || 'Failed to add status.', 'error'));
}
function addSimpleSetting(key, inputId, endpoint) {
const input = document.getElementById(inputId);
const val = input.value.trim();
if (!val) return;
if (admin.templateSettings[key].some((i) => i.value === val)) { showToast('Value already exists.', 'warning'); return; }
fetchJson(`${API}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
.then((created) => {
admin.templateSettings[key].push(created);
cacheState();
input.value = '';
renderTemplateSettings();
})
.catch((err) => showToast(err.message || 'Failed to add item.', 'error'));
}
function renderTemplateSettings() {
/* Categories list */
renderSettingList('tsCatList', admin.templateSettings.categories, (item) => {
fetchJson(`${API}/categories/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.categories = admin.templateSettings.categories.filter((c) => c.id !== item.id);
admin.templateSettings.subCategories = admin.templateSettings.subCategories.filter((s) => s.categoryId !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
item.value = newVal;
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
/* SubCategories list */
const catName = (categoryId) => admin.templateSettings.categories.find((c) => c.id === categoryId)?.value || '?';
renderSettingListFn('tsSubCatList', admin.templateSettings.subCategories, (item) => `${item.value} (${esc(catName(item.categoryId))})`, (item) => {
fetchJson(`${API}/sub-categories/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.subCategories = admin.templateSettings.subCategories.filter((s) => s.id !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/sub-categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, categoryId: item.categoryId }) }).then(() => {
item.value = newVal;
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
/* Parent dropdown for sub categories */
const parentSel = document.getElementById('tsSubCatParent');
if (parentSel) {
const current = parentSel.value;
parentSel.innerHTML = '';
admin.templateSettings.categories.forEach((c) => {
parentSel.innerHTML += ``;
});
parentSel.value = current;
}
/* Severities */
renderSettingList('tsSevList', admin.templateSettings.severities, (item) => {
fetchJson(`${API}/severities/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.severities = admin.templateSettings.severities.filter((i) => i.id !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/severities/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
item.value = newVal; cacheState(); renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
/* Statuses — custom rendering with requirement indicators */
renderStatusList('tsStatusList', admin.templateSettings.statuses);
/* Handled By */
renderSettingList('tsHandledList', admin.templateSettings.handledBy, (item) => {
fetchJson(`${API}/handled-by/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.handledBy = admin.templateSettings.handledBy.filter((i) => i.id !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/handled-by/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
item.value = newVal; cacheState(); renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* SETTINGS › TASK — Projects + Processes (parent required)
* ═══════════════════════════════════════════════════════════════════════════ */
function bindTaskSettingsForm() {
on('click', '[data-tk-action="add-proj"]', addProject);
on('click', '[data-tk-action="add-proc"]', addProcess);
bindEnter('tkProjInput', addProject);
bindEnter('tkProcInput', addProcess);
}
function addProject() {
const input = document.getElementById('tkProjInput');
const val = input.value.trim();
if (!val) return;
if (admin.taskSettings.projects.some((p) => p.value === val)) { showToast('Project already exists.', 'warning'); return; }
fetchJson(`${API}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
.then((created) => {
admin.taskSettings.projects.push(created);
cacheState();
input.value = '';
renderTaskSettings();
})
.catch((err) => showToast(err.message || 'Failed to add project.', 'error'));
}
function addProcess() {
const input = document.getElementById('tkProcInput');
const parentSel = document.getElementById('tkProcParent');
const val = input.value.trim();
const projectId = Number(parentSel.value);
if (!val) return;
if (!projectId) { showToast('Select a parent project first.', 'warning'); return; }
fetchJson(`${API}/processes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, projectId }) })
.then((created) => {
admin.taskSettings.processes.push(created);
cacheState();
input.value = '';
renderTaskSettings();
})
.catch((err) => showToast(err.message || 'Failed to add process.', 'error'));
}
function renderTaskSettings() {
renderSettingList('tkProjList', admin.taskSettings.projects, (item) => {
fetchJson(`${API}/projects/${item.id}`, { method: 'DELETE' }).then(() => {
admin.taskSettings.projects = admin.taskSettings.projects.filter((p) => p.id !== item.id);
admin.taskSettings.processes = admin.taskSettings.processes.filter((p) => p.projectId !== item.id);
cacheState();
renderTaskSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/projects/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
item.value = newVal; cacheState(); renderTaskSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
const projName = (id) => admin.taskSettings.projects.find((p) => p.id === id)?.value || '?';
renderSettingListFn('tkProcList', admin.taskSettings.processes, (item) => `${item.value} (${esc(projName(item.projectId))})`, (item) => {
fetchJson(`${API}/processes/${item.id}`, { method: 'DELETE' }).then(() => {
admin.taskSettings.processes = admin.taskSettings.processes.filter((p) => p.id !== item.id);
cacheState();
renderTaskSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}, (item, newVal) => {
fetchJson(`${API}/processes/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, projectId: item.projectId }) }).then(() => {
item.value = newVal; cacheState(); renderTaskSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
/* Parent dropdown */
const parentSel = document.getElementById('tkProcParent');
if (parentSel) {
const current = parentSel.value;
parentSel.innerHTML = '';
admin.taskSettings.projects.forEach((p) => {
parentSel.innerHTML += ``;
});
parentSel.value = current;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* USERS — list-first with inline form
* ═══════════════════════════════════════════════════════════════════════════ */
function bindUserForm() {
const form = document.getElementById('userForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); saveUser(); });
document.getElementById('cancelUserBtn')?.addEventListener('click', hideUserForm);
document.getElementById('showUserFormBtn')?.addEventListener('click', () => {
clearUserForm(); showSection('userFormSection');
});
}
function showSection(id) { const s = document.getElementById(id); if (s) s.style.display = ''; }
function hideSection(id) { const s = document.getElementById(id); if (s) s.style.display = 'none'; }
function saveUser() {
const el = (id) => document.getElementById(id);
const data = {
email: el('userEmail').value.trim(),
password: el('userPassword').value,
name: el('userName').value.trim(),
familyName: el('userFamilyName').value.trim(),
company: el('userCompany').value.trim(),
role: el('userRole').value
};
if (!data.email || !data.name || !data.familyName || !data.role) {
showToast('Email, Name, Family Name, and Role are required.', 'warning'); return;
}
if (admin.editingType === 'user' && admin.editingId) {
fetchJson(`${API}/users/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((updated) => {
const idx = admin.users.findIndex((u) => u.id === admin.editingId);
if (idx >= 0) admin.users[idx] = updated;
resetEditing();
cacheState();
hideUserForm();
renderUserList();
showToast('User saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
fetchJson(`${API}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.users.push(created);
cacheState();
hideUserForm();
renderUserList();
showToast('User saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearUserForm() {
document.getElementById('userForm')?.reset();
document.getElementById('userFormHeading').textContent = 'Add User';
resetEditing();
}
function hideUserForm() { clearUserForm(); hideSection('userFormSection'); }
function editUser(id) {
const u = admin.users.find((x) => x.id === id);
if (!u) return;
admin.editingId = id; admin.editingType = 'user';
const el = (eid) => document.getElementById(eid);
el('userEmail').value = u.email || '';
el('userPassword').value = u.password || '';
el('userName').value = u.name || '';
el('userFamilyName').value = u.familyName || '';
el('userCompany').value = u.company || '';
el('userRole').value = u.role || '';
el('userFormHeading').textContent = 'Edit User';
showSection('userFormSection');
}
function deleteUser(id) {
if (!confirm('Delete this user?')) return;
fetchJson(`${API}/users/${id}`, { method: 'DELETE' }).then(() => {
admin.users = admin.users.filter((u) => u.id !== id);
admin.tasks = admin.tasks.filter((t) => t.userId !== id);
cacheState();
renderUserList();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderUserList() {
const container = document.getElementById('userListContainer');
if (!container) return;
if (!admin.users.length) {
container.innerHTML = '
No users
Click "Add User" to create one.
';
return;
}
const rows = admin.users.map((u) => `
| ${esc(u.email)} | ${esc(u.name)} | ${esc(u.familyName)} |
${esc(u.company || '-')} | ${esc(u.role || '-')} |
|
`).join('');
container.innerHTML = `
| Email | Name | Family Name | Company | Role | Actions |
${rows}
`;
container.querySelectorAll('[data-edit-user]').forEach((b) => b.addEventListener('click', () => editUser(Number(b.dataset.editUser))));
container.querySelectorAll('[data-delete-user]').forEach((b) => b.addEventListener('click', () => deleteUser(Number(b.dataset.deleteUser))));
}
/* ═══════════════════════════════════════════════════════════════════════════
* SITES — list-first with inline form; Host is dropdown (OBE, PXS)
* ═══════════════════════════════════════════════════════════════════════════ */
function bindSiteForm() {
const form = document.getElementById('siteForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); saveSite(); });
document.getElementById('cancelSiteBtn')?.addEventListener('click', hideSiteForm);
document.getElementById('showSiteFormBtn')?.addEventListener('click', () => {
clearSiteForm(); showSection('siteFormSection');
});
}
function saveSite() {
const el = (id) => document.getElementById(id);
const data = {
siteCode: el('siteSiteCode').value.trim(),
host: el('siteHost').value,
obeSiteCode: el('siteObe').value.trim(),
pxsSiteCode: el('sitePxs').value.trim()
};
if (!data.siteCode) { showToast('Site Code is required.', 'warning'); return; }
if (admin.editingType === 'site' && admin.editingId) {
fetchJson(`${API}/sites/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((updated) => {
const idx = admin.sites.findIndex((s) => s.id === admin.editingId);
if (idx >= 0) admin.sites[idx] = updated;
resetEditing();
cacheState();
hideSiteForm();
renderSiteList();
showToast('Site saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
fetchJson(`${API}/sites`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.sites.push(created);
cacheState();
hideSiteForm();
renderSiteList();
showToast('Site saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearSiteForm() {
document.getElementById('siteForm')?.reset();
document.getElementById('siteFormHeading').textContent = 'Add Site';
resetEditing();
}
function hideSiteForm() { clearSiteForm(); hideSection('siteFormSection'); }
function editSite(id) {
const s = admin.sites.find((x) => x.id === id);
if (!s) return;
admin.editingId = id; admin.editingType = 'site';
const el = (eid) => document.getElementById(eid);
el('siteSiteCode').value = s.siteCode;
el('siteHost').value = s.host || '';
el('siteObe').value = s.obeSiteCode || '';
el('sitePxs').value = s.pxsSiteCode || '';
el('siteFormHeading').textContent = 'Edit Site';
showSection('siteFormSection');
}
function deleteSite(id) {
if (!confirm('Delete this site?')) return;
fetchJson(`${API}/sites/${id}`, { method: 'DELETE' }).then(() => {
admin.sites = admin.sites.filter((s) => s.id !== id);
admin.tasks = admin.tasks.filter((t) => t.siteId !== id);
cacheState();
renderSiteList();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderSiteList() {
const container = document.getElementById('siteListContainer');
if (!container) return;
if (!admin.sites.length) {
container.innerHTML = 'No sites
Click "Add Site" to create one.
';
return;
}
const rows = admin.sites.map((s) => `
| ${esc(s.siteCode)} | ${esc(s.host || '-')} |
${esc(s.obeSiteCode || '-')} | ${esc(s.pxsSiteCode || '-')} |
|
`).join('');
container.innerHTML = `
| Site Code | Host | OBE Site Code | PXS Site Code | Actions |
${rows}
`;
container.querySelectorAll('[data-edit-site]').forEach((b) => b.addEventListener('click', () => editSite(Number(b.dataset.editSite))));
container.querySelectorAll('[data-delete-site]').forEach((b) => b.addEventListener('click', () => deleteSite(Number(b.dataset.deleteSite))));
}
/* ═══════════════════════════════════════════════════════════════════════════
* CHECK LISTS › RECORDS
* ═══════════════════════════════════════════════════════════════════════════ */
function bindClRecordForm() {
const form = document.getElementById('clRecordForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); saveClRecord(); });
document.getElementById('cancelClRecBtn')?.addEventListener('click', hideClRecForm);
document.getElementById('showClRecFormBtn')?.addEventListener('click', () => {
clearClRecordForm(); refreshRecordDropdowns(); showSection('clRecFormSection');
});
}
function refreshRecordDropdowns() {
populateSelect('clRecCategory', admin.templateSettings.categories.map((c) => c.value));
/* Sub-category is filtered by selected category */
updateRecordSubCategoryDropdown();
populateSelect('clRecSeverity', admin.templateSettings.severities.map((s) => s.value));
populateSelect('clRecStatus', admin.templateSettings.statuses.map((s) => s.value));
populateSelect('clRecHandledBy', admin.templateSettings.handledBy.map((h) => h.value));
/* Bind category change to filter sub-categories */
const catSel = document.getElementById('clRecCategory');
if (catSel && !catSel._boundSubCatFilter) {
catSel.addEventListener('change', updateRecordSubCategoryDropdown);
catSel._boundSubCatFilter = true;
}
}
function updateRecordSubCategoryDropdown() {
const catSel = document.getElementById('clRecCategory');
const selectedCatValue = catSel?.value || '';
if (!selectedCatValue) {
/* No category selected — show empty sub-category dropdown */
populateSelect('clRecSubCategory', []);
return;
}
/* Find the category object to get its id */
const cat = admin.templateSettings.categories.find((c) => c.value === selectedCatValue);
if (!cat) { populateSelect('clRecSubCategory', []); return; }
/* Only show sub-categories that belong to the selected category */
const filtered = admin.templateSettings.subCategories
.filter((s) => s.categoryId === cat.id)
.map((s) => s.value);
populateSelect('clRecSubCategory', filtered);
}
function saveClRecord() {
const el = (id) => document.getElementById(id);
const sortVal = Number(el('clRecSort').value);
if (!sortVal) { showToast('Sort number is required.', 'warning'); return; }
const existing = admin.clRecords.find((r) => r.sort === sortVal && r.id !== admin.editingId);
if (existing) { showToast(`Sort value ${sortVal} already used.`, 'warning'); return; }
const data = {
sort: sortVal,
category: el('clRecCategory').value,
subCategory: el('clRecSubCategory').value,
severity: el('clRecSeverity').value,
imageRequired: el('clRecImageRequired').checked,
descriptionEN: el('clRecDescEN').value.trim(),
descriptionFR: el('clRecDescFR').value.trim(),
descriptionNL: el('clRecDescNL').value.trim()
};
if (admin.editingType === 'clRecord' && admin.editingId) {
fetchJson(`${API}/cl-records/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((updated) => {
const idx = admin.clRecords.findIndex((r) => r.id === admin.editingId);
if (idx >= 0) admin.clRecords[idx] = updated;
admin.clRecords.sort((a, b) => a.sort - b.sort);
resetEditing();
cacheState();
hideClRecForm();
renderClRecordList();
showToast('Record saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
fetchJson(`${API}/cl-records`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.clRecords.push(created);
admin.clRecords.sort((a, b) => a.sort - b.sort);
cacheState();
hideClRecForm();
renderClRecordList();
showToast('Record saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearClRecordForm() {
document.getElementById('clRecordForm')?.reset();
document.getElementById('clRecFormHeading').textContent = 'Add Record';
resetEditing();
}
function hideClRecForm() { clearClRecordForm(); hideSection('clRecFormSection'); }
function editClRecord(id) {
const rec = admin.clRecords.find((r) => r.id === id);
if (!rec) return;
admin.editingId = id; admin.editingType = 'clRecord';
refreshRecordDropdowns();
const el = (eid) => document.getElementById(eid);
el('clRecSort').value = rec.sort;
el('clRecCategory').value = rec.category || '';
/* Re-filter sub-categories after setting the category value */
updateRecordSubCategoryDropdown();
el('clRecSubCategory').value = rec.subCategory || '';
el('clRecSeverity').value = rec.severity || '';
el('clRecImageRequired').checked = !!rec.imageRequired;
el('clRecDescEN').value = rec.descriptionEN || '';
el('clRecDescFR').value = rec.descriptionFR || '';
el('clRecDescNL').value = rec.descriptionNL || '';
el('clRecFormHeading').textContent = 'Edit Record';
showSection('clRecFormSection');
}
function deleteClRecord(id) {
if (!confirm('Delete this record?')) return;
fetchJson(`${API}/cl-records/${id}`, { method: 'DELETE' }).then(() => {
admin.clRecords = admin.clRecords.filter((r) => r.id !== id);
admin.clTemplates.forEach((tpl) => { tpl.recordIds = (tpl.recordIds || []).filter((rid) => rid !== id); });
cacheState();
renderClRecordList();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderClRecordList() {
const container = document.getElementById('clRecordListContainer');
const countEl = document.getElementById('clRecCount');
if (!container) return;
if (countEl) countEl.textContent = `${admin.clRecords.length} record(s)`;
if (!admin.clRecords.length) {
container.innerHTML = 'No records
Click "Add Record" to create one.
';
return;
}
const rows = admin.clRecords.map((r) => `
| ${r.sort} | ${esc(r.category || '-')} | ${esc(r.subCategory || '-')} |
${esc(r.descriptionEN || '-')} | ${esc(r.severity || '-')} |
${r.imageRequired ? '✓' : '-'} |
|
`).join('');
container.innerHTML = `
| Sort | Category | Sub Cat. | Description EN | Severity | Img | Actions |
${rows}
`;
container.querySelectorAll('[data-edit-rec]').forEach((b) => b.addEventListener('click', () => editClRecord(Number(b.dataset.editRec))));
container.querySelectorAll('[data-delete-rec]').forEach((b) => b.addEventListener('click', () => deleteClRecord(Number(b.dataset.deleteRec))));
}
/* ═══════════════════════════════════════════════════════════════════════════
* CHECK LISTS › TEMPLATES
* ═══════════════════════════════════════════════════════════════════════════ */
function bindClTemplateForm() {
const form = document.getElementById('clTemplateForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); saveClTemplate(); });
document.getElementById('cancelClTplBtn')?.addEventListener('click', hideClTplForm);
document.getElementById('showClTplFormBtn')?.addEventListener('click', () => {
clearClTemplateForm(); renderClTemplateRecordSelection(); showSection('clTplFormSection');
});
}
function renderClTemplateRecordSelection() {
const container = document.getElementById('clTplRecordSelection');
if (!container) return;
if (!admin.clRecords.length) {
container.innerHTML = 'No records available. Add records first.
';
return;
}
const selectedIds = new Set();
if (admin.editingType === 'clTemplate' && admin.editingId) {
const tpl = admin.clTemplates.find((t) => t.id === admin.editingId);
if (tpl) (tpl.recordIds || []).forEach((rid) => selectedIds.add(rid));
}
const rows = admin.clRecords.map((r) => {
const checked = selectedIds.has(r.id) ? 'checked' : '';
return ` |
${r.sort} | ${esc(r.category || '-')} | ${esc(r.descriptionEN || '-')} |
`;
}).join('');
container.innerHTML = `
| Include | Sort | Category | Description EN |
${rows}
`;
}
function saveClTemplate() {
const el = (id) => document.getElementById(id);
const data = {
name: el('clTplName').value.trim(),
scope: el('clTplScope').value,
version: el('clTplVersion').value.trim(),
validFrom: el('clTplValidFrom').value || null,
validTill: el('clTplValidTill').value || null,
recordIds: [...document.querySelectorAll('.tpl-rec-check:checked')].map((cb) => Number(cb.value))
};
if (!data.name) { showToast('Template name required.', 'warning'); return; }
if (admin.editingType === 'clTemplate' && admin.editingId) {
fetchJson(`${API}/cl-templates/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((updated) => {
const idx = admin.clTemplates.findIndex((t) => t.id === admin.editingId);
if (idx >= 0) admin.clTemplates[idx] = updated;
resetEditing();
cacheState();
hideClTplForm();
renderClTemplateList();
showToast('Template saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
fetchJson(`${API}/cl-templates`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.clTemplates.push(created);
cacheState();
hideClTplForm();
renderClTemplateList();
showToast('Template saved.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearClTemplateForm() {
document.getElementById('clTemplateForm')?.reset();
document.getElementById('clTplFormHeading').textContent = 'Add Template';
resetEditing();
}
function hideClTplForm() { clearClTemplateForm(); hideSection('clTplFormSection'); }
function editClTemplate(id) {
const tpl = admin.clTemplates.find((t) => t.id === id);
if (!tpl) return;
admin.editingId = id; admin.editingType = 'clTemplate';
const el = (eid) => document.getElementById(eid);
el('clTplName').value = tpl.name;
el('clTplScope').value = tpl.scope || '';
el('clTplVersion').value = tpl.version || '';
el('clTplValidFrom').value = tpl.validFrom || '';
el('clTplValidTill').value = tpl.validTill || '';
el('clTplFormHeading').textContent = 'Edit Template';
renderClTemplateRecordSelection();
showSection('clTplFormSection');
}
function deleteClTemplate(id) {
if (!confirm('Delete this template?')) return;
fetchJson(`${API}/cl-templates/${id}`, { method: 'DELETE' }).then(() => {
admin.clTemplates = admin.clTemplates.filter((t) => t.id !== id);
admin.tasks = admin.tasks.filter((t) => t.templateId !== id);
cacheState();
renderClTemplateList();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderClTemplateList() {
const container = document.getElementById('clTemplateListContainer');
const countEl = document.getElementById('clTplCount');
if (!container) return;
if (countEl) countEl.textContent = `${admin.clTemplates.length} template(s)`;
if (!admin.clTemplates.length) {
container.innerHTML = 'No templates
Click "Add Template" to create one.
';
return;
}
const fmtDate = (d) => d ? formatDateDisplay(d) : '-';
const rows = admin.clTemplates.map((t) => `
| ${esc(t.name)} | ${esc(t.scope || '-')} | ${esc(t.version || '-')} |
${fmtDate(t.validFrom)} | ${fmtDate(t.validTill)} | ${(t.recordIds || []).length} |
|
`).join('');
container.innerHTML = `
| Name | Scope | Version | Valid From | Valid Till | Records | Actions |
${rows}
`;
container.querySelectorAll('[data-edit-tpl]').forEach((b) => b.addEventListener('click', () => editClTemplate(Number(b.dataset.editTpl))));
container.querySelectorAll('[data-delete-tpl]').forEach((b) => b.addEventListener('click', () => deleteClTemplate(Number(b.dataset.deleteTpl))));
}
/* ═══════════════════════════════════════════════════════════════════════════
* REPORTS — Task assignment (list-first)
* ═══════════════════════════════════════════════════════════════════════════ */
function bindTaskForm() {
const form = document.getElementById('taskForm');
if (!form) return;
form.addEventListener('submit', (e) => { e.preventDefault(); saveTask(); });
document.getElementById('cancelTaskBtn')?.addEventListener('click', hideTaskForm);
document.getElementById('showTaskFormBtn')?.addEventListener('click', () => {
clearTaskForm(); refreshTaskDropdowns(); showSection('taskFormSection');
});
}
function refreshTaskDropdowns() {
const siteOpts = admin.sites.map((s) => ({ value: s.id, label: s.siteCode }));
populateSelectObjects('taskSite', siteOpts, '— Select site —');
const userOpts = admin.users.map((u) => ({ value: u.id, label: `${u.name} ${u.familyName} (${u.email})` }));
populateSelectObjects('taskUser', userOpts, '— Select user —');
const tplOpts = admin.clTemplates.map((t) => ({ value: t.id, label: `${t.name} (v${t.version || '?'})` }));
populateSelectObjects('taskTemplate', tplOpts, '— Select template —');
populateSelect('taskProject', admin.taskSettings.projects.map((p) => p.value));
/* Processes filtered by selected project — bind change listener */
updateTaskProcessDropdown();
const projSel = document.getElementById('taskProject');
if (projSel && !projSel._boundTaskProc) {
projSel.addEventListener('change', updateTaskProcessDropdown);
projSel._boundTaskProc = true;
}
}
function updateTaskProcessDropdown() {
const projSel = document.getElementById('taskProject');
const selectedProject = projSel?.value || '';
if (!selectedProject) {
populateSelect('taskProcess', admin.taskSettings.processes.map((p) => p.value));
} else {
const parentProj = admin.taskSettings.projects.find((p) => p.value === selectedProject);
const filtered = admin.taskSettings.processes
.filter((p) => p.projectId === (parentProj?.id || 0))
.map((p) => p.value);
populateSelect('taskProcess', filtered);
}
}
function saveTask() {
const el = (id) => document.getElementById(id);
const data = {
siteId: Number(el('taskSite').value),
userId: Number(el('taskUser').value),
templateId: Number(el('taskTemplate').value),
project: el('taskProject').value,
process: el('taskProcess').value
};
if (!data.siteId || !data.userId || !data.templateId) {
showToast('User, Site, and Template are required.', 'warning'); return;
}
if (admin.editingType === 'task' && admin.editingId) {
const existing = admin.tasks.find((t) => t.id === admin.editingId);
const payload = { ...data, status: existing?.status || 'pending' };
fetchJson(`${API}/tasks/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
.then((updated) => {
const idx = admin.tasks.findIndex((t) => t.id === admin.editingId);
if (idx >= 0) admin.tasks[idx] = updated;
resetEditing();
cacheState();
hideTaskForm();
renderTaskList();
showToast('Task assigned.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
} else {
data.status = 'pending';
fetchJson(`${API}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
.then((created) => {
admin.tasks.push(created);
cacheState();
hideTaskForm();
renderTaskList();
showToast('Task assigned.', 'success');
})
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
}
}
function clearTaskForm() {
document.getElementById('taskForm')?.reset();
document.getElementById('taskFormHeading').textContent = 'Create Task Assignment';
resetEditing();
}
function hideTaskForm() { clearTaskForm(); hideSection('taskFormSection'); }
function editTask(id) {
const task = admin.tasks.find((t) => t.id === id);
if (!task) return;
admin.editingId = id; admin.editingType = 'task';
refreshTaskDropdowns();
const el = (eid) => document.getElementById(eid);
el('taskSite').value = task.siteId || '';
el('taskUser').value = task.userId || '';
el('taskTemplate').value = task.templateId || '';
el('taskProject').value = task.project || '';
el('taskProcess').value = task.process || '';
el('taskFormHeading').textContent = 'Edit Task Assignment';
showSection('taskFormSection');
}
function deleteTask(id) {
if (!confirm('Delete this task? This will also permanently remove all uploaded images.')) return;
fetchJson(`${API}/tasks/${id}`, { method: 'DELETE' }).then(() => {
admin.tasks = admin.tasks.filter((t) => t.id !== id);
cacheState();
renderTaskList();
/* Also delete the report and image files from the server */
if (navigator.onLine) {
fetch(`/api/v1/reports/${id}`, { method: 'DELETE' }).catch(() => {});
}
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
}
function renderTaskList() {
const container = document.getElementById('taskListContainer');
if (!container) return;
if (!admin.tasks.length) {
container.innerHTML = 'No tasks
Click "Add Task" to assign one.
';
return;
}
const siteName = (id) => admin.sites.find((s) => s.id === id)?.siteCode || '?';
const userName = (id) => { const u = admin.users.find((x) => x.id === id); return u ? `${u.name} ${u.familyName}` : '?'; };
const tplName = (id) => admin.clTemplates.find((t) => t.id === id)?.name || '?';
const statusBadge = (s) => {
const cls = s === 'final' ? 'badge-online' : s === 'draft' ? 'badge-offline' : 'badge-neutral';
return `${esc(s || 'pending')}`;
};
const rows = admin.tasks.map((t) => `
| ${esc(siteName(t.siteId))} | ${esc(userName(t.userId))} |
${esc(tplName(t.templateId))} | ${esc(t.project || '-')} |
${esc(t.process || '-')} | ${statusBadge(t.status)} |
${t.status === 'final' ? `` : ''}
|
`).join('');
container.innerHTML = `
| Site | User | Template | Project | Process | Status | Actions |
${rows}
`;
container.querySelectorAll('[data-view-task]').forEach((b) => b.addEventListener('click', () => viewTaskReport(Number(b.dataset.viewTask))));
container.querySelectorAll('[data-map-task]').forEach((b) => b.addEventListener('click', () => openTaskImagesMap(Number(b.dataset.mapTask))));
container.querySelectorAll('[data-reopen-task]').forEach((b) => b.addEventListener('click', () => reopenTask(Number(b.dataset.reopenTask))));
container.querySelectorAll('[data-edit-task]').forEach((b) => b.addEventListener('click', () => editTask(Number(b.dataset.editTask))));
container.querySelectorAll('[data-delete-task]').forEach((b) => b.addEventListener('click', () => deleteTask(Number(b.dataset.deleteTask))));
}
/* ═══════════════════════════════════════════════════════════════════════════
* Task Report Viewer (admin preview — like user portal display)
* ═══════════════════════════════════════════════════════════════════════════ */
function viewTaskReport(taskId) {
const task = admin.tasks.find(t => t.id === taskId);
if (!task) { showToast('Task not found.', 'error'); return; }
const site = admin.sites.find(s => s.id === task.siteId);
const user = admin.users.find(u => u.id === task.userId);
const tpl = admin.clTemplates.find(t => t.id === task.templateId);
const recordIds = tpl?.recordIds || [];
const records = recordIds.map(rid => admin.clRecords.find(r => r.id === rid)).filter(Boolean).sort((a, b) => a.sort - b.sort);
/* Try to get data from server first, fallback to empty */
fetchReportDataForAdmin(taskId).then(serverData => {
const data = serverData || { visitDate: '', records: {} };
buildAndShowReportModal(task, site, user, tpl, data, records);
/* Always fetch images from DB for submitted reports */
if (navigator.onLine) {
fetchReportImagesForAdmin(taskId, records);
}
});
}
function buildAndShowReportModal(task, site, user, tpl, data, records) {
let recordsHtml = '';
if (!records.length) {
recordsHtml = 'No records in template.
';
} else {
recordsHtml = records.map(rec => {
const rd = data.records[rec.id] || {};
const desc = rec.descriptionEN || rec.descriptionFR || rec.descriptionNL || 'No description';
const status = rd.status || '-';
const handledBy = rd.handledBy || '-';
const comment = rd.comment || '-';
const images = rd.images || [];
/* Show a loading placeholder — real images will be fetched from server files */
const imgPlaceholder = images.length
? `Loading ${images.length} image(s)...`
: 'No images';
const serverImgPlaceholder = `${imgPlaceholder}
`;
const statusBadge = status === 'NOK' ? 'bg-danger' : status === 'OK' ? 'bg-success' : status === 'TBC' ? 'bg-warning text-dark' : 'bg-secondary';
return `
#${rec.sort}
${esc(desc)}
${rec.imageRequired ? 'IMG REQ' : ''}
Category: ${esc(rec.category || '-')}
Sub: ${esc(rec.subCategory || '-')}
Severity: ${esc(rec.severity || '-')}
Status: ${esc(status)}
Handled By: ${esc(handledBy)}
Comment: ${esc(comment)}
${serverImgPlaceholder}
`;
}).join('');
}
const taskStatusBadge = task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary';
/* Build & show modal */
let modal = document.getElementById('adminReportViewModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'adminReportViewModal';
modal.className = 'modal fade';
modal.tabIndex = -1;
modal.innerHTML = ``;
document.body.appendChild(modal);
}
const taskId = task.id;
document.getElementById('adminReportModalTitle').textContent = `Report — ${site?.siteCode || 'Unknown'} / ${tpl?.name || 'Unknown'}`;
document.getElementById('adminReportModalBody').innerHTML = `
Site${esc(site?.siteCode || '-')}
Project${esc(task.project || '-')}
Process${esc(task.process || '-')}
Status${esc(task.status || 'pending')}
User${esc(user ? `${user.name} ${user.familyName}` : '-')}
Visit Date: ${esc(data.visitDate || 'Not set')}
Records (${records.length})
${recordsHtml}
`;
/* Reopen button (only for final tasks) */
const footer = document.getElementById('adminReportModalFooter');
footer.innerHTML = task.status === 'final'
? `
`
: ``;
document.getElementById('adminReopenTaskBtn')?.addEventListener('click', () => {
reopenTask(taskId);
bootstrap.Modal.getInstance(modal)?.hide();
});
/* Bind image lightbox clicks (with EXIF info) */
modal.querySelectorAll('[data-admin-lightbox]').forEach(img => {
img.addEventListener('click', () => openAdminLightbox(img.src, img.alt, {
name: img.dataset.imgName,
size: Number(img.dataset.imgSize) || 0,
width: img.dataset.imgWidth,
height: img.dataset.imgHeight,
exif: img.dataset.imgExif ? safeJsonParse(img.dataset.imgExif) : null
}));
});
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
}
/**
* Fetches report answers from the server to get record data (status, handledBy, comments).
*/
async function fetchReportDataForAdmin(taskId) {
if (!navigator.onLine) return null;
try {
const resp = await fetch(`/api/v1/reports/${taskId}`, { headers: { Accept: 'application/json' } });
if (!resp.ok) return null;
const report = await resp.json();
return report.answers || null;
} catch {
return null;
}
}
/**
* Fetches images from the server for a report and renders them
* into the already-open modal under each record's placeholder slot.
* Images are served as URLs pointing to files on disk.
*/
async function fetchReportImagesForAdmin(taskId, records) {
try {
const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } });
if (!resp.ok) {
/* Clear loading placeholders on failure */
for (const rec of records) {
const slot = document.querySelector(`.server-images-slot[data-rec-id="${rec.id}"]`);
if (slot) slot.innerHTML = 'Could not load images.';
}
return;
}
const imagesByRecord = await resp.json();
for (const rec of records) {
const slot = document.querySelector(`.server-images-slot[data-rec-id="${rec.id}"]`);
if (!slot) continue;
const imgs = imagesByRecord[rec.id];
if (!imgs?.length) {
slot.innerHTML = 'No images';
continue;
}
slot.innerHTML = imgs.map((img, i) =>
`
${esc(img.name || `img-${i}`)}
${formatFileSize(img.size)}
`
).join('');
/* Bind lightbox clicks for newly added images */
slot.querySelectorAll('[data-admin-lightbox]').forEach(imgEl => {
imgEl.addEventListener('click', () => openAdminLightbox(imgEl.src, imgEl.alt, {
name: imgEl.dataset.imgName,
size: Number(imgEl.dataset.imgSize) || 0,
width: imgEl.dataset.imgWidth,
height: imgEl.dataset.imgHeight,
exif: imgEl.dataset.imgExif ? safeJsonParse(imgEl.dataset.imgExif) : null
}));
});
}
} catch (err) {
console.warn('Failed to fetch report images:', err.message);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Admin: Reopen a final task
* ═══════════════════════════════════════════════════════════════════════════ */
function reopenTask(taskId) {
const task = admin.tasks.find(t => t.id === taskId);
if (!task) return;
const payload = { siteId: task.siteId, userId: task.userId, templateId: task.templateId, project: task.project, process: task.process, status: 'draft' };
fetchJson(`${API}/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
.then(() => {
task.status = 'draft';
cacheState();
renderTaskList();
showToast('Task reopened — user can now edit it again.', 'success');
})
.catch((err) => showToast(err.message || 'Reopen failed.', 'error'));
}
/* ═══════════════════════════════════════════════════════════════════════════
* Admin: Image lightbox (with EXIF, name, size info)
* ═══════════════════════════════════════════════════════════════════════════ */
function openAdminLightbox(src, alt, meta) {
let modal = document.getElementById('adminImageLightbox');
if (!modal) {
modal = document.createElement('div');
modal.id = 'adminImageLightbox';
modal.className = 'modal fade';
modal.tabIndex = -1;
modal.innerHTML = ``;
document.body.appendChild(modal);
let rotation = 0;
let scale = 1;
const imgEl = document.getElementById('adminLightboxImg');
function applyAdminTransform() { imgEl.style.transform = `rotate(${rotation}deg) scale(${scale})`; }
document.getElementById('adminLightboxRotateLeft').addEventListener('click', () => {
rotation = (rotation - 90) % 360;
applyAdminTransform();
});
document.getElementById('adminLightboxRotateRight').addEventListener('click', () => {
rotation = (rotation + 90) % 360;
applyAdminTransform();
});
document.getElementById('adminLightboxZoomIn').addEventListener('click', () => {
scale = Math.min(scale + 0.25, 5);
applyAdminTransform();
});
document.getElementById('adminLightboxZoomOut').addEventListener('click', () => {
scale = Math.max(scale - 0.25, 0.25);
applyAdminTransform();
});
modal.addEventListener('hidden.bs.modal', () => { rotation = 0; scale = 1; imgEl.style.transform = ''; });
}
document.getElementById('adminLightboxImg').src = src;
document.getElementById('adminLightboxImg').style.transform = '';
document.getElementById('adminLightboxTitle').textContent = meta?.name || alt || 'Image Preview';
/* Build info panel with file metadata and EXIF */
const infoPanel = document.getElementById('adminLightboxInfo');
let infoHtml = 'Image Details
';
infoHtml += '';
if (meta?.name) infoHtml += `| File Name | ${esc(meta.name)} |
`;
if (meta?.size) infoHtml += `| Size | ${formatFileSize(meta.size)} |
`;
if (meta?.width && meta?.height) infoHtml += `| Dimensions | ${meta.width} × ${meta.height} px |
`;
infoHtml += '
';
/* EXIF data */
const exif = meta?.exif;
if (exif && typeof exif === 'object' && Object.keys(exif).length) {
infoHtml += 'EXIF Data
';
infoHtml += '';
const exifLabels = {
make: 'Camera Make', model: 'Camera Model', dateTimeOriginal: 'Date Taken',
dateTime: 'Date/Time', focalLength: 'Focal Length', exposureTime: 'Exposure',
fNumber: 'F-Number', isoSpeed: 'ISO', orientation: 'Orientation',
pixelXDimension: 'Width (EXIF)', pixelYDimension: 'Height (EXIF)',
latitude: 'Latitude', longitude: 'Longitude', altitude: 'Altitude'
};
for (const [key, label] of Object.entries(exifLabels)) {
if (exif[key] != null && exif[key] !== '') {
let val = exif[key];
if (key === 'focalLength') val = `${Number(val).toFixed(1)} mm`;
else if (key === 'exposureTime') val = val < 1 ? `1/${Math.round(1 / val)}s` : `${val}s`;
else if (key === 'fNumber') val = `f/${Number(val).toFixed(1)}`;
else if (key === 'latitude' || key === 'longitude') val = `${Number(val).toFixed(6)}°`;
else if (key === 'altitude') val = `${Number(val).toFixed(1)} m`;
infoHtml += `| ${label} | ${esc(String(val))} |
`;
}
}
infoHtml += '
';
/* Show on map button if GPS coords present */
if (exif.latitude != null && exif.longitude != null) {
infoHtml += ``;
}
} else {
infoHtml += 'No EXIF data available.
';
}
infoPanel.innerHTML = infoHtml;
/* Bind map button if present */
const mapBtn = document.getElementById('adminLightboxMapBtn');
if (mapBtn && exif?.latitude != null && exif?.longitude != null) {
mapBtn.addEventListener('click', () => {
openAdminMapModal([{ lat: exif.latitude, lng: exif.longitude, name: meta?.name || 'Image', dataUrl: src }]);
});
}
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Admin: Map modal (Leaflet + OpenStreetMap)
* ═══════════════════════════════════════════════════════════════════════════ */
/**
* Open a modal with a Leaflet map showing markers for the given points.
* Each point: { lat, lng, name }
*/
function openAdminMapModal(points) {
if (!points?.length) { showToast('No geo-tagged images found.', 'info'); return; }
let modal = document.getElementById('adminMapModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'adminMapModal';
modal.className = 'modal fade';
modal.tabIndex = -1;
modal.style.zIndex = '1070'; /* above lightbox modal (1055) */
modal.innerHTML = ``;
document.body.appendChild(modal);
}
/* Ensure backdrop also appears above lightbox */
modal.addEventListener('shown.bs.modal', () => {
const backdrop = document.querySelector('.modal-backdrop:last-child');
if (backdrop) backdrop.style.zIndex = '1065';
}, { once: true });
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
bsModal.show();
/* Initialize map after modal is shown (Leaflet needs visible container) */
modal.addEventListener('shown.bs.modal', function initMap() {
modal.removeEventListener('shown.bs.modal', initMap);
const container = document.getElementById('adminMapContainer');
container.innerHTML = ''; /* clear any previous map instance */
container._leaflet_id = null; /* allow re-init */
const map = L.map(container).setView([points[0].lat, points[0].lng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
const bounds = L.latLngBounds();
for (const pt of points) {
const marker = L.marker([pt.lat, pt.lng]).addTo(map);
let popupHtml = '';
if (pt.dataUrl) {
popupHtml += `
`;
}
popupHtml += `${esc(pt.name)}
${pt.lat.toFixed(6)}, ${pt.lng.toFixed(6)}`;
marker.bindPopup(popupHtml, { maxWidth: 200 });
bounds.extend([pt.lat, pt.lng]);
}
if (points.length > 1) map.fitBounds(bounds, { padding: [30, 30] });
/* Fix map tiles when modal is resized */
setTimeout(() => map.invalidateSize(), 200);
const observer = new ResizeObserver(() => map.invalidateSize());
observer.observe(container);
modal.addEventListener('hidden.bs.modal', () => { observer.disconnect(); map.remove(); }, { once: true });
}, { once: true });
}
/**
* Fetch images for a task and open the map with all geo-tagged images.
*/
async function openTaskImagesMap(taskId) {
try {
const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } });
if (!resp.ok) { showToast('Could not load images for map.', 'error'); return; }
const imagesByRecord = await resp.json();
const points = [];
for (const recId of Object.keys(imagesByRecord)) {
for (const img of imagesByRecord[recId]) {
if (img.exif?.latitude != null && img.exif?.longitude != null) {
points.push({ lat: img.exif.latitude, lng: img.exif.longitude, name: img.name || 'Image', dataUrl: img.dataUrl || null });
}
}
}
if (!points.length) { showToast('No images with GPS coordinates found for this task.', 'info'); return; }
openAdminMapModal(points);
} catch (err) {
showToast('Failed to load images: ' + err.message, 'error');
}
}
function formatFileSize(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`;
}
function safeJsonParse(str) {
try { return JSON.parse(str); } catch { return null; }
}
/* ═══════════════════════════════════════════════════════════════════════════
* Shared helpers
* ═══════════════════════════════════════════════════════════════════════════ */
function resetEditing() { admin.editingId = null; admin.editingType = null; }
function populateSelect(selectId, options) {
const sel = document.getElementById(selectId);
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '';
(options || []).forEach((v) => { sel.innerHTML += ``; });
sel.value = cur;
}
function populateSelectObjects(selectId, items, placeholder) {
const sel = document.getElementById(selectId);
if (!sel) return;
const cur = sel.value;
sel.innerHTML = ``;
items.forEach(({ value, label }) => { sel.innerHTML += ``; });
sel.value = cur;
}
/** Render status list with requirement indicators and edit support. */
function renderStatusList(containerId, items) {
const container = document.getElementById(containerId);
if (!container) return;
if (!items.length) { container.innerHTML = 'No items yet.
'; return; }
container.innerHTML = '';
items.forEach((item) => {
const row = document.createElement('div');
row.className = 'setting-list-item';
const badges = [];
if (item.requireHandledBy) badges.push('HB');
if (item.requireComment) badges.push('CMT');
row.innerHTML = `${esc(item.value)}${badges.join('')}
`;
row.querySelector('.setting-del-btn').addEventListener('click', () => {
fetchJson(`${API}/statuses/${item.id}`, { method: 'DELETE' }).then(() => {
admin.templateSettings.statuses = admin.templateSettings.statuses.filter((i) => i.id !== item.id);
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
});
row.querySelector('.setting-edit-btn').addEventListener('click', () => {
const newVal = prompt('Edit status value:', item.value);
if (newVal === null || !newVal.trim()) return;
const reqHB = confirm('Require "Handled By" for this status?');
const reqCmt = confirm('Require "Comment" for this status?');
fetchJson(`${API}/statuses/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal.trim(), requireHandledBy: reqHB, requireComment: reqCmt }) }).then(() => {
item.value = newVal.trim();
item.requireHandledBy = reqHB;
item.requireComment = reqCmt;
cacheState();
renderTemplateSettings();
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
});
container.appendChild(row);
});
}
/** Render an editable/deletable list of {id, value} items. */
function renderSettingList(containerId, items, onDelete, onEdit) {
renderSettingListFn(containerId, items, (item) => esc(item.value), onDelete, onEdit);
}
/** Render list with a custom label function. */
function renderSettingListFn(containerId, items, labelFn, onDelete, onEdit) {
const container = document.getElementById(containerId);
if (!container) return;
if (!items.length) { container.innerHTML = 'No items yet.
'; return; }
container.innerHTML = '';
items.forEach((item) => {
const row = document.createElement('div');
row.className = 'setting-list-item';
row.innerHTML = `${labelFn(item)}
`;
row.querySelector('.setting-del-btn').addEventListener('click', () => onDelete(item));
row.querySelector('.setting-edit-btn').addEventListener('click', () => {
const newVal = prompt('Edit value:', item.value);
if (newVal !== null && newVal.trim()) onEdit(item, newVal.trim());
});
container.appendChild(row);
});
}
function showToast(message, tone) {
let toast = document.getElementById('adminToast');
if (!toast) { toast = document.createElement('div'); toast.id = 'adminToast'; toast.className = 'admin-toast'; document.body.appendChild(toast); }
const cls = tone === 'success' ? 'badge-online' : tone === 'error' ? 'badge-error' : 'badge-offline';
toast.textContent = message;
toast.className = `admin-toast badge ${cls} admin-toast-visible`;
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.classList.remove('admin-toast-visible'); }, 3000);
}
function fmtKb(bytes) {
if (!bytes) return '-';
return `${Math.round(bytes / 1024)} KB`;
}
function formatDateDisplay(dateStr) {
if (!dateStr) return '-';
/* HTML date inputs give yyyy-mm-dd — display as dd/mm/yyyy */
const parts = dateStr.split('-');
if (parts.length === 3) return `${parts[2]}/${parts[1]}/${parts[0]}`;
return dateStr;
}
function esc(text) { const d = document.createElement('div'); d.textContent = text ?? ''; return d.innerHTML; }
function escAttr(text) { return esc(text).replace(/"/g, '"').replace(/'/g, '''); }
/** Delegate event to document for dynamically created elements. */
function on(event, selector, handler) {
document.addEventListener(event, (e) => {
const target = e.target.closest(selector);
if (target) handler(e, target);
});
}
function bindEnter(inputId, handler) {
const el = document.getElementById(inputId);
if (el) el.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); handler(); } });
}