stage 1
This commit is contained in:
+780
@@ -0,0 +1,780 @@
|
||||
/*
|
||||
* 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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user