/* * 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 = 'Syncing…'; } try { await loadFromServer(); filterTasksByUser(); renderTaskListView(); renderSidebarTasks(); } finally { if (btn) { btn.disabled = false; btn.innerHTML = '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 = '
No tasks assigned

Tasks will appear here once an administrator assigns them to you.

'; 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 ` ${esc(site?.siteCode || '-')} ${esc(tpl?.name || '-')} ${esc(task.project || '-')} ${esc(task.process || '-')} ${esc(task.status || 'pending')} `; }).join(''); container.innerHTML = `${rows}
SiteTemplateProjectProcessStatus
`; 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 = '

No tasks.

'; 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 ``; }).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 = ``; categories.forEach(cat => { html += ``; }); 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 = '

No records match the current filter.

'; 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 += `
`; /* 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 ? 'IMG REQUIRED' : ''; const desc = getRecordDescription(rec); const category = rec.category || '-'; const subCategory = rec.subCategory || '-'; const severity = rec.severity || '-'; html += `
#${rec.sort} ${esc(desc)} ${imgRequired}
Category: ${esc(category)} Sub: ${esc(subCategory)} Severity: ${esc(severity)}
${renderRecordImages(rd.images || [], rec.id)}
Drag & drop images here or click to upload
`; } html += '
'; /* 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 'No images attached.'; const task = userState.tasks.find(t => t.id === userState.currentTaskId); const locked = isTaskLocked(task); return images.map((img, idx) => { if (img.dataUrl) { return ` Attachment ${idx + 1} `; } /* Image on server — show clickable placeholder that loads on click */ return ` ${esc(img.name || 'uploaded')} `; }).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) => ` ${esc(img.name)} `).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 = ' 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) => `
  • ${esc(e)}
  • `).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 = ``; 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(`${esc(imgData.name)}`); 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 = 'This task is finalized and locked for editing. An administrator can reopen it if changes are needed.'; 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 = `Storage: ${usedMB} MB / ${quotaMB} MB (${pct}%)
    `; }).catch(() => { el.innerHTML = 'Storage: estimate unavailable'; }); } /* ═══════════════════════════════════════════════════════════════════════════ * 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; }