2384 lines
115 KiB
JavaScript
2384 lines
115 KiB
JavaScript
/*
|
||
* Admin module — self-contained admin console logic for the Check List PoC.
|
||
*
|
||
* Manages:
|
||
* • Sidebar navigation (category expand/collapse, panel switching)
|
||
* • Image policy editor (Settings › Image Policy) — server-backed via API
|
||
* • Template settings (Settings › Template) — server-backed via API with list view (edit/remove)
|
||
* • Task settings (Settings › Task) — server-backed via API with list view (edit/remove)
|
||
* • Users CRUD (Users — single panel list-first with add/edit form)
|
||
* • Sites CRUD (Sites — single panel list-first with add/edit form)
|
||
* • CL Records CRUD (Check Lists › Records — list-first with add/edit form)
|
||
* • CL Templates CRUD (Check Lists › Templates — list-first with add/edit form)
|
||
* • Tasks CRUD (Reports — list-first with add/edit form)
|
||
*
|
||
* Data notes:
|
||
* • Sub Categories require a parent Category
|
||
* • Processes require a parent Project
|
||
* • Records: Status, Handled By, Comment are disabled placeholders
|
||
* • Records: "Image Required" checkbox determines if user must add images
|
||
* • Sites: Host is dropdown (OBE, PXS)
|
||
* • Users: field is Email (not Username), Role options: CW, ANT, CW/ANT
|
||
* • Templates: Scope dropdown (CW, ANT, ANT_CPsite), dates use date picker
|
||
*/
|
||
|
||
import { fetchJson } from './api.js';
|
||
import { dbPut, dbGet } from './db.js';
|
||
import { STORE_CONFIG } from './constants.js';
|
||
import { validateImageRulesPayload } from './validation.js';
|
||
|
||
/* ── API base path for admin entity CRUD ────────────────────────────────── */
|
||
|
||
const API = '/admin';
|
||
|
||
/* ── Admin state ────────────────────────────────────────────────────────── */
|
||
|
||
const admin = {
|
||
imageRules: null,
|
||
users: [],
|
||
sites: [],
|
||
clRecords: [],
|
||
clTemplates: [],
|
||
tasks: [],
|
||
templateSettings: {
|
||
categories: [], // [{id, value}]
|
||
subCategories: [], // [{id, value, categoryId}]
|
||
severities: [], // [{id, value}]
|
||
statuses: [], // [{id, value}]
|
||
handledBy: [] // [{id, value}]
|
||
},
|
||
taskSettings: {
|
||
projects: [], // [{id, value}]
|
||
processes: [] // [{id, value, projectId}]
|
||
},
|
||
appConfig: {},
|
||
editingId: null,
|
||
editingType: null
|
||
};
|
||
|
||
/* ── Persistence helpers (MariaDB via REST API, IndexedDB as offline cache) ── */
|
||
|
||
/*
|
||
* Loads all admin entity data from the server (MariaDB) via the bulk endpoint,
|
||
* and caches in IndexedDB for offline use.
|
||
*/
|
||
async function loadFromServer() {
|
||
try {
|
||
const data = await fetchJson(`${API}/all`);
|
||
admin.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
|
||
admin.taskSettings = data.taskSettings || { projects: [], processes: [] };
|
||
admin.users = data.users || [];
|
||
admin.sites = data.sites || [];
|
||
admin.clRecords = data.clRecords || [];
|
||
admin.clTemplates = data.clTemplates || [];
|
||
admin.tasks = data.tasks || [];
|
||
admin.appConfig = data.appConfig || {};
|
||
/* Cache in IndexedDB for offline access */
|
||
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {});
|
||
return true;
|
||
} catch (err) {
|
||
console.warn('Failed to load admin data from server, using IndexedDB cache', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/*
|
||
* Loads from IndexedDB cache (offline fallback).
|
||
*/
|
||
async function loadFromCache() {
|
||
try {
|
||
const row = await dbGet(STORE_CONFIG, 'admin_all');
|
||
const data = row?.value;
|
||
if (!data) return;
|
||
admin.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
|
||
admin.taskSettings = data.taskSettings || { projects: [], processes: [] };
|
||
admin.users = data.users || [];
|
||
admin.sites = data.sites || [];
|
||
admin.clRecords = data.clRecords || [];
|
||
admin.clTemplates = data.clTemplates || [];
|
||
admin.tasks = data.tasks || [];
|
||
admin.appConfig = data.appConfig || {};
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
/*
|
||
* Caches current admin state to IndexedDB.
|
||
*/
|
||
function cacheState() {
|
||
const data = {
|
||
templateSettings: admin.templateSettings,
|
||
taskSettings: admin.taskSettings,
|
||
users: admin.users,
|
||
sites: admin.sites,
|
||
clRecords: admin.clRecords,
|
||
clTemplates: admin.clTemplates,
|
||
tasks: admin.tasks,
|
||
appConfig: admin.appConfig
|
||
};
|
||
dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {});
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Initialization
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
export async function initAdmin({ imageRules }) {
|
||
admin.imageRules = imageRules;
|
||
|
||
/* Load from server (MariaDB) first; data is also cached in IndexedDB. */
|
||
if (navigator.onLine) {
|
||
await loadFromServer();
|
||
} else {
|
||
await loadFromCache();
|
||
}
|
||
|
||
initNavigation();
|
||
bindImagePolicyForm();
|
||
bindTemplateSettingsForm();
|
||
bindTaskSettingsForm();
|
||
bindGeoSettingsForm();
|
||
bindUserForm();
|
||
bindSiteForm();
|
||
bindClRecordForm();
|
||
bindClTemplateForm();
|
||
bindTaskForm();
|
||
|
||
renderImagePolicy();
|
||
renderTemplateSettings();
|
||
renderTaskSettings();
|
||
renderGeoSettings();
|
||
renderUserList();
|
||
renderSiteList();
|
||
renderClRecordList();
|
||
renderClTemplateList();
|
||
renderTaskList();
|
||
|
||
updateConnectionBadge();
|
||
window.addEventListener('online', updateConnectionBadge);
|
||
window.addEventListener('offline', updateConnectionBadge);
|
||
|
||
/* Sync */
|
||
document.getElementById('adminSyncBtn')?.addEventListener('click', async () => {
|
||
const btn = document.getElementById('adminSyncBtn');
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Syncing…';
|
||
}
|
||
try {
|
||
await loadFromServer();
|
||
renderImagePolicy();
|
||
renderTemplateSettings();
|
||
renderTaskSettings();
|
||
renderGeoSettings();
|
||
renderUserList();
|
||
renderSiteList();
|
||
refreshRecordDropdowns(); renderClRecordList();
|
||
renderClTemplateRecordSelection(); renderClTemplateList();
|
||
refreshTaskDropdowns(); renderTaskList();
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Sync';
|
||
}
|
||
}
|
||
});
|
||
|
||
/* Logout */
|
||
document.getElementById('adminLogoutBtn')?.addEventListener('click', async () => {
|
||
try { await fetch('/api/v1/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ }
|
||
window.location.href = '/';
|
||
});
|
||
|
||
if (navigator.onLine) syncImageRules();
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Navigation
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function initNavigation() {
|
||
const nav = document.getElementById('adminNav');
|
||
if (!nav) return;
|
||
|
||
nav.querySelectorAll('.admin-nav-cat-btn').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const cat = btn.closest('.admin-nav-cat');
|
||
cat.classList.toggle('is-open');
|
||
const arrow = btn.querySelector('.nav-arrow');
|
||
if (arrow) arrow.textContent = cat.classList.contains('is-open') ? '▾' : '▸';
|
||
});
|
||
});
|
||
|
||
nav.querySelectorAll('.admin-nav-item').forEach((item) => {
|
||
item.addEventListener('click', () => {
|
||
activateNavItem(item);
|
||
showPanel(item.dataset.panel);
|
||
});
|
||
});
|
||
}
|
||
|
||
function activateNavItem(item) {
|
||
const nav = document.getElementById('adminNav');
|
||
nav.querySelectorAll('.admin-nav-item').forEach((i) => i.classList.remove('is-active'));
|
||
item.classList.add('is-active');
|
||
const cat = item.closest('.admin-nav-cat');
|
||
if (cat && !cat.classList.contains('is-open')) {
|
||
cat.classList.add('is-open');
|
||
const arrow = cat.querySelector('.nav-arrow');
|
||
if (arrow) arrow.textContent = '▾';
|
||
}
|
||
}
|
||
|
||
function showPanel(panelId) {
|
||
document.querySelectorAll('.admin-panel').forEach((p) => p.classList.remove('admin-panel-active'));
|
||
const target = document.getElementById('panel-' + panelId);
|
||
if (target) target.classList.add('admin-panel-active');
|
||
|
||
switch (panelId) {
|
||
case 'users': renderUserList(); break;
|
||
case 'sites': renderSiteList(); break;
|
||
case 'cl-records': refreshRecordDropdowns(); renderClRecordList(); break;
|
||
case 'cl-templates': renderClTemplateRecordSelection(); renderClTemplateList(); break;
|
||
case 'reports': refreshTaskDropdowns(); renderTaskList(); break;
|
||
case 'settings-template': renderTemplateSettings(); break;
|
||
case 'settings-task': renderTaskSettings(); break;
|
||
case 'settings-geo': renderGeoSettings(); break;
|
||
}
|
||
}
|
||
|
||
function navigateToPanel(panelId) {
|
||
const nav = document.getElementById('adminNav');
|
||
const navItem = nav.querySelector(`[data-panel="${panelId}"]`);
|
||
if (navItem) activateNavItem(navItem);
|
||
showPanel(panelId);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Connection badge
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function updateConnectionBadge() {
|
||
const badge = document.getElementById('connectionBadge');
|
||
if (!badge) return;
|
||
badge.textContent = navigator.onLine ? 'Online' : 'Offline';
|
||
badge.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* SETTINGS › IMAGE POLICY
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
async function syncImageRules() {
|
||
try {
|
||
const rules = await fetchJson('/config/image-rules');
|
||
admin.imageRules = rules;
|
||
await dbPut(STORE_CONFIG, { key: 'imageRules', value: rules });
|
||
renderImagePolicy();
|
||
} catch (err) { console.error('Image-rules sync failed', err); }
|
||
}
|
||
|
||
function bindImagePolicyForm() {
|
||
const form = document.getElementById('adminImageRulesForm');
|
||
if (!form) return;
|
||
form.addEventListener('submit', (e) => { e.preventDefault(); void saveImagePolicy(); });
|
||
document.getElementById('resetImageRulesButton')?.addEventListener('click', () => populateImagePolicyForm(admin.imageRules));
|
||
}
|
||
|
||
function renderImagePolicy() {
|
||
populateImagePolicyForm(admin.imageRules);
|
||
|
||
const syncState = document.getElementById('adminSyncState');
|
||
const mimeTypes = document.getElementById('adminPolicyMimeTypes');
|
||
const fileSize = document.getElementById('adminPolicyFileSize');
|
||
const opt = document.getElementById('adminPolicyOptimization');
|
||
const limits = document.getElementById('adminPolicyLimits');
|
||
|
||
if (!admin.imageRules) {
|
||
if (syncState) { syncState.textContent = 'No image rules loaded'; syncState.className = 'badge badge-offline'; }
|
||
[mimeTypes, fileSize, opt, limits].forEach((el) => { if (el) el.textContent = '-'; });
|
||
return;
|
||
}
|
||
|
||
if (syncState) {
|
||
syncState.textContent = navigator.onLine ? 'Live configuration' : 'Offline (cached)';
|
||
syncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
|
||
}
|
||
if (mimeTypes) mimeTypes.textContent = (admin.imageRules.allowedMimeTypes || []).join(', ');
|
||
if (fileSize) fileSize.textContent = fmtKb(admin.imageRules.maxFileSizeBytes);
|
||
if (opt) opt.textContent = `${admin.imageRules.oversizeBehavior}, quality ${admin.imageRules.jpegQuality || admin.imageRules.imageQuality || '-'}%`;
|
||
if (limits) limits.textContent = `${admin.imageRules.maxWidthPx}×${admin.imageRules.maxHeightPx}px`;
|
||
}
|
||
|
||
function populateImagePolicyForm(rules) {
|
||
if (!rules) return;
|
||
/* Checkboxes for MIME types */
|
||
const checkboxes = document.querySelectorAll('#adminMimeCheckboxes input[name="mimeType"]');
|
||
const allowed = new Set(rules.allowedMimeTypes || []);
|
||
checkboxes.forEach((cb) => { cb.checked = allowed.has(cb.value); });
|
||
|
||
const el = (id) => document.getElementById(id);
|
||
if (el('adminMaxFileSizeKb')) el('adminMaxFileSizeKb').value = String(Math.round((rules.maxFileSizeBytes || 0) / 1024));
|
||
if (el('adminMaxWidthPx')) el('adminMaxWidthPx').value = String(rules.maxWidthPx || '');
|
||
if (el('adminMaxHeightPx')) el('adminMaxHeightPx').value = String(rules.maxHeightPx || '');
|
||
if (el('adminImageQuality')) el('adminImageQuality').value = String(rules.jpegQuality || rules.imageQuality || '');
|
||
if (el('adminOversizeBehavior')) el('adminOversizeBehavior').value = rules.oversizeBehavior || 'auto_optimize';
|
||
}
|
||
|
||
async function saveImagePolicy() {
|
||
if (!navigator.onLine) { showToast('Go online to save.', 'warning'); return; }
|
||
|
||
const el = (id) => document.getElementById(id);
|
||
const mimeTypes = [...document.querySelectorAll('#adminMimeCheckboxes input[name="mimeType"]:checked')].map((cb) => cb.value);
|
||
const payload = {
|
||
name: 'default',
|
||
allowedMimeTypes: mimeTypes,
|
||
maxFileSizeBytes: Number(el('adminMaxFileSizeKb').value) * 1024,
|
||
maxWidthPx: Number(el('adminMaxWidthPx').value),
|
||
maxHeightPx: Number(el('adminMaxHeightPx').value),
|
||
jpegQuality: Number(el('adminImageQuality').value),
|
||
oversizeBehavior: el('adminOversizeBehavior').value,
|
||
maxAttachmentsPerField: 5
|
||
};
|
||
|
||
const msg = validateImageRulesPayload(payload);
|
||
if (msg) { showToast(msg, 'error'); return; }
|
||
|
||
const btn = document.getElementById('saveImageRulesButton');
|
||
btn.disabled = true;
|
||
try {
|
||
const result = await fetchJson('/config/image-rules', {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
|
||
});
|
||
admin.imageRules = result;
|
||
await dbPut(STORE_CONFIG, { key: 'imageRules', value: result });
|
||
renderImagePolicy();
|
||
showToast('Image policy saved.', 'success');
|
||
} catch (err) { console.error(err); showToast(err.message || 'Save failed.', 'error'); }
|
||
finally { btn.disabled = false; }
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* SETTINGS › TEMPLATE — list-based editing with parent for SubCategories
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function bindTemplateSettingsForm() {
|
||
on('click', '[data-ts-action="add-cat"]', openAddCategoryModal);
|
||
on('click', '[data-ts-action="add-subcat"]', openAddSubCategoryModal);
|
||
on('click', '[data-ts-action="add-sev"]', openAddSeverityModal);
|
||
on('click', '[data-ts-action="add-status"]', openAddStatusModal);
|
||
on('click', '[data-ts-action="add-handled"]', openAddHandledByModal);
|
||
}
|
||
|
||
/* ── Settings modal helpers ─────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Generic helper: open the shared settings modal with custom body HTML.
|
||
* onSave(bodyEl) is called with the modal body element when Save is clicked.
|
||
*/
|
||
function openSettingsModal(title, bodyHtml, onSave) {
|
||
document.getElementById('settingsItemModalLabel').textContent = title;
|
||
document.getElementById('settingsItemModalBody').innerHTML = bodyHtml;
|
||
|
||
const saveBtn = document.getElementById('settingsItemModalSave');
|
||
const newSaveBtn = saveBtn.cloneNode(true);
|
||
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
|
||
|
||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('settingsItemModal'));
|
||
newSaveBtn.addEventListener('click', () => {
|
||
const handled = onSave(document.getElementById('settingsItemModalBody'));
|
||
if (handled) modal.hide();
|
||
});
|
||
modal.show();
|
||
}
|
||
|
||
function openAddCategoryModal() {
|
||
openSettingsModal('Add Category',
|
||
`<div class="mb-3">
|
||
<label class="form-label">Category name</label>
|
||
<input id="sltModalVal" class="form-control" type="text" placeholder="Category name…" />
|
||
</div>`,
|
||
(body) => {
|
||
const val = body.querySelector('#sltModalVal').value.trim();
|
||
if (!val) { showToast('Value is required.', 'warning'); return false; }
|
||
if (admin.templateSettings.categories.some((c) => c.value === val)) { showToast('Category already exists.', 'warning'); return false; }
|
||
fetchJson(`${API}/categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
|
||
.then((created) => { admin.templateSettings.categories.push(created); cacheState(); renderTemplateSettings(); })
|
||
.catch((err) => showToast(err.message || 'Failed to add category.', 'error'));
|
||
return true;
|
||
}
|
||
);
|
||
}
|
||
|
||
function openAddSubCategoryModal() {
|
||
const catOpts = admin.templateSettings.categories.map((c) =>
|
||
`<option value="${c.id}">${esc(c.value)}</option>`).join('');
|
||
openSettingsModal('Add Sub Category',
|
||
`<div class="mb-3">
|
||
<label class="form-label">Parent Category</label>
|
||
<select id="sltModalParent" class="form-select">
|
||
<option value="">— Select parent —</option>${catOpts}
|
||
</select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Sub Category name</label>
|
||
<input id="sltModalVal" class="form-control" type="text" placeholder="Sub category name…" />
|
||
</div>`,
|
||
(body) => {
|
||
const val = body.querySelector('#sltModalVal').value.trim();
|
||
const categoryId = Number(body.querySelector('#sltModalParent').value);
|
||
if (!val) { showToast('Value is required.', 'warning'); return false; }
|
||
if (!categoryId) { showToast('Select a parent category.', 'warning'); return false; }
|
||
fetchJson(`${API}/sub-categories`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, categoryId }) })
|
||
.then((created) => { admin.templateSettings.subCategories.push(created); cacheState(); renderTemplateSettings(); })
|
||
.catch((err) => showToast(err.message || 'Failed to add sub-category.', 'error'));
|
||
return true;
|
||
}
|
||
);
|
||
}
|
||
|
||
function openAddSeverityModal() {
|
||
openSettingsModal('Add Severity',
|
||
`<div class="mb-3">
|
||
<label class="form-label">Severity name</label>
|
||
<input id="sltModalVal" class="form-control" type="text" placeholder="Severity name…" />
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Background color <span class="text-muted small">(optional)</span></label>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<div class="form-check mb-0">
|
||
<input class="form-check-input" type="checkbox" id="sltModalColorEn" />
|
||
<label class="form-check-label small" for="sltModalColorEn">Set a color</label>
|
||
</div>
|
||
<input id="sltModalColor" type="color" class="form-control form-control-color" value="#4dabf7" title="Pick a color" style="display:none;width:2.5rem" />
|
||
</div>
|
||
</div>`,
|
||
(body) => {
|
||
const val = body.querySelector('#sltModalVal').value.trim();
|
||
if (!val) { showToast('Value is required.', 'warning'); return false; }
|
||
if (admin.templateSettings.severities.some((i) => i.value === val)) { showToast('Already exists.', 'warning'); return false; }
|
||
const colorEn = body.querySelector('#sltModalColorEn')?.checked;
|
||
const color = colorEn ? body.querySelector('#sltModalColor')?.value : null;
|
||
fetchJson(`${API}/severities`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, color }) })
|
||
.then((created) => { admin.templateSettings.severities.push(created); cacheState(); renderTemplateSettings(); })
|
||
.catch((err) => showToast(err.message || 'Failed to add severity.', 'error'));
|
||
return true;
|
||
}
|
||
);
|
||
/* Show/hide color picker when checkbox is toggled */
|
||
const cb = document.getElementById('sltModalColorEn');
|
||
const cp = document.getElementById('sltModalColor');
|
||
if (cb && cp) cb.addEventListener('change', () => { cp.style.display = cb.checked ? 'inline-block' : 'none'; });
|
||
}
|
||
|
||
function openAddStatusModal() {
|
||
openSettingsModal('Add Status',
|
||
`<div class="mb-3">
|
||
<label class="form-label">Status name</label>
|
||
<input id="sltModalVal" class="form-control" type="text" placeholder="Status name…" />
|
||
</div>
|
||
<div class="mb-2">
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox" id="sltModalReqHB" />
|
||
<label class="form-check-label" for="sltModalReqHB">Handled By required</label>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox" id="sltModalReqCmt" />
|
||
<label class="form-check-label" for="sltModalReqCmt">Comment required</label>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Background color <span class="text-muted small">(optional)</span></label>
|
||
<div class="d-flex align-items-center gap-2">
|
||
<div class="form-check mb-0">
|
||
<input class="form-check-input" type="checkbox" id="sltModalColorEn" />
|
||
<label class="form-check-label small" for="sltModalColorEn">Set a color</label>
|
||
</div>
|
||
<input id="sltModalColor" type="color" class="form-control form-control-color" value="#4dabf7" title="Pick a color" style="display:none;width:2.5rem" />
|
||
</div>
|
||
</div>`,
|
||
(body) => {
|
||
const val = body.querySelector('#sltModalVal').value.trim();
|
||
if (!val) { showToast('Value is required.', 'warning'); return false; }
|
||
if (admin.templateSettings.statuses.some((i) => i.value === val)) { showToast('Already exists.', 'warning'); return false; }
|
||
const requireHandledBy = body.querySelector('#sltModalReqHB').checked;
|
||
const requireComment = body.querySelector('#sltModalReqCmt').checked;
|
||
const colorEn = body.querySelector('#sltModalColorEn')?.checked;
|
||
const color = colorEn ? body.querySelector('#sltModalColor')?.value : null;
|
||
fetchJson(`${API}/statuses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, requireHandledBy, requireComment, color }) })
|
||
.then((created) => { admin.templateSettings.statuses.push(created); cacheState(); renderTemplateSettings(); })
|
||
.catch((err) => showToast(err.message || 'Failed to add status.', 'error'));
|
||
return true;
|
||
}
|
||
);
|
||
/* Show/hide color picker when checkbox is toggled */
|
||
const cb = document.getElementById('sltModalColorEn');
|
||
const cp = document.getElementById('sltModalColor');
|
||
if (cb && cp) cb.addEventListener('change', () => { cp.style.display = cb.checked ? 'inline-block' : 'none'; });
|
||
}
|
||
|
||
function openAddHandledByModal() {
|
||
openSettingsModal('Add Handled By',
|
||
`<div class="mb-3">
|
||
<label class="form-label">Handler name</label>
|
||
<input id="sltModalVal" class="form-control" type="text" placeholder="Handler name…" />
|
||
</div>`,
|
||
(body) => {
|
||
const val = body.querySelector('#sltModalVal').value.trim();
|
||
if (!val) { showToast('Value is required.', 'warning'); return false; }
|
||
if (admin.templateSettings.handledBy.some((i) => i.value === val)) { showToast('Already exists.', 'warning'); return false; }
|
||
fetchJson(`${API}/handled-by`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
|
||
.then((created) => { admin.templateSettings.handledBy.push(created); cacheState(); renderTemplateSettings(); })
|
||
.catch((err) => showToast(err.message || 'Failed to add handler.', 'error'));
|
||
return true;
|
||
}
|
||
);
|
||
}
|
||
|
||
function openAddProjectModal() {
|
||
openSettingsModal('Add Project',
|
||
`<div class="mb-3">
|
||
<label class="form-label">Project name</label>
|
||
<input id="sltModalVal" class="form-control" type="text" placeholder="Project name…" />
|
||
</div>`,
|
||
(body) => {
|
||
const val = body.querySelector('#sltModalVal').value.trim();
|
||
if (!val) { showToast('Value is required.', 'warning'); return false; }
|
||
if (admin.taskSettings.projects.some((p) => p.value === val)) { showToast('Already exists.', 'warning'); return false; }
|
||
fetchJson(`${API}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val }) })
|
||
.then((created) => { admin.taskSettings.projects.push(created); cacheState(); renderTaskSettings(); })
|
||
.catch((err) => showToast(err.message || 'Failed to add project.', 'error'));
|
||
return true;
|
||
}
|
||
);
|
||
}
|
||
|
||
function openAddProcessModal() {
|
||
const projOpts = admin.taskSettings.projects.map((p) =>
|
||
`<option value="${p.id}">${esc(p.value)}</option>`).join('');
|
||
openSettingsModal('Add Process',
|
||
`<div class="mb-3">
|
||
<label class="form-label">Parent Project</label>
|
||
<select id="sltModalParent" class="form-select">
|
||
<option value="">— Select project —</option>${projOpts}
|
||
</select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">Process name</label>
|
||
<input id="sltModalVal" class="form-control" type="text" placeholder="Process name…" />
|
||
</div>`,
|
||
(body) => {
|
||
const val = body.querySelector('#sltModalVal').value.trim();
|
||
const projectId = Number(body.querySelector('#sltModalParent').value);
|
||
if (!val) { showToast('Value is required.', 'warning'); return false; }
|
||
if (!projectId) { showToast('Select a parent project.', 'warning'); return false; }
|
||
fetchJson(`${API}/processes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: val, projectId }) })
|
||
.then((created) => { admin.taskSettings.processes.push(created); cacheState(); renderTaskSettings(); })
|
||
.catch((err) => showToast(err.message || 'Failed to add process.', 'error'));
|
||
return true;
|
||
}
|
||
);
|
||
}
|
||
|
||
function renderTemplateSettings() {
|
||
/* ── Categories ─────────────────────────────────────────────────────── */
|
||
renderSettingList('tsCatList', admin.templateSettings.categories,
|
||
(item) => {
|
||
if (!confirm('Delete this category? All sub-categories under it will also be removed.')) return;
|
||
fetchJson(`${API}/categories/${item.id}`, { method: 'DELETE' }).then(() => {
|
||
admin.templateSettings.categories = admin.templateSettings.categories.filter((c) => c.id !== item.id);
|
||
admin.templateSettings.subCategories = admin.templateSettings.subCategories.filter((s) => s.categoryId !== item.id);
|
||
cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
},
|
||
(item, newVal) => {
|
||
fetchJson(`${API}/categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
|
||
item.value = newVal; cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
|
||
}
|
||
);
|
||
|
||
/* ── Sub Categories — Value + Parent Category columns ───────────────── */
|
||
const catName = (categoryId) => admin.templateSettings.categories.find((c) => c.id === categoryId)?.value || '?';
|
||
renderSettingTable('tsSubCatList', admin.templateSettings.subCategories,
|
||
[
|
||
{ header: 'Value',
|
||
cell: (item) => esc(item.value) },
|
||
{ header: 'Parent Category',
|
||
cell: (item) => esc(catName(item.categoryId)),
|
||
editHtml: (item) => {
|
||
const opts = admin.templateSettings.categories.map((c) =>
|
||
`<option value="${c.id}" ${c.id === item.categoryId ? 'selected' : ''}>${esc(c.value)}</option>`
|
||
).join('');
|
||
return `<select class="form-select form-select-sm" style="max-width:180px" data-slt-subcat-par="${item.id}">
|
||
<option value="">Parent…</option>${opts}
|
||
</select>`;
|
||
},
|
||
collect: (id) => Number(document.querySelector(`[data-slt-subcat-par="${id}"]`)?.value || 0)
|
||
}
|
||
],
|
||
(item) => {
|
||
if (!confirm('Delete this sub-category?')) return;
|
||
fetchJson(`${API}/sub-categories/${item.id}`, { method: 'DELETE' }).then(() => {
|
||
admin.templateSettings.subCategories = admin.templateSettings.subCategories.filter((s) => s.id !== item.id);
|
||
cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
},
|
||
(item, newVal, [categoryId]) => {
|
||
const resolvedCatId = categoryId || item.categoryId;
|
||
if (!resolvedCatId) { showToast('Parent category is required.', 'warning'); return; }
|
||
fetchJson(`${API}/sub-categories/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, categoryId: resolvedCatId }) }).then(() => {
|
||
item.value = newVal; item.categoryId = resolvedCatId;
|
||
cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
|
||
}
|
||
);
|
||
|
||
/* ── Severities — Value + Color columns ────────────────────────────── */
|
||
renderSettingTable('tsSevList', admin.templateSettings.severities,
|
||
[
|
||
{ header: 'Value',
|
||
cell: (item) => esc(item.value) },
|
||
{ header: 'Color',
|
||
cell: (item) => item.color
|
||
? `<span class="d-inline-block border rounded px-2 py-1 small" style="background:${escAttr(item.color)};color:${colorContrast(item.color)}">${esc(item.color)}</span>`
|
||
: '<span class="text-muted">—</span>',
|
||
editHtml: (item) => {
|
||
const hasColor = !!item.color;
|
||
return `<div class="d-flex align-items-center gap-2">
|
||
<div class="form-check mb-0">
|
||
<input class="form-check-input" type="checkbox" id="slt-color-en-${item.id}" data-slt-color-en="${item.id}" ${hasColor ? 'checked' : ''} />
|
||
<label class="form-check-label small" for="slt-color-en-${item.id}">Set a color</label>
|
||
</div>
|
||
<input type="color" data-slt-color="${item.id}" value="${escAttr(item.color || '#4dabf7')}" class="form-control form-control-color" style="width:2.5rem;${hasColor ? '' : 'display:none'}" />
|
||
</div>`;
|
||
},
|
||
collect: (id) => {
|
||
const cb = document.querySelector(`[data-slt-color-en="${id}"]`);
|
||
const cp = document.querySelector(`[data-slt-color="${id}"]`);
|
||
return (cb?.checked && cp?.value) ? cp.value : null;
|
||
}
|
||
}
|
||
],
|
||
(item) => {
|
||
if (!confirm('Delete this severity?')) return;
|
||
fetchJson(`${API}/severities/${item.id}`, { method: 'DELETE' }).then(() => {
|
||
admin.templateSettings.severities = admin.templateSettings.severities.filter((i) => i.id !== item.id);
|
||
cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
},
|
||
(item, newVal, [color]) => {
|
||
fetchJson(`${API}/severities/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, color }) }).then(() => {
|
||
item.value = newVal; item.color = color;
|
||
cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
|
||
}
|
||
);
|
||
/* Bind color-enable checkboxes inside the rendered severity list */
|
||
document.querySelectorAll('[data-slt-color-en]').forEach((cb) => {
|
||
const id = cb.dataset.sltColorEn;
|
||
const cp = document.querySelector(`[data-slt-color="${id}"]`);
|
||
if (cp) cb.addEventListener('change', () => { cp.style.display = cb.checked ? 'inline-block' : 'none'; });
|
||
});
|
||
|
||
/* ── Statuses — Value + Handled By req. + Comment req. + Color columns ─ */
|
||
renderSettingTable('tsStatusList', admin.templateSettings.statuses,
|
||
[
|
||
{ header: 'Value',
|
||
cell: (item) => esc(item.value) },
|
||
{ header: 'Handled By req.',
|
||
cell: (item) => item.requireHandledBy
|
||
? '<span class="badge bg-info text-dark">Yes</span>'
|
||
: '<span class="text-muted">—</span>',
|
||
editHtml: (item) => `<div class="form-check mb-0">
|
||
<input class="form-check-input" type="checkbox" id="slt-hb-${item.id}" data-slt-hb="${item.id}" ${item.requireHandledBy ? 'checked' : ''} />
|
||
<label class="form-check-label small" for="slt-hb-${item.id}">Handled By req.</label>
|
||
</div>`,
|
||
collect: (id) => document.querySelector(`[data-slt-hb="${id}"]`)?.checked || false
|
||
},
|
||
{ header: 'Comment req.',
|
||
cell: (item) => item.requireComment
|
||
? '<span class="badge bg-info text-dark">Yes</span>'
|
||
: '<span class="text-muted">—</span>',
|
||
editHtml: (item) => `<div class="form-check mb-0">
|
||
<input class="form-check-input" type="checkbox" id="slt-cmt-${item.id}" data-slt-cmt="${item.id}" ${item.requireComment ? 'checked' : ''} />
|
||
<label class="form-check-label small" for="slt-cmt-${item.id}">Comment req.</label>
|
||
</div>`,
|
||
collect: (id) => document.querySelector(`[data-slt-cmt="${id}"]`)?.checked || false
|
||
},
|
||
{ header: 'Color',
|
||
cell: (item) => item.color
|
||
? `<span class="d-inline-block border rounded px-2 py-1 small" style="background:${escAttr(item.color)};color:${colorContrast(item.color)}">${esc(item.color)}</span>`
|
||
: '<span class="text-muted">—</span>',
|
||
editHtml: (item) => {
|
||
const hasColor = !!item.color;
|
||
return `<div class="d-flex align-items-center gap-2">
|
||
<div class="form-check mb-0">
|
||
<input class="form-check-input" type="checkbox" id="slt-color-en-${item.id}" data-slt-status-color-en="${item.id}" ${hasColor ? 'checked' : ''} />
|
||
<label class="form-check-label small" for="slt-color-en-${item.id}">Set a color</label>
|
||
</div>
|
||
<input type="color" data-slt-status-color="${item.id}" value="${escAttr(item.color || '#4dabf7')}" class="form-control form-control-color" style="width:2.5rem;${hasColor ? '' : 'display:none'}" />
|
||
</div>`;
|
||
},
|
||
collect: (id) => {
|
||
const cb = document.querySelector(`[data-slt-status-color-en="${id}"]`);
|
||
const cp = document.querySelector(`[data-slt-status-color="${id}"]`);
|
||
return (cb?.checked && cp?.value) ? cp.value : null;
|
||
}
|
||
}
|
||
],
|
||
(item) => {
|
||
if (!confirm('Delete this status?')) return;
|
||
fetchJson(`${API}/statuses/${item.id}`, { method: 'DELETE' }).then(() => {
|
||
admin.templateSettings.statuses = admin.templateSettings.statuses.filter((i) => i.id !== item.id);
|
||
cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
},
|
||
(item, newVal, [reqHB, reqCmt, color]) => {
|
||
fetchJson(`${API}/statuses/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, requireHandledBy: reqHB, requireComment: reqCmt, color }) }).then(() => {
|
||
item.value = newVal; item.requireHandledBy = reqHB; item.requireComment = reqCmt; item.color = color;
|
||
cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
|
||
}
|
||
);
|
||
|
||
/* ── Handled By ─────────────────────────────────────────────────────── */
|
||
renderSettingList('tsHandledList', admin.templateSettings.handledBy,
|
||
(item) => {
|
||
if (!confirm('Delete this handler?')) return;
|
||
fetchJson(`${API}/handled-by/${item.id}`, { method: 'DELETE' }).then(() => {
|
||
admin.templateSettings.handledBy = admin.templateSettings.handledBy.filter((i) => i.id !== item.id);
|
||
cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
},
|
||
(item, newVal) => {
|
||
fetchJson(`${API}/handled-by/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
|
||
item.value = newVal; cacheState(); renderTemplateSettings();
|
||
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
|
||
}
|
||
);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* SETTINGS › TASK — Projects + Processes (parent required)
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function bindTaskSettingsForm() {
|
||
on('click', '[data-tk-action="add-proj"]', openAddProjectModal);
|
||
on('click', '[data-tk-action="add-proc"]', openAddProcessModal);
|
||
}
|
||
|
||
function renderTaskSettings() {
|
||
/* ── Projects ───────────────────────────────────────────────────────── */
|
||
renderSettingList('tkProjList', admin.taskSettings.projects,
|
||
(item) => {
|
||
if (!confirm('Delete this project? All processes under it will also be removed.')) return;
|
||
fetchJson(`${API}/projects/${item.id}`, { method: 'DELETE' }).then(() => {
|
||
admin.taskSettings.projects = admin.taskSettings.projects.filter((p) => p.id !== item.id);
|
||
admin.taskSettings.processes = admin.taskSettings.processes.filter((p) => p.projectId !== item.id);
|
||
cacheState(); renderTaskSettings();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
},
|
||
(item, newVal) => {
|
||
fetchJson(`${API}/projects/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal }) }).then(() => {
|
||
item.value = newVal; cacheState(); renderTaskSettings();
|
||
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
|
||
}
|
||
);
|
||
|
||
/* ── Processes — Value + Parent Project columns ─────────────────────── */
|
||
const projName = (id) => admin.taskSettings.projects.find((p) => p.id === id)?.value || '?';
|
||
renderSettingTable('tkProcList', admin.taskSettings.processes,
|
||
[
|
||
{ header: 'Value',
|
||
cell: (item) => esc(item.value) },
|
||
{ header: 'Parent Project',
|
||
cell: (item) => esc(projName(item.projectId)),
|
||
editHtml: (item) => {
|
||
const opts = admin.taskSettings.projects.map((p) =>
|
||
`<option value="${p.id}" ${p.id === item.projectId ? 'selected' : ''}>${esc(p.value)}</option>`
|
||
).join('');
|
||
return `<select class="form-select form-select-sm" style="max-width:180px" data-slt-proc-par="${item.id}">
|
||
<option value="">Parent project…</option>${opts}
|
||
</select>`;
|
||
},
|
||
collect: (id) => Number(document.querySelector(`[data-slt-proc-par="${id}"]`)?.value || 0)
|
||
}
|
||
],
|
||
(item) => {
|
||
if (!confirm('Delete this process?')) return;
|
||
fetchJson(`${API}/processes/${item.id}`, { method: 'DELETE' }).then(() => {
|
||
admin.taskSettings.processes = admin.taskSettings.processes.filter((p) => p.id !== item.id);
|
||
cacheState(); renderTaskSettings();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
},
|
||
(item, newVal, [projectId]) => {
|
||
const resolvedProjId = projectId || item.projectId;
|
||
if (!resolvedProjId) { showToast('Parent project is required.', 'warning'); return; }
|
||
fetchJson(`${API}/processes/${item.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: newVal, projectId: resolvedProjId }) }).then(() => {
|
||
item.value = newVal; item.projectId = resolvedProjId; cacheState(); renderTaskSettings();
|
||
}).catch((err) => showToast(err.message || 'Update failed.', 'error'));
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* USERS — list-first with inline form
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function bindUserForm() {
|
||
const form = document.getElementById('userForm');
|
||
if (!form) return;
|
||
form.addEventListener('submit', (e) => { e.preventDefault(); saveUser(); });
|
||
document.getElementById('showUserFormBtn')?.addEventListener('click', () => {
|
||
clearUserForm(); showModal('userFormModal');
|
||
});
|
||
/* Reset editing state when modal is closed without saving */
|
||
document.getElementById('userFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing());
|
||
}
|
||
|
||
function showSection(id) { const s = document.getElementById(id); if (s) s.style.display = ''; }
|
||
function hideSection(id) { const s = document.getElementById(id); if (s) s.style.display = 'none'; }
|
||
|
||
function showModal(id) { bootstrap.Modal.getOrCreateInstance(document.getElementById(id)).show(); }
|
||
function hideModal(id) { bootstrap.Modal.getOrCreateInstance(document.getElementById(id)).hide(); }
|
||
|
||
function saveUser() {
|
||
const el = (id) => document.getElementById(id);
|
||
const data = {
|
||
email: el('userEmail').value.trim(),
|
||
password: el('userPassword').value,
|
||
name: el('userName').value.trim(),
|
||
familyName: el('userFamilyName').value.trim(),
|
||
company: el('userCompany').value.trim(),
|
||
role: el('userRole').value
|
||
};
|
||
if (!data.email || !data.name || !data.familyName || !data.role) {
|
||
showToast('Email, Name, Family Name, and Role are required.', 'warning'); return;
|
||
}
|
||
|
||
if (admin.editingType === 'user' && admin.editingId) {
|
||
fetchJson(`${API}/users/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((updated) => {
|
||
const idx = admin.users.findIndex((u) => u.id === admin.editingId);
|
||
if (idx >= 0) admin.users[idx] = updated;
|
||
resetEditing();
|
||
cacheState();
|
||
hideUserForm();
|
||
renderUserList();
|
||
showToast('User saved.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
} else {
|
||
fetchJson(`${API}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((created) => {
|
||
admin.users.push(created);
|
||
cacheState();
|
||
hideUserForm();
|
||
renderUserList();
|
||
showToast('User saved.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
}
|
||
}
|
||
|
||
function clearUserForm() {
|
||
document.getElementById('userForm')?.reset();
|
||
document.getElementById('userFormHeading').textContent = 'Add User';
|
||
resetEditing();
|
||
}
|
||
|
||
function hideUserForm() { clearUserForm(); hideModal('userFormModal'); }
|
||
|
||
function editUser(id) {
|
||
const u = admin.users.find((x) => x.id === id);
|
||
if (!u) return;
|
||
admin.editingId = id; admin.editingType = 'user';
|
||
const el = (eid) => document.getElementById(eid);
|
||
el('userEmail').value = u.email || '';
|
||
el('userPassword').value = u.password || '';
|
||
el('userName').value = u.name || '';
|
||
el('userFamilyName').value = u.familyName || '';
|
||
el('userCompany').value = u.company || '';
|
||
el('userRole').value = u.role || '';
|
||
el('userFormHeading').textContent = 'Edit User';
|
||
showModal('userFormModal');
|
||
}
|
||
|
||
function deleteUser(id) {
|
||
if (!confirm('Delete this user?')) return;
|
||
fetchJson(`${API}/users/${id}`, { method: 'DELETE' }).then(() => {
|
||
admin.users = admin.users.filter((u) => u.id !== id);
|
||
admin.tasks = admin.tasks.filter((t) => t.userId !== id);
|
||
cacheState();
|
||
renderUserList();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
}
|
||
|
||
function renderUserList() {
|
||
const container = document.getElementById('userListContainer');
|
||
if (!container) return;
|
||
if (!admin.users.length) {
|
||
container.innerHTML = '<div class="empty-state"><h3>No users</h3><p>Click "Add User" to create one.</p></div>';
|
||
return;
|
||
}
|
||
const rows = admin.users.map((u) => {
|
||
const taskCount = admin.tasks.filter((t) => t.userId === u.id).length;
|
||
const taskBadge = taskCount > 0
|
||
? `<span class="badge bg-primary">${taskCount}</span>`
|
||
: `<span class="badge bg-secondary">0</span>`;
|
||
return `<tr>
|
||
<td>${esc(u.email)}</td><td>${esc(u.name)}</td><td>${esc(u.familyName)}</td>
|
||
<td>${esc(u.company || '-')}</td><td>${esc(u.role || '-')}</td>
|
||
<td class="text-center">${taskBadge}</td>
|
||
<td class="admin-table-actions">
|
||
<button class="button button-small button-secondary" data-edit-user="${u.id}">Edit</button>
|
||
<button class="button button-small button-ghost" data-delete-user="${u.id}">Delete</button>
|
||
</td></tr>`;
|
||
}).join('');
|
||
container.innerHTML = `<table class="admin-table"><thead><tr>
|
||
<th>Email</th><th>Name</th><th>Family Name</th><th>Company</th><th>Role</th><th>Tasks</th><th>Actions</th>
|
||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||
container.querySelectorAll('[data-edit-user]').forEach((b) => b.addEventListener('click', () => editUser(Number(b.dataset.editUser))));
|
||
container.querySelectorAll('[data-delete-user]').forEach((b) => b.addEventListener('click', () => deleteUser(Number(b.dataset.deleteUser))));
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* SETTINGS › GEO LOCATION — configurable geo-fence radius
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function bindGeoSettingsForm() {
|
||
const form = document.getElementById('geoSettingsForm');
|
||
if (!form) return;
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const val = parseInt(document.getElementById('geoFenceRadius').value, 10);
|
||
if (isNaN(val) || val < 1) { showToast('Enter a valid radius (≥ 1 m).', 'warning'); return; }
|
||
try {
|
||
await fetchJson(`${API}/app-config/geo_fence_radius_m`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ value: String(val) })
|
||
});
|
||
admin.appConfig.geo_fence_radius_m = String(val);
|
||
cacheState();
|
||
showToast('Geo fence radius saved.', 'success');
|
||
} catch (err) {
|
||
showToast(err.message || 'Save failed.', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderGeoSettings() {
|
||
const el = document.getElementById('geoFenceRadius');
|
||
if (!el) return;
|
||
el.value = admin.appConfig?.geo_fence_radius_m ?? '50';
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* SITES — list-first with inline form; Host is dropdown (OBE, PXS)
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
/* Leaflet map instance for the site modal picker (one instance, re-used). */
|
||
let _siteMap = null;
|
||
let _siteMarker = null;
|
||
|
||
function bindSiteForm() {
|
||
const form = document.getElementById('siteForm');
|
||
if (!form) return;
|
||
form.addEventListener('submit', (e) => { e.preventDefault(); saveSite(); });
|
||
document.getElementById('showSiteFormBtn')?.addEventListener('click', () => {
|
||
clearSiteForm(); showModal('siteFormModal');
|
||
});
|
||
/* Init Leaflet map once the modal is fully visible */
|
||
document.getElementById('siteFormModal')?.addEventListener('shown.bs.modal', () => {
|
||
const mapEl = document.getElementById('siteMapPicker');
|
||
if (!mapEl) return;
|
||
if (!_siteMap) {
|
||
/* Default view: centre of Europe */
|
||
_siteMap = L.map('siteMapPicker').setView([51.5, 10], 4);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||
}).addTo(_siteMap);
|
||
_siteMap.on('click', (ev) => {
|
||
const { lat, lng } = ev.latlng;
|
||
_setSiteMapMarker(lat, lng);
|
||
document.getElementById('siteLat').value = lat.toFixed(7);
|
||
document.getElementById('siteLng').value = lng.toFixed(7);
|
||
});
|
||
/* Sync typed lat/lng inputs → marker */
|
||
['siteLat', 'siteLng'].forEach((id) => {
|
||
document.getElementById(id)?.addEventListener('change', _syncMapFromInputs);
|
||
});
|
||
} else {
|
||
_siteMap.invalidateSize();
|
||
}
|
||
/* If editing a site that already has coords, position the map there */
|
||
const lat = parseFloat(document.getElementById('siteLat').value);
|
||
const lng = parseFloat(document.getElementById('siteLng').value);
|
||
if (!isNaN(lat) && !isNaN(lng)) {
|
||
_setSiteMapMarker(lat, lng);
|
||
_siteMap.setView([lat, lng], 14);
|
||
}
|
||
});
|
||
document.getElementById('siteFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing());
|
||
}
|
||
|
||
function _setSiteMapMarker(lat, lng) {
|
||
if (!_siteMap) return;
|
||
if (_siteMarker) {
|
||
_siteMarker.setLatLng([lat, lng]);
|
||
} else {
|
||
_siteMarker = L.marker([lat, lng], { draggable: true }).addTo(_siteMap);
|
||
_siteMarker.on('dragend', () => {
|
||
const pos = _siteMarker.getLatLng();
|
||
document.getElementById('siteLat').value = pos.lat.toFixed(7);
|
||
document.getElementById('siteLng').value = pos.lng.toFixed(7);
|
||
});
|
||
}
|
||
}
|
||
|
||
function _syncMapFromInputs() {
|
||
if (!_siteMap) return;
|
||
const lat = parseFloat(document.getElementById('siteLat').value);
|
||
const lng = parseFloat(document.getElementById('siteLng').value);
|
||
if (!isNaN(lat) && !isNaN(lng)) {
|
||
_setSiteMapMarker(lat, lng);
|
||
_siteMap.setView([lat, lng], Math.max(_siteMap.getZoom(), 12));
|
||
}
|
||
}
|
||
|
||
function saveSite() {
|
||
const el = (id) => document.getElementById(id);
|
||
const latVal = el('siteLat').value.trim();
|
||
const lngVal = el('siteLng').value.trim();
|
||
const data = {
|
||
siteCode: el('siteSiteCode').value.trim(),
|
||
host: el('siteHost').value,
|
||
obeSiteCode: el('siteObe').value.trim(),
|
||
pxsSiteCode: el('sitePxs').value.trim(),
|
||
latitude: latVal !== '' ? parseFloat(latVal) : null,
|
||
longitude: lngVal !== '' ? parseFloat(lngVal) : null
|
||
};
|
||
if (!data.siteCode) { showToast('Site Code is required.', 'warning'); return; }
|
||
|
||
if (admin.editingType === 'site' && admin.editingId) {
|
||
fetchJson(`${API}/sites/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((updated) => {
|
||
const idx = admin.sites.findIndex((s) => s.id === admin.editingId);
|
||
if (idx >= 0) admin.sites[idx] = updated;
|
||
resetEditing();
|
||
cacheState();
|
||
hideSiteForm();
|
||
renderSiteList();
|
||
showToast('Site saved.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
} else {
|
||
fetchJson(`${API}/sites`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((created) => {
|
||
admin.sites.push(created);
|
||
cacheState();
|
||
hideSiteForm();
|
||
renderSiteList();
|
||
showToast('Site saved.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
}
|
||
}
|
||
|
||
function clearSiteForm() {
|
||
document.getElementById('siteForm')?.reset();
|
||
document.getElementById('siteFormHeading').textContent = 'Add Site';
|
||
/* Remove the map marker when clearing so it doesn't carry over to new-site form */
|
||
if (_siteMarker && _siteMap) { _siteMap.removeLayer(_siteMarker); _siteMarker = null; }
|
||
resetEditing();
|
||
}
|
||
function hideSiteForm() { clearSiteForm(); hideModal('siteFormModal'); }
|
||
|
||
function editSite(id) {
|
||
const s = admin.sites.find((x) => x.id === id);
|
||
if (!s) return;
|
||
admin.editingId = id; admin.editingType = 'site';
|
||
const el = (eid) => document.getElementById(eid);
|
||
el('siteSiteCode').value = s.siteCode;
|
||
el('siteHost').value = s.host || '';
|
||
el('siteObe').value = s.obeSiteCode || '';
|
||
el('sitePxs').value = s.pxsSiteCode || '';
|
||
el('siteLat').value = s.latitude != null ? s.latitude : '';
|
||
el('siteLng').value = s.longitude != null ? s.longitude : '';
|
||
el('siteFormHeading').textContent = 'Edit Site';
|
||
showModal('siteFormModal');
|
||
}
|
||
|
||
function deleteSite(id) {
|
||
if (!confirm('Delete this site?')) return;
|
||
fetchJson(`${API}/sites/${id}`, { method: 'DELETE' }).then(() => {
|
||
admin.sites = admin.sites.filter((s) => s.id !== id);
|
||
admin.tasks = admin.tasks.filter((t) => t.siteId !== id);
|
||
cacheState();
|
||
renderSiteList();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
}
|
||
|
||
function renderSiteList() {
|
||
const container = document.getElementById('siteListContainer');
|
||
if (!container) return;
|
||
if (!admin.sites.length) {
|
||
container.innerHTML = '<div class="empty-state"><h3>No sites</h3><p>Click "Add Site" to create one.</p></div>';
|
||
return;
|
||
}
|
||
const rows = admin.sites.map((s) => `<tr>
|
||
<td>${esc(s.siteCode)}</td><td>${esc(s.host || '-')}</td>
|
||
<td>${esc(s.obeSiteCode || '-')}</td><td>${esc(s.pxsSiteCode || '-')}</td>
|
||
<td>${s.latitude != null ? parseFloat(s.latitude).toFixed(5) : '-'}</td>\n <td>${s.longitude != null ? parseFloat(s.longitude).toFixed(5) : '-'}</td>
|
||
<td class="admin-table-actions">
|
||
<button class="button button-small button-secondary" data-edit-site="${s.id}">Edit</button>
|
||
<button class="button button-small button-ghost" data-delete-site="${s.id}">Delete</button>
|
||
</td></tr>`).join('');
|
||
container.innerHTML = `<table class="admin-table"><thead><tr>
|
||
<th>Site Code</th><th>Host</th><th>OBE Site Code</th><th>PXS Site Code</th><th>Latitude</th><th>Longitude</th><th>Actions</th>
|
||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||
container.querySelectorAll('[data-edit-site]').forEach((b) => b.addEventListener('click', () => editSite(Number(b.dataset.editSite))));
|
||
container.querySelectorAll('[data-delete-site]').forEach((b) => b.addEventListener('click', () => deleteSite(Number(b.dataset.deleteSite))));
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* CHECK LISTS › RECORDS
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function bindClRecordForm() {
|
||
const form = document.getElementById('clRecordForm');
|
||
if (!form) return;
|
||
form.addEventListener('submit', (e) => { e.preventDefault(); saveClRecord(); });
|
||
document.getElementById('showClRecFormBtn')?.addEventListener('click', () => {
|
||
clearClRecordForm(); refreshRecordDropdowns(); showModal('clRecFormModal');
|
||
});
|
||
document.getElementById('clRecFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing());
|
||
}
|
||
|
||
function refreshRecordDropdowns() {
|
||
populateSelectObjects('clRecCategory', admin.templateSettings.categories.map((c) => ({ value: c.id, label: c.value })), '— Select —');
|
||
/* Sub-category is filtered by selected category */
|
||
updateRecordSubCategoryDropdown();
|
||
populateSelectObjects('clRecSeverity', admin.templateSettings.severities.map((s) => ({ value: s.id, label: s.value })), '— Select —');
|
||
populateSelect('clRecStatus', admin.templateSettings.statuses.map((s) => s.value));
|
||
populateSelect('clRecHandledBy', admin.templateSettings.handledBy.map((h) => h.value));
|
||
|
||
/* Bind category change to filter sub-categories */
|
||
const catSel = document.getElementById('clRecCategory');
|
||
if (catSel && !catSel._boundSubCatFilter) {
|
||
catSel.addEventListener('change', updateRecordSubCategoryDropdown);
|
||
catSel._boundSubCatFilter = true;
|
||
}
|
||
}
|
||
|
||
function updateRecordSubCategoryDropdown() {
|
||
const catSel = document.getElementById('clRecCategory');
|
||
const selectedCatId = Number(catSel?.value || 0);
|
||
if (!selectedCatId) {
|
||
populateSelectObjects('clRecSubCategory', [], '— Select —');
|
||
return;
|
||
}
|
||
const filtered = admin.templateSettings.subCategories
|
||
.filter((s) => s.categoryId === selectedCatId)
|
||
.map((s) => ({ value: s.id, label: s.value }));
|
||
populateSelectObjects('clRecSubCategory', filtered, '— Select —');
|
||
}
|
||
|
||
function saveClRecord() {
|
||
const el = (id) => document.getElementById(id);
|
||
const sortVal = Number(el('clRecSort').value);
|
||
if (!sortVal) { showToast('Sort number is required.', 'warning'); return; }
|
||
const existing = admin.clRecords.find((r) => r.sort === sortVal && r.id !== admin.editingId);
|
||
if (existing) { showToast(`Sort value ${sortVal} already used.`, 'warning'); return; }
|
||
|
||
const data = {
|
||
sort: sortVal,
|
||
categoryId: Number(el('clRecCategory').value) || null,
|
||
subCategoryId: Number(el('clRecSubCategory').value) || null,
|
||
severityId: Number(el('clRecSeverity').value) || null,
|
||
imageRequired: el('clRecImageRequired').checked,
|
||
descriptionEN: el('clRecDescEN').value.trim(),
|
||
descriptionFR: el('clRecDescFR').value.trim(),
|
||
descriptionNL: el('clRecDescNL').value.trim()
|
||
};
|
||
|
||
if (admin.editingType === 'clRecord' && admin.editingId) {
|
||
fetchJson(`${API}/cl-records/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((updated) => {
|
||
const idx = admin.clRecords.findIndex((r) => r.id === admin.editingId);
|
||
if (idx >= 0) admin.clRecords[idx] = updated;
|
||
admin.clRecords.sort((a, b) => a.sort - b.sort);
|
||
resetEditing();
|
||
cacheState();
|
||
hideClRecForm();
|
||
renderClRecordList();
|
||
showToast('Record saved.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
} else {
|
||
fetchJson(`${API}/cl-records`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((created) => {
|
||
admin.clRecords.push(created);
|
||
admin.clRecords.sort((a, b) => a.sort - b.sort);
|
||
cacheState();
|
||
hideClRecForm();
|
||
renderClRecordList();
|
||
showToast('Record saved.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
}
|
||
}
|
||
|
||
function clearClRecordForm() {
|
||
document.getElementById('clRecordForm')?.reset();
|
||
document.getElementById('clRecFormHeading').textContent = 'Add Record';
|
||
resetEditing();
|
||
}
|
||
function hideClRecForm() { clearClRecordForm(); hideModal('clRecFormModal'); }
|
||
|
||
function editClRecord(id) {
|
||
const rec = admin.clRecords.find((r) => r.id === id);
|
||
if (!rec) return;
|
||
admin.editingId = id; admin.editingType = 'clRecord';
|
||
refreshRecordDropdowns();
|
||
const el = (eid) => document.getElementById(eid);
|
||
el('clRecSort').value = rec.sort;
|
||
el('clRecCategory').value = rec.categoryId || '';
|
||
/* Re-filter sub-categories after setting the category value */
|
||
updateRecordSubCategoryDropdown();
|
||
el('clRecSubCategory').value = rec.subCategoryId || '';
|
||
el('clRecSeverity').value = rec.severityId || '';
|
||
el('clRecImageRequired').checked = !!rec.imageRequired;
|
||
el('clRecDescEN').value = rec.descriptionEN || '';
|
||
el('clRecDescFR').value = rec.descriptionFR || '';
|
||
el('clRecDescNL').value = rec.descriptionNL || '';
|
||
el('clRecFormHeading').textContent = 'Edit Record';
|
||
showModal('clRecFormModal');
|
||
}
|
||
|
||
function deleteClRecord(id) {
|
||
if (!confirm('Delete this record?')) return;
|
||
fetchJson(`${API}/cl-records/${id}`, { method: 'DELETE' }).then(() => {
|
||
admin.clRecords = admin.clRecords.filter((r) => r.id !== id);
|
||
admin.clTemplates.forEach((tpl) => { tpl.recordIds = (tpl.recordIds || []).filter((rid) => rid !== id); });
|
||
cacheState();
|
||
renderClRecordList();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
}
|
||
|
||
function renderClRecordList() {
|
||
const container = document.getElementById('clRecordListContainer');
|
||
const countEl = document.getElementById('clRecCount');
|
||
if (!container) return;
|
||
if (countEl) countEl.textContent = `${admin.clRecords.length} record(s)`;
|
||
if (!admin.clRecords.length) {
|
||
container.innerHTML = '<div class="empty-state"><h3>No records</h3><p>Click "Add Record" to create one.</p></div>';
|
||
return;
|
||
}
|
||
const rows = admin.clRecords.map((r) => `<tr>
|
||
<td>${r.sort}</td><td>${esc(r.category || '-')}</td><td>${esc(r.subCategory || '-')}</td>
|
||
<td>${esc(r.descriptionEN || '-')}</td><td>${esc(r.severity || '-')}</td>
|
||
<td>${r.imageRequired ? '✓' : '-'}</td>
|
||
<td class="admin-table-actions">
|
||
<button class="button button-small button-secondary" data-edit-rec="${r.id}">Edit</button>
|
||
<button class="button button-small button-ghost" data-delete-rec="${r.id}">Delete</button>
|
||
</td></tr>`).join('');
|
||
container.innerHTML = `<table class="admin-table admin-table-compact"><thead><tr>
|
||
<th>Sort</th><th>Category</th><th>Sub Cat.</th><th>Description EN</th><th>Severity</th><th>Img</th><th>Actions</th>
|
||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||
container.querySelectorAll('[data-edit-rec]').forEach((b) => b.addEventListener('click', () => editClRecord(Number(b.dataset.editRec))));
|
||
container.querySelectorAll('[data-delete-rec]').forEach((b) => b.addEventListener('click', () => deleteClRecord(Number(b.dataset.deleteRec))));
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* CHECK LISTS › TEMPLATES
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function bindClTemplateForm() {
|
||
const form = document.getElementById('clTemplateForm');
|
||
if (!form) return;
|
||
form.addEventListener('submit', (e) => { e.preventDefault(); saveClTemplate(); });
|
||
document.getElementById('showClTplFormBtn')?.addEventListener('click', () => {
|
||
clearClTemplateForm(); renderClTemplateRecordSelection(); showModal('clTplFormModal');
|
||
});
|
||
document.getElementById('clTplFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing());
|
||
}
|
||
|
||
function renderClTemplateRecordSelection() {
|
||
const container = document.getElementById('clTplRecordSelection');
|
||
if (!container) return;
|
||
if (!admin.clRecords.length) {
|
||
container.innerHTML = '<p class="field-help">No records available. Add records first.</p>';
|
||
return;
|
||
}
|
||
const selectedIds = new Set();
|
||
if (admin.editingType === 'clTemplate' && admin.editingId) {
|
||
const tpl = admin.clTemplates.find((t) => t.id === admin.editingId);
|
||
if (tpl) (tpl.recordIds || []).forEach((rid) => selectedIds.add(rid));
|
||
}
|
||
const rows = admin.clRecords.map((r) => {
|
||
const checked = selectedIds.has(r.id) ? 'checked' : '';
|
||
return `<tr><td><input type="checkbox" class="tpl-rec-check" value="${r.id}" ${checked}/></td>
|
||
<td>${r.sort}</td><td>${esc(r.category || '-')}</td><td>${esc(r.descriptionEN || '-')}</td></tr>`;
|
||
}).join('');
|
||
container.innerHTML = `<table class="admin-table admin-table-compact"><thead><tr>
|
||
<th style="width:50px">Include</th><th>Sort</th><th>Category</th><th>Description EN</th>
|
||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||
}
|
||
|
||
function saveClTemplate() {
|
||
const el = (id) => document.getElementById(id);
|
||
const data = {
|
||
name: el('clTplName').value.trim(),
|
||
scope: el('clTplScope').value,
|
||
version: el('clTplVersion').value.trim(),
|
||
validFrom: el('clTplValidFrom').value || null,
|
||
validTill: el('clTplValidTill').value || null,
|
||
recordIds: [...document.querySelectorAll('.tpl-rec-check:checked')].map((cb) => Number(cb.value))
|
||
};
|
||
if (!data.name) { showToast('Template name required.', 'warning'); return; }
|
||
|
||
if (admin.editingType === 'clTemplate' && admin.editingId) {
|
||
fetchJson(`${API}/cl-templates/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((updated) => {
|
||
const idx = admin.clTemplates.findIndex((t) => t.id === admin.editingId);
|
||
if (idx >= 0) admin.clTemplates[idx] = updated;
|
||
resetEditing();
|
||
cacheState();
|
||
hideClTplForm();
|
||
renderClTemplateList();
|
||
showToast('Template saved.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
} else {
|
||
fetchJson(`${API}/cl-templates`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((created) => {
|
||
admin.clTemplates.push(created);
|
||
cacheState();
|
||
hideClTplForm();
|
||
renderClTemplateList();
|
||
showToast('Template saved.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
}
|
||
}
|
||
|
||
function clearClTemplateForm() {
|
||
document.getElementById('clTemplateForm')?.reset();
|
||
document.getElementById('clTplFormHeading').textContent = 'Add Template';
|
||
resetEditing();
|
||
}
|
||
function hideClTplForm() { clearClTemplateForm(); hideModal('clTplFormModal'); }
|
||
|
||
function editClTemplate(id) {
|
||
const tpl = admin.clTemplates.find((t) => t.id === id);
|
||
if (!tpl) return;
|
||
admin.editingId = id; admin.editingType = 'clTemplate';
|
||
const el = (eid) => document.getElementById(eid);
|
||
el('clTplName').value = tpl.name;
|
||
el('clTplScope').value = tpl.scope || '';
|
||
el('clTplVersion').value = tpl.version || '';
|
||
el('clTplValidFrom').value = tpl.validFrom || '';
|
||
el('clTplValidTill').value = tpl.validTill || '';
|
||
el('clTplFormHeading').textContent = 'Edit Template';
|
||
renderClTemplateRecordSelection();
|
||
showModal('clTplFormModal');
|
||
}
|
||
|
||
function deleteClTemplate(id) {
|
||
if (!confirm('Delete this template?')) return;
|
||
fetchJson(`${API}/cl-templates/${id}`, { method: 'DELETE' }).then(() => {
|
||
admin.clTemplates = admin.clTemplates.filter((t) => t.id !== id);
|
||
admin.tasks = admin.tasks.filter((t) => t.templateId !== id);
|
||
cacheState();
|
||
renderClTemplateList();
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
}
|
||
|
||
function renderClTemplateList() {
|
||
const container = document.getElementById('clTemplateListContainer');
|
||
const countEl = document.getElementById('clTplCount');
|
||
if (!container) return;
|
||
if (countEl) countEl.textContent = `${admin.clTemplates.length} template(s)`;
|
||
if (!admin.clTemplates.length) {
|
||
container.innerHTML = '<div class="empty-state"><h3>No templates</h3><p>Click "Add Template" to create one.</p></div>';
|
||
return;
|
||
}
|
||
const fmtDate = (d) => d ? formatDateDisplay(d) : '-';
|
||
const rows = admin.clTemplates.map((t) => `<tr>
|
||
<td>${esc(t.name)}</td><td>${esc(t.scope || '-')}</td><td>${esc(t.version || '-')}</td>
|
||
<td>${fmtDate(t.validFrom)}</td><td>${fmtDate(t.validTill)}</td><td>${(t.recordIds || []).length}</td>
|
||
<td class="admin-table-actions">
|
||
<button class="button button-small button-secondary" data-edit-tpl="${t.id}">Edit</button>
|
||
<button class="button button-small button-ghost" data-delete-tpl="${t.id}">Delete</button>
|
||
</td></tr>`).join('');
|
||
container.innerHTML = `<table class="admin-table"><thead><tr>
|
||
<th>Name</th><th>Scope</th><th>Version</th><th>Valid From</th><th>Valid Till</th><th>Records</th><th>Actions</th>
|
||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||
container.querySelectorAll('[data-edit-tpl]').forEach((b) => b.addEventListener('click', () => editClTemplate(Number(b.dataset.editTpl))));
|
||
container.querySelectorAll('[data-delete-tpl]').forEach((b) => b.addEventListener('click', () => deleteClTemplate(Number(b.dataset.deleteTpl))));
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* REPORTS — Task assignment (list-first)
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function bindTaskForm() {
|
||
const form = document.getElementById('taskForm');
|
||
if (!form) return;
|
||
form.addEventListener('submit', (e) => { e.preventDefault(); saveTask(); });
|
||
document.getElementById('showTaskFormBtn')?.addEventListener('click', () => {
|
||
clearTaskForm(); refreshTaskDropdowns(); showModal('taskFormModal');
|
||
});
|
||
document.getElementById('taskFormModal')?.addEventListener('hidden.bs.modal', () => resetEditing());
|
||
}
|
||
|
||
function refreshTaskDropdowns() {
|
||
const siteOpts = admin.sites.map((s) => ({ value: s.id, label: s.siteCode }));
|
||
populateSelectObjects('taskSite', siteOpts, '— Select site —');
|
||
const userOpts = admin.users.map((u) => ({ value: u.id, label: `${u.name} ${u.familyName} (${u.email})` }));
|
||
populateSelectObjects('taskUser', userOpts, '— Select user —');
|
||
const tplOpts = admin.clTemplates.map((t) => ({ value: t.id, label: `${t.name} (v${t.version || '?'})` }));
|
||
populateSelectObjects('taskTemplate', tplOpts, '— Select template —');
|
||
populateSelectObjects('taskProject', admin.taskSettings.projects.map((p) => ({ value: p.id, label: p.value })), '— Select project —');
|
||
/* Processes filtered by selected project — bind change listener */
|
||
updateTaskProcessDropdown();
|
||
const projSel = document.getElementById('taskProject');
|
||
if (projSel && !projSel._boundTaskProc) {
|
||
projSel.addEventListener('change', updateTaskProcessDropdown);
|
||
projSel._boundTaskProc = true;
|
||
}
|
||
}
|
||
|
||
function updateTaskProcessDropdown() {
|
||
const projSel = document.getElementById('taskProject');
|
||
const selectedProjectId = Number(projSel?.value || 0);
|
||
if (!selectedProjectId) {
|
||
populateSelectObjects('taskProcess', admin.taskSettings.processes.map((p) => ({ value: p.id, label: p.value })), '— Select process —');
|
||
} else {
|
||
const filtered = admin.taskSettings.processes
|
||
.filter((p) => p.projectId === selectedProjectId)
|
||
.map((p) => ({ value: p.id, label: p.value }));
|
||
populateSelectObjects('taskProcess', filtered, '— Select process —');
|
||
}
|
||
}
|
||
|
||
function saveTask() {
|
||
const el = (id) => document.getElementById(id);
|
||
const data = {
|
||
siteId: Number(el('taskSite').value),
|
||
userId: Number(el('taskUser').value),
|
||
templateId: Number(el('taskTemplate').value),
|
||
projectId: Number(el('taskProject').value) || null,
|
||
processId: Number(el('taskProcess').value) || null
|
||
};
|
||
if (!data.siteId || !data.userId || !data.templateId) {
|
||
showToast('User, Site, and Template are required.', 'warning'); return;
|
||
}
|
||
if (admin.editingType === 'task' && admin.editingId) {
|
||
const existing = admin.tasks.find((t) => t.id === admin.editingId);
|
||
const payload = { ...data, status: existing?.status || 'pending' };
|
||
fetchJson(`${API}/tasks/${admin.editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
||
.then((updated) => {
|
||
const idx = admin.tasks.findIndex((t) => t.id === admin.editingId);
|
||
if (idx >= 0) admin.tasks[idx] = updated;
|
||
resetEditing();
|
||
cacheState();
|
||
hideTaskForm();
|
||
renderTaskList();
|
||
showToast('Task assigned.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
} else {
|
||
data.status = 'pending';
|
||
fetchJson(`${API}/tasks`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||
.then((created) => {
|
||
admin.tasks.push(created);
|
||
cacheState();
|
||
hideTaskForm();
|
||
renderTaskList();
|
||
showToast('Task assigned.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Save failed.', 'error'));
|
||
}
|
||
}
|
||
|
||
function clearTaskForm() {
|
||
document.getElementById('taskForm')?.reset();
|
||
document.getElementById('taskFormHeading').textContent = 'Create Task Assignment';
|
||
resetEditing();
|
||
}
|
||
function hideTaskForm() { clearTaskForm(); hideModal('taskFormModal'); }
|
||
|
||
function editTask(id) {
|
||
const task = admin.tasks.find((t) => t.id === id);
|
||
if (!task) return;
|
||
admin.editingId = id; admin.editingType = 'task';
|
||
refreshTaskDropdowns();
|
||
const el = (eid) => document.getElementById(eid);
|
||
el('taskSite').value = task.siteId || '';
|
||
el('taskUser').value = task.userId || '';
|
||
el('taskTemplate').value = task.templateId || '';
|
||
el('taskProject').value = task.projectId || '';
|
||
updateTaskProcessDropdown();
|
||
el('taskProcess').value = task.processId || '';
|
||
el('taskFormHeading').textContent = 'Edit Task Assignment';
|
||
showModal('taskFormModal');
|
||
}
|
||
|
||
function deleteTask(id) {
|
||
if (!confirm('Delete this task? This will also permanently remove all uploaded images.')) return;
|
||
fetchJson(`${API}/tasks/${id}`, { method: 'DELETE' }).then(() => {
|
||
admin.tasks = admin.tasks.filter((t) => t.id !== id);
|
||
cacheState();
|
||
renderTaskList();
|
||
/* Also delete the report and image files from the server */
|
||
if (navigator.onLine) {
|
||
fetch(`/api/v1/reports/${id}`, { method: 'DELETE' }).catch(() => {});
|
||
}
|
||
}).catch((err) => showToast(err.message || 'Delete failed.', 'error'));
|
||
}
|
||
|
||
function renderTaskList() {
|
||
const container = document.getElementById('taskListContainer');
|
||
if (!container) return;
|
||
if (!admin.tasks.length) {
|
||
container.innerHTML = '<div class="empty-state"><h3>No tasks</h3><p>Click "Add Task" to assign one.</p></div>';
|
||
return;
|
||
}
|
||
const siteName = (id) => admin.sites.find((s) => s.id === id)?.siteCode || '?';
|
||
const userName = (id) => { const u = admin.users.find((x) => x.id === id); return u ? `${u.name} ${u.familyName}` : '?'; };
|
||
const tplName = (id) => admin.clTemplates.find((t) => t.id === id)?.name || '?';
|
||
const statusBadge = (s) => {
|
||
const cls = s === 'final' ? 'badge-online' : s === 'draft' ? 'badge-offline' : 'badge-neutral';
|
||
return `<span class="badge ${cls}">${esc(s || 'pending')}</span>`;
|
||
};
|
||
const rows = admin.tasks.map((t) => `<tr>
|
||
<td>${esc(siteName(t.siteId))}</td><td>${esc(userName(t.userId))}</td>
|
||
<td>${esc(tplName(t.templateId))}</td><td>${esc(t.project || '-')}</td>
|
||
<td>${esc(t.process || '-')}</td><td>${statusBadge(t.status)}</td>
|
||
<td class="admin-table-actions">
|
||
<button class="button button-small button-primary" data-view-task="${t.id}">View</button>
|
||
<button class="button button-small" data-map-task="${t.id}" title="Show images on map" style="background:#0dcaf0;color:#000;border:none;"><i class="bi bi-geo-alt"></i> Map</button>
|
||
${t.status === 'final' ? `<button class="button button-small button-warning" data-reopen-task="${t.id}">Reopen</button>` : ''}
|
||
<button class="button button-small button-secondary" data-edit-task="${t.id}">Edit</button>
|
||
<button class="button button-small button-ghost" data-delete-task="${t.id}">Delete</button>
|
||
</td></tr>`).join('');
|
||
container.innerHTML = `<table class="admin-table"><thead><tr>
|
||
<th>Site</th><th>User</th><th>Template</th><th>Project</th><th>Process</th><th>Status</th><th>Actions</th>
|
||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||
container.querySelectorAll('[data-view-task]').forEach((b) => b.addEventListener('click', () => viewTaskReport(Number(b.dataset.viewTask))));
|
||
container.querySelectorAll('[data-map-task]').forEach((b) => b.addEventListener('click', () => openTaskImagesMap(Number(b.dataset.mapTask))));
|
||
container.querySelectorAll('[data-reopen-task]').forEach((b) => b.addEventListener('click', () => reopenTask(Number(b.dataset.reopenTask))));
|
||
container.querySelectorAll('[data-edit-task]').forEach((b) => b.addEventListener('click', () => editTask(Number(b.dataset.editTask))));
|
||
container.querySelectorAll('[data-delete-task]').forEach((b) => b.addEventListener('click', () => deleteTask(Number(b.dataset.deleteTask))));
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Task Report Viewer (admin preview — like user portal display)
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function viewTaskReport(taskId) {
|
||
const task = admin.tasks.find(t => t.id === taskId);
|
||
if (!task) { showToast('Task not found.', 'error'); return; }
|
||
|
||
const site = admin.sites.find(s => s.id === task.siteId);
|
||
const user = admin.users.find(u => u.id === task.userId);
|
||
const tpl = admin.clTemplates.find(t => t.id === task.templateId);
|
||
|
||
const recordIds = tpl?.recordIds || [];
|
||
const records = recordIds.map(rid => admin.clRecords.find(r => r.id === rid)).filter(Boolean).sort((a, b) => a.sort - b.sort);
|
||
|
||
/* Try to get data from server first, fallback to empty */
|
||
fetchReportDataForAdmin(taskId).then(serverData => {
|
||
const data = serverData || { visitDate: '', records: {} };
|
||
buildAndShowReportModal(task, site, user, tpl, data, records);
|
||
/* Always fetch images from DB for submitted reports */
|
||
if (navigator.onLine) {
|
||
fetchReportImagesForAdmin(taskId, records, site);
|
||
}
|
||
});
|
||
}
|
||
|
||
function buildAndShowReportModal(task, site, user, tpl, data, records) {
|
||
/* Look up configured color for a severity or status value */
|
||
const sevColor = (val) => admin.templateSettings.severities.find((s) => s.value === val)?.color || null;
|
||
const statColor = (val) => admin.templateSettings.statuses.find((s) => s.value === val)?.color || null;
|
||
|
||
/* Build a colored badge using configured color or fall back to Bootstrap class */
|
||
const coloredBadge = (val, fallbackClass) => {
|
||
if (!val || val === '-') return `<span class="badge ${fallbackClass || 'bg-secondary'}">${esc(val)}</span>`;
|
||
const color = statColor(val);
|
||
if (color) {
|
||
return `<span class="badge" style="background:${color};color:${colorContrast(color)}">${esc(val)}</span>`;
|
||
}
|
||
/* Legacy fallback for NOK/OK/TBC if no color configured */
|
||
const cls = val === 'NOK' ? 'bg-danger' : val === 'OK' ? 'bg-success' : val === 'TBC' ? 'bg-warning text-dark' : 'bg-secondary';
|
||
return `<span class="badge ${cls}">${esc(val)}</span>`;
|
||
};
|
||
|
||
const severityBadge = (val) => {
|
||
if (!val || val === '-') return `<span class="text-muted">${esc(val)}</span>`;
|
||
const color = sevColor(val);
|
||
if (color) {
|
||
return `<span class="badge" style="background:${color};color:${colorContrast(color)}">${esc(val)}</span>`;
|
||
}
|
||
return `<span>${esc(val)}</span>`;
|
||
};
|
||
|
||
let recordsHtml = '';
|
||
if (!records.length) {
|
||
recordsHtml = '<p class="text-muted">No records in template.</p>';
|
||
} else {
|
||
recordsHtml = records.map(rec => {
|
||
const rd = data.records[rec.id] || {};
|
||
const desc = rec.descriptionEN || rec.descriptionFR || rec.descriptionNL || 'No description';
|
||
const status = rd.status || '-';
|
||
const handledBy = rd.handledBy || '-';
|
||
const comment = rd.comment || '-';
|
||
const images = rd.images || [];
|
||
/* Show a loading placeholder — real images will be fetched from server files */
|
||
const imgPlaceholder = images.length
|
||
? `<span class="text-muted small"><i class="bi bi-hourglass-split me-1"></i>Loading ${images.length} image(s)...</span>`
|
||
: '<span class="text-muted small">No images</span>';
|
||
const serverImgPlaceholder = `<div class="server-images-slot" data-rec-id="${rec.id}">${imgPlaceholder}</div>`;
|
||
|
||
return `<div style="border:1px solid #dee2e6;border-radius:8px;padding:12px;margin-bottom:10px;">
|
||
<div class="d-flex align-items-center gap-2 mb-2">
|
||
<span class="badge bg-primary">#${rec.sort}</span>
|
||
<strong>${esc(desc)}</strong>
|
||
${rec.imageRequired ? '<span class="badge bg-warning text-dark">IMG REQ</span>' : ''}
|
||
</div>
|
||
<div class="d-flex gap-3 small text-muted mb-2">
|
||
<span><strong>Category:</strong> ${esc(rec.category || '-')}</span>
|
||
<span><strong>Sub:</strong> ${esc(rec.subCategory || '-')}</span>
|
||
<span><strong>Severity:</strong> ${severityBadge(rec.severity || '-')}</span>
|
||
</div>
|
||
<div class="row g-2 small">
|
||
<div class="col-md-3"><strong>Status:</strong> ${coloredBadge(status)}</div>
|
||
<div class="col-md-3"><strong>Handled By:</strong> ${esc(handledBy)}</div>
|
||
<div class="col-md-6"><strong>Comment:</strong> ${esc(comment)}</div>
|
||
</div>
|
||
<div class="mt-2">${serverImgPlaceholder}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
const taskStatusBadge = task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary';
|
||
|
||
/* Build & show modal */
|
||
let modal = document.getElementById('adminReportViewModal');
|
||
if (!modal) {
|
||
modal = document.createElement('div');
|
||
modal.id = 'adminReportViewModal';
|
||
modal.className = 'modal fade';
|
||
modal.tabIndex = -1;
|
||
modal.innerHTML = `<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="adminReportModalTitle">Report Preview</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body" id="adminReportModalBody"></div>
|
||
<div class="modal-footer" id="adminReportModalFooter">
|
||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
const taskId = task.id;
|
||
document.getElementById('adminReportModalTitle').textContent = `Report — ${site?.siteCode || 'Unknown'} / ${tpl?.name || 'Unknown'}`;
|
||
document.getElementById('adminReportModalBody').innerHTML = `
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-md-3"><div class="card border-primary"><div class="card-body py-2 px-3"><small class="text-muted">Site</small><div class="fw-semibold">${esc(site?.siteCode || '-')}</div></div></div></div>
|
||
<div class="col-md-2"><div class="card"><div class="card-body py-2 px-3"><small class="text-muted">Project</small><div class="fw-semibold">${esc(task.project || '-')}</div></div></div></div>
|
||
<div class="col-md-2"><div class="card"><div class="card-body py-2 px-3"><small class="text-muted">Process</small><div class="fw-semibold">${esc(task.process || '-')}</div></div></div></div>
|
||
<div class="col-md-2"><div class="card"><div class="card-body py-2 px-3"><small class="text-muted">Status</small><div><span class="badge ${taskStatusBadge}">${esc(task.status || 'pending')}</span></div></div></div></div>
|
||
<div class="col-md-3"><div class="card"><div class="card-body py-2 px-3"><small class="text-muted">User</small><div class="fw-semibold">${esc(user ? `${user.name} ${user.familyName}` : '-')}</div></div></div></div>
|
||
</div>
|
||
<div class="mb-3"><strong>Visit Date:</strong> ${esc(data.visitDate || 'Not set')}</div>
|
||
<h6 class="fw-semibold mb-3">Records (${records.length})</h6>
|
||
${recordsHtml}
|
||
`;
|
||
|
||
/* Reopen button (only for final tasks) */
|
||
const footer = document.getElementById('adminReportModalFooter');
|
||
footer.innerHTML = task.status === 'final'
|
||
? `<button type="button" class="btn btn-warning btn-sm" id="adminReopenTaskBtn">Reopen Task</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>`
|
||
: `<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>`;
|
||
|
||
document.getElementById('adminReopenTaskBtn')?.addEventListener('click', () => {
|
||
reopenTask(taskId);
|
||
bootstrap.Modal.getInstance(modal)?.hide();
|
||
});
|
||
|
||
/* Bind image lightbox clicks (with EXIF info) */
|
||
modal.querySelectorAll('[data-admin-lightbox]').forEach(img => {
|
||
img.addEventListener('click', () => openAdminLightbox(img.src, img.alt, {
|
||
name: img.dataset.imgName,
|
||
size: Number(img.dataset.imgSize) || 0,
|
||
width: img.dataset.imgWidth,
|
||
height: img.dataset.imgHeight,
|
||
exif: img.dataset.imgExif ? safeJsonParse(img.dataset.imgExif) : null,
|
||
site
|
||
}));
|
||
});
|
||
|
||
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
|
||
bsModal.show();
|
||
}
|
||
|
||
/**
|
||
* Fetches report answers from the server to get record data (status, handledBy, comments).
|
||
*/
|
||
async function fetchReportDataForAdmin(taskId) {
|
||
if (!navigator.onLine) return null;
|
||
try {
|
||
const resp = await fetch(`/api/v1/reports/${taskId}`, { headers: { Accept: 'application/json' } });
|
||
if (!resp.ok) return null;
|
||
const report = await resp.json();
|
||
return report.answers || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fetches images from the server for a report and renders them
|
||
* into the already-open modal under each record's placeholder slot.
|
||
* Images are served as URLs pointing to files on disk.
|
||
*/
|
||
/**
|
||
* Returns distance in metres between two WGS-84 coordinates (Haversine formula).
|
||
*/
|
||
function adminHaversineDistanceM(lat1, lon1, lat2, lon2) {
|
||
const R = 6_371_000;
|
||
const toRad = (d) => (d * Math.PI) / 180;
|
||
const dLat = toRad(lat2 - lat1);
|
||
const dLon = toRad(lon2 - lon1);
|
||
const a =
|
||
Math.sin(dLat / 2) ** 2 +
|
||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
}
|
||
|
||
/**
|
||
* Returns an HTML badge showing the geo-fence result for one image.
|
||
* - Green : GPS present, within radius
|
||
* - Amber : GPS present, outside radius
|
||
* - Grey : no GPS data in EXIF
|
||
*/
|
||
function buildGeoFenceBadge(img, site) {
|
||
/* No site coordinates configured — can't evaluate */
|
||
if (!site?.latitude || !site?.longitude) {
|
||
return '<span class="badge bg-secondary ms-1" title="Site has no geo coordinates configured">GPS: N/A</span>';
|
||
}
|
||
|
||
const lat = img.exif?.latitude;
|
||
const lon = img.exif?.longitude;
|
||
|
||
if (lat == null || lon == null) {
|
||
return '<span class="badge bg-secondary ms-1" title="No GPS data in image EXIF">No GPS</span>';
|
||
}
|
||
|
||
const radiusM = parseInt(admin.appConfig?.geo_fence_radius_m ?? '50', 10) || 50;
|
||
const distanceM = Math.round(adminHaversineDistanceM(lat, lon, site.latitude, site.longitude));
|
||
|
||
if (distanceM <= radiusM) {
|
||
return `<span class="badge bg-success ms-1" title="Image taken ${distanceM}\u00a0m from site (allowed: ${radiusM}\u00a0m)">✓ GPS ${distanceM}\u00a0m</span>`;
|
||
}
|
||
return `<span class="badge bg-warning text-dark ms-1" title="Image taken ${distanceM}\u00a0m from site (allowed: ${radiusM}\u00a0m)">⚠ GPS ${distanceM}\u00a0m</span>`;
|
||
}
|
||
|
||
async function fetchReportImagesForAdmin(taskId, records, site) {
|
||
try {
|
||
const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } });
|
||
if (!resp.ok) {
|
||
/* Clear loading placeholders on failure */
|
||
for (const rec of records) {
|
||
const slot = document.querySelector(`.server-images-slot[data-rec-id="${rec.id}"]`);
|
||
if (slot) slot.innerHTML = '<span class="text-muted small">Could not load images.</span>';
|
||
}
|
||
return;
|
||
}
|
||
const imagesByRecord = await resp.json();
|
||
|
||
for (const rec of records) {
|
||
const slot = document.querySelector(`.server-images-slot[data-rec-id="${rec.id}"]`);
|
||
if (!slot) continue;
|
||
const imgs = imagesByRecord[rec.id];
|
||
if (!imgs?.length) {
|
||
slot.innerHTML = '<span class="text-muted small">No images</span>';
|
||
continue;
|
||
}
|
||
|
||
slot.innerHTML = imgs.map((img, i) =>
|
||
`<div class="d-inline-block text-center me-2 mb-2" style="max-width:90px;">
|
||
<div class="position-relative d-inline-block">
|
||
<img src="${img.dataUrl}" alt="${esc(img.name || `img-${i}`)}" data-admin-lightbox data-img-name="${escAttr(img.name || '')}" data-img-size="${img.size || 0}" data-img-width="${img.width || ''}" data-img-height="${img.height || ''}" data-img-exif="${escAttr(JSON.stringify(img.exif || {}))}" style="width:60px;height:60px;object-fit:cover;border-radius:4px;border:1px solid #dee2e6;cursor:pointer" />
|
||
</div>
|
||
<div class="small text-muted text-truncate" style="max-width:90px" title="${esc(img.name || '')}">${esc(img.name || `img-${i}`)}</div>
|
||
<div class="small text-muted">${formatFileSize(img.size)}</div>
|
||
<div>${buildGeoFenceBadge(img, site)}</div>
|
||
</div>`
|
||
).join('');
|
||
|
||
/* Bind lightbox clicks for newly added images */
|
||
slot.querySelectorAll('[data-admin-lightbox]').forEach(imgEl => {
|
||
imgEl.addEventListener('click', () => openAdminLightbox(imgEl.src, imgEl.alt, {
|
||
name: imgEl.dataset.imgName,
|
||
size: Number(imgEl.dataset.imgSize) || 0,
|
||
width: imgEl.dataset.imgWidth,
|
||
height: imgEl.dataset.imgHeight,
|
||
exif: imgEl.dataset.imgExif ? safeJsonParse(imgEl.dataset.imgExif) : null,
|
||
site
|
||
}));
|
||
});
|
||
}
|
||
} catch (err) {
|
||
console.warn('Failed to fetch report images:', err.message);
|
||
}
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Admin: Reopen a final task
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function reopenTask(taskId) {
|
||
const task = admin.tasks.find(t => t.id === taskId);
|
||
if (!task) return;
|
||
const payload = { siteId: task.siteId, userId: task.userId, templateId: task.templateId, project: task.project, process: task.process, status: 'draft' };
|
||
fetchJson(`${API}/tasks/${taskId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
||
.then(() => {
|
||
task.status = 'draft';
|
||
cacheState();
|
||
renderTaskList();
|
||
showToast('Task reopened — user can now edit it again.', 'success');
|
||
})
|
||
.catch((err) => showToast(err.message || 'Reopen failed.', 'error'));
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Admin: Image lightbox (with EXIF, name, size info)
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function openAdminLightbox(src, alt, meta) {
|
||
let modal = document.getElementById('adminImageLightbox');
|
||
if (!modal) {
|
||
modal = document.createElement('div');
|
||
modal.id = 'adminImageLightbox';
|
||
modal.className = 'modal fade';
|
||
modal.tabIndex = -1;
|
||
modal.innerHTML = `<div class="modal-dialog modal-xl modal-dialog-centered">
|
||
<div class="modal-content" style="resize:both;overflow:auto;">
|
||
<div class="modal-header">
|
||
<h6 class="modal-title" id="adminLightboxTitle">Image</h6>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body p-2">
|
||
<div class="row">
|
||
<div class="col-md-8 text-center">
|
||
<div class="mb-2">
|
||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" id="adminLightboxZoomOut" title="Zoom out"><i class="bi bi-zoom-out"></i></button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm me-2" id="adminLightboxZoomIn" title="Zoom in"><i class="bi bi-zoom-in"></i></button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm me-1" id="adminLightboxRotateLeft" title="Rotate left"><i class="bi bi-arrow-counterclockwise"></i></button>
|
||
<button type="button" class="btn btn-outline-secondary btn-sm" id="adminLightboxRotateRight" title="Rotate right"><i class="bi bi-arrow-clockwise"></i></button>
|
||
</div>
|
||
<div style="overflow:auto;max-height:72vh;">
|
||
<img id="adminLightboxImg" src="" alt="" style="max-width:100%;max-height:70vh;object-fit:contain;transition:transform 0.3s;" />
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4" id="adminLightboxInfo" style="font-size:0.85rem;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
|
||
let rotation = 0;
|
||
let scale = 1;
|
||
const imgEl = document.getElementById('adminLightboxImg');
|
||
function applyAdminTransform() { imgEl.style.transform = `rotate(${rotation}deg) scale(${scale})`; }
|
||
document.getElementById('adminLightboxRotateLeft').addEventListener('click', () => {
|
||
rotation = (rotation - 90) % 360;
|
||
applyAdminTransform();
|
||
});
|
||
document.getElementById('adminLightboxRotateRight').addEventListener('click', () => {
|
||
rotation = (rotation + 90) % 360;
|
||
applyAdminTransform();
|
||
});
|
||
document.getElementById('adminLightboxZoomIn').addEventListener('click', () => {
|
||
scale = Math.min(scale + 0.25, 5);
|
||
applyAdminTransform();
|
||
});
|
||
document.getElementById('adminLightboxZoomOut').addEventListener('click', () => {
|
||
scale = Math.max(scale - 0.25, 0.25);
|
||
applyAdminTransform();
|
||
});
|
||
modal.addEventListener('hidden.bs.modal', () => { rotation = 0; scale = 1; imgEl.style.transform = ''; });
|
||
}
|
||
document.getElementById('adminLightboxImg').src = src;
|
||
document.getElementById('adminLightboxImg').style.transform = '';
|
||
document.getElementById('adminLightboxTitle').textContent = meta?.name || alt || 'Image Preview';
|
||
|
||
/* Build info panel with file metadata and EXIF */
|
||
const infoPanel = document.getElementById('adminLightboxInfo');
|
||
let infoHtml = '<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-1"></i>Image Details</h6>';
|
||
infoHtml += '<table class="table table-sm table-borderless mb-3">';
|
||
if (meta?.name) infoHtml += `<tr><td class="fw-semibold text-nowrap">File Name</td><td class="text-break">${esc(meta.name)}</td></tr>`;
|
||
if (meta?.size) infoHtml += `<tr><td class="fw-semibold">Size</td><td>${formatFileSize(meta.size)}</td></tr>`;
|
||
if (meta?.width && meta?.height) infoHtml += `<tr><td class="fw-semibold">Dimensions</td><td>${meta.width} × ${meta.height} px</td></tr>`;
|
||
infoHtml += '</table>';
|
||
|
||
/* EXIF data */
|
||
const exif = meta?.exif;
|
||
if (exif && typeof exif === 'object' && Object.keys(exif).length) {
|
||
infoHtml += '<h6 class="fw-semibold mb-2"><i class="bi bi-camera me-1"></i>EXIF Data</h6>';
|
||
infoHtml += '<table class="table table-sm table-borderless">';
|
||
const exifLabels = {
|
||
make: 'Camera Make', model: 'Camera Model', dateTimeOriginal: 'Date Taken',
|
||
dateTime: 'Date/Time', focalLength: 'Focal Length', exposureTime: 'Exposure',
|
||
fNumber: 'F-Number', isoSpeed: 'ISO', orientation: 'Orientation',
|
||
pixelXDimension: 'Width (EXIF)', pixelYDimension: 'Height (EXIF)',
|
||
latitude: 'Latitude', longitude: 'Longitude', altitude: 'Altitude'
|
||
};
|
||
for (const [key, label] of Object.entries(exifLabels)) {
|
||
if (exif[key] != null && exif[key] !== '') {
|
||
let val = exif[key];
|
||
if (key === 'focalLength') val = `${Number(val).toFixed(1)} mm`;
|
||
else if (key === 'exposureTime') val = val < 1 ? `1/${Math.round(1 / val)}s` : `${val}s`;
|
||
else if (key === 'fNumber') val = `f/${Number(val).toFixed(1)}`;
|
||
else if (key === 'latitude' || key === 'longitude') val = `${Number(val).toFixed(6)}°`;
|
||
else if (key === 'altitude') val = `${Number(val).toFixed(1)} m`;
|
||
infoHtml += `<tr><td class="fw-semibold text-nowrap">${label}</td><td>${esc(String(val))}</td></tr>`;
|
||
}
|
||
}
|
||
infoHtml += '</table>';
|
||
|
||
/* Show on map button if GPS coords present */
|
||
if (exif.latitude != null && exif.longitude != null) {
|
||
infoHtml += `<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-2" id="adminLightboxMapBtn"><i class="bi bi-geo-alt me-1"></i>Show on Map</button>`;
|
||
}
|
||
} else {
|
||
infoHtml += '<p class="text-muted small">No EXIF data available.</p>';
|
||
}
|
||
|
||
/* Geo-fence result */
|
||
if (meta?.site) {
|
||
const geoBadge = buildGeoFenceBadge({ exif: meta.exif }, meta.site);
|
||
infoHtml += `<div class="mt-2"><span class="fw-semibold">Geo-fence:</span> ${geoBadge}</div>`;
|
||
if (meta.exif?.latitude != null && meta.exif?.longitude != null && meta.site?.latitude != null && meta.site?.longitude != null) {
|
||
const radiusM = parseInt(admin.appConfig?.geo_fence_radius_m ?? '50', 10) || 50;
|
||
const dist = Math.round(adminHaversineDistanceM(meta.exif.latitude, meta.exif.longitude, meta.site.latitude, meta.site.longitude));
|
||
infoHtml += `<p class="text-muted small mb-0">Distance to site: <strong>${dist}\u00a0m</strong> — allowed radius: <strong>${radiusM}\u00a0m</strong></p>`;
|
||
}
|
||
}
|
||
|
||
infoPanel.innerHTML = infoHtml;
|
||
|
||
/* Bind map button if present */
|
||
const mapBtn = document.getElementById('adminLightboxMapBtn');
|
||
if (mapBtn && exif?.latitude != null && exif?.longitude != null) {
|
||
mapBtn.addEventListener('click', () => {
|
||
openAdminMapModal([{ lat: exif.latitude, lng: exif.longitude, name: meta?.name || 'Image', dataUrl: src }],
|
||
meta?.site ? { site: meta.site, radiusM: parseInt(admin.appConfig?.geo_fence_radius_m ?? '50', 10) || 50 } : {});
|
||
});
|
||
}
|
||
|
||
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
|
||
bsModal.show();
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Admin: Map modal (Leaflet + OpenStreetMap)
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
/**
|
||
* Open a modal with a Leaflet map showing markers for the given points.
|
||
* Each point: { lat, lng, name }
|
||
*/
|
||
/**
|
||
* Opens the image-locations map modal.
|
||
* @param {Array} points – [{lat, lng, name, dataUrl}]
|
||
* @param {object} [opts] – { site: {siteCode, latitude, longitude}, radiusM: number }
|
||
*/
|
||
function openAdminMapModal(points, opts = {}) {
|
||
const hasSite = opts.site?.latitude != null && opts.site?.longitude != null;
|
||
const hasImages = points?.length > 0;
|
||
|
||
if (!hasSite && !hasImages) {
|
||
showToast('No geo-tagged images and no site location available.', 'info');
|
||
return;
|
||
}
|
||
|
||
let modal = document.getElementById('adminMapModal');
|
||
if (!modal) {
|
||
modal = document.createElement('div');
|
||
modal.id = 'adminMapModal';
|
||
modal.className = 'modal fade';
|
||
modal.tabIndex = -1;
|
||
modal.style.zIndex = '1070'; /* above lightbox modal (1055) */
|
||
modal.innerHTML = `<div class="modal-dialog modal-xl modal-dialog-centered">
|
||
<div class="modal-content" style="resize:both;overflow:auto;">
|
||
<div class="modal-header">
|
||
<h6 class="modal-title" id="adminMapTitle"><i class="bi bi-geo-alt me-1"></i>Image Locations</h6>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body p-0">
|
||
<div id="adminMapContainer" style="height:500px;width:100%;"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<div id="adminMapLegend" class="me-auto d-flex gap-3 flex-wrap small"></div>
|
||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
/* Ensure backdrop also appears above lightbox */
|
||
modal.addEventListener('shown.bs.modal', () => {
|
||
const backdrop = document.querySelector('.modal-backdrop:last-child');
|
||
if (backdrop) backdrop.style.zIndex = '1065';
|
||
}, { once: true });
|
||
|
||
const bsModal = bootstrap.Modal.getOrCreateInstance(modal);
|
||
bsModal.show();
|
||
|
||
/* Initialize map after modal is shown (Leaflet needs visible container) */
|
||
modal.addEventListener('shown.bs.modal', function initMap() {
|
||
modal.removeEventListener('shown.bs.modal', initMap);
|
||
const container = document.getElementById('adminMapContainer');
|
||
container.innerHTML = ''; /* clear any previous map instance */
|
||
container._leaflet_id = null; /* allow re-init */
|
||
|
||
/* Decide initial centre: prefer site, then first image */
|
||
const centre = hasSite
|
||
? [opts.site.latitude, opts.site.longitude]
|
||
: [points[0].lat, points[0].lng];
|
||
|
||
const map = L.map(container).setView(centre, 15);
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors',
|
||
maxZoom: 19
|
||
}).addTo(map);
|
||
|
||
const bounds = L.latLngBounds();
|
||
|
||
/* ─ Site marker + geo-fence circle ──────────────────────────── */
|
||
if (hasSite) {
|
||
const siteLat = opts.site.latitude;
|
||
const siteLng = opts.site.longitude;
|
||
const radiusM = opts.radiusM || 50;
|
||
|
||
/* Geo-fence circle */
|
||
L.circle([siteLat, siteLng], {
|
||
radius: radiusM,
|
||
color: '#0d6efd',
|
||
fillColor: '#0d6efd',
|
||
fillOpacity: 0.10,
|
||
weight: 2,
|
||
dashArray: '6 4'
|
||
}).addTo(map).bindTooltip(`Geo-fence: ${radiusM} m radius`, { permanent: false });
|
||
|
||
/* Site pin — distinct blue icon */
|
||
const siteIcon = L.divIcon({
|
||
className: '',
|
||
html: `<div style="width:16px;height:16px;background:#0d6efd;border:3px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,.4);"></div>`,
|
||
iconSize: [16, 16],
|
||
iconAnchor: [8, 8]
|
||
});
|
||
L.marker([siteLat, siteLng], { icon: siteIcon })
|
||
.addTo(map)
|
||
.bindPopup(`<strong>📍 Site: ${esc(opts.site.siteCode || 'Unknown')}</strong><br><small>${siteLat.toFixed(6)}, ${siteLng.toFixed(6)}</small><br><small>Geo-fence radius: ${radiusM} m</small>`, { maxWidth: 220 });
|
||
|
||
bounds.extend([siteLat, siteLng]);
|
||
/* Also include the circle edge in bounds so it's fully visible */
|
||
const edgeOffset = radiusM / 111_320;
|
||
bounds.extend([siteLat + edgeOffset, siteLng]);
|
||
bounds.extend([siteLat - edgeOffset, siteLng]);
|
||
}
|
||
|
||
/* ─ Image markers ─────────────────────────────────────── */
|
||
for (const pt of points) {
|
||
const inZone = hasSite && opts.radiusM
|
||
? adminHaversineDistanceM(pt.lat, pt.lng, opts.site.latitude, opts.site.longitude) <= opts.radiusM
|
||
: null;
|
||
|
||
/* Colour the marker dot: green = within zone, amber = outside, grey = unknown */
|
||
const dotColor = inZone === true ? '#198754' : inZone === false ? '#ffc107' : '#6c757d';
|
||
const imgIcon = L.divIcon({
|
||
className: '',
|
||
html: `<div style="width:14px;height:14px;background:${dotColor};border:2px solid #fff;border-radius:50%;box-shadow:0 0 4px rgba(0,0,0,.4);"></div>`,
|
||
iconSize: [14, 14],
|
||
iconAnchor: [7, 7]
|
||
});
|
||
|
||
const marker = L.marker([pt.lat, pt.lng], { icon: imgIcon }).addTo(map);
|
||
let popupHtml = '';
|
||
if (pt.dataUrl) {
|
||
popupHtml += `<img src="${pt.dataUrl}" style="width:120px;height:90px;object-fit:cover;border-radius:4px;display:block;margin-bottom:6px;" />`;
|
||
}
|
||
popupHtml += `<strong>${esc(pt.name)}</strong><br><small>${pt.lat.toFixed(6)}, ${pt.lng.toFixed(6)}</small>`;
|
||
if (inZone !== null) {
|
||
const dist = Math.round(adminHaversineDistanceM(pt.lat, pt.lng, opts.site.latitude, opts.site.longitude));
|
||
popupHtml += `<br><small>${inZone ? '✅' : '⚠️'} ${dist} m from site</small>`;
|
||
}
|
||
marker.bindPopup(popupHtml, { maxWidth: 200 });
|
||
bounds.extend([pt.lat, pt.lng]);
|
||
}
|
||
|
||
if (bounds.isValid()) {
|
||
map.fitBounds(bounds, { padding: [40, 40] });
|
||
}
|
||
|
||
/* Build legend */
|
||
const legendEl = document.getElementById('adminMapLegend');
|
||
if (legendEl) {
|
||
let legendHtml = '';
|
||
if (hasSite) {
|
||
legendHtml += `<span><span style="display:inline-block;width:12px;height:12px;background:#0d6efd;border-radius:50%;border:2px solid #fff;box-shadow:0 0 3px rgba(0,0,0,.3);vertical-align:middle;"></span> Site location</span>`;
|
||
legendHtml += `<span style="border:1px dashed #0d6efd;padding:1px 6px;border-radius:3px;color:#0d6efd;">Geo-fence zone</span>`;
|
||
}
|
||
if (hasImages) {
|
||
legendHtml += `<span><span style="display:inline-block;width:12px;height:12px;background:#198754;border-radius:50%;border:2px solid #fff;vertical-align:middle;"></span> Image within zone</span>`;
|
||
legendHtml += `<span><span style="display:inline-block;width:12px;height:12px;background:#ffc107;border-radius:50%;border:2px solid #fff;vertical-align:middle;"></span> Image outside zone</span>`;
|
||
}
|
||
legendEl.innerHTML = legendHtml;
|
||
}
|
||
|
||
/* Fix map tiles when modal is resized */
|
||
setTimeout(() => map.invalidateSize(), 200);
|
||
const observer = new ResizeObserver(() => map.invalidateSize());
|
||
observer.observe(container);
|
||
modal.addEventListener('hidden.bs.modal', () => { observer.disconnect(); map.remove(); }, { once: true });
|
||
}, { once: true });
|
||
}
|
||
|
||
/**
|
||
* Fetch images for a task and open the map with all geo-tagged images,
|
||
* the site marker, and the geo-fence zone circle.
|
||
*/
|
||
async function openTaskImagesMap(taskId) {
|
||
try {
|
||
const task = admin.tasks.find(t => t.id === taskId);
|
||
const site = task ? admin.sites.find(s => s.id === task.siteId) : null;
|
||
const radiusM = parseInt(admin.appConfig?.geo_fence_radius_m ?? '50', 10) || 50;
|
||
|
||
const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } });
|
||
if (!resp.ok) { showToast('Could not load images for map.', 'error'); return; }
|
||
const imagesByRecord = await resp.json();
|
||
|
||
const points = [];
|
||
for (const recId of Object.keys(imagesByRecord)) {
|
||
for (const img of imagesByRecord[recId]) {
|
||
if (img.exif?.latitude != null && img.exif?.longitude != null) {
|
||
points.push({ lat: img.exif.latitude, lng: img.exif.longitude, name: img.name || 'Image', dataUrl: img.dataUrl || null });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!points.length && !site?.latitude) {
|
||
showToast('No GPS images and no site coordinates available.', 'info');
|
||
return;
|
||
}
|
||
|
||
openAdminMapModal(points, { site, radiusM });
|
||
} catch (err) {
|
||
showToast('Failed to load images: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (!bytes) return '-';
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1048576).toFixed(2)} MB`;
|
||
}
|
||
|
||
function safeJsonParse(str) {
|
||
try { return JSON.parse(str); } catch { return null; }
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Shared helpers
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
function resetEditing() { admin.editingId = null; admin.editingType = null; }
|
||
|
||
function populateSelect(selectId, options) {
|
||
const sel = document.getElementById(selectId);
|
||
if (!sel) return;
|
||
const cur = sel.value;
|
||
sel.innerHTML = '<option value="">— Select —</option>';
|
||
(options || []).forEach((v) => { sel.innerHTML += `<option value="${esc(v)}">${esc(v)}</option>`; });
|
||
sel.value = cur;
|
||
}
|
||
|
||
function populateSelectObjects(selectId, items, placeholder) {
|
||
const sel = document.getElementById(selectId);
|
||
if (!sel) return;
|
||
const cur = sel.value;
|
||
sel.innerHTML = `<option value="">${placeholder || '— Select —'}</option>`;
|
||
items.forEach(({ value, label }) => { sel.innerHTML += `<option value="${esc(value)}">${esc(label)}</option>`; });
|
||
sel.value = cur;
|
||
}
|
||
|
||
/** Render an editable/deletable list of {id, value} items as a table. */
|
||
function renderSettingList(containerId, items, onDelete, onEdit) {
|
||
renderSettingTable(containerId, items,
|
||
[{ header: 'Value', cell: (item) => esc(item.value) }],
|
||
onDelete,
|
||
(item, newVal) => onEdit(item, newVal)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Render a settings list as an admin table with proper named columns and an
|
||
* inline edit row that expands under each row when "Edit" is clicked.
|
||
*
|
||
* columns — array of column definition objects:
|
||
* {
|
||
* header : string — column heading
|
||
* cell : (item) => html — display content for that cell
|
||
* editHtml : (item) => html — edit control injected into the inline edit row
|
||
* (omit for the primary Value column; it always gets a text input)
|
||
* collect : (itemId) => any — reads the edit control and returns the extra value
|
||
* (omit for the primary Value column)
|
||
* }
|
||
* The first column is always the primary text value; its editHtml/collect are
|
||
* auto-generated (a text input whose value is passed as newVal to onEdit).
|
||
* Extra columns supply additional display + edit controls.
|
||
*
|
||
* onDelete(item) — called when Delete is confirmed
|
||
* onEdit(item, newVal, extras) — newVal = primary text; extras = array of
|
||
* collect() results for columns[1], [2], …
|
||
*/
|
||
function renderSettingTable(containerId, items, columns, onDelete, onEdit) {
|
||
const container = document.getElementById(containerId);
|
||
if (!container) return;
|
||
const colSpan = columns.length + 1; // +1 for Actions
|
||
|
||
if (!items.length) {
|
||
const hdrs = columns.map((c) => `<th>${c.header}</th>`).join('') + '<th>Actions</th>';
|
||
container.innerHTML = `<table class="admin-table"><thead><tr>${hdrs}</tr></thead>
|
||
<tbody><tr><td colspan="${colSpan}"><div class="empty-state p-3"><p class="mb-0 text-muted">No items yet.</p></div></td></tr></tbody></table>`;
|
||
return;
|
||
}
|
||
|
||
const tbodyRows = items.map((item) => {
|
||
const dataCells = columns.map((c) => `<td>${c.cell(item)}</td>`).join('');
|
||
return `<tr>
|
||
${dataCells}
|
||
<td class="admin-table-actions">
|
||
<button class="button button-small button-secondary" data-slt-edit="${item.id}">Edit</button>
|
||
<button class="button button-small button-ghost" data-slt-del="${item.id}">Delete</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
const hdrRow = columns.map((c) => `<th>${c.header}</th>`).join('') + '<th>Actions</th>';
|
||
container.innerHTML = `<table class="admin-table"><thead><tr>${hdrRow}</tr></thead>
|
||
<tbody>${tbodyRows}</tbody></table>`;
|
||
|
||
container.querySelectorAll('[data-slt-edit]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const id = Number(btn.dataset.sltEdit);
|
||
const item = items.find((i) => i.id === id);
|
||
if (!item) return;
|
||
|
||
/* Build modal body — primary text input + extra controls from columns[1..n] */
|
||
const extraInputsHtml = columns.slice(1).map((c) => `
|
||
<div class="mb-3">${c.editHtml(item)}</div>`).join('');
|
||
document.getElementById('settingsItemModalLabel').textContent = 'Edit Item';
|
||
document.getElementById('settingsItemModalBody').innerHTML = `
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">${esc(columns[0].header)}</label>
|
||
<input type="text" class="form-control" id="sltModalVal" value="${escAttr(item.value)}" />
|
||
</div>
|
||
${extraInputsHtml}`;
|
||
|
||
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('settingsItemModal'));
|
||
|
||
const saveBtn = document.getElementById('settingsItemModalSave');
|
||
/* Remove previous listener by cloning */
|
||
const newSaveBtn = saveBtn.cloneNode(true);
|
||
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
|
||
newSaveBtn.addEventListener('click', () => {
|
||
const newVal = document.getElementById('sltModalVal')?.value.trim();
|
||
if (!newVal) { showToast('Value is required.', 'warning'); return; }
|
||
const extras = columns.slice(1).map((c) => c.collect(id));
|
||
modal.hide();
|
||
onEdit(item, newVal, extras);
|
||
});
|
||
|
||
modal.show();
|
||
});
|
||
});
|
||
container.querySelectorAll('[data-slt-del]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const id = Number(btn.dataset.sltDel);
|
||
const item = items.find((i) => i.id === id);
|
||
if (!item) return;
|
||
onDelete(item);
|
||
});
|
||
});
|
||
}
|
||
|
||
function showToast(message, tone) {
|
||
let toast = document.getElementById('adminToast');
|
||
if (!toast) { toast = document.createElement('div'); toast.id = 'adminToast'; toast.className = 'admin-toast'; document.body.appendChild(toast); }
|
||
const cls = tone === 'success' ? 'badge-online' : tone === 'error' ? 'badge-error' : 'badge-offline';
|
||
toast.textContent = message;
|
||
toast.className = `admin-toast badge ${cls} admin-toast-visible`;
|
||
clearTimeout(toast._timer);
|
||
toast._timer = setTimeout(() => { toast.classList.remove('admin-toast-visible'); }, 3000);
|
||
}
|
||
|
||
function fmtKb(bytes) {
|
||
if (!bytes) return '-';
|
||
return `${Math.round(bytes / 1024)} KB`;
|
||
}
|
||
|
||
/* Returns '#000000' or '#ffffff' depending on which gives better contrast on hexColor. */
|
||
function colorContrast(hexColor) {
|
||
if (!hexColor || hexColor.length < 7) return '#000000';
|
||
const r = parseInt(hexColor.slice(1, 3), 16);
|
||
const g = parseInt(hexColor.slice(3, 5), 16);
|
||
const b = parseInt(hexColor.slice(5, 7), 16);
|
||
/* Perceived luminance formula (WCAG) */
|
||
return (r * 0.299 + g * 0.587 + b * 0.114) > 128 ? '#000000' : '#ffffff';
|
||
}
|
||
|
||
function formatDateDisplay(dateStr) {
|
||
if (!dateStr) return '-';
|
||
/* HTML date inputs give yyyy-mm-dd — display as dd/mm/yyyy */
|
||
const parts = dateStr.split('-');
|
||
if (parts.length === 3) return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||
return dateStr;
|
||
}
|
||
|
||
function esc(text) { const d = document.createElement('div'); d.textContent = text ?? ''; return d.innerHTML; }
|
||
function escAttr(text) { return esc(text).replace(/"/g, '"').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(); } });
|
||
}
|