780 lines
27 KiB
JavaScript
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)
|
|
};
|
|
} |