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