/* * Check List PoC — Main entry point (orchestrator). * * This file wires together the split ES modules, binds DOM events, and manages * the application lifecycle (init, sync, autosave, etc.). Business logic for * rendering, validation, database, image optimization, and exports lives in * dedicated modules under /js/. * * Architecture improvements implemented here: * - A1: monolithic app.js split into focused ES modules * - A2: single-request batch template fetch via ?include=definitions * - A3: all API calls go through /api/v1/ (versioned endpoints) * - A4: per-resource resilient sync (each resource saved independently) * - A5: multi-store IndexedDB transactions for atomic deletes * - A6: stale template cleanup after sync * - A7: validation extracted to shared module (js/validation.js) * - P2: debounced form re-render after field changes * - P6: dirty-flag autosave — skips write when no changes occurred * - F1: report submission to server * - F4: report search & status filter */ import { state, elements, getCurrentReport, getTemplateRecord } from './js/state.js'; import { STORE_TEMPLATES, STORE_LOOKUPS, STORE_CONFIG, STORE_REPORTS, STORE_ATTACHMENTS, STORE_SETTINGS, DEFAULT_AUTOSAVE_SECONDS, RENDER_DEBOUNCE_MS } from './js/constants.js'; import { openDatabase, dbGetAll, dbGet, dbPut, dbDelete, dbGetAllByIndex, dbTransaction, saveSetting, loadSetting } from './js/db.js'; import { fetchJson, registerServiceWorker } from './js/api.js'; import { optimizeImage } from './js/images.js'; import { validateImageRulesPayload } from './js/validation.js'; import { exportReportCSV, exportReportAttachments } from './js/export.js'; import { render, renderReportList, renderCurrentReport, renderMeta, renderValidation, renderImagePolicy, renderAdminImageRules, renderTemplateSummary, populateAdminImageRulesForm, updateConnectionBadge, updateSaveBadge } from './js/renderer.js'; import { makeTemplateKey, deriveTemplateCatalog, generateReportNumber, buildGeneratedFilename, formatTime, debounce } from './js/utils.js'; import { t } from './js/i18n.js'; /* ── Initialization ─────────────────────────────────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { void init(); }); async function init() { /* * Initialization restores local state first so the app remains useful offline. * Only after the cache is available do we try to refresh from the server. That * ordering is intentional because a blank UI during a failed sync would defeat * the main offline-first goal of the PoC. */ cacheElements(); bindEvents(); state.db = await openDatabase(); await hydrateFromLocalCache(); registerServiceWorker(); startAutosaveLoop(); updateConnectionBadge(); if (navigator.onLine) { await syncTemplatesAndConfig(); } render(fieldCallbacks); } /* ── DOM caching ────────────────────────────────────────────────────────── */ function cacheElements() { elements.connectionBadge = document.querySelector('#connectionBadge'); elements.saveBadge = document.querySelector('#saveBadge'); elements.syncTemplatesButton = document.querySelector('#syncTemplatesButton'); elements.templateSelect = document.querySelector('#templateSelect'); elements.createReportButton = document.querySelector('#createReportButton'); elements.userAreaLink = document.querySelector('#userAreaLink'); elements.adminAreaLink = document.querySelector('#adminAreaLink'); elements.reportList = document.querySelector('#reportList'); elements.reportListItemTemplate = document.querySelector('#reportListItemTemplate'); elements.reportCount = document.querySelector('#reportCount'); /* User workspace elements — null on the admin page. */ elements.heroTitle = document.querySelector('#heroTitle'); elements.heroSubtitle = document.querySelector('#heroSubtitle'); elements.reportStatusSelect = document.querySelector('#reportStatusSelect'); elements.deleteReportButton = document.querySelector('#deleteReportButton'); elements.submitReportButton = document.querySelector('#submitReportButton'); elements.exportReportButton = document.querySelector('#exportReportButton'); elements.summaryTemplate = document.querySelector('#summaryTemplate'); elements.summaryVersion = document.querySelector('#summaryVersion'); elements.validationHeadline = document.querySelector('#validationHeadline'); elements.validationDetail = document.querySelector('#validationDetail'); elements.syncHeadline = document.querySelector('#syncHeadline'); elements.syncDetail = document.querySelector('#syncDetail'); elements.reportForm = document.querySelector('#reportForm'); elements.editorHint = document.querySelector('#editorHint'); elements.reportMeta = document.querySelector('#reportMeta'); elements.validationList = document.querySelector('#validationList'); elements.imagePolicyText = document.querySelector('#imagePolicyText'); /* Admin workspace elements — null on the user page. */ elements.adminSyncState = document.querySelector('#adminSyncState'); elements.adminImageRulesForm = document.querySelector('#adminImageRulesForm'); elements.saveImageRulesButton = document.querySelector('#saveImageRulesButton'); elements.resetImageRulesButton = document.querySelector('#resetImageRulesButton'); elements.adminPolicyName = document.querySelector('#adminPolicyName'); elements.adminAllowedMimeTypes = document.querySelector('#adminAllowedMimeTypes'); elements.adminMaxFileSizeMb = document.querySelector('#adminMaxFileSizeMb'); elements.adminMaxAttachmentsPerField = document.querySelector('#adminMaxAttachmentsPerField'); elements.adminMaxWidthPx = document.querySelector('#adminMaxWidthPx'); elements.adminMaxHeightPx = document.querySelector('#adminMaxHeightPx'); elements.adminJpegQuality = document.querySelector('#adminJpegQuality'); elements.adminOversizeBehavior = document.querySelector('#adminOversizeBehavior'); elements.adminPolicyCode = document.querySelector('#adminPolicyCode'); elements.adminPolicyMimeTypes = document.querySelector('#adminPolicyMimeTypes'); elements.adminPolicyOptimization = document.querySelector('#adminPolicyOptimization'); elements.adminPolicyLimits = document.querySelector('#adminPolicyLimits'); /* F4 — search and filter controls (user page only). */ elements.reportSearchInput = document.querySelector('#reportSearchInput'); elements.reportFilterSelect = document.querySelector('#reportFilterSelect'); } /* ── Event binding ──────────────────────────────────────────────────────── */ function bindEvents() { elements.syncTemplatesButton.addEventListener('click', () => { void syncTemplatesAndConfig(); }); elements.templateSelect.addEventListener('change', (event) => { state.selectedTemplateCode = event.target.value || null; void saveSetting('selectedTemplateCode', state.selectedTemplateCode); if (elements.reportForm) { renderTemplateSummary(); } }); if (elements.createReportButton) { elements.createReportButton.addEventListener('click', () => { void createReport(); }); } /* User workspace events — only bound when the report editor exists. */ if (elements.reportStatusSelect) { elements.reportStatusSelect.addEventListener('change', (event) => { const report = getCurrentReport(); if (!report) { return; } report.status = event.target.value; markDirty(t('statusChanged')); renderReportList(); renderValidation(); }); } if (elements.deleteReportButton) { elements.deleteReportButton.addEventListener('click', () => { void deleteCurrentReport(); }); } /* F1 — submit report to server */ if (elements.submitReportButton) { elements.submitReportButton.addEventListener('click', () => { void submitCurrentReport(); }); } /* F2 — CSV export */ if (elements.exportReportButton) { elements.exportReportButton.addEventListener('click', () => { void handleExport(); }); } /* F4 — report search */ if (elements.reportSearchInput) { elements.reportSearchInput.addEventListener('input', debounce((event) => { state.reportSearchQuery = event.target.value.trim(); renderReportList(); }, 250)); } /* F4 — report status filter */ if (elements.reportFilterSelect) { elements.reportFilterSelect.addEventListener('change', (event) => { state.reportFilterStatus = event.target.value; renderReportList(); }); } /* Admin workspace events — only bound when the admin form exists. */ if (elements.adminImageRulesForm) { elements.adminImageRulesForm.addEventListener('submit', (event) => { event.preventDefault(); void saveImageRules(); }); } if (elements.resetImageRulesButton) { elements.resetImageRulesButton.addEventListener('click', () => { populateAdminImageRulesForm(state.imageRules); }); } /* Use event delegation on the report list to avoid per-item listeners. */ if (elements.reportList) { elements.reportList.addEventListener('click', (event) => { const button = event.target.closest('.report-list-item'); if (button?.dataset.reportId) { void openReport(button.dataset.reportId); } }); } window.addEventListener('online', () => { updateConnectionBadge(); void syncTemplatesAndConfig(); }); window.addEventListener('offline', () => { updateConnectionBadge(); }); } /* ── Local cache hydration ──────────────────────────────────────────────── */ async function hydrateFromLocalCache() { const [templateRows, lookupRows, configRows, reports, selectedTemplateCode, lastReportId, lastSyncAt] = await Promise.all([ dbGetAll(STORE_TEMPLATES), dbGetAll(STORE_LOOKUPS), dbGetAll(STORE_CONFIG), dbGetAll(STORE_REPORTS), loadSetting('selectedTemplateCode'), loadSetting('currentReportId'), loadSetting('lastSyncAt') ]); state.templateDefinitions = new Map( templateRows.map((item) => [makeTemplateKey(item.code, item.version), item]) ); state.templatesCatalog = deriveTemplateCatalog(templateRows); state.lookups = new Map(lookupRows.map((item) => [item.code, item])); state.appConfig = new Map(configRows.map((item) => [item.key, item.value])); state.imageRules = state.appConfig.get('imageRules') || null; state.reports = reports.sort((left, right) => { return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(); }); state.selectedTemplateCode = selectedTemplateCode || state.templatesCatalog[0]?.code || null; state.lastSyncAt = lastSyncAt || null; if (lastReportId && state.reports.some((report) => report.id === lastReportId)) { await openReport(lastReportId); } } /* ── Sync (A2 batch, A4 resilient, A6 stale cleanup) ───────────────────── */ async function syncTemplatesAndConfig() { if (!navigator.onLine) { updateSaveBadge(t('offlineMode'), 'warning'); return; } updateSaveBadge(t('syncingTemplates'), 'neutral'); /* * A4 — Each resource type is fetched and persisted independently. If one * resource fails (e.g. lookups), the others still save successfully so the * local cache stays as fresh as possible. */ const errors = []; /* A2 — Batch template fetch: single request returns all active definitions. */ try { const templatesResponse = await fetchJson('/templates?include=definitions'); const templateRecords = templatesResponse.items.map((item) => ({ cacheKey: makeTemplateKey(item.code, item.version), code: item.code, name: item.name, description: item.description, version: item.version, publishedAt: item.publishedAt, definition: item.definition, isActive: true })); await Promise.all(templateRecords.map((item) => dbPut(STORE_TEMPLATES, item))); /* A6 — Remove stale templates no longer in the server's active set. */ const activeKeys = new Set(templateRecords.map((r) => r.cacheKey)); const cachedTemplates = await dbGetAll(STORE_TEMPLATES); for (const cached of cachedTemplates) { if (cached.isActive && !activeKeys.has(cached.cacheKey)) { await dbDelete(STORE_TEMPLATES, cached.cacheKey); } } const freshTemplateRows = await dbGetAll(STORE_TEMPLATES); state.templateDefinitions = new Map( freshTemplateRows.map((item) => [makeTemplateKey(item.code, item.version), item]) ); state.templatesCatalog = templatesResponse.items.map((item) => ({ code: item.code, name: item.name, description: item.description, activeVersion: item.version, publishedAt: item.publishedAt })); } catch (error) { console.error('Template sync failed', error); errors.push('templates'); } try { const lookupsResponse = await fetchJson('/lookups'); await Promise.all(lookupsResponse.items.map((item) => dbPut(STORE_LOOKUPS, item))); state.lookups = new Map(lookupsResponse.items.map((item) => [item.code, item])); } catch (error) { console.error('Lookup sync failed', error); errors.push('lookups'); } try { const [imageRules, exportProfile, appConfigResponse] = await Promise.all([ fetchJson('/config/image-rules'), fetchJson('/config/export'), fetchJson('/config/app-config') ]); await Promise.all([ dbPut(STORE_CONFIG, { key: 'imageRules', value: imageRules }), dbPut(STORE_CONFIG, { key: 'exportProfile', value: exportProfile }), ...appConfigResponse.items.map((item) => dbPut(STORE_CONFIG, { key: item.key, value: item.value })) ]); state.imageRules = imageRules; state.appConfig = new Map([ ...appConfigResponse.items.map((item) => [item.key, item.value]), ['imageRules', imageRules], ['exportProfile', exportProfile] ]); } catch (error) { console.error('Config sync failed', error); errors.push('config'); } state.lastSyncAt = new Date().toISOString(); await saveSetting('lastSyncAt', state.lastSyncAt); if (!state.selectedTemplateCode && state.templatesCatalog.length) { state.selectedTemplateCode = state.templatesCatalog[0].code; await saveSetting('selectedTemplateCode', state.selectedTemplateCode); } startAutosaveLoop(); if (errors.length) { updateSaveBadge(`Partial sync (${errors.join(', ')} failed)`, 'warning'); } else { updateSaveBadge(t('templatesSynced'), 'success'); } render(fieldCallbacks); } /* ── Report CRUD ────────────────────────────────────────────────────────── */ async function createReport() { const templateCode = state.selectedTemplateCode || elements.templateSelect.value; if (!templateCode) { updateSaveBadge(t('selectTemplate'), 'warning'); return; } const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode); const versionNumber = catalogEntry?.activeVersion; const template = getTemplateRecord(templateCode, versionNumber); if (!template) { updateSaveBadge(t('templateNotAvailable'), 'error'); return; } const report = buildNewReport(template); state.reports.unshift(report); state.currentReportId = report.id; state.currentAttachments = []; await Promise.all([ dbPut(STORE_REPORTS, report), saveSetting('currentReportId', report.id) ]); updateSaveBadge(t('newReportCreated'), 'success'); render(fieldCallbacks); } function buildNewReport(template) { const now = new Date().toISOString(); const reportNumber = generateReportNumber(); const answers = {}; for (const section of template.definition.sections || []) { for (const field of section.fields || []) { if (field.defaultValue !== undefined) { answers[field.id] = field.defaultValue; } else if (field.id === 'reportNumber') { answers[field.id] = reportNumber; } else if (field.type === 'date') { answers[field.id] = now.slice(0, 10); } else if (field.type === 'checkbox') { answers[field.id] = false; } else { answers[field.id] = ''; } } } return { id: crypto.randomUUID(), reportNumber, title: `${template.name} ${new Date().toLocaleDateString()}`, templateCode: template.code, templateVersion: template.version, status: 'draft', answers, createdAt: now, updatedAt: now }; } async function openReport(reportId) { const report = state.reports.find((item) => item.id === reportId); if (!report) { return; } await flushDirtyReport(); state.currentReportId = reportId; state.currentAttachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', reportId); state.selectedTemplateCode = report.templateCode; await Promise.all([ saveSetting('currentReportId', reportId), saveSetting('selectedTemplateCode', report.templateCode) ]); render(fieldCallbacks); } async function deleteCurrentReport() { const report = getCurrentReport(); if (!report) { return; } const confirmed = window.confirm(t('deleteReportConfirm', report.reportNumber)); if (!confirmed) { return; } /* A5 — Atomic multi-store delete: report + all its attachments in one tx. */ const attachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', report.id); await dbTransaction([STORE_REPORTS, STORE_ATTACHMENTS], 'readwrite', (getStore) => { getStore(STORE_REPORTS).delete(report.id); for (const attachment of attachments) { getStore(STORE_ATTACHMENTS).delete(attachment.id); } }); state.reports = state.reports.filter((item) => item.id !== report.id); state.currentReportId = state.reports[0]?.id || null; state.currentAttachments = state.currentReportId ? await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', state.currentReportId) : []; await saveSetting('currentReportId', state.currentReportId); updateSaveBadge(t('reportDeleted'), 'success'); render(fieldCallbacks); } /* ── Report submission to server (F1) ───────────────────────────────────── */ async function submitCurrentReport() { const report = getCurrentReport(); if (!report) { return; } if (!navigator.onLine) { updateSaveBadge(t('goOnlineToSubmit'), 'warning'); return; } await flushDirtyReport(); updateSaveBadge(t('submitting'), 'neutral'); try { await fetchJson('/reports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: report.id, reportNumber: report.reportNumber, templateCode: report.templateCode, templateVersion: report.templateVersion, status: report.status, answers: report.answers }) }); report.status = 'exported'; report.updatedAt = new Date().toISOString(); await dbPut(STORE_REPORTS, report); updateSaveBadge(t('submitted'), 'success'); renderReportList(); renderCurrentReport(fieldCallbacks); } catch (error) { console.error(error); updateSaveBadge(t('submitFailed'), 'error'); } } /* ── Export (F2) ────────────────────────────────────────────────────────── */ async function handleExport() { const report = getCurrentReport(); if (!report) { updateSaveBadge(t('noReportToExport'), 'warning'); return; } updateSaveBadge(t('exportStarted'), 'neutral'); try { await exportReportCSV(); await exportReportAttachments(); updateSaveBadge(t('exportComplete'), 'success'); } catch (error) { console.error(error); updateSaveBadge(t('exportFailed'), 'error'); } } /* ── Attachment operations ──────────────────────────────────────────────── */ async function attachFiles(field, report, files) { if (!files.length) { return; } const currentFieldAttachments = state.currentAttachments.filter((item) => item.fieldId === field.id); const maxAttachments = field.maxAttachments || state.imageRules?.maxAttachmentsPerField || 5; if (currentFieldAttachments.length + files.length > maxAttachments) { updateSaveBadge(t('maxAttachments', maxAttachments), 'warning'); return; } let addedCount = 0; for (let index = 0; index < files.length; index += 1) { const file = files[index]; try { const optimized = await optimizeImage(file, state.imageRules); const sequence = currentFieldAttachments.length + addedCount + 1; const generatedFilename = buildGeneratedFilename(report, field, sequence, optimized.extension); const attachment = { id: crypto.randomUUID(), reportId: report.id, fieldId: field.id, originalFilename: file.name, generatedFilename, mimeType: optimized.blob.type, sizeBytes: optimized.blob.size, width: optimized.width, height: optimized.height, blob: optimized.blob, createdAt: new Date().toISOString() }; state.currentAttachments.push(attachment); await dbPut(STORE_ATTACHMENTS, attachment); addedCount += 1; } catch (error) { console.error(error); updateSaveBadge(t('imageSkipped', file.name), 'error'); } } if (addedCount === 0) { return; } report.updatedAt = new Date().toISOString(); markDirty(t('imagesUpdated')); renderCurrentReport(fieldCallbacks); } async function removeAttachment(attachmentId) { const report = getCurrentReport(); if (!report) { return; } await dbDelete(STORE_ATTACHMENTS, attachmentId); state.currentAttachments = state.currentAttachments.filter((item) => item.id !== attachmentId); report.updatedAt = new Date().toISOString(); markDirty(t('attachmentRemoved')); renderCurrentReport(fieldCallbacks); } /* ── Field change handler (P2 debounced) ────────────────────────────────── */ const debouncedRenderAfterFieldChange = debounce(() => { renderMeta(getCurrentReport()); renderValidation(); renderReportList(); }, RENDER_DEBOUNCE_MS); function updateFieldValue(field, nextValue) { const report = getCurrentReport(); if (!report) { return; } report.answers[field.id] = field.type === 'number' && nextValue !== '' ? Number(nextValue) : nextValue; report.updatedAt = new Date().toISOString(); if (report.status === 'draft') { report.status = 'in_progress'; if (elements.reportStatusSelect) { elements.reportStatusSelect.value = report.status; } } markDirty(t('draftUpdated')); /* P2 — Debounce DOM updates so rapid typing does not trigger full re-renders. */ debouncedRenderAfterFieldChange(); } /* * Callback bundle passed into the renderer so form nodes can trigger state * mutations without circular imports. */ const fieldCallbacks = { onFieldChange: updateFieldValue, onAttachFiles: attachFiles, onRemoveAttachment: removeAttachment }; /* ── Dirty-state tracking & autosave (P6) ───────────────────────────────── */ function markDirty(label) { state.dirty = true; state.saveState = 'dirty'; updateSaveBadge(label, 'warning'); clearTimeout(state.saveTimer); state.saveTimer = window.setTimeout(() => { void flushDirtyReport(); }, 700); } async function flushDirtyReport() { /* P6 — Skip write when no changes have been made since the last save. */ if (!state.dirty && state.saveState !== 'dirty') { return; } const report = getCurrentReport(); if (!report) { return; } report.updatedAt = new Date().toISOString(); await dbPut(STORE_REPORTS, report); state.reports = state.reports .map((item) => (item.id === report.id ? structuredClone(report) : item)) .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()); state.dirty = false; state.saveState = 'idle'; updateSaveBadge(t('saved', formatTime(report.updatedAt)), 'success'); renderReportList(); renderMeta(report); } function startAutosaveLoop() { clearInterval(state.autosaveIntervalId); const autosaveConfig = state.appConfig.get('autosave') || { intervalSeconds: DEFAULT_AUTOSAVE_SECONDS }; const intervalSeconds = Number(autosaveConfig.intervalSeconds || DEFAULT_AUTOSAVE_SECONDS); state.autosaveIntervalId = window.setInterval(() => { void flushDirtyReport(); }, intervalSeconds * 1000); } /* ── Admin: image rules ─────────────────────────────────────────────────── */ async function saveImageRules() { if (!navigator.onLine) { updateSaveBadge(t('goOnlineToSave'), 'warning'); return; } const payload = collectImageRulesPayload(); const validationMessage = validateImageRulesPayload(payload); if (validationMessage) { updateSaveBadge(validationMessage, 'error'); return; } elements.saveImageRulesButton.disabled = true; updateSaveBadge(t('savingImagePolicy'), 'neutral'); try { const nextImageRules = await fetchJson('/config/image-rules', { method: 'PUT', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(payload) }); state.imageRules = nextImageRules; state.appConfig.set('imageRules', nextImageRules); await dbPut(STORE_CONFIG, { key: 'imageRules', value: nextImageRules }); renderImagePolicy(); renderAdminImageRules(); updateSaveBadge(t('imagePolicySaved'), 'success'); } catch (error) { console.error(error); updateSaveBadge(error.message || t('submitFailed'), 'error'); } finally { elements.saveImageRulesButton.disabled = false; } } function collectImageRulesPayload() { return { name: elements.adminPolicyName.value.trim(), allowedMimeTypes: elements.adminAllowedMimeTypes.value .split(',') .map((value) => value.trim()) .filter(Boolean), maxFileSizeBytes: Math.round(Number(elements.adminMaxFileSizeMb.value) * 1024 * 1024), maxWidthPx: Number(elements.adminMaxWidthPx.value), maxHeightPx: Number(elements.adminMaxHeightPx.value), jpegQuality: Number(elements.adminJpegQuality.value), oversizeBehavior: elements.adminOversizeBehavior.value, maxAttachmentsPerField: Number(elements.adminMaxAttachmentsPerField.value) }; }