1703 lines
75 KiB
JavaScript
1703 lines
75 KiB
JavaScript
/*
|
||
* 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,
|
||
currentTaskId: null,
|
||
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 || [];
|
||
} 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 || [];
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* Initialization
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
export async function initUser() {
|
||
userState.language = localStorage.getItem('user_language') || 'EN';
|
||
await openUserDB();
|
||
/* 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();
|
||
} finally {
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Sync';
|
||
}
|
||
}
|
||
}
|
||
|
||
function filterTasksByUser() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const userId = params.get('userId');
|
||
if (userId) {
|
||
const uid = Number(userId);
|
||
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('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');
|
||
}
|
||
|
||
function closeSettingsView() {
|
||
hideAllViews();
|
||
if (userState.currentTaskId) {
|
||
document.getElementById('taskDetailView').classList.add('workspace-view-active');
|
||
} else {
|
||
document.getElementById('taskListView').classList.add('workspace-view-active');
|
||
}
|
||
}
|
||
|
||
async function showListView() {
|
||
userState.currentTaskId = null;
|
||
hideAllViews();
|
||
document.getElementById('taskListView').classList.add('workspace-view-active');
|
||
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');
|
||
/* 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;
|
||
}
|
||
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('');
|
||
container.innerHTML = `<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>`;
|
||
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));
|
||
});
|
||
});
|
||
}
|
||
|
||
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 || '-';
|
||
|
||
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> ${esc(severity)}</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;
|
||
|
||
/* 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;
|
||
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
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
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;
|
||
}
|
||
|
||
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(' — ');
|
||
} 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;
|
||
|
||
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 IndexedDB already has record data for this task. */
|
||
const existing = userState.taskData[id];
|
||
if (existing && Object.keys(existing.records || {}).length > 0) 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;
|
||
}
|