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

1861 lines
82 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* User module — task processing workspace logic.
*
* Features:
* • Language selection (EN/FR/NL) — stored in localStorage
* • Category tabs for record navigation — generated from unique categories
* • Full text search within active tab
* • Drag & drop + click-to-upload images
* • Records display category, subcategory, severity, description per language
* • Save as Draft / Final with validation
* • Task data (including images) stored in IndexedDB (no 5 MB localStorage limit)
*
* Validation rules for "Save as Final":
* 1. All records must have a Status value set.
* 2. If Status is "NOK", "TBC", or "ADD work" then:
* - Handled By must have a value
* - Comment must have a value
* - If the record has imageRequired=true, at least one image must be attached
*/
import { openUserDB, loadTaskData, saveOneTaskData, getStorageEstimate } from './user-db.js';
import { optimizeImage } from './images.js';
import { parseExifFromDataUrl } from './exif.js';
import { dbPut, dbGet } from './db.js';
import { STORE_CONFIG } from './constants.js';
/* ── State ──────────────────────────────────────────────────────────────── */
const userState = {
tasks: [],
users: [],
sites: [],
clRecords: [],
clTemplates: [],
templateSettings: { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] },
taskSettings: { projects: [], processes: [] },
taskData: {},
imageRules: null,
appConfig: {},
currentTaskId: null,
currentUserId: null,
currentUserName: '',
language: 'EN',
activeCategory: null,
searchQuery: '',
unsavedChanges: false
};
/*
* Temporary storage for bulk images per subcategory.
* Key: subcategory name, Value: array of { name, dataUrl, size }
*/
const bulkImagesStore = new Map();
/* ── Persistence (IndexedDB for local cache, server for source of truth) ── */
/*
* Loads all admin entity data from the server bulk endpoint and caches in IndexedDB.
*
* Failure handling:
* 401 — Session is gone (e.g. server restarted). Redirect to login so the
* user can re-authenticate and return with a fresh session.
* Other non-2xx — Transient server/network problem. Fall back to the last
* successful snapshot stored in IndexedDB so the user can still see
* their assigned tasks.
*/
async function loadFromServer() {
try {
const resp = await fetch('/api/v1/admin/all', { headers: { Accept: 'application/json' } });
if (!resp.ok) {
if (resp.status === 401) {
/* Session expired or server restarted — must re-authenticate. */
window.location.href = '/login-user';
return;
}
/* Other server error — use last cached snapshot so tasks remain visible. */
console.warn('Server returned', resp.status, '— falling back to IndexedDB cache.');
await loadFromCache();
return;
}
const data = await resp.json();
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data });
userState.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
userState.taskSettings = data.taskSettings || { projects: [], processes: [] };
userState.users = data.users || [];
userState.sites = data.sites || [];
userState.clRecords = data.clRecords || [];
userState.clTemplates = data.clTemplates || [];
userState.tasks = data.tasks || [];
userState.appConfig = data.appConfig || {};
} catch (err) {
/* Network failure (offline, DNS, etc.) — fall back to cache. */
console.warn('Failed to load data from server, using IndexedDB cache', err);
await loadFromCache();
}
}
/*
* 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;
userState.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
userState.taskSettings = data.taskSettings || { projects: [], processes: [] };
userState.users = data.users || [];
userState.sites = data.sites || [];
userState.clRecords = data.clRecords || [];
userState.clTemplates = data.clTemplates || [];
userState.tasks = data.tasks || [];
userState.appConfig = data.appConfig || {};
} catch { /* ignore */ }
}
/* ═══════════════════════════════════════════════════════════════════════════
* Initialization
* ═══════════════════════════════════════════════════════════════════════════ */
export async function initUser() {
userState.language = localStorage.getItem('user_language') || 'EN';
await openUserDB();
/* Identify the logged-in user from the server session cookie */
try {
const checkResp = await fetch('/api/v1/auth/check');
if (checkResp.ok) {
const checkData = await checkResp.json();
if (checkData.authenticated && checkData.session?.type === 'user') {
userState.currentUserId = checkData.session.id;
userState.currentUserName = `${checkData.session.name || ''} ${checkData.session.familyName || ''}`.trim();
}
}
} catch (e) { /* non-blocking */ }
/* Display logged-in user name in sidebar */
const nameEl = document.getElementById('userDisplayName');
if (nameEl && userState.currentUserName) nameEl.textContent = userState.currentUserName;
/* Load admin entity data from server before reading IndexedDB cache. */
if (navigator.onLine) {
await loadFromServer();
} else {
await loadFromCache();
}
/* Task data (images etc.) stored in user-specific IndexedDB */
userState.taskData = await loadTaskData();
fetchImageRules(); /* async, non-blocking */
filterTasksByUser();
bindEvents();
renderTaskListView();
renderSidebarTasks();
updateConnectionBadge();
updateSaveIndicator();
updateStorageIndicator();
initLanguageSelect();
ensureLightboxModal();
window.addEventListener('online', updateConnectionBadge);
window.addEventListener('offline', updateConnectionBadge);
}
async function loadAllData() {
if (navigator.onLine) {
await loadFromServer();
} else {
await loadFromCache();
}
userState.taskData = await loadTaskData();
}
/*
* Manually triggered synchronization with the server.
* Called when the user presses the "Sync" button.
* Fetches fresh data, re-applies the user filter, and re-renders task lists.
*/
async function forceSyncWithServer() {
const btn = document.getElementById('syncBtn');
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();
filterTasksByUser();
renderTaskListView();
renderSidebarTasks();
/* If a task detail is open, also refresh its record data from the server so
* changes made on another device become visible immediately after a manual sync. */
if (userState.currentTaskId) {
await maybeHydrateFromServer(userState.currentTaskId);
await maybeDownloadImages(userState.currentTaskId);
renderTaskDetail();
}
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Sync';
}
}
}
function filterTasksByUser() {
/* Prefer session-based user id; fall back to URL param for compatibility */
const params = new URLSearchParams(window.location.search);
const urlUserId = params.get('userId');
const uid = userState.currentUserId || (urlUserId ? Number(urlUserId) : null);
if (uid) {
userState.tasks = userState.tasks.filter(t => t.userId === uid);
}
}
function bindEvents() {
document.getElementById('backToListBtn')?.addEventListener('click', showListView);
document.getElementById('saveDraftBtn')?.addEventListener('click', saveDraft);
document.getElementById('saveFinalBtn')?.addEventListener('click', saveFinal);
document.getElementById('mobileSaveDraftBtn')?.addEventListener('click', saveDraft);
document.getElementById('mobileSaveFinalBtn')?.addEventListener('click', saveFinal);
document.getElementById('logoutBtn')?.addEventListener('click', logoutUser);
document.getElementById('showSettingsBtn')?.addEventListener('click', showSettingsView);
document.getElementById('syncBtn')?.addEventListener('click', forceSyncWithServer);
document.getElementById('closeSettingsBtn')?.addEventListener('click', closeSettingsView);
document.getElementById('recordSearchInput')?.addEventListener('input', onSearchInput);
/* Mobile sidebar toggle */
const sidebar = document.querySelector('.sidebar-bs');
const backdrop = document.getElementById('sidebarBackdrop');
const menuBtn = document.getElementById('mobileMenuBtn');
if (menuBtn && sidebar && backdrop) {
const openSidebar = () => { sidebar.classList.add('sidebar-open'); backdrop.classList.add('show'); };
const closeSidebar = () => { sidebar.classList.remove('sidebar-open'); backdrop.classList.remove('show'); };
menuBtn.addEventListener('click', openSidebar);
backdrop.addEventListener('click', closeSidebar);
/* Close sidebar when a nav link inside is clicked */
sidebar.querySelectorAll('a, button').forEach(el => el.addEventListener('click', () => { setTimeout(closeSidebar, 150); }));
}
}
function initLanguageSelect() {
const sel = document.getElementById('userLanguageSelect');
if (!sel) return;
sel.value = userState.language;
sel.addEventListener('change', () => {
userState.language = sel.value;
localStorage.setItem('user_language', sel.value);
if (userState.currentTaskId) renderTaskDetail();
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Views
* ═══════════════════════════════════════════════════════════════════════════ */
function hideAllViews() {
document.querySelectorAll('.workspace-view').forEach(v => v.classList.remove('workspace-view-active'));
}
function showSettingsView() {
hideAllViews();
document.getElementById('settingsView').classList.add('workspace-view-active');
document.getElementById('desktopSaveActions')?.classList.remove('is-visible');
}
function closeSettingsView() {
hideAllViews();
if (userState.currentTaskId) {
document.getElementById('taskDetailView').classList.add('workspace-view-active');
document.getElementById('desktopSaveActions')?.classList.add('is-visible');
} else {
document.getElementById('taskListView').classList.add('workspace-view-active');
}
}
async function showListView() {
userState.currentTaskId = null;
hideAllViews();
document.getElementById('taskListView').classList.add('workspace-view-active');
const msavHide = document.getElementById('mobileSaveActions');
if (msavHide) msavHide.style.display = 'none';
document.getElementById('desktopSaveActions')?.classList.remove('is-visible');
await loadAllData();
filterTasksByUser();
renderTaskListView();
renderSidebarTasks();
}
function showDetailView(taskId) {
/* Always work with a numeric ID. dataset attributes and sidebar event handlers
may pass either a string or a number depending on the call site. Normalizing
here keeps every downstream function consistent. */
const id = Number(taskId);
userState.currentTaskId = id;
userState.activeCategory = null;
userState.searchQuery = '';
const searchInput = document.getElementById('recordSearchInput');
if (searchInput) searchInput.value = '';
hideAllViews();
document.getElementById('taskDetailView').classList.add('workspace-view-active');
const msav = document.getElementById('mobileSaveActions');
if (msav) msav.style.display = '';
document.getElementById('desktopSaveActions')?.classList.add('is-visible');
/* If task was reopened and images were stripped, try to re-download from server.
Chain: hydrate values first → then fetch image blobs → then render. */
maybeHydrateFromServer(id)
.then(() => maybeDownloadImages(id))
.then(() => renderTaskDetail());
highlightSidebarTask(id);
}
/* ═══════════════════════════════════════════════════════════════════════════
* Task list rendering (main area)
* ═══════════════════════════════════════════════════════════════════════════ */
function renderTaskListView() {
const container = document.getElementById('taskListContainer');
if (!container) return;
if (!userState.tasks.length) {
container.innerHTML = '<div class="p-4 text-center text-muted"><h5>No tasks assigned</h5><p>Tasks will appear here once an administrator assigns them to you.</p></div>';
return;
}
/* Desktop: table layout */
const rows = userState.tasks.map((task) => {
const site = userState.sites.find((s) => s.id === task.siteId);
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
const badgeCls = task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary';
return `<tr>
<td>${esc(site?.siteCode || '-')}</td>
<td>${esc(tpl?.name || '-')}</td>
<td>${esc(task.project || '-')}</td>
<td>${esc(task.process || '-')}</td>
<td><span class="badge ${badgeCls}">${esc(task.status || 'pending')}</span></td>
<td><button class="btn btn-primary btn-sm" data-open-task="${task.id}">Open</button></td>
</tr>`;
}).join('');
/* Mobile: card layout — fields expand horizontally with flex-wrap */
const cards = userState.tasks.map((task) => {
const site = userState.sites.find((s) => s.id === task.siteId);
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
const badgeCls = task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary';
return `<div class="task-list-card">
<div class="task-list-card-fields">
<span class="task-list-field"><small>Site</small><strong>${esc(site?.siteCode || '-')}</strong></span>
<span class="task-list-field"><small>Template</small><strong>${esc(tpl?.name || '-')}</strong></span>
<span class="task-list-field"><small>Project</small><strong>${esc(task.project || '-')}</strong></span>
<span class="task-list-field"><small>Process</small><strong>${esc(task.process || '-')}</strong></span>
<span class="badge ${badgeCls} align-self-center">${esc(task.status || 'pending')}</span>
</div>
<button class="btn btn-primary btn-sm flex-shrink-0" data-open-task="${task.id}">Open</button>
</div>`;
}).join('');
container.innerHTML =
`<div class="d-none d-md-block"><table class="table table-hover table-sm mb-0"><thead><tr>
<th>Site</th><th>Template</th><th>Project</th><th>Process</th><th>Status</th><th></th>
</tr></thead><tbody>${rows}</tbody></table></div>` +
`<div class="d-md-none">${cards}</div>`;
container.querySelectorAll('[data-open-task]').forEach((b) => {
b.addEventListener('click', () => showDetailView(b.dataset.openTask));
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Sidebar task list
* ═══════════════════════════════════════════════════════════════════════════ */
function renderSidebarTasks() {
const container = document.getElementById('taskListSidebar');
const countEl = document.getElementById('taskCount');
if (!container) return;
if (countEl) countEl.textContent = String(userState.tasks.length);
if (!userState.tasks.length) {
container.innerHTML = '<p class="text-muted small">No tasks.</p>';
return;
}
container.innerHTML = userState.tasks.map((task) => {
const site = userState.sites.find((s) => s.id === task.siteId);
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
const badgeCls = task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary';
const active = task.id === userState.currentTaskId ? ' report-list-item--active' : '';
return `<button class="report-list-item${active}" type="button" data-sidebar-task="${task.id}">
<span class="report-list-item__header">
<strong class="report-list-item__title">${esc(site?.siteCode || 'Task')}</strong>
<span class="badge ${badgeCls}">${esc(task.status || 'pending')}</span>
</span>
<span class="report-list-item__meta">${esc(tpl?.name || '-')} · ${esc(task.project || '-')}</span>
</button>`;
}).join('');
container.querySelectorAll('[data-sidebar-task]').forEach((b) => {
b.addEventListener('click', () => showDetailView(Number(b.dataset.sidebarTask)));
});
}
function highlightSidebarTask(taskId) {
document.querySelectorAll('[data-sidebar-task]').forEach((b) => {
b.classList.toggle('report-list-item--active', Number(b.dataset.sidebarTask) === taskId);
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Task detail rendering
* ═══════════════════════════════════════════════════════════════════════════ */
function renderTaskDetail() {
const task = userState.tasks.find((t) => t.id === userState.currentTaskId);
if (!task) { showListView(); return; }
const site = userState.sites.find((s) => s.id === task.siteId);
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
/* Header */
document.getElementById('taskDetailEyebrow').textContent = `Task — ${tpl?.name || 'Unknown template'}`;
document.getElementById('taskDetailTitle').textContent = site?.siteCode || 'Unknown site';
document.getElementById('taskDetailSubtitle').textContent = `${task.project || '-'} / ${task.process || '-'}`;
/* Summary cards */
document.getElementById('taskInfoSite').textContent = site?.siteCode || '-';
document.getElementById('taskInfoProject').textContent = task.project || '-';
document.getElementById('taskInfoProcess').textContent = task.process || '-';
const statusEl = document.getElementById('taskInfoStatus');
statusEl.textContent = task.status || 'pending';
statusEl.className = `badge ${task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary'}`;
/* Visit date with validation against template range */
const data = getTaskData(task.id);
const visitDateEl = document.getElementById('visitDate');
visitDateEl.value = data.visitDate || '';
if (tpl?.validFrom) visitDateEl.setAttribute('min', tpl.validFrom);
else visitDateEl.removeAttribute('min');
if (tpl?.validTill) visitDateEl.setAttribute('max', tpl.validTill);
else visitDateEl.removeAttribute('max');
/* Bind date change to check range */
visitDateEl.onchange = () => {
validateVisitDate(visitDateEl, tpl);
markUnsaved();
};
/* Records — build category tabs then render */
renderCategoryTabs(task, tpl);
renderTaskRecords(task, tpl, data);
/* Lock form if task is final */
applyTaskLock(task);
/* Hide validation panel */
document.getElementById('taskValidationPanel').style.display = 'none';
}
function getTaskData(taskId) {
if (!userState.taskData[taskId]) {
userState.taskData[taskId] = { visitDate: '', records: {} };
}
return userState.taskData[taskId];
}
/* ═══════════════════════════════════════════════════════════════════════════
* Category Tabs
* ═══════════════════════════════════════════════════════════════════════════ */
function renderCategoryTabs(task, tpl) {
const tabsContainer = document.getElementById('recordCategoryTabs');
if (!tabsContainer) return;
const recordIds = tpl?.recordIds || [];
const records = recordIds
.map((rid) => userState.clRecords.find((r) => r.id === rid))
.filter(Boolean);
/* Get unique categories */
const categories = [...new Set(records.map(r => r.category || 'Uncategorized'))].sort();
/* If no active category set, default to "All" */
if (!userState.activeCategory) userState.activeCategory = '__all__';
let html = `<li class="nav-item">
<button class="nav-link${userState.activeCategory === '__all__' ? ' active' : ''}" type="button" data-cat="__all__">All</button>
</li>`;
categories.forEach(cat => {
html += `<li class="nav-item">
<button class="nav-link${userState.activeCategory === cat ? ' active' : ''}" type="button" data-cat="${esc(cat)}">${esc(cat)}</button>
</li>`;
});
tabsContainer.innerHTML = html;
tabsContainer.querySelectorAll('[data-cat]').forEach(btn => {
btn.addEventListener('click', () => {
userState.activeCategory = btn.dataset.cat;
tabsContainer.querySelectorAll('.nav-link').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const taskObj = userState.tasks.find(t => t.id === userState.currentTaskId);
const tplObj = userState.clTemplates.find(t => t.id === taskObj?.templateId);
renderTaskRecords(taskObj, tplObj, getTaskData(userState.currentTaskId));
});
});
/* Scroll-arrow setup — runs after the DOM is painted so scrollWidth is correct */
requestAnimationFrame(() => initTabsScrollArrows(tabsContainer));
}
/**
* Attaches left/right scroll-arrow logic to the category tabs wrapper.
* Arrows are shown only when the tab list overflows its container,
* and each arrow scrolls by roughly the width of three tabs.
* Called via requestAnimationFrame so layout is complete before measuring.
*/
function initTabsScrollArrows(tabsEl) {
const wrapper = tabsEl?.closest('.tabs-scroll-wrapper');
const btnLeft = document.getElementById('tabsScrollLeft');
const btnRight = document.getElementById('tabsScrollRight');
if (!wrapper || !btnLeft || !btnRight) return;
function updateArrows() {
const overflows = tabsEl.scrollWidth > tabsEl.clientWidth;
wrapper.classList.toggle('has-overflow', overflows);
wrapper.classList.toggle('at-start', tabsEl.scrollLeft <= 2);
wrapper.classList.toggle('at-end',
tabsEl.scrollLeft >= tabsEl.scrollWidth - tabsEl.clientWidth - 2);
}
/* Scroll by ~3 average tab widths on each click */
const scrollStep = () => Math.max(tabsEl.clientWidth * 0.45, 120);
btnLeft.addEventListener('click', () => {
tabsEl.scrollBy({ left: -scrollStep(), behavior: 'smooth' });
});
btnRight.addEventListener('click', () => {
tabsEl.scrollBy({ left: scrollStep(), behavior: 'smooth' });
});
tabsEl.addEventListener('scroll', updateArrows, { passive: true });
/* Re-evaluate when the container is resized (e.g. sidebar open/close) */
if (window._tabsResizeObs) window._tabsResizeObs.disconnect();
window._tabsResizeObs = new ResizeObserver(updateArrows);
window._tabsResizeObs.observe(tabsEl);
updateArrows();
}
function onSearchInput(e) {
userState.searchQuery = e.target.value.toLowerCase().trim();
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
if (!task) return;
const tpl = userState.clTemplates.find(t => t.id === task.templateId);
renderTaskRecords(task, tpl, getTaskData(userState.currentTaskId));
}
/* ═══════════════════════════════════════════════════════════════════════════
* Records rendering per task (with category filter + search)
* ═══════════════════════════════════════════════════════════════════════════ */
function getRecordDescription(rec) {
const lang = userState.language;
if (lang === 'FR' && rec.descriptionFR) return rec.descriptionFR;
if (lang === 'NL' && rec.descriptionNL) return rec.descriptionNL;
return rec.descriptionEN || rec.descriptionFR || rec.descriptionNL || 'No description';
}
function renderTaskRecords(task, tpl, data) {
const container = document.getElementById('taskRecordsContainer');
const countEl = document.getElementById('taskRecordCount');
if (!container) return;
/* Get records assigned to the template */
const recordIds = tpl?.recordIds || [];
let records = recordIds
.map((rid) => userState.clRecords.find((r) => r.id === rid))
.filter(Boolean)
.sort((a, b) => a.sort - b.sort);
/* Filter by active category tab */
if (userState.activeCategory && userState.activeCategory !== '__all__') {
records = records.filter(r => (r.category || 'Uncategorized') === userState.activeCategory);
}
/* Full text search filter */
if (userState.searchQuery) {
const q = userState.searchQuery;
records = records.filter(r => {
const desc = getRecordDescription(r).toLowerCase();
const cat = (r.category || '').toLowerCase();
const sub = (r.subCategory || '').toLowerCase();
const sev = (r.severity || '').toLowerCase();
const sort = String(r.sort);
return desc.includes(q) || cat.includes(q) || sub.includes(q) || sev.includes(q) || sort.includes(q);
});
}
if (countEl) countEl.textContent = `${records.length} record(s)`;
if (!records.length) {
container.innerHTML = '<div class="text-center text-muted py-3"><p>No records match the current filter.</p></div>';
return;
}
/* Status and Handled By options */
const statusOpts = userState.templateSettings.statuses.map((s) => s.value);
const handledOpts = userState.templateSettings.handledBy.map((h) => h.value);
/* Group records by sub-category */
const subCatGroups = new Map();
for (const rec of records) {
const key = rec.subCategory || 'Uncategorized';
if (!subCatGroups.has(key)) subCatGroups.set(key, []);
subCatGroups.get(key).push(rec);
}
let html = '';
for (const [subCat, groupRecs] of subCatGroups) {
const scId = `sc-${subCat.replace(/[^a-zA-Z0-9]/g, '_')}`;
/* Sub-category header with bulk actions */
html += `<div class="sub-cat-group mb-3">
<div class="sub-cat-header d-flex align-items-center gap-2 p-2 bg-light border rounded-top" data-bs-toggle="collapse" data-bs-target="#${scId}-bulk" role="button" aria-expanded="false">
<i class="bi bi-chevron-right sub-cat-chevron"></i>
<strong class="flex-grow-1">${esc(subCat)}</strong>
<span class="badge bg-secondary">${groupRecs.length}</span>
<button type="button" class="btn btn-outline-primary btn-sm sub-cat-bulk-toggle" data-sc="${esc(subCat)}">Bulk</button>
</div>
<div class="collapse sub-cat-bulk-panel" id="${scId}-bulk">
<div class="p-2 border border-top-0 bg-light-subtle rounded-bottom mb-2">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-0">Status (all)</label>
<select class="form-select form-select-sm bulk-status" data-sc="${esc(subCat)}">
<option value="">— Select —</option>
${statusOpts.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join('')}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-0">Handled By (all)</label>
<select class="form-select form-select-sm bulk-handled" data-sc="${esc(subCat)}">
<option value="">— Select —</option>
${handledOpts.map((h) => `<option value="${esc(h)}">${esc(h)}</option>`).join('')}
</select>
</div>
<div class="col-md-3">
<label class="form-label small mb-0">Comment (all)</label>
<textarea class="form-control form-control-sm bulk-comment" data-sc="${esc(subCat)}" rows="1"></textarea>
</div>
<div class="col-md-3">
<label class="form-label small mb-0">Images (all)</label>
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm bulk-add-images-btn" data-sc="${esc(subCat)}">
<i class="bi bi-images me-1"></i>Add
</button>
<input type="file" class="bulk-image-input d-none" data-sc="${esc(subCat)}" accept="image/*" multiple />
<div class="bulk-images-preview d-flex flex-wrap gap-1" data-sc="${esc(subCat)}"></div>
</div>
</div>
<div class="col-md-1">
<button type="button" class="btn btn-primary btn-sm w-100 bulk-apply-btn" data-sc="${esc(subCat)}">Apply</button>
</div>
</div>
</div>
</div>`;
/* Individual records */
for (const rec of groupRecs) {
const rd = data.records[rec.id] || { status: '', handledBy: '', comment: '', images: [] };
const imgCount = (rd.images || []).length;
const imgRequired = rec.imageRequired ? '<span class="badge bg-warning text-dark ms-2">IMG REQUIRED</span>' : '';
const desc = getRecordDescription(rec);
const category = rec.category || '-';
const subCategory = rec.subCategory || '-';
const severity = rec.severity || '-';
const sevColor = getSevColor(rec.severityId);
const sevBadge = sevColor
? `<span class="badge" style="background:${sevColor};color:${colorContrast(sevColor)}">${esc(severity)}</span>`
: esc(severity);
html += `<div class="task-record-card" data-record-id="${rec.id}" data-sub-cat="${esc(subCat)}">
<div class="task-record-header">
<span class="task-record-sort badge bg-primary">#${rec.sort}</span>
<span class="task-record-desc fw-semibold">${esc(desc)}</span>
${imgRequired}
</div>
<div class="d-flex gap-3 mb-2 small text-muted">
<span><strong>Category:</strong> ${esc(category)}</span>
<span><strong>Sub:</strong> ${esc(subCategory)}</span>
<span><strong>Severity:</strong> ${sevBadge}</span>
</div>
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small fw-semibold">Status</label>
<select class="form-select form-select-sm rec-status" data-rec="${rec.id}">
<option value="">— Select —</option>
${statusOpts.map((s) => `<option value="${esc(s)}" ${rd.status === s ? 'selected' : ''}>${esc(s)}</option>`).join('')}
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Handled By</label>
<select class="form-select form-select-sm rec-handled" data-rec="${rec.id}">
<option value="">— Select —</option>
${handledOpts.map((h) => `<option value="${esc(h)}" ${rd.handledBy === h ? 'selected' : ''}>${esc(h)}</option>`).join('')}
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-semibold">Comment</label>
<textarea class="form-control form-control-sm rec-comment" data-rec="${rec.id}" rows="2">${esc(rd.comment || '')}</textarea>
</div>
<div class="col-12">
<label class="form-label small fw-semibold">Images ${imgCount ? `(${imgCount})` : ''}</label>
<div class="task-record-images d-flex flex-wrap gap-2 align-items-start" data-rec="${rec.id}">
${renderRecordImages(rd.images || [], rec.id)}
</div>
<div class="drop-zone border border-2 border-dashed rounded p-3 text-center text-muted mt-2" data-rec="${rec.id}">
<i class="bi bi-cloud-arrow-up fs-4 d-block mb-1"></i>
<small>Drag & drop images here or click to upload</small>
<input type="file" class="rec-image-input d-none" data-rec="${rec.id}" accept="image/*" multiple />
</div>
</div>
</div>
</div>`;
}
html += '</div>'; /* close sub-cat-group */
}
container.innerHTML = html;
/* Apply colour coding to status selects based on current value */
applyStatusColors(container);
/* Bind change events */
container.querySelectorAll('.rec-status').forEach((el) => el.addEventListener('change', onRecordFieldChange));
container.querySelectorAll('.rec-handled').forEach((el) => el.addEventListener('change', onRecordFieldChange));
container.querySelectorAll('.rec-comment').forEach((el) => el.addEventListener('input', onRecordFieldChange));
container.querySelectorAll('.rec-image-input').forEach((el) => el.addEventListener('change', onImageAdd));
container.querySelectorAll('[data-remove-img]').forEach((el) => el.addEventListener('click', onImageRemove));
container.querySelectorAll('[data-lightbox-img]').forEach((el) => el.addEventListener('click', openLightbox));
container.querySelectorAll('[data-load-server-img]').forEach((el) => el.addEventListener('click', onLoadServerImage));
/* Bind bulk image add buttons */
container.querySelectorAll('.bulk-add-images-btn').forEach((btn) => {
const sc = btn.dataset.sc;
const fileInput = btn.parentElement.querySelector('.bulk-image-input');
btn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) handleBulkImageAdd(sc, e.target.files);
e.target.value = '';
});
});
/* Bind bulk apply buttons */
container.querySelectorAll('.bulk-apply-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const sc = btn.dataset.sc;
const panel = btn.closest('.sub-cat-bulk-panel');
const bulkStatus = panel.querySelector('.bulk-status')?.value || '';
const bulkHandled = panel.querySelector('.bulk-handled')?.value || '';
const bulkComment = panel.querySelector('.bulk-comment')?.value || '';
const bulkImages = bulkImagesStore.get(sc) || [];
const recCards = container.querySelectorAll(`.task-record-card[data-sub-cat="${sc}"]`);
const taskData = getTaskData(userState.currentTaskId);
recCards.forEach((card) => {
const recId = Number(card.dataset.recordId);
if (!taskData.records[recId]) taskData.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
if (!taskData.records[recId].images) taskData.records[recId].images = [];
if (bulkStatus) { taskData.records[recId].status = bulkStatus; }
if (bulkHandled) { taskData.records[recId].handledBy = bulkHandled; }
if (bulkComment) { taskData.records[recId].comment = bulkComment; }
/* Append bulk images to each record */
if (bulkImages.length) {
for (const img of bulkImages) {
taskData.records[recId].images.push({ ...img });
}
}
});
/* Clear bulk images after applying */
bulkImagesStore.delete(sc);
markUnsaved();
safeSave();
renderTaskRecords(task, tpl, taskData);
});
});
/* Bind sub-cat header chevron toggle */
container.querySelectorAll('.sub-cat-header').forEach((hdr) => {
const target = hdr.nextElementSibling;
if (target) {
target.addEventListener('show.bs.collapse', () => hdr.querySelector('.sub-cat-chevron')?.classList.add('rotated'));
target.addEventListener('hide.bs.collapse', () => hdr.querySelector('.sub-cat-chevron')?.classList.remove('rotated'));
}
});
/* Bind drag & drop zones */
container.querySelectorAll('.drop-zone').forEach((zone) => {
const fileInput = zone.querySelector('.rec-image-input');
zone.addEventListener('click', () => fileInput.click());
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('border-primary', 'bg-light'); });
zone.addEventListener('dragleave', () => { zone.classList.remove('border-primary', 'bg-light'); });
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('border-primary', 'bg-light');
const files = e.dataTransfer.files;
if (files.length) handleFileDrop(zone.dataset.rec, files);
});
});
}
function renderRecordImages(images, recId) {
if (!images || !images.length) return '<span class="text-muted small">No images attached.</span>';
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
const locked = isTaskLocked(task);
return images.map((img, idx) => {
if (img.dataUrl) {
return `<span class="task-img-thumb">
<img src="${img.dataUrl}" alt="Attachment ${idx + 1}" data-lightbox-img data-rec="${recId}" data-img-idx="${idx}" style="cursor:pointer" />
<button type="button" class="btn-remove" data-remove-img="${idx}" data-rec="${recId}" ${locked ? 'style="display:none"' : ''}>×</button>
</span>`;
}
/* Image on server — show clickable placeholder that loads on click */
return `<span class="task-img-thumb">
<span class="badge bg-info text-dark d-inline-flex align-items-center gap-1" style="height:64px;padding:8px 12px;cursor:pointer" data-load-server-img data-rec="${recId}" data-img-idx="${idx}" title="Click to load from server"><i class="bi bi-cloud-download"></i>${esc(img.name || 'uploaded')}</span>
<button type="button" class="btn-remove" data-remove-img="${idx}" data-rec="${recId}" data-server-img="${esc(img.name || '')}" ${locked ? 'style="display:none"' : ''}>×</button>
</span>`;
}).join('');
}
/* ═══════════════════════════════════════════════════════════════════════════
* Record field change handlers
* ═══════════════════════════════════════════════════════════════════════════ */
function onRecordFieldChange(e) {
const recId = e.target.dataset.rec;
if (!recId || !userState.currentTaskId) return;
const data = getTaskData(userState.currentTaskId);
if (!data.records[recId]) data.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
if (e.target.classList.contains('rec-status')) {
data.records[recId].status = e.target.value;
const color = getStatColor(e.target.value);
e.target.style.borderLeft = color ? `4px solid ${color}` : '';
} else if (e.target.classList.contains('rec-handled')) data.records[recId].handledBy = e.target.value;
else if (e.target.classList.contains('rec-comment')) data.records[recId].comment = e.target.value;
data.visitDate = document.getElementById('visitDate').value;
safeSave();
markUnsaved();
}
async function handleFileDrop(recId, files) {
if (!recId || !userState.currentTaskId) return;
const data = getTaskData(userState.currentTaskId);
if (!data.records[recId]) data.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const dataUrl = await readFileAsDataUrl(file);
data.records[recId].images.push({ name: file.name, dataUrl, size: file.size });
}
safeSave();
markUnsaved();
refreshImagesForRecord(recId);
}
/**
* Handles images added to the bulk operation panel for a subcategory.
* Images are stored temporarily and applied to all records when "Apply" is clicked.
*/
async function handleBulkImageAdd(sc, files) {
if (!bulkImagesStore.has(sc)) bulkImagesStore.set(sc, []);
const images = bulkImagesStore.get(sc);
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const dataUrl = await readFileAsDataUrl(file);
images.push({ name: file.name, dataUrl, size: file.size });
}
renderBulkImagesPreview(sc);
}
/**
* Renders preview thumbnails for bulk images in a subcategory panel.
*/
function renderBulkImagesPreview(sc) {
const preview = document.querySelector(`.bulk-images-preview[data-sc="${sc}"]`);
if (!preview) return;
const images = bulkImagesStore.get(sc) || [];
if (!images.length) {
preview.innerHTML = '';
return;
}
preview.innerHTML = images.map((img, idx) => `
<span class="bulk-img-thumb position-relative" style="width:40px;height:40px;">
<img src="${img.dataUrl}" alt="${esc(img.name)}" style="width:40px;height:40px;object-fit:cover;border-radius:4px;" />
<button type="button" class="btn-close position-absolute top-0 end-0 bg-white rounded-circle"
style="font-size:0.5rem;padding:2px;" data-remove-bulk-img="${idx}" data-sc="${esc(sc)}"></button>
</span>
`).join('');
/* Bind remove buttons */
preview.querySelectorAll('[data-remove-bulk-img]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = Number(btn.dataset.removeBulkImg);
const subCat = btn.dataset.sc;
const imgs = bulkImagesStore.get(subCat);
if (imgs) {
imgs.splice(idx, 1);
renderBulkImagesPreview(subCat);
}
});
});
}
async function onImageAdd(e) {
const recId = e.target.dataset.rec;
if (!recId || !userState.currentTaskId) return;
const files = e.target.files;
if (!files.length) return;
const data = getTaskData(userState.currentTaskId);
if (!data.records[recId]) data.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const dataUrl = await readFileAsDataUrl(file);
data.records[recId].images.push({ name: file.name, dataUrl, size: file.size });
}
safeSave();
markUnsaved();
refreshImagesForRecord(recId);
e.target.value = '';
}
function onImageRemove(e) {
const btn = e.target.closest('[data-remove-img]');
if (!btn) return;
const idx = Number(btn.dataset.removeImg);
const recId = btn.dataset.rec;
if (!recId || !userState.currentTaskId) return;
const data = getTaskData(userState.currentTaskId);
if (data.records[recId]?.images) {
const img = data.records[recId].images[idx];
/* If this image is on the server, delete it there too */
if (img?.uploadedToServer && img.name && navigator.onLine) {
fetch(`/api/v1/reports/${userState.currentTaskId}/images/${recId}/${encodeURIComponent(img.name)}`, {
method: 'DELETE'
}).catch(() => {});
}
data.records[recId].images.splice(idx, 1);
safeSave();
markUnsaved();
refreshImagesForRecord(recId);
}
}
function refreshImagesForRecord(recId) {
const data = getTaskData(userState.currentTaskId);
const imgContainer = document.querySelector(`.task-record-images[data-rec="${recId}"]`);
if (imgContainer) {
imgContainer.innerHTML = renderRecordImages(data.records[recId]?.images || [], recId);
imgContainer.querySelectorAll('[data-remove-img]').forEach((el) => el.addEventListener('click', onImageRemove));
imgContainer.querySelectorAll('[data-lightbox-img]').forEach((el) => el.addEventListener('click', openLightbox));
imgContainer.querySelectorAll('[data-load-server-img]').forEach((el) => el.addEventListener('click', onLoadServerImage));
}
}
/**
* Loads a single image from the server when user clicks on a server placeholder badge.
* Fetches all images for the report, updates the specific record, and refreshes display.
*/
async function onLoadServerImage(e) {
const el = e.target.closest('[data-load-server-img]');
if (!el) return;
const recId = el.dataset.rec;
const imgIdx = Number(el.dataset.imgIdx);
const taskId = userState.currentTaskId;
if (!taskId || !recId || !navigator.onLine) {
showToast('Cannot load image while offline.', 'error');
return;
}
el.innerHTML = '<i class="bi bi-hourglass-split"></i> Loading...';
try {
const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } });
if (!resp.ok) { showToast('Failed to load images from server.', 'error'); return; }
const imagesByRecord = await resp.json();
const data = getTaskData(taskId);
const serverImages = imagesByRecord[recId];
if (data.records[recId]?.images && serverImages) {
/* Restore dataUrl directly from server response */
for (let idx = 0; idx < data.records[recId].images.length; idx++) {
const img = data.records[recId].images[idx];
if (img.uploadedToServer && !img.dataUrl) {
const srvImg = serverImages.find(s => s.name === img.name) || serverImages[idx];
if (srvImg?.dataUrl) {
data.records[recId].images[idx] = { ...img, dataUrl: srvImg.dataUrl, uploadedToServer: false };
}
}
}
safeSave();
refreshImagesForRecord(recId);
}
} catch (err) {
showToast('Could not load image: ' + err.message, 'error');
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Save as Draft / Final
* ═══════════════════════════════════════════════════════════════════════════ */
async function saveDraft() {
if (!userState.currentTaskId) return;
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
if (isTaskLocked(task)) { showToast('Task is finalized and cannot be edited.', 'error'); return; }
persistCurrentFormData();
/* Optimize images per policy (same as final, but without renaming) */
await optimizeImagesIfNeeded(task.id);
updateTaskStatus('draft');
markSaved();
uploadReport('draft');
showToast('Saved as draft.', 'success');
}
async function saveFinal() {
if (!userState.currentTaskId) return;
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
if (isTaskLocked(task)) { showToast('Task is already finalized.', 'error'); return; }
persistCurrentFormData();
/* Validate visit date range */
const tpl = task ? userState.clTemplates.find(t => t.id === task.templateId) : null;
const visitDateEl = document.getElementById('visitDate');
if (!validateVisitDate(visitDateEl, tpl)) {
showToast('Visit date is outside the allowed template range.', 'error');
return;
}
const errors = validateForFinal();
if (errors.length) {
showValidationErrors(errors);
showToast('Validation failed — see issues below.', 'error');
return;
}
/* Optimize images (resize per policy) and rename before uploading */
showToast('Processing images...', 'info');
await processImagesForFinal(task, tpl);
updateTaskStatus('final');
markSaved();
const uploadOk = await uploadReport('final');
if (uploadOk) {
/* Remove image dataUrls from IndexedDB to free space (uploaded to server) */
stripImageDataFromStorage(userState.currentTaskId);
showToast('Saved as final.', 'success');
} else {
showToast('Saved locally as final but upload failed — images kept locally.', 'warning');
}
document.getElementById('taskValidationPanel').style.display = 'none';
}
function persistCurrentFormData() {
const data = getTaskData(userState.currentTaskId);
data.visitDate = document.getElementById('visitDate').value;
/* Also read all current form values from DOM into data.records */
const container = document.getElementById('taskRecordsContainer');
if (container) {
container.querySelectorAll('.rec-status').forEach(el => {
const recId = el.dataset.rec;
if (!data.records[recId]) data.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
data.records[recId].status = el.value;
});
container.querySelectorAll('.rec-handled').forEach(el => {
const recId = el.dataset.rec;
if (data.records[recId]) data.records[recId].handledBy = el.value;
});
container.querySelectorAll('.rec-comment').forEach(el => {
const recId = el.dataset.rec;
if (data.records[recId]) data.records[recId].comment = el.value;
});
}
safeSave();
}
function updateTaskStatus(status) {
/* Update in local state */
const task = userState.tasks.find((t) => t.id === userState.currentTaskId);
if (task) {
task.status = status;
/* Persist status to server via admin tasks API */
const payload = { siteId: task.siteId, userId: task.userId, templateId: task.templateId, project: task.project || '', process: task.process || '', status };
fetch(`/api/v1/admin/tasks/${task.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload)
}).catch((err) => console.warn('Task status sync failed', err));
/* Update IndexedDB cache */
dbGet(STORE_CONFIG, 'admin_all').then((row) => {
const data = row?.value;
if (data) {
const idx = (data.tasks || []).findIndex(t => t.id === task.id);
if (idx >= 0) { data.tasks[idx].status = status; dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {}); }
}
}).catch(() => {});
renderTaskDetail();
renderSidebarTasks();
highlightSidebarTask(userState.currentTaskId);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Validation for "Save as Final"
* ═══════════════════════════════════════════════════════════════════════════ */
function validateForFinal() {
const errors = [];
const task = userState.tasks.find((t) => t.id === userState.currentTaskId);
if (!task) return ['Task not found.'];
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
const recordIds = tpl?.recordIds || [];
const records = recordIds.map((rid) => userState.clRecords.find((r) => r.id === rid)).filter(Boolean);
const data = getTaskData(task.id);
/* Build lookup from status value to its requirements */
const statusReqs = {};
(userState.templateSettings.statuses || []).forEach((s) => {
statusReqs[s.value] = { requireHandledBy: !!s.requireHandledBy, requireComment: !!s.requireComment };
});
for (const rec of records) {
const rd = data.records[rec.id] || {};
const label = `Record #${rec.sort}`;
if (!rd.status) {
errors.push(`${label}: Status is required.`);
continue;
}
const reqs = statusReqs[rd.status] || {};
if (reqs.requireHandledBy && !rd.handledBy) {
errors.push(`${label}: Handled By is required when Status is "${rd.status}".`);
}
if (reqs.requireComment && !rd.comment?.trim()) {
errors.push(`${label}: Comment is required when Status is "${rd.status}".`);
}
if ((reqs.requireHandledBy || reqs.requireComment) && rec.imageRequired && (!rd.images || rd.images.length === 0)) {
errors.push(`${label}: At least one image is required (status "${rd.status}" + image-required flag).`);
}
}
return errors;
}
function showValidationErrors(errors) {
const panel = document.getElementById('taskValidationPanel');
const list = document.getElementById('taskValidationList');
if (!panel || !list) return;
panel.style.display = '';
list.innerHTML = errors.map((e) => `<li>${esc(e)}</li>`).join('');
}
/* ═══════════════════════════════════════════════════════════════════════════
* Helpers
* ═══════════════════════════════════════════════════════════════════════════ */
async function logoutUser() {
try {
await fetch('/api/v1/auth/logout', { method: 'POST' });
} catch (e) { /* ignore */ }
window.location.href = '/';
}
function updateConnectionBadge() {
const badge = document.getElementById('connectionBadge');
if (!badge) return;
badge.textContent = navigator.onLine ? 'Online' : 'Offline';
badge.className = `badge ${navigator.onLine ? 'bg-success' : 'bg-warning text-dark'}`;
}
function showToast(message, tone) {
let toast = document.getElementById('userToast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'userToast';
toast.className = 'admin-toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.style.background = tone === 'success' ? '#198754' : tone === 'error' ? '#dc3545' : tone === 'info' ? '#0d6efd' : '#ffc107';
toast.style.color = tone === 'error' || tone === 'success' || tone === 'info' ? '#fff' : '#000';
toast.classList.add('admin-toast-visible');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.classList.remove('admin-toast-visible'); }, 3000);
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function esc(text) {
const d = document.createElement('div');
d.textContent = text ?? '';
return d.innerHTML;
}
/* Returns white or black depending on background luminance */
function colorContrast(hex) {
if (!hex || hex.length < 7) return '#000000';
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.5 ? '#000000' : '#ffffff';
}
function getSevColor(severityId) {
const sev = userState.templateSettings.severities.find(s => s.id === severityId);
return sev?.color || '';
}
function getStatColor(statusValue) {
const stat = userState.templateSettings.statuses.find(s => s.value === statusValue);
return stat?.color || '';
}
/* Applies a colored left border to each status select based on the selected value */
function applyStatusColors(container) {
container.querySelectorAll('.rec-status').forEach(sel => {
const color = getStatColor(sel.value);
sel.style.borderLeft = color ? `4px solid ${color}` : '';
});
}
function formatFileSizeUser(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`;
}
/* ═══════════════════════════════════════════════════════════════════════════
* Safe save — writes task data to IndexedDB
* ═══════════════════════════════════════════════════════════════════════════ */
function safeSave() {
const taskId = userState.currentTaskId;
const data = userState.taskData[taskId];
if (!taskId || !data) return;
/* Async write to IndexedDB — fire and forget with error handling */
saveOneTaskData(taskId, data).catch(err => {
console.error('IndexedDB save failed:', err);
showToast('Failed to save data to storage.', 'error');
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Save status indicator
* ═══════════════════════════════════════════════════════════════════════════ */
function markUnsaved() {
userState.unsavedChanges = true;
updateSaveIndicator();
}
function markSaved() {
userState.unsavedChanges = false;
updateSaveIndicator();
}
function updateSaveIndicator() {
let el = document.getElementById('saveStatusIndicator');
if (!el) {
el = document.createElement('span');
el.id = 'saveStatusIndicator';
el.className = 'badge ms-2';
const header = document.getElementById('taskDetailTitle');
if (header) header.parentNode.insertBefore(el, header.nextSibling);
else return;
}
if (userState.unsavedChanges) {
el.textContent = 'Unsaved changes';
el.className = 'badge bg-warning text-dark ms-2';
el.style.display = '';
} else {
el.textContent = 'All changes saved';
el.className = 'badge bg-success ms-2';
el.style.display = userState.currentTaskId ? '' : 'none';
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Visit date validation
* ═══════════════════════════════════════════════════════════════════════════ */
function validateVisitDate(inputEl, tpl) {
const val = inputEl?.value;
if (!val) return true; /* empty is allowed — final validation will require it if needed */
const dateVal = val;
if (tpl?.validFrom && dateVal < tpl.validFrom) {
inputEl.classList.add('is-invalid');
showToast(`Visit date must be on or after ${tpl.validFrom}`, 'error');
return false;
}
if (tpl?.validTill && dateVal > tpl.validTill) {
inputEl.classList.add('is-invalid');
showToast(`Visit date must be on or before ${tpl.validTill}`, 'error');
return false;
}
inputEl.classList.remove('is-invalid');
return true;
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image lightbox modal
* ═══════════════════════════════════════════════════════════════════════════ */
function ensureLightboxModal() {
if (document.getElementById('imageLightboxModal')) return;
const modal = document.createElement('div');
modal.id = 'imageLightboxModal';
modal.className = 'modal fade';
modal.tabIndex = -1;
modal.innerHTML = `<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content" style="resize:both;overflow:auto;">
<div class="modal-header">
<h6 class="modal-title" id="lightboxTitle">Image Preview</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center p-2">
<div class="mb-2">
<button type="button" class="btn btn-outline-secondary btn-sm me-1" id="lightboxZoomOut" title="Zoom out"><i class="bi bi-zoom-out"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm me-2" id="lightboxZoomIn" title="Zoom in"><i class="bi bi-zoom-in"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm me-1" id="lightboxRotateLeft" title="Rotate left"><i class="bi bi-arrow-counterclockwise"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="lightboxRotateRight" title="Rotate right"><i class="bi bi-arrow-clockwise"></i></button>
</div>
<div style="overflow:auto;max-height:72vh;">
<img id="lightboxImage" src="" alt="Full size image" style="max-width:100%;max-height:70vh;object-fit:contain;transition:transform 0.3s;" />
</div>
<div id="lightboxFileInfo" class="mt-2 text-muted small"></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('lightboxImage');
function applyUserTransform() { imgEl.style.transform = `rotate(${rotation}deg) scale(${scale})`; }
document.getElementById('lightboxRotateLeft').addEventListener('click', () => {
rotation = (rotation - 90) % 360;
applyUserTransform();
});
document.getElementById('lightboxRotateRight').addEventListener('click', () => {
rotation = (rotation + 90) % 360;
applyUserTransform();
});
document.getElementById('lightboxZoomIn').addEventListener('click', () => {
scale = Math.min(scale + 0.25, 5);
applyUserTransform();
});
document.getElementById('lightboxZoomOut').addEventListener('click', () => {
scale = Math.max(scale - 0.25, 0.25);
applyUserTransform();
});
modal.addEventListener('hidden.bs.modal', () => { rotation = 0; scale = 1; imgEl.style.transform = ''; });
}
function openLightbox(e) {
const img = e.target;
const modalEl = document.getElementById('imageLightboxModal');
if (!modalEl) return;
document.getElementById('lightboxImage').src = img.src;
document.getElementById('lightboxImage').style.transform = '';
document.getElementById('lightboxTitle').textContent = img.alt || 'Image Preview';
/* Show file name and size */
const recId = img.dataset.rec;
const idx = Number(img.dataset.imgIdx);
const data = getTaskData(userState.currentTaskId);
const imgData = data?.records?.[recId]?.images?.[idx];
const infoEl = document.getElementById('lightboxFileInfo');
if (imgData) {
const parts = [];
if (imgData.name) parts.push(`<strong>${esc(imgData.name)}</strong>`);
if (imgData.size) parts.push(formatFileSizeUser(imgData.size));
infoEl.innerHTML = parts.join(' &mdash; ');
} else {
infoEl.innerHTML = '';
}
const bsModal = bootstrap.Modal.getOrCreateInstance(modalEl);
bsModal.show();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Task locking (disable editing after final)
* ═══════════════════════════════════════════════════════════════════════════ */
function isTaskLocked(task) {
return task?.status === 'final';
}
function applyTaskLock(task) {
const locked = isTaskLocked(task);
const container = document.getElementById('taskRecordsContainer');
const visitDate = document.getElementById('visitDate');
const saveDraftBtn = document.getElementById('saveDraftBtn');
const saveFinalBtn = document.getElementById('saveFinalBtn');
if (visitDate) visitDate.disabled = locked;
if (saveDraftBtn) saveDraftBtn.disabled = locked;
if (saveFinalBtn) saveFinalBtn.disabled = locked;
const mobileSaveDraftBtn = document.getElementById('mobileSaveDraftBtn');
const mobileSaveFinalBtn = document.getElementById('mobileSaveFinalBtn');
if (mobileSaveDraftBtn) mobileSaveDraftBtn.disabled = locked;
if (mobileSaveFinalBtn) mobileSaveFinalBtn.disabled = locked;
if (container && locked) {
container.querySelectorAll('select, textarea, input').forEach(el => { el.disabled = true; });
container.querySelectorAll('.drop-zone').forEach(el => { el.style.display = 'none'; });
container.querySelectorAll('.btn-remove').forEach(el => { el.style.display = 'none'; });
}
/* Show locked banner */
let banner = document.getElementById('taskLockedBanner');
if (locked) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'taskLockedBanner';
banner.className = 'alert alert-info d-flex align-items-center gap-2 mb-3';
banner.innerHTML = '<i class="bi bi-lock-fill"></i><span>This task is finalized and locked for editing. An administrator can reopen it if changes are needed.</span>';
const form = document.getElementById('taskProcessingForm');
if (form) form.prepend(banner);
}
banner.style.display = '';
} else if (banner) {
banner.style.display = 'none';
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Strip images from IndexedDB after final save
* ═══════════════════════════════════════════════════════════════════════════ */
function stripImageDataFromStorage(taskId) {
const data = getTaskData(taskId);
if (!data.records) return;
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
if (rd.images && rd.images.length) {
/* Keep metadata (name, size) but remove the heavy dataUrl */
rd.images = rd.images.map(img => ({
name: img.name,
size: img.size,
uploadedToServer: true
/* dataUrl removed to save IndexedDB space */
}));
}
}
safeSave();
updateStorageIndicator();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Hydrate task data from server when IndexedDB has no local copy
* ═══════════════════════════════════════════════════════════════════════════ */
/*
* If the browser's IndexedDB has no record data for this task (cleared storage,
* first access on a new device, or fresh browser profile), fetch the last
* submitted report from the server and seed the local state so previously
* filled values are visible without the user having to re-enter them.
*
* Images are not re-embedded here — they are pointed to the server with
* uploadedToServer:true so that maybeDownloadImages (called next in the chain)
* can fetch the actual blobs.
*/
async function maybeHydrateFromServer(taskId) {
const id = Number(taskId);
/* Skip if the user has unsaved local changes — don't overwrite their in-progress work.
* unsavedChanges is set by markUnsaved() on every field edit and cleared by markSaved()
* after an explicit Save Draft / Save Final action. It resets to false on page reload,
* so opening the task on a fresh device (or a new tab) always fetches from the server. */
if (userState.unsavedChanges) return;
if (!navigator.onLine) return;
try {
const resp = await fetch(`/api/v1/reports/${id}`, { headers: { Accept: 'application/json' } });
if (!resp.ok) return; /* 404 = task has never been saved — empty form is correct */
const report = await resp.json();
if (!report?.answers?.records) return;
/* Rebuild records, keeping all field values but replacing image dataUrls with
uploadedToServer markers so the image-download step can restore them. */
const hydratedRecords = {};
for (const [recId, rd] of Object.entries(report.answers.records)) {
hydratedRecords[recId] = {
status: rd.status || '',
handledBy: rd.handledBy || '',
comment: rd.comment || '',
images: (rd.images || []).map(img => ({
name: img.name || '',
size: img.size || 0,
uploadedToServer: true
/* dataUrl intentionally omitted — fetched by maybeDownloadImages */
}))
};
}
userState.taskData[id] = {
visitDate: report.answers.visitDate || '',
records: hydratedRecords
};
/* Persist to IndexedDB so future visits within the same browser are instant. */
await saveOneTaskData(id, userState.taskData[id]);
} catch (err) {
console.warn('Could not hydrate task data from server:', err.message);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Download images from server when task is reopened
* ═══════════════════════════════════════════════════════════════════════════ */
async function maybeDownloadImages(taskId) {
/* Normalize to number — callers may pass a string from a dataset attribute. */
const id = Number(taskId);
const task = userState.tasks.find(t => t.id === id);
if (!task) return;
const data = getTaskData(id);
if (!data.records) return;
/* Check if any images have uploadedToServer flag but no dataUrl */
let needsDownload = false;
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
if (rd.images?.some(img => img.uploadedToServer && !img.dataUrl)) {
needsDownload = true;
break;
}
}
if (!needsDownload || !navigator.onLine) return;
/* Fetch images (as dataUrls) from the server */
try {
const resp = await fetch(`/api/v1/reports/${id}/images`, {
headers: { Accept: 'application/json' }
});
if (!resp.ok) return;
const imagesByRecord = await resp.json();
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
const serverImages = imagesByRecord[recId];
if (rd.images && serverImages) {
/* Restore dataUrl from server response */
for (let idx = 0; idx < rd.images.length; idx++) {
const img = rd.images[idx];
if (img.uploadedToServer && !img.dataUrl) {
const srvImg = serverImages.find(s => s.name === img.name) || serverImages[idx];
if (srvImg?.dataUrl) {
rd.images[idx] = { ...img, dataUrl: srvImg.dataUrl, uploadedToServer: false };
}
}
}
}
}
safeSave();
} catch (err) {
console.warn('Could not download images from server:', err.message);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Storage utilization indicator (IndexedDB via StorageManager API)
* ═══════════════════════════════════════════════════════════════════════════ */
function updateStorageIndicator() {
let el = document.getElementById('storageIndicator');
if (!el) {
/* Create in the sidebar */
const sidebar = document.querySelector('.sidebar-bs .p-3.border-bottom');
if (!sidebar) return;
el = document.createElement('div');
el.id = 'storageIndicator';
el.className = 'mt-2';
sidebar.appendChild(el);
}
getStorageEstimate(userState.taskData).then(({ usedMB, quotaMB, pct }) => {
const barColor = pct > 80 ? 'bg-danger' : pct > 50 ? 'bg-warning' : 'bg-success';
el.innerHTML = `<small class="text-muted d-block mb-1"><i class="bi bi-database me-1"></i>Storage: ${usedMB} MB / ${quotaMB} MB (${pct}%)</small>
<div class="progress" style="height:5px"><div class="progress-bar ${barColor}" style="width:${pct}%"></div></div>`;
}).catch(() => {
el.innerHTML = '<small class="text-muted"><i class="bi bi-database me-1"></i>Storage: estimate unavailable</small>';
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Upload report to database
* ═══════════════════════════════════════════════════════════════════════════ */
async function uploadReport(status) {
if (!navigator.onLine) return false; /* Skip upload when offline */
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
if (!task) return false;
const tpl = userState.clTemplates.find(t => t.id === task.templateId);
const data = getTaskData(task.id);
const payload = {
id: String(task.id),
reportNumber: `RPT-${String(task.id).padStart(8, '0').slice(0, 8).toUpperCase()}`,
templateCode: tpl?.name || 'unknown',
templateVersion: Number(tpl?.version) || 1,
status: status,
answers: {
taskId: task.id,
siteId: task.siteId,
userId: task.userId,
project: task.project,
process: task.process,
visitDate: data.visitDate,
records: data.records
}
};
try {
const resp = await fetch('/api/v1/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload)
});
if (!resp.ok) {
console.warn(`Report upload returned ${resp.status}`);
return false;
}
return true;
} catch (err) {
console.warn('Report upload failed:', err.message);
return false;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image rules (fetch from server)
* ═══════════════════════════════════════════════════════════════════════════ */
async function fetchImageRules() {
if (!navigator.onLine) return;
try {
const resp = await fetch('/api/v1/config/image-rules', { headers: { Accept: 'application/json' } });
if (resp.ok) {
userState.imageRules = await resp.json();
}
} catch (err) {
console.warn('Could not fetch image rules:', err.message);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image optimization (shared between draft and final save).
* Resizes/optimizes images per policy without renaming.
* ═══════════════════════════════════════════════════════════════════════════ */
async function optimizeImagesIfNeeded(taskId) {
const data = getTaskData(taskId);
if (!data.records) return;
const rules = userState.imageRules;
const shouldOptimize = rules && (rules.oversizeBehavior === 'auto_optimize' || rules.oversizeBehavior === 'warn_then_optimize');
if (!shouldOptimize) return;
let changed = false;
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
if (!rd.images?.length) continue;
for (let i = 0; i < rd.images.length; i++) {
const img = rd.images[i];
if (!img.dataUrl) continue;
if (needsOptimization(img, rules)) {
/* Extract EXIF before optimizing (resize strips EXIF) */
if (!img.exif) img.exif = parseExifFromDataUrl(img.dataUrl) || null;
try {
const file = dataUrlToFile(img.dataUrl, img.name || 'image.jpg');
const result = await optimizeImage(file, rules);
img.dataUrl = await blobToDataUrl(result.blob);
img.size = result.blob.size;
img.width = result.width;
img.height = result.height;
changed = true;
} catch (err) {
console.warn(`Image optimization failed for ${img.name}:`, err.message);
}
} else if (!img.width || !img.height) {
const dims = await getImageDimensions(img.dataUrl);
img.width = dims.width;
img.height = dims.height;
changed = true;
}
}
}
if (changed) safeSave();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image processing on final save:
* 1. Resize/optimize per image policy (if oversizeBehavior is auto_optimize)
* 2. Extract EXIF metadata before resize
* 3. Rename files: SiteCode_Project_Process_Date_Sort_ImageNumber
* ═══════════════════════════════════════════════════════════════════════════ */
async function processImagesForFinal(task, tpl) {
const data = getTaskData(task.id);
if (!data.records) return;
const site = userState.sites.find(s => s.id === task.siteId);
const siteCode = sanitizeFileName(site?.siteCode || 'SITE');
const project = sanitizeFileName(task.project || 'PROJECT');
const process = sanitizeFileName(task.process || 'PROCESS');
const visitDate = (data.visitDate || new Date().toISOString().slice(0, 10)).replace(/-/g, '');
const recordIds = tpl?.recordIds || [];
const records = recordIds.map(rid => userState.clRecords.find(r => r.id === rid)).filter(Boolean).sort((a, b) => a.sort - b.sort);
const sortMap = {};
records.forEach(r => { sortMap[r.id] = r.sort; });
const rules = userState.imageRules;
const shouldOptimize = rules && (rules.oversizeBehavior === 'auto_optimize' || rules.oversizeBehavior === 'warn_then_optimize');
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
if (!rd.images?.length) continue;
const sort = sortMap[recId] != null ? String(sortMap[recId]).padStart(3, '0') : '000';
for (let i = 0; i < rd.images.length; i++) {
const img = rd.images[i];
if (!img.dataUrl) continue;
/* 1. Extract EXIF before any processing (resize strips EXIF) */
img.exif = parseExifFromDataUrl(img.dataUrl) || null;
/* 2. Optimize/resize if policy says so */
if (shouldOptimize && needsOptimization(img, rules)) {
try {
const file = dataUrlToFile(img.dataUrl, img.name || 'image.jpg');
const result = await optimizeImage(file, rules);
/* Convert optimized blob back to dataUrl */
img.dataUrl = await blobToDataUrl(result.blob);
img.size = result.blob.size;
img.width = result.width;
img.height = result.height;
} catch (err) {
console.warn(`Image optimization failed for ${img.name}:`, err.message);
}
} else {
/* Still capture dimensions if not optimizing */
if (!img.width || !img.height) {
const dims = await getImageDimensions(img.dataUrl);
img.width = dims.width;
img.height = dims.height;
}
}
/* 3. Rename file: SiteCode_Project_Process_Date_Sort_ImageNumber */
const imgNum = String(i + 1).padStart(2, '0');
const ext = getExtensionFromDataUrl(img.dataUrl) || getExtension(img.name) || 'jpg';
img.name = `${siteCode}_${project}_${process}_${visitDate}_${sort}_${imgNum}.${ext}`;
}
}
/* Persist to IndexedDB (updated images with new names, sizes, exif) */
safeSave();
}
function needsOptimization(img, rules) {
/* Check if image exceeds file size or dimension limits */
if (rules.maxFileSizeBytes && img.size > rules.maxFileSizeBytes) return true;
if (img.width && rules.maxWidthPx && img.width > rules.maxWidthPx) return true;
if (img.height && rules.maxHeightPx && img.height > rules.maxHeightPx) return true;
/* If we don't know dimensions, attempt optimization to be safe */
if (!img.width && !img.height) return true;
return false;
}
function dataUrlToFile(dataUrl, fileName) {
const [header, base64] = dataUrl.split(',');
const mime = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg';
const binary = atob(base64);
const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return new File([array], fileName, { type: mime });
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function getImageDimensions(dataUrl) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => resolve({ width: 0, height: 0 });
img.src = dataUrl;
});
}
function sanitizeFileName(str) {
return str.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
}
function getExtensionFromDataUrl(dataUrl) {
const mime = dataUrl.match(/data:image\/([^;]+)/)?.[1];
if (mime === 'jpeg' || mime === 'jpg') return 'jpg';
if (mime === 'png') return 'png';
if (mime === 'webp') return 'webp';
return mime || null;
}
function getExtension(filename) {
if (!filename) return null;
const parts = filename.split('.');
return parts.length > 1 ? parts.pop().toLowerCase() : null;
}