Files
CLProject/public/app.js
T
2026-04-19 21:14:16 +02:00

780 lines
27 KiB
JavaScript

/*
* 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)
};
}