modified version

This commit is contained in:
Stan
2026-04-21 23:26:13 +02:00
parent e7127f3215
commit bdd06105dd
46 changed files with 1250 additions and 5248 deletions
+28 -28
View File
@@ -30,33 +30,13 @@
<!-- Navigation -->
<nav id="adminNav" class="flex-grow-1 overflow-auto p-2">
<!-- Settings -->
<!-- Reports -->
<div class="admin-nav-cat is-open mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-gear me-1"></i>Settings</span><span class="nav-arrow"></span>
<span><i class="bi bi-file-earmark-text me-1"></i>Reports</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100 is-active" type="button" data-panel="settings-policies">Image Policy</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-template">Template</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-task">Task</button>
</div>
</div>
<!-- Users -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-people me-1"></i>Users</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="users">Users</button>
</div>
</div>
<!-- Sites -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-building me-1"></i>Sites</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="sites">Sites</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100 is-active" type="button" data-panel="reports">Tasks</button>
</div>
</div>
<!-- Check Lists -->
@@ -69,13 +49,33 @@
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="cl-records">Records</button>
</div>
</div>
<!-- Reports -->
<!-- Sites -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-file-earmark-text me-1"></i>Reports</span><span class="nav-arrow"></span>
<span><i class="bi bi-building me-1"></i>Sites</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="reports">Tasks</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="sites">Sites</button>
</div>
</div>
<!-- Users -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-people me-1"></i>Users</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="users">Users</button>
</div>
</div>
<!-- Settings -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-gear me-1"></i>Settings</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-policies">Image Policy</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-template">Template</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-task">Task</button>
</div>
</div>
</nav>
@@ -91,7 +91,7 @@
<main class="flex-grow-1 overflow-auto p-4 bg-white">
<!-- SETTINGS > IMAGE POLICY -->
<section id="panel-settings-policies" class="admin-panel admin-panel-active">
<section id="panel-settings-policies" class="admin-panel">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<p class="text-muted small mb-0">Settings Image Policy</p>
@@ -532,7 +532,7 @@
</section>
<!-- REPORTS -->
<section id="panel-reports" class="admin-panel">
<section id="panel-reports" class="admin-panel admin-panel-active">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="text-muted small mb-0">Reports</p>
-780
View File
@@ -1,780 +0,0 @@
/*
* 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)
};
}
-280
View File
@@ -1,280 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="d-flex vh-100">
<!-- Sidebar -->
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:280px;min-width:280px;">
<div class="p-3 border-bottom">
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
<h5 class="fw-bold mb-0">Check List</h5>
<small class="text-muted">Offline-first proof of concept for template-driven quality reports.</small>
</div>
<div class="p-3 border-bottom">
<div class="d-flex gap-2 mb-2">
<span id="connectionBadge" class="badge bg-secondary">Checking connection</span>
<span id="saveBadge" class="badge bg-secondary">No changes</span>
</div>
<button id="syncTemplatesButton" class="btn btn-outline-secondary btn-sm w-100" type="button">
<i class="bi bi-arrow-repeat me-1"></i>Sync templates
</button>
</div>
<div class="p-3 border-bottom">
<label class="form-label small fw-semibold" for="templateSelect">Template</label>
<select id="templateSelect" class="form-select form-select-sm mb-2"></select>
<button id="createReportButton" class="btn btn-primary btn-sm w-100" type="button">
<i class="bi bi-plus-lg me-1"></i>Create new report
</button>
</div>
<div class="p-3 border-bottom">
<a id="userAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/user">User area</a>
<a id="adminAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
</div>
<div class="flex-grow-1 overflow-auto p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-semibold mb-0">Local reports</h6>
<span id="reportCount" class="badge bg-secondary">0</span>
</div>
<div class="mb-2">
<input id="reportSearchInput" class="form-control form-control-sm mb-1" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="form-select form-select-sm">
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</div>
<div id="reportList" class="report-list"></div>
</div>
</aside>
<!-- Main content -->
<main class="flex-grow-1 overflow-auto p-4 bg-white">
<!-- Operator workspace -->
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="text-muted small mb-0">Proof of concept frontend</p>
<h3 id="heroTitle" class="fw-bold">No report selected</h3>
<p id="heroSubtitle" class="text-muted">Start by syncing templates and creating a local draft.</p>
</div>
<div class="d-flex gap-2 align-items-center">
<label class="d-flex align-items-center gap-1 small">
<span>Status</span>
<select id="reportStatusSelect" class="form-select form-select-sm" style="width:auto">
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</label>
<button id="submitReportButton" class="btn btn-outline-secondary btn-sm" type="button">Submit</button>
<button id="exportReportButton" class="btn btn-outline-secondary btn-sm" type="button">Export CSV</button>
<button id="deleteReportButton" class="btn btn-outline-danger btn-sm" type="button">Delete</button>
</div>
</div>
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-primary">
<div class="card-body py-2 px-3">
<small class="text-muted">Template</small>
<div class="fw-semibold" id="summaryTemplate">Not loaded</div>
<small class="text-muted" id="summaryVersion">Version -</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body py-2 px-3">
<small class="text-muted">Validation</small>
<div class="fw-semibold" id="validationHeadline">No report selected</div>
<small class="text-muted" id="validationDetail">Draft validation will appear here.</small>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body py-2 px-3">
<small class="text-muted">Offline cache</small>
<div class="fw-semibold" id="syncHeadline">No sync yet</div>
<small class="text-muted" id="syncDetail">Templates are cached locally after the first successful sync.</small>
</div>
</div>
</div>
</div>
<!-- Editor + Inspector -->
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold">Report editor</h6>
<small id="editorHint" class="text-muted">Dynamic form rendering from template JSON</small>
</div>
<div class="card-body">
<form id="reportForm" class="report-form">
<div class="text-center text-muted py-4">
<h5>No report open</h5>
<p>Choose a template and create a report to start editing locally.</p>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header fw-semibold">Inspector view</div>
<div class="card-body">
<dl id="reportMeta" class="mb-0">
<dt class="small text-muted">Report ID</dt><dd class="mb-2">-</dd>
<dt class="small text-muted">Template</dt><dd class="mb-2">-</dd>
<dt class="small text-muted">Created</dt><dd class="mb-2">-</dd>
<dt class="small text-muted">Updated</dt><dd class="mb-0">-</dd>
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-semibold">Validation issues</div>
<div class="card-body">
<ul id="validationList" class="mb-0 ps-3">
<li>No report selected.</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold">Image policy</div>
<div class="card-body">
<p id="imagePolicyText" class="mb-0 text-muted small">
Load server configuration to see image limits and optimization rules.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Admin workspace (legacy) -->
<section id="adminWorkspace" class="workspace-view" hidden>
<div class="mb-4">
<p class="text-muted small mb-0">Administrator workspace</p>
<h3 class="fw-bold">Configuration control</h3>
<p class="text-muted">Update centrally managed image requirements used by the inspection frontend.</p>
<span id="adminSyncState" class="badge bg-secondary">Server-backed settings</span>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header fw-semibold">Image policy editor</div>
<div class="card-body">
<form id="adminImageRulesForm">
<div class="row g-3 mb-3">
<div class="col-12">
<label for="adminPolicyName" class="form-label">Policy name</label>
<input id="adminPolicyName" name="name" class="form-control" type="text" />
</div>
<div class="col-12">
<label for="adminAllowedMimeTypes" class="form-label">Allowed MIME types</label>
<input id="adminAllowedMimeTypes" name="allowedMimeTypes" class="form-control" type="text" placeholder="image/jpeg, image/png, image/webp" />
<div class="form-text">Comma-separated values used by the attachment field and browser validation.</div>
</div>
<div class="col-md-6">
<label for="adminMaxFileSizeMb" class="form-label">Max file size (MB)</label>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="form-control" type="number" min="1" step="0.1" />
</div>
<div class="col-md-6">
<label for="adminMaxAttachmentsPerField" class="form-label">Max attachments per field</label>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="form-control" type="number" min="1" step="1" />
</div>
<div class="col-md-4">
<label for="adminMaxWidthPx" class="form-label">Max width (px)</label>
<input id="adminMaxWidthPx" name="maxWidthPx" class="form-control" type="number" min="1" step="1" />
</div>
<div class="col-md-4">
<label for="adminMaxHeightPx" class="form-label">Max height (px)</label>
<input id="adminMaxHeightPx" name="maxHeightPx" class="form-control" type="number" min="1" step="1" />
</div>
<div class="col-md-4">
<label for="adminJpegQuality" class="form-label">JPEG quality</label>
<input id="adminJpegQuality" name="jpegQuality" class="form-control" type="number" min="1" max="100" step="1" />
</div>
<div class="col-md-6">
<label for="adminOversizeBehavior" class="form-label">Oversize behavior</label>
<select id="adminOversizeBehavior" name="oversizeBehavior" class="form-select">
<option value="auto_optimize">Auto optimize</option>
<option value="warn_then_optimize">Warn then optimize</option>
<option value="block">Block oversized files</option>
</select>
</div>
</div>
<div class="d-flex gap-2">
<button id="saveImageRulesButton" class="btn btn-primary" type="submit">Save image policy</button>
<button id="resetImageRulesButton" class="btn btn-outline-secondary" type="button">Reset form</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header fw-semibold">Admin summary</div>
<div class="card-body">
<dl class="mb-0">
<dt class="small text-muted">Active policy code</dt><dd id="adminPolicyCode" class="mb-2">-</dd>
<dt class="small text-muted">Allowed types</dt><dd id="adminPolicyMimeTypes" class="mb-2">-</dd>
<dt class="small text-muted">Optimization</dt><dd id="adminPolicyOptimization" class="mb-2">-</dd>
<dt class="small text-muted">Limits</dt><dd id="adminPolicyLimits" class="mb-0">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header fw-semibold">Admin notes</div>
<div class="card-body">
<ul id="adminNotesList" class="mb-0 ps-3">
<li>Changes are stored on the server and reused by report attachments.</li>
<li>Operators will use the updated policy after the next sync.</li>
</ul>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<template id="reportListItemTemplate">
<button class="report-list-item" type="button" data-report-id="">
<span class="report-list-item__header">
<strong class="report-list-item__title"></strong>
<span class="report-list-item__status badge"></span>
</span>
<span class="report-list-item__meta"></span>
</button>
</template>
<script type="module" src="/app.js"></script>
</body>
</html>
+10 -3
View File
@@ -655,15 +655,22 @@ function renderUserList() {
container.innerHTML = '<div class="empty-state"><h3>No users</h3><p>Click "Add User" to create one.</p></div>';
return;
}
const rows = admin.users.map((u) => `<tr>
const rows = admin.users.map((u) => {
const taskCount = admin.tasks.filter((t) => t.userId === u.id).length;
const taskBadge = taskCount > 0
? `<span class="badge bg-primary">${taskCount}</span>`
: `<span class="badge bg-secondary">0</span>`;
return `<tr>
<td>${esc(u.email)}</td><td>${esc(u.name)}</td><td>${esc(u.familyName)}</td>
<td>${esc(u.company || '-')}</td><td>${esc(u.role || '-')}</td>
<td class="text-center">${taskBadge}</td>
<td class="admin-table-actions">
<button class="button button-small button-secondary" data-edit-user="${u.id}">Edit</button>
<button class="button button-small button-ghost" data-delete-user="${u.id}">Delete</button>
</td></tr>`).join('');
</td></tr>`;
}).join('');
container.innerHTML = `<table class="admin-table"><thead><tr>
<th>Email</th><th>Name</th><th>Family Name</th><th>Company</th><th>Role</th><th>Actions</th>
<th>Email</th><th>Name</th><th>Family Name</th><th>Company</th><th>Role</th><th>Tasks</th><th>Actions</th>
</tr></thead><tbody>${rows}</tbody></table>`;
container.querySelectorAll('[data-edit-user]').forEach((b) => b.addEventListener('click', () => editUser(Number(b.dataset.editUser))));
container.querySelectorAll('[data-delete-user]').forEach((b) => b.addEventListener('click', () => deleteUser(Number(b.dataset.deleteUser))));
+8 -12
View File
@@ -1,13 +1,17 @@
/*
* API communication module. Centralizes fetch calls and service-worker
* registration so network details stay out of rendering and state logic.
* API communication module. Centralizes fetch calls so network details stay
* out of rendering and state logic. All JSON traffic from the frontend goes
* through `fetchJson()`, which prepends the versioned base path and unwraps
* structured error responses into regular thrown errors.
*/
import { API_BASE } from './constants.js';
/*
* Generic JSON fetcher. All frontend API calls pass through this function so
* error handling, header defaults, and base path are consistent everywhere.
* Generic JSON fetcher. Prepends the API base path when the caller passes a
* relative path, forwards headers, and parses the response body on success.
* Non-2xx responses raise an `Error` whose message is the server's `message`
* field when present, otherwise a generic status-code fallback.
*/
export async function fetchJson(path, options = {}) {
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
@@ -40,11 +44,3 @@ export async function fetchJson(path, options = {}) {
return response.json();
}
export function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.error('Service worker registration failed', error);
});
}
}
-100
View File
@@ -1,100 +0,0 @@
/*
* CSV and attachment export module (F2). Generates a CSV file from the current
* report's answers and allows downloading individual attachments. XLSX and ZIP
* export can be added by integrating SheetJS and JSZip libraries.
*/
import { state, getCurrentReport, getTemplateRecord } from './state.js';
import { dbGetAllByIndex } from './db.js';
import { STORE_ATTACHMENTS } from './constants.js';
/*
* Exports the active report as a CSV file. Columns are derived from the template
* definition so field labels appear as headers and field values as the row.
*/
export async function exportReportCSV() {
const report = getCurrentReport();
if (!report) {
throw new Error('No report to export');
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
if (!template) {
throw new Error('Template definition needed for export');
}
const headers = [];
const values = [];
/* Meta columns. */
headers.push('Report Number', 'Template', 'Version', 'Status', 'Created', 'Updated');
values.push(
report.reportNumber,
report.templateCode,
String(report.templateVersion),
report.status,
report.createdAt,
report.updatedAt
);
/* Dynamic field columns derived from the template definition. */
for (const section of template.definition.sections || []) {
for (const field of section.fields || []) {
if (field.type === 'attachment') {
continue;
}
headers.push(field.label);
const raw = report.answers[field.id];
values.push(raw === undefined || raw === null ? '' : String(raw));
}
}
const csvContent = [
headers.map(csvEscape).join(','),
values.map(csvEscape).join(',')
].join('\r\n');
downloadBlob(
new Blob([csvContent], { type: 'text/csv;charset=utf-8' }),
`${report.reportNumber || 'report'}.csv`
);
}
/*
* Exports all attachments for the active report as individual file downloads.
* A future iteration could bundle these into a ZIP archive using JSZip.
*/
export async function exportReportAttachments() {
const report = getCurrentReport();
if (!report) {
throw new Error('No report to export');
}
const attachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', report.id);
for (const attachment of attachments) {
downloadBlob(attachment.blob, attachment.generatedFilename);
}
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function csvEscape(value) {
const str = String(value).replace(/"/g, '""');
return /[",\r\n]/.test(str) ? `"${str}"` : str;
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
-239
View File
@@ -1,239 +0,0 @@
/*
* Dynamic form field creation. Each function returns a DOM node tree for a
* single template field. The approach keeps template-driven rendering in one
* module while the orchestrator (app.js) provides callbacks for state mutations.
*
* Callback contract:
* onFieldChange(field, nextValue) — called when the user edits a field
* onAttachFiles(field, report, files) — called when files are selected
* onRemoveAttachment(attachmentId) — called to remove an attachment
*/
import { evaluateRequiredWhen } from './validation.js';
import { formatFileSize } from './utils.js';
export function createFieldNode(field, report, { state, onFieldChange, onAttachFiles, onRemoveAttachment }) {
const wrapper = document.createElement('div');
wrapper.className = `field ${field.type === 'comment' || field.type === 'attachment' ? 'field-full' : ''}`;
const isRequired = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
const currentValue = report.answers[field.id];
wrapper.innerHTML = `
<div class="field-header">
<label class="field-label" for="field-${field.id}">${escapeHtml(field.label)}</label>
${isRequired ? '<span class="required-pill">Required</span>' : ''}
</div>
`;
let inputNode;
switch (field.type) {
case 'text':
case 'date':
case 'number':
inputNode = document.createElement('input');
inputNode.className = 'text-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.type = field.type;
inputNode.value = currentValue ?? '';
inputNode.readOnly = Boolean(field.readOnly);
if (field.validation?.min !== undefined) {
inputNode.min = String(field.validation.min);
}
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
break;
case 'lookup': {
inputNode = document.createElement('select');
inputNode.className = 'select-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = 'Select an option';
inputNode.append(emptyOption);
const lookup = state.lookups.get(field.lookupCode);
for (const optionData of lookup?.values || []) {
const option = document.createElement('option');
option.value = optionData.value;
option.textContent = optionData.label;
option.selected = optionData.value === currentValue;
inputNode.append(option);
}
inputNode.addEventListener('change', (event) => {
onFieldChange(field, event.target.value);
});
break;
}
case 'checkbox':
inputNode = document.createElement('label');
inputNode.className = 'checkbox-row';
inputNode.innerHTML = `
<input id="field-${field.id}" name="${field.id}" type="checkbox" ${currentValue ? 'checked' : ''} />
<span>${escapeHtml(field.label)}</span>
`;
inputNode.querySelector('input').addEventListener('change', (event) => {
onFieldChange(field, event.target.checked);
});
break;
case 'comment':
inputNode = document.createElement('textarea');
inputNode.className = 'text-area';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.maxLength = field.maxLength || 5000;
inputNode.value = currentValue ?? '';
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
break;
case 'attachment':
inputNode = createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment });
break;
default:
inputNode = document.createElement('input');
inputNode.className = 'text-input';
inputNode.id = `field-${field.id}`;
inputNode.name = field.id;
inputNode.type = 'text';
inputNode.value = currentValue ?? '';
inputNode.addEventListener('input', (event) => {
onFieldChange(field, event.target.value);
});
}
wrapper.append(inputNode);
if (field.requiredWhen?.field) {
const help = document.createElement('p');
help.className = 'field-help';
help.textContent = `Required when ${field.requiredWhen.field} is ${String(field.requiredWhen.equals)}.`;
wrapper.append(help);
}
return wrapper;
}
/* ── Attachment field ───────────────────────────────────────────────────── */
function createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment }) {
const container = document.createElement('div');
container.className = 'attachment-list';
const toolbar = document.createElement('div');
toolbar.className = 'attachment-toolbar';
const input = document.createElement('input');
input.className = 'file-input';
input.id = `field-${field.id}`;
input.type = 'file';
input.accept = state.imageRules?.allowedMimeTypes?.join(',') || 'image/*';
input.multiple = true;
input.addEventListener('change', async (event) => {
const files = Array.from(event.target.files || []);
await onAttachFiles(field, report, files);
event.target.value = '';
});
toolbar.append(input);
container.append(toolbar);
/*
* P3 — Lazy-load attachment previews. Thumbnails are created from object URLs
* on demand using IntersectionObserver. Only attachments scrolled into view
* allocate a Blob URL, keeping memory use proportional to the visible area.
*/
const attachments = state.currentAttachments.filter((item) => item.fieldId === field.id);
if (!attachments.length) {
const hint = document.createElement('p');
hint.className = 'field-help';
hint.textContent = 'No images attached yet.';
container.append(hint);
return container;
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue;
}
const img = entry.target;
const attachment = attachments.find((a) => a.id === img.dataset.attachmentId);
if (attachment?.blob) {
const objectUrl = URL.createObjectURL(attachment.blob);
img.src = objectUrl;
img.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true });
}
observer.unobserve(img);
}
}, { rootMargin: '200px' });
for (const attachment of attachments) {
const card = document.createElement('article');
card.className = 'attachment-card';
const preview = document.createElement('img');
preview.className = 'attachment-preview';
preview.alt = attachment.generatedFilename;
preview.dataset.attachmentId = attachment.id;
/* Actual src loaded lazily by the IntersectionObserver above. */
observer.observe(preview);
const copy = document.createElement('div');
copy.className = 'attachment-card__copy';
copy.innerHTML = `
<strong>${escapeHtml(attachment.generatedFilename)}</strong>
<span>${escapeHtml(attachment.originalFilename)}</span>
<span>${attachment.width}x${attachment.height}px &bull; ${formatFileSize(attachment.sizeBytes)}</span>
`;
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'button button-small button-ghost';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
void onRemoveAttachment(attachment.id);
});
card.append(preview, copy, removeButton);
container.append(card);
}
return container;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
-121
View File
@@ -1,121 +0,0 @@
/*
* Lightweight i18n module. All user-facing strings are collected here so the app
* can be translated by swapping or extending the locale object. The current
* implementation is English-only; a future iteration could load locale files
* dynamically based on a user preference.
*/
const en = {
/* General */
appTitle: 'Check List',
appTagline: 'Offline-first proof of concept for template-driven quality reports.',
checkingConnection: 'Checking connection',
online: 'Online',
offline: 'Offline',
noChanges: 'No changes',
/* Sync */
syncingTemplates: 'Syncing templates',
templatesSynced: 'Templates synced',
syncFailed: 'Sync failed, using cache',
offlineMode: 'Offline mode',
/* Reports */
noReportSelected: 'No report selected',
noReportSelectedHint: 'Start by syncing templates and creating a local draft.',
noLocalReports: 'No local reports yet.',
selectTemplate: 'Select a template first',
templateNotAvailable: 'Template not available locally',
newReportCreated: 'New report created',
reportDeleted: 'Report deleted',
deleteReportConfirm: 'Delete local report {0}?',
draftUpdated: 'Draft updated',
statusChanged: 'Status changed',
saved: 'Saved {0}',
/* Submission */
submitting: 'Submitting report…',
submitted: 'Report submitted to server',
submitFailed: 'Submission failed',
goOnlineToSubmit: 'Go online to submit a report',
/* Validation */
readyForExport: 'Ready for export validation',
noBlockingIssues: 'No blocking validation issues detected for the current draft.',
noValidationIssues: 'No validation issues.',
issueCount: '{0} issue(s) to resolve',
issueReadyWarning: 'The report is marked ready, but validation still has blocking items.',
issueDraftHint: 'Draft save is still allowed, but export should be blocked until these issues are fixed.',
templateUnavailable: 'Template unavailable',
validationNeedsTemplate: 'Validation cannot run until the template is cached again.',
required: 'value is required.',
numberInvalid: 'number is invalid.',
numberMin: 'must be at least {0}.',
imageRequired: 'at least one image is required.',
templateMissing: 'Template definition missing for this report version.',
/* Attachments */
noImagesAttached: 'No images attached yet.',
maxAttachments: 'Only {0} attachment(s) allowed',
imageSkipped: 'Image skipped: {0}',
imagesUpdated: 'Images updated',
attachmentRemoved: 'Attachment removed',
unsupportedFileType: 'Unsupported file type: {0}',
fileExceedsLimit: 'File exceeds limit: {0}',
optimizeFailed: 'Failed to optimize image: {0}',
optimizedStillExceeds: 'Optimized image still exceeds limit: {0}',
/* Admin */
savingImagePolicy: 'Saving image policy',
imagePolicySaved: 'Image policy saved',
goOnlineToSave: 'Go online to save admin settings',
policyNameRequired: 'Policy name is required',
addMimeType: 'Add at least one MIME type',
maxFileSizePositive: 'Max file size must be greater than 0',
maxWidthPositive: 'Max width must be a positive number',
maxHeightPositive: 'Max height must be a positive number',
jpegQualityRange: 'JPEG quality must be between 1 and 100',
maxAttachmentsPositive: 'Max attachments must be a positive number',
/* Template management */
publishingVersion: 'Publishing version…',
versionPublished: 'Template version published',
publishFailed: 'Failed to publish version',
/* Export */
exportStarted: 'Preparing export…',
exportComplete: 'Export ready — file downloaded',
exportFailed: 'Export failed',
noReportToExport: 'Open a report before exporting',
noTemplateForExport: 'Template definition needed for export',
/* Search */
searchPlaceholder: 'Search reports…',
/* Misc */
noTemplatesCached: 'No cached templates available',
liveConfig: 'Live server configuration',
offlineCachedConfig: 'Offline cached configuration',
noImageRulesLoaded: 'No image rules loaded',
imagePolicyHint: 'Load server configuration to see image limits and optimization rules.'
};
let currentLocale = en;
/*
* Retrieve a translated string by key. Optional positional parameters replace
* {0}, {1}, etc. placeholders.
*/
export function t(key, ...params) {
let text = currentLocale[key] ?? key;
for (let i = 0; i < params.length; i++) {
text = text.replace(`{${i}}`, String(params[i]));
}
return text;
}
export function setLocale(locale) {
currentLocale = { ...en, ...locale };
}
-389
View File
@@ -1,389 +0,0 @@
/*
* Rendering module. All functions that manipulate the visible DOM live here.
* Each render function reads from the shared state and writes to the cached
* element references. The module has no side effects beyond DOM mutation.
*/
import { state, elements, getCurrentReport, getTemplateRecord } from './state.js';
import { createFieldNode } from './forms.js';
import { validateReport } from './validation.js';
import {
formatDateTime,
formatTime,
formatRelativeTime,
formatFileSize,
prettifyStatus,
badgeClassForTone
} from './utils.js';
import { t } from './i18n.js';
/* ── Top-level render orchestrator ──────────────────────────────────────── */
export function render(fieldCallbacks) {
renderTemplateSelector();
/* User workspace renders — only when the report editor DOM exists. */
if (elements.reportForm) {
renderReportList();
renderTemplateSummary();
renderCurrentReport(fieldCallbacks);
renderSyncSummary();
renderImagePolicy();
}
/* Admin workspace renders — only when the admin form DOM exists. */
if (elements.adminImageRulesForm) {
renderAdminImageRules();
}
}
/* ── Template selector ──────────────────────────────────────────────────── */
export function renderTemplateSelector() {
const currentValue = state.selectedTemplateCode;
elements.templateSelect.innerHTML = '';
if (!state.templatesCatalog.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = t('noTemplatesCached');
elements.templateSelect.append(option);
return;
}
for (const template of state.templatesCatalog) {
const option = document.createElement('option');
option.value = template.code;
option.textContent = `${template.name} (v${template.activeVersion})`;
option.selected = template.code === currentValue;
elements.templateSelect.append(option);
}
}
/* ── Report list (F4 — with search & status filter) ─────────────────────── */
export function renderReportList() {
if (!elements.reportList) {
return;
}
elements.reportList.innerHTML = '';
/* Apply search query and status filter. */
let filtered = state.reports;
if (state.reportSearchQuery) {
const q = state.reportSearchQuery.toLowerCase();
filtered = filtered.filter((r) => {
const num = (r.answers?.reportNumber || r.reportNumber || '').toLowerCase();
const title = (r.title || '').toLowerCase();
return num.includes(q) || title.includes(q);
});
}
if (state.reportFilterStatus) {
filtered = filtered.filter((r) => r.status === state.reportFilterStatus);
}
elements.reportCount.textContent = String(filtered.length);
if (!filtered.length) {
const empty = document.createElement('p');
empty.className = 'field-help';
empty.textContent = state.reports.length ? 'No reports match the current filter.' : t('noLocalReports');
elements.reportList.append(empty);
return;
}
for (const report of filtered) {
const fragment = elements.reportListItemTemplate.content.cloneNode(true);
const button = fragment.querySelector('.report-list-item');
const title = fragment.querySelector('.report-list-item__title');
const statusEl = fragment.querySelector('.report-list-item__status');
const meta = fragment.querySelector('.report-list-item__meta');
title.textContent = report.answers.reportNumber || report.reportNumber;
statusEl.textContent = prettifyStatus(report.status);
statusEl.classList.add(`status-${report.status}`);
meta.textContent = `${report.title} • Updated ${formatDateTime(report.updatedAt)}`;
if (report.id === state.currentReportId) {
button.classList.add('is-active');
}
button.dataset.reportId = report.id;
elements.reportList.append(button);
}
}
/* ── Template summary cards ─────────────────────────────────────────────── */
export function renderTemplateSummary() {
if (!elements.summaryTemplate) {
return;
}
const report = getCurrentReport();
const templateCode = report?.templateCode || state.selectedTemplateCode;
const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode);
if (!catalogEntry) {
elements.summaryTemplate.textContent = 'Not loaded';
elements.summaryVersion.textContent = 'Version -';
return;
}
elements.summaryTemplate.textContent = catalogEntry.name;
elements.summaryVersion.textContent = report
? `Version ${report.templateVersion}`
: `Version ${catalogEntry.activeVersion}`;
}
export function renderSyncSummary() {
if (!elements.syncHeadline) {
return;
}
elements.syncHeadline.textContent = state.lastSyncAt
? `Last sync ${formatRelativeTime(state.lastSyncAt)}`
: 'No sync yet';
elements.syncDetail.textContent = state.lastSyncAt
? `Cached template data from ${formatDateTime(state.lastSyncAt)}`
: 'Templates are cached locally after the first successful sync.';
}
/* ── Image policy ───────────────────────────────────────────────────────── */
export function renderImagePolicy() {
if (!elements.imagePolicyText) {
return;
}
if (!state.imageRules) {
elements.imagePolicyText.textContent = t('imagePolicyHint');
return;
}
elements.imagePolicyText.textContent = `${state.imageRules.name}: ${state.imageRules.allowedMimeTypes.join(
', '
)}, max ${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, behavior ${state.imageRules.oversizeBehavior}.`;
}
/* ── Admin image rules ──────────────────────────────────────────────────── */
export function renderAdminImageRules() {
if (!elements.adminSyncState) {
return;
}
populateAdminImageRulesForm(state.imageRules);
if (!state.imageRules) {
elements.adminSyncState.textContent = t('noImageRulesLoaded');
elements.adminSyncState.className = 'badge badge-offline';
elements.adminPolicyCode.textContent = '-';
elements.adminPolicyMimeTypes.textContent = '-';
elements.adminPolicyOptimization.textContent = '-';
elements.adminPolicyLimits.textContent = '-';
return;
}
elements.adminSyncState.textContent = navigator.onLine ? t('liveConfig') : t('offlineCachedConfig');
elements.adminSyncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
elements.adminPolicyCode.textContent = state.imageRules.code;
elements.adminPolicyMimeTypes.textContent = state.imageRules.allowedMimeTypes.join(', ');
elements.adminPolicyOptimization.textContent = `${state.imageRules.oversizeBehavior}, JPEG ${state.imageRules.jpegQuality}%`;
elements.adminPolicyLimits.textContent = `${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, ${state.imageRules.maxAttachmentsPerField} attachment(s)`;
}
export function populateAdminImageRulesForm(imageRules) {
if (!imageRules || !elements.adminPolicyName) {
return;
}
elements.adminPolicyName.value = imageRules.name || '';
elements.adminAllowedMimeTypes.value = (imageRules.allowedMimeTypes || []).join(', ');
elements.adminMaxFileSizeMb.value = String((Number(imageRules.maxFileSizeBytes || 0) / (1024 * 1024)).toFixed(1));
elements.adminMaxAttachmentsPerField.value = String(imageRules.maxAttachmentsPerField || 1);
elements.adminMaxWidthPx.value = String(imageRules.maxWidthPx || '');
elements.adminMaxHeightPx.value = String(imageRules.maxHeightPx || '');
elements.adminJpegQuality.value = String(imageRules.jpegQuality || '');
elements.adminOversizeBehavior.value = imageRules.oversizeBehavior || 'auto_optimize';
}
/* ── Current report ─────────────────────────────────────────────────────── */
/*
* `renderCurrentReport` accepts a `fieldCallbacks` object so the form module
* can invoke actions (field change, attach, remove) owned by the orchestrator
* without creating circular imports.
*/
export function renderCurrentReport(fieldCallbacks = {}) {
if (!elements.reportForm) {
return;
}
const report = getCurrentReport();
if (!report) {
elements.heroTitle.textContent = t('noReportSelected');
elements.heroSubtitle.textContent = t('noReportSelectedHint');
elements.reportStatusSelect.value = 'draft';
elements.reportStatusSelect.disabled = true;
elements.deleteReportButton.disabled = true;
if (elements.submitReportButton) elements.submitReportButton.disabled = true;
if (elements.exportReportButton) elements.exportReportButton.disabled = true;
elements.editorHint.textContent = 'Dynamic form rendering from template JSON';
elements.reportForm.innerHTML = `
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
`;
renderMeta(null);
renderValidation();
return;
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
elements.reportStatusSelect.disabled = false;
elements.reportStatusSelect.value = report.status;
elements.deleteReportButton.disabled = false;
if (elements.submitReportButton) elements.submitReportButton.disabled = false;
if (elements.exportReportButton) elements.exportReportButton.disabled = false;
elements.heroTitle.textContent = report.answers.reportNumber || report.reportNumber;
elements.heroSubtitle.textContent = `${template?.name || report.templateCode} • Local draft bound to template version ${report.templateVersion}`;
elements.editorHint.textContent = `${state.currentAttachments.length} attachment(s) stored in IndexedDB for this report`;
if (!template) {
elements.reportForm.innerHTML = `
<div class="empty-state">
<h3>Template missing</h3>
<p>This draft is bound to template version ${report.templateVersion}, but that definition is not cached locally.</p>
</div>
`;
renderMeta(report);
renderValidation();
return;
}
elements.reportForm.innerHTML = '';
for (const section of template.definition.sections || []) {
const sectionNode = document.createElement('section');
sectionNode.className = 'template-section';
const heading = document.createElement('div');
heading.className = 'section-heading-row';
heading.innerHTML = `<h3>${escapeHtml(section.title)}</h3><span class="panel-note">${section.fields.length} field(s)</span>`;
sectionNode.append(heading);
const fieldGrid = document.createElement('div');
fieldGrid.className = 'field-grid';
for (const field of section.fields || []) {
fieldGrid.append(createFieldNode(field, report, {
state,
onFieldChange: fieldCallbacks.onFieldChange || (() => {}),
onAttachFiles: fieldCallbacks.onAttachFiles || (() => {}),
onRemoveAttachment: fieldCallbacks.onRemoveAttachment || (() => {})
}));
}
sectionNode.append(fieldGrid);
elements.reportForm.append(sectionNode);
}
renderMeta(report);
renderValidation();
}
/* ── Meta & validation ──────────────────────────────────────────────────── */
export function renderMeta(report) {
if (!elements.reportMeta) {
return;
}
const values = report
? [
report.id,
`${report.templateCode} v${report.templateVersion}`,
formatDateTime(report.createdAt),
formatDateTime(report.updatedAt)
]
: ['-', '-', '-', '-'];
elements.reportMeta.querySelectorAll('dd').forEach((node, index) => {
node.textContent = values[index];
});
}
export function renderValidation() {
if (!elements.validationHeadline) {
return;
}
const report = getCurrentReport();
if (!report) {
elements.validationHeadline.textContent = t('noReportSelected');
elements.validationDetail.textContent = 'Draft validation will appear here.';
elements.validationList.innerHTML = '<li>No report selected.</li>';
return;
}
const template = getTemplateRecord(report.templateCode, report.templateVersion);
if (!template) {
elements.validationHeadline.textContent = t('templateUnavailable');
elements.validationDetail.textContent = t('validationNeedsTemplate');
elements.validationList.innerHTML = `<li>${t('templateMissing')}</li>`;
return;
}
const issues = validateReport(report, template, state.currentAttachments);
if (!issues.length) {
elements.validationHeadline.textContent = t('readyForExport');
elements.validationDetail.textContent = t('noBlockingIssues');
elements.validationList.innerHTML = `<li>${t('noValidationIssues')}</li>`;
return;
}
elements.validationHeadline.textContent = t('issueCount', issues.length);
elements.validationDetail.textContent =
report.status === 'ready_for_export' ? t('issueReadyWarning') : t('issueDraftHint');
elements.validationList.innerHTML = '';
for (const issue of issues) {
const item = document.createElement('li');
item.textContent = issue;
elements.validationList.append(item);
}
}
/* ── Badge helpers ──────────────────────────────────────────────────────── */
export function updateConnectionBadge() {
if (navigator.onLine) {
elements.connectionBadge.textContent = t('online');
elements.connectionBadge.className = 'badge badge-online';
} else {
elements.connectionBadge.textContent = t('offline');
elements.connectionBadge.className = 'badge badge-offline';
}
}
export function updateSaveBadge(text, tone) {
elements.saveBadge.textContent = text;
elements.saveBadge.className = `badge ${badgeClassForTone(tone)}`;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
+122 -8
View File
@@ -53,11 +53,30 @@ const bulkImagesStore = new Map();
/*
* Loads all admin entity data from the server bulk endpoint and caches in IndexedDB.
*
* Failure handling:
* 401 — Session is gone (e.g. server restarted). Redirect to login so the
* user can re-authenticate and return with a fresh session.
* Other non-2xx — Transient server/network problem. Fall back to the last
* successful snapshot stored in IndexedDB so the user can still see
* their assigned tasks.
*/
async function loadFromServer() {
try {
const resp = await fetch('/api/v1/admin/all', { headers: { Accept: 'application/json' } });
if (!resp.ok) return;
if (!resp.ok) {
if (resp.status === 401) {
/* Session expired or server restarted — must re-authenticate. */
window.location.href = '/login-user';
return;
}
/* Other server error — use last cached snapshot so tasks remain visible. */
console.warn('Server returned', resp.status, '— falling back to IndexedDB cache.');
await loadFromCache();
return;
}
const data = await resp.json();
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data });
userState.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
@@ -68,7 +87,9 @@ async function loadFromServer() {
userState.clTemplates = data.clTemplates || [];
userState.tasks = data.tasks || [];
} catch (err) {
/* Network failure (offline, DNS, etc.) — fall back to cache. */
console.warn('Failed to load data from server, using IndexedDB cache', err);
await loadFromCache();
}
}
@@ -128,6 +149,30 @@ async function loadAllData() {
userState.taskData = await loadTaskData();
}
/*
* Manually triggered synchronization with the server.
* Called when the user presses the "Sync" button.
* Fetches fresh data, re-applies the user filter, and re-renders task lists.
*/
async function forceSyncWithServer() {
const btn = document.getElementById('syncBtn');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Syncing…';
}
try {
await loadFromServer();
filterTasksByUser();
renderTaskListView();
renderSidebarTasks();
} finally {
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Sync';
}
}
}
function filterTasksByUser() {
const params = new URLSearchParams(window.location.search);
const userId = params.get('userId');
@@ -142,6 +187,7 @@ function bindEvents() {
document.getElementById('saveDraftBtn')?.addEventListener('click', saveDraft);
document.getElementById('saveFinalBtn')?.addEventListener('click', saveFinal);
document.getElementById('showSettingsBtn')?.addEventListener('click', showSettingsView);
document.getElementById('syncBtn')?.addEventListener('click', forceSyncWithServer);
document.getElementById('closeSettingsBtn')?.addEventListener('click', closeSettingsView);
document.getElementById('recordSearchInput')?.addEventListener('input', onSearchInput);
@@ -203,16 +249,23 @@ async function showListView() {
}
function showDetailView(taskId) {
userState.currentTaskId = Number(taskId);
/* Always work with a numeric ID. dataset attributes and sidebar event handlers
may pass either a string or a number depending on the call site. Normalizing
here keeps every downstream function consistent. */
const id = Number(taskId);
userState.currentTaskId = id;
userState.activeCategory = null;
userState.searchQuery = '';
const searchInput = document.getElementById('recordSearchInput');
if (searchInput) searchInput.value = '';
hideAllViews();
document.getElementById('taskDetailView').classList.add('workspace-view-active');
/* If task was reopened and images were stripped, try to re-download from server */
maybeDownloadImages(taskId).then(() => renderTaskDetail());
highlightSidebarTask(taskId);
/* If task was reopened and images were stripped, try to re-download from server.
Chain: hydrate values first → then fetch image blobs → then render. */
maybeHydrateFromServer(id)
.then(() => maybeDownloadImages(id))
.then(() => renderTaskDetail());
highlightSidebarTask(id);
}
/* ═══════════════════════════════════════════════════════════════════════════
@@ -1274,15 +1327,76 @@ function stripImageDataFromStorage(taskId) {
updateStorageIndicator();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Hydrate task data from server when IndexedDB has no local copy
* ═══════════════════════════════════════════════════════════════════════════ */
/*
* If the browser's IndexedDB has no record data for this task (cleared storage,
* first access on a new device, or fresh browser profile), fetch the last
* submitted report from the server and seed the local state so previously
* filled values are visible without the user having to re-enter them.
*
* Images are not re-embedded here — they are pointed to the server with
* uploadedToServer:true so that maybeDownloadImages (called next in the chain)
* can fetch the actual blobs.
*/
async function maybeHydrateFromServer(taskId) {
const id = Number(taskId);
/* Skip if IndexedDB already has record data for this task. */
const existing = userState.taskData[id];
if (existing && Object.keys(existing.records || {}).length > 0) return;
if (!navigator.onLine) return;
try {
const resp = await fetch(`/api/v1/reports/${id}`, { headers: { Accept: 'application/json' } });
if (!resp.ok) return; /* 404 = task has never been saved — empty form is correct */
const report = await resp.json();
if (!report?.answers?.records) return;
/* Rebuild records, keeping all field values but replacing image dataUrls with
uploadedToServer markers so the image-download step can restore them. */
const hydratedRecords = {};
for (const [recId, rd] of Object.entries(report.answers.records)) {
hydratedRecords[recId] = {
status: rd.status || '',
handledBy: rd.handledBy || '',
comment: rd.comment || '',
images: (rd.images || []).map(img => ({
name: img.name || '',
size: img.size || 0,
uploadedToServer: true
/* dataUrl intentionally omitted — fetched by maybeDownloadImages */
}))
};
}
userState.taskData[id] = {
visitDate: report.answers.visitDate || '',
records: hydratedRecords
};
/* Persist to IndexedDB so future visits within the same browser are instant. */
await saveOneTaskData(id, userState.taskData[id]);
} catch (err) {
console.warn('Could not hydrate task data from server:', err.message);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Download images from server when task is reopened
* ═══════════════════════════════════════════════════════════════════════════ */
async function maybeDownloadImages(taskId) {
const task = userState.tasks.find(t => t.id === taskId);
/* Normalize to number — callers may pass a string from a dataset attribute. */
const id = Number(taskId);
const task = userState.tasks.find(t => t.id === id);
if (!task) return;
const data = getTaskData(taskId);
const data = getTaskData(id);
if (!data.records) return;
/* Check if any images have uploadedToServer flag but no dataUrl */
@@ -1299,7 +1413,7 @@ async function maybeDownloadImages(taskId) {
/* Fetch images (as dataUrls) from the server */
try {
const resp = await fetch(`/api/v1/reports/${taskId}/images`, {
const resp = await fetch(`/api/v1/reports/${id}/images`, {
headers: { Accept: 'application/json' }
});
if (!resp.ok) return;
-150
View File
@@ -1,150 +0,0 @@
/*
* Utility functions used across multiple frontend modules. These are pure
* functions with no side effects and no dependency on application state.
*/
/* ── Formatting ─────────────────────────────────────────────────────────── */
export function prettifyStatus(status) {
return status
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function formatDateTime(value) {
return new Date(value).toLocaleString();
}
export function formatTime(value) {
return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function formatRelativeTime(value) {
const diffMs = Date.now() - new Date(value).getTime();
const diffMinutes = Math.round(diffMs / 60000);
if (diffMinutes < 1) {
return 'just now';
}
if (diffMinutes < 60) {
return `${diffMinutes} minute(s) ago`;
}
const diffHours = Math.round(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours} hour(s) ago`;
}
return `${Math.round(diffHours / 24)} day(s) ago`;
}
export function formatFileSize(bytes) {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/* ── Naming & Sanitization ──────────────────────────────────────────────── */
export function sanitizeForFilename(value) {
return String(value || 'report')
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-_]/g, '')
.slice(0, 40);
}
export function generateReportNumber() {
const now = new Date();
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate()
).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(
now.getMinutes()
).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
return `POC-${stamp}`;
}
export function buildGeneratedFilename(report, field, sequence, extension) {
const reportNumber = sanitizeForFilename(report.answers.reportNumber || report.reportNumber);
const sectionCode = sanitizeForFilename(field.id).slice(0, 10).toUpperCase();
return `${reportNumber}_${sectionCode}_${String(sequence).padStart(3, '0')}.${extension}`;
}
/* ── Badge helpers ──────────────────────────────────────────────────────── */
export function badgeClassForTone(tone) {
if (tone === 'success') {
return 'badge-online';
}
if (tone === 'error') {
return 'badge-error';
}
if (tone === 'warning') {
return 'badge-offline';
}
return 'badge-neutral';
}
/* ── Template helpers ───────────────────────────────────────────────────── */
export function makeTemplateKey(code, version) {
return `${code}::${version}`;
}
/*
* Multiple versions of the same template can exist in local cache because old
* drafts remain bound to the version they started with. The catalog therefore
* picks the newest version per template code for the creation UI while keeping
* older records available in the version lookup map.
*/
export function deriveTemplateCatalog(templateRows) {
const byCode = new Map();
for (const row of templateRows) {
const existing = byCode.get(row.code);
const shouldReplace = !existing || Number(row.version) > Number(existing.version);
if (shouldReplace) {
byCode.set(row.code, row);
}
}
return Array.from(byCode.values())
.sort((left, right) => left.name.localeCompare(right.name))
.map((item) => ({
code: item.code,
name: item.name,
description: item.description,
activeVersion: item.version,
publishedAt: item.publishedAt
}));
}
/* ── General ────────────────────────────────────────────────────────────── */
/*
* Debounce helper. Returns a wrapper that delays invocation until `ms`
* milliseconds of inactivity have passed, preventing expensive operations
* (such as a full form re-render) from running on every keystroke.
*/
export function debounce(fn, ms) {
let timer = null;
return function debounced(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), ms);
};
}
+52
View File
@@ -197,6 +197,58 @@ body {
border: 1px solid rgba(0, 0, 0, 0.15);
}
/* ── Admin list tables ──────────────────────────────────────────────────── */
/*
* All admin CRUD list tables share the admin-table class. They are rendered
* into containers that are already full-width, but without an explicit width
* the browser collapses tables to their content width. Setting width:100%
* stretches every table to the panel width. Striped rows and a light-bordered
* header make the data easier to scan. The compact variant reduces cell
* padding for tables with many columns (e.g. CL Records).
*/
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.admin-table thead th {
background: #f8f9fa;
border-bottom: 2px solid #dee2e6;
padding: 8px 12px;
font-weight: 600;
white-space: nowrap;
}
.admin-table tbody td {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.admin-table tbody tr:last-child td {
border-bottom: none;
}
.admin-table tbody tr:hover td {
background: #f8f9fa;
}
.admin-table-compact thead th {
padding: 6px 8px;
}
.admin-table-compact tbody td {
padding: 6px 8px;
}
.admin-table-actions {
white-space: nowrap;
text-align: right;
width: 1%; /* shrink-wrap — the remaining columns get all the space */
}
/* ── Drop zone (drag & drop images) ────────────────────────────────────── */
.drop-zone {
-118
View File
@@ -1,118 +0,0 @@
const CACHE_NAME = 'check-list-poc-v3';
const DYNAMIC_CACHE_LIMIT = 50;
const APP_SHELL = [
'/',
'/user.html',
'/admin.html',
'/styles.css',
'/app.js',
'/manifest.webmanifest',
'/js/constants.js',
'/js/state.js',
'/js/i18n.js',
'/js/utils.js',
'/js/db.js',
'/js/api.js',
'/js/validation.js',
'/js/images.js',
'/js/image-worker.js',
'/js/forms.js',
'/js/renderer.js',
'/js/export.js'
];
/*
* P5 — Bounded SW cache with LRU eviction. Static shell assets use cache-first.
* API requests use network-first with automatic pruning of old dynamic entries
* so the cache stays within DYNAMIC_CACHE_LIMIT.
*/
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin) {
return;
}
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
event.respondWith(cacheFirst(request));
});
async function cacheFirst(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
if (cached) {
return cached;
}
const response = await fetch(request);
cache.put(request, response.clone());
return response;
}
async function networkFirst(request) {
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
cache.put(request, response.clone());
await trimCache(cache, DYNAMIC_CACHE_LIMIT);
return response;
} catch {
const cached = await cache.match(request);
if (cached) {
return cached;
}
return new Response(JSON.stringify({ message: 'Offline and no cached response available.' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
}
/**
* P5 — Remove oldest entries once the cache exceeds the limit. Only dynamic
* (non-shell) entries are evicted so the app shell remains always available.
*/
async function trimCache(cache, maxEntries) {
const keys = await cache.keys();
if (keys.length <= maxEntries) {
return;
}
const shellSet = new Set(APP_SHELL.map((path) => new URL(path, self.location.origin).href));
const evictable = keys.filter((request) => !shellSet.has(request.url));
while (evictable.length + APP_SHELL.length > maxEntries && evictable.length > 0) {
await cache.delete(evictable.shift());
}
}
+11 -4
View File
@@ -73,10 +73,17 @@
<!-- TASK LIST VIEW (shown by default) -->
<section id="taskListView" class="workspace-view workspace-view-active">
<div class="mb-4">
<p class="text-muted small mb-0">User workspace</p>
<h3 class="fw-bold">Assigned Tasks</h3>
<p class="text-muted">Select a task to begin processing.</p>
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="text-muted small mb-0">User workspace</p>
<h3 class="fw-bold">Assigned Tasks</h3>
<p class="text-muted mb-0">Select a task to begin processing.</p>
</div>
<!-- Force a fresh fetch from the server (useful after a server restart
that cleared in-memory sessions — log in again first, then press Sync). -->
<button id="syncBtn" class="btn btn-outline-secondary btn-sm mt-1" type="button">
<i class="bi bi-arrow-repeat me-1"></i>Sync
</button>
</div>
<div class="card">