This commit is contained in:
Stan
2026-04-19 21:14:16 +02:00
parent 0c74a75126
commit 28d167f11f
42 changed files with 5681 additions and 55 deletions
+196
View File
@@ -0,0 +1,196 @@
<!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 — Admin</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
Administrator workspace: server-backed configuration editing for image
policies and other centrally managed settings.
-->
<div class="app-shell">
<aside class="sidebar panel">
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link is-active" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
</div>
</aside>
<main class="workspace">
<section id="adminWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Administrator workspace</p>
<h2>Configuration control</h2>
<p class="hero-copy">
Update centrally managed image requirements used by the inspection frontend.
</p>
</div>
<div class="hero-actions">
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
</div>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Image policy editor</h2>
<span class="panel-note">Updates the active server rule</span>
</div>
<form id="adminImageRulesForm" class="report-form admin-form">
<section class="template-section">
<div class="field-grid">
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminPolicyName">Policy name</label>
</div>
<input id="adminPolicyName" name="name" class="text-input" type="text" />
</div>
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
</div>
<input
id="adminAllowedMimeTypes"
name="allowedMimeTypes"
class="text-input"
type="text"
placeholder="image/jpeg, image/png, image/webp"
/>
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
</div>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
</div>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
</div>
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
</div>
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
</div>
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
</div>
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
<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>
</section>
<div class="admin-actions">
<button id="saveImageRulesButton" class="button button-primary" type="submit">
Save image policy
</button>
<button id="resetImageRulesButton" class="button button-secondary" type="button">
Reset form
</button>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Admin summary</h2>
<span class="panel-note">Live configuration preview</span>
</div>
<dl class="meta-list">
<div>
<dt>Active policy code</dt>
<dd id="adminPolicyCode">-</dd>
</div>
<div>
<dt>Allowed types</dt>
<dd id="adminPolicyMimeTypes">-</dd>
</div>
<div>
<dt>Optimization</dt>
<dd id="adminPolicyOptimization">-</dd>
</div>
<div>
<dt>Limits</dt>
<dd id="adminPolicyLimits">-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Admin notes</h3>
<ul class="validation-list" id="adminNotesList">
<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>
</aside>
</section>
</section>
</main>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
+780
View File
@@ -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)
};
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#9d3d2e"/>
<rect x="96" y="80" width="320" height="400" rx="32" fill="#fff8f0"/>
<line x1="160" y1="180" x2="360" y2="180" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
<line x1="160" y1="250" x2="360" y2="250" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
<line x1="160" y1="320" x2="320" y2="320" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
<polyline points="128,170 148,190 188,150" fill="none" stroke="#25624c" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="128,240 148,260 188,220" fill="none" stroke="#25624c" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="128" y="305" width="40" height="40" rx="6" fill="none" stroke="#685f53" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

+347
View File
@@ -0,0 +1,347 @@
<!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="stylesheet" href="/styles.css" />
</head>
<body>
<!--
This document is the shared app shell for both operator and administrator
routes. JavaScript decides which workspace to reveal based on the current
URL so the project can keep one frontend bundle while still presenting two
distinct entry points.
-->
<div class="app-shell">
<aside class="sidebar panel">
<!--
The sidebar keeps app-level actions visible across both workspaces:
sync status, template selection, navigation links, and the local draft
list. That supports quick report switching on small operational screens.
-->
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
<button id="createReportButton" class="button button-primary" type="button">
Create new report
</button>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
</div>
<div class="sidebar-section grow-section">
<div class="section-heading-row">
<h2>Local reports</h2>
<span id="reportCount" class="muted-count">0</span>
</div>
<!-- F4 — Search and status filter for the local report list -->
<div class="report-filter-row">
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="select-input select-input-small">
<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 class="workspace">
<!-- Operator workspace: draft editing, validation, and local attachments. -->
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Proof of concept frontend</p>
<h2 id="heroTitle">No report selected</h2>
<p id="heroSubtitle" class="hero-copy">
Start by syncing templates and creating a local draft.
</p>
</div>
<div class="hero-actions">
<label class="status-picker">
<span>Status</span>
<select id="reportStatusSelect" class="select-input">
<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="button button-secondary" type="button">
Submit
</button>
<button id="exportReportButton" class="button button-secondary" type="button">
Export CSV
</button>
<button id="deleteReportButton" class="button button-ghost" type="button">
Delete report
</button>
</div>
</section>
<section class="summary-grid">
<article class="summary-card panel accent-card">
<p class="summary-label">Template</p>
<strong id="summaryTemplate">Not loaded</strong>
<span id="summaryVersion" class="summary-note">Version -</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Validation</p>
<strong id="validationHeadline">No report selected</strong>
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Offline cache</p>
<strong id="syncHeadline">No sync yet</strong>
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
</article>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Report editor</h2>
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
</div>
<form id="reportForm" class="report-form">
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Inspector view</h2>
<span class="panel-note">Local draft summary</span>
</div>
<dl id="reportMeta" class="meta-list">
<div>
<dt>Report ID</dt>
<dd>-</dd>
</div>
<div>
<dt>Template</dt>
<dd>-</dd>
</div>
<div>
<dt>Created</dt>
<dd>-</dd>
</div>
<div>
<dt>Updated</dt>
<dd>-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Validation issues</h3>
<ul id="validationList" class="validation-list">
<li>No report selected.</li>
</ul>
</div>
<div class="attachment-policy">
<h3>Image policy</h3>
<p id="imagePolicyText" class="policy-copy">
Load server configuration to see image limits and optimization rules.
</p>
</div>
</aside>
</section>
</section>
<!-- Administrator workspace: server-backed configuration editing. -->
<section id="adminWorkspace" class="workspace-view" hidden>
<section class="hero panel">
<div>
<p class="eyebrow">Administrator workspace</p>
<h2>Configuration control</h2>
<p class="hero-copy">
Update centrally managed image requirements used by the inspection frontend.
</p>
</div>
<div class="hero-actions">
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
</div>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Image policy editor</h2>
<span class="panel-note">Updates the active server rule</span>
</div>
<form id="adminImageRulesForm" class="report-form admin-form">
<section class="template-section">
<div class="field-grid">
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminPolicyName">Policy name</label>
</div>
<input id="adminPolicyName" name="name" class="text-input" type="text" />
</div>
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
</div>
<input
id="adminAllowedMimeTypes"
name="allowedMimeTypes"
class="text-input"
type="text"
placeholder="image/jpeg, image/png, image/webp"
/>
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
</div>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
</div>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
</div>
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
</div>
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
</div>
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
</div>
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
<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>
</section>
<div class="admin-actions">
<button id="saveImageRulesButton" class="button button-primary" type="submit">
Save image policy
</button>
<button id="resetImageRulesButton" class="button button-secondary" type="button">
Reset form
</button>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Admin summary</h2>
<span class="panel-note">Live configuration preview</span>
</div>
<dl class="meta-list">
<div>
<dt>Active policy code</dt>
<dd id="adminPolicyCode">-</dd>
</div>
<div>
<dt>Allowed types</dt>
<dd id="adminPolicyMimeTypes">-</dd>
</div>
<div>
<dt>Optimization</dt>
<dd id="adminPolicyOptimization">-</dd>
</div>
<div>
<dt>Limits</dt>
<dd id="adminPolicyLimits">-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Admin notes</h3>
<ul class="validation-list" id="adminNotesList">
<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>
</aside>
</section>
</section>
</main>
</div>
<!--
Report list items are rendered from this template at runtime so the sidebar
can update without rebuilding the entire page markup from strings.
-->
<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>
+50
View File
@@ -0,0 +1,50 @@
/*
* API communication module. Centralizes fetch calls and service-worker
* registration so network details stay out of rendering and state logic.
*/
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.
*/
export async function fetchJson(path, options = {}) {
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
const requestOptions = {
...options,
headers: {
Accept: 'application/json',
...(options.headers || {})
}
};
const response = await fetch(url, requestOptions);
if (!response.ok) {
let message = `Request failed for ${url}: ${response.status}`;
try {
const errorPayload = await response.json();
if (errorPayload?.message) {
message = errorPayload.message;
}
} catch {
/* Ignore JSON parse errors for non-JSON responses. */
}
throw new Error(message);
}
return response.json();
}
export function registerServiceWorker() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.error('Service worker registration failed', error);
});
}
}
+30
View File
@@ -0,0 +1,30 @@
/*
* Application-wide constants shared by all frontend modules.
*
* Centralizing these values prevents magic strings and numbers from being
* scattered across the codebase and makes configuration changes easy to trace.
*/
/* IndexedDB database name and schema version (bump when stores change). */
export const DB_NAME = 'check-list-poc-db';
export const DB_VERSION = 2;
/* IndexedDB object store names. */
export const STORE_TEMPLATES = 'templates';
export const STORE_LOOKUPS = 'lookups';
export const STORE_CONFIG = 'config';
export const STORE_REPORTS = 'reports';
export const STORE_ATTACHMENTS = 'attachments';
export const STORE_SETTINGS = 'settings';
/* Autosave interval used when the server does not supply a value. */
export const DEFAULT_AUTOSAVE_SECONDS = 20;
/* Base path for all versioned API calls. */
export const API_BASE = '/api/v1';
/* Minimum delay before re-rendering the form after a field change. */
export const RENDER_DEBOUNCE_MS = 200;
/* Maximum entries kept in the Service Worker dynamic cache (LRU eviction). */
export const SW_DYNAMIC_CACHE_LIMIT = 50;
+133
View File
@@ -0,0 +1,133 @@
/*
* IndexedDB operations module. Provides typed helpers for reading and writing to
* the browser-side database. All functions depend on `state.db` being set during
* initialization.
*
* Changes from the original monolithic app.js:
* - A5: `dbTransaction()` wraps multi-store operations in a single IndexedDB
* transaction so related writes (e.g. delete report + delete attachments) are
* atomic and won't leave orphaned records on mid-operation failure.
*/
import { state } from './state.js';
import {
DB_NAME,
DB_VERSION,
STORE_TEMPLATES,
STORE_LOOKUPS,
STORE_CONFIG,
STORE_REPORTS,
STORE_ATTACHMENTS,
STORE_SETTINGS
} from './constants.js';
/* ── Database bootstrap ─────────────────────────────────────────────────── */
export function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_TEMPLATES)) {
db.createObjectStore(STORE_TEMPLATES, { keyPath: 'cacheKey' });
}
if (!db.objectStoreNames.contains(STORE_LOOKUPS)) {
db.createObjectStore(STORE_LOOKUPS, { keyPath: 'code' });
}
if (!db.objectStoreNames.contains(STORE_CONFIG)) {
db.createObjectStore(STORE_CONFIG, { keyPath: 'key' });
}
if (!db.objectStoreNames.contains(STORE_REPORTS)) {
db.createObjectStore(STORE_REPORTS, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_ATTACHMENTS)) {
const store = db.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
store.createIndex('byReportId', 'reportId', { unique: false });
}
if (!db.objectStoreNames.contains(STORE_SETTINGS)) {
db.createObjectStore(STORE_SETTINGS, { keyPath: 'key' });
}
};
});
}
/* ── Single-store helpers ───────────────────────────────────────────────── */
export function dbGetAll(storeName) {
return executeStoreRequest(storeName, 'readonly', (store) => store.getAll());
}
export function dbGet(storeName, key) {
return executeStoreRequest(storeName, 'readonly', (store) => store.get(key));
}
export function dbPut(storeName, value) {
return executeStoreRequest(storeName, 'readwrite', (store) => store.put(value));
}
export function dbDelete(storeName, key) {
return executeStoreRequest(storeName, 'readwrite', (store) => store.delete(key));
}
export function dbGetAllByIndex(storeName, indexName, key) {
return executeStoreRequest(storeName, 'readonly', (store) => {
return store.index(indexName).getAll(IDBKeyRange.only(key));
});
}
function executeStoreRequest(storeName, mode, callback) {
return new Promise((resolve, reject) => {
const transaction = state.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const request = callback(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/* ── Multi-store transaction (A5) ───────────────────────────────────────── */
/*
* Wraps multiple writes across different object stores in a single IndexedDB
* transaction. The callback receives a helper that returns a store by name.
* All writes either commit together or roll back as a unit.
*
* Example:
* await dbTransaction([STORE_REPORTS, STORE_ATTACHMENTS], 'readwrite', (getStore) => {
* getStore(STORE_REPORTS).delete(reportId);
* getStore(STORE_ATTACHMENTS).delete(attachmentId);
* });
*/
export function dbTransaction(storeNames, mode, callback) {
return new Promise((resolve, reject) => {
const tx = state.db.transaction(storeNames, mode);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));
const getStore = (name) => tx.objectStore(name);
callback(getStore);
});
}
/* ── Settings helpers ───────────────────────────────────────────────────── */
export async function saveSetting(key, value) {
await dbPut(STORE_SETTINGS, { key, value });
}
export async function loadSetting(key) {
const record = await dbGet(STORE_SETTINGS, key);
return record?.value;
}
+100
View File
@@ -0,0 +1,100 @@
/*
* 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
@@ -0,0 +1,239 @@
/*
* 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
@@ -0,0 +1,121 @@
/*
* 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 };
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Web Worker for image optimization (P1). Offloads the expensive bitmap decode,
* resize, and JPEG/PNG compression to a background thread so the UI remains
* responsive while processing large photos.
*
* Uses OffscreenCanvas which is available in Chrome 69+, Firefox 105+, and
* Safari 16.4+. The main module (images.js) detects support at runtime and
* falls back to the main thread for older browsers.
*/
function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
}
self.onmessage = async (event) => {
const { id, file, imageRules } = event.data;
try {
const imageBitmap = await createImageBitmap(file);
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d');
context.drawImage(imageBitmap, 0, 0, width, height);
imageBitmap.close();
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
const blob = await canvas.convertToBlob({ type: targetMimeType, quality });
if (!blob) {
throw new Error(`Failed to optimize image: ${file.name}`);
}
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
}
self.postMessage({
id,
result: {
blob,
width,
height,
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
}
});
} catch (error) {
self.postMessage({ id, error: error.message });
}
};
+134
View File
@@ -0,0 +1,134 @@
/*
* Image optimization module (P1). When OffscreenCanvas is available the heavy
* pixel work runs in a dedicated Web Worker so the UI thread stays responsive
* during large-image processing. On browsers that lack OffscreenCanvas support
* (or when running inside a Worker is not possible) the module falls back to
* main-thread canvas operations.
*/
let worker = null;
let workerSupported = null;
function isWorkerSupported() {
if (workerSupported !== null) {
return workerSupported;
}
try {
/* OffscreenCanvas is required inside the Worker to draw without a DOM. */
workerSupported = typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined';
} catch {
workerSupported = false;
}
return workerSupported;
}
function getWorker() {
if (!worker && isWorkerSupported()) {
worker = new Worker('/js/image-worker.js');
}
return worker;
}
/*
* Public entry point. Validates the file against the image rules, then delegates
* the actual resize/compress work to the worker or the main-thread fallback.
*/
export async function optimizeImage(file, imageRules) {
if (imageRules?.allowedMimeTypes?.length && !imageRules.allowedMimeTypes.includes(file.type)) {
throw new Error(`Unsupported file type: ${file.type}`);
}
if (imageRules?.oversizeBehavior === 'block' && file.size > imageRules.maxFileSizeBytes) {
throw new Error(`File exceeds limit: ${file.name}`);
}
const w = getWorker();
if (w) {
return optimizeInWorker(w, file, imageRules);
}
return optimizeOnMainThread(file, imageRules);
}
/* ── Worker path ────────────────────────────────────────────────────────── */
function optimizeInWorker(w, file, imageRules) {
return new Promise((resolve, reject) => {
const messageId = crypto.randomUUID();
function handler(event) {
if (event.data?.id !== messageId) {
return;
}
w.removeEventListener('message', handler);
w.removeEventListener('error', errorHandler);
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data.result);
}
}
function errorHandler(event) {
w.removeEventListener('message', handler);
w.removeEventListener('error', errorHandler);
reject(new Error(event.message || 'Worker error'));
}
w.addEventListener('message', handler);
w.addEventListener('error', errorHandler);
w.postMessage({ id: messageId, file, imageRules });
});
}
/* ── Main-thread fallback ───────────────────────────────────────────────── */
async function optimizeOnMainThread(file, imageRules) {
const imageBitmap = await createImageBitmap(file);
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.drawImage(imageBitmap, 0, 0, width, height);
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
const blob = await new Promise((resolve) => {
canvas.toBlob(resolve, targetMimeType, quality);
});
if (!blob) {
throw new Error(`Failed to optimize image: ${file.name}`);
}
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
}
return {
blob,
width,
height,
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
};
}
export function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
return {
width: Math.round(originalWidth * ratio),
height: Math.round(originalHeight * ratio)
};
}
+389
View File
@@ -0,0 +1,389 @@
/*
* 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;
}
+58
View File
@@ -0,0 +1,58 @@
/*
* Centralized application state. Every module imports the same `state` object so
* all shared data lives in one place. The `elements` object caches DOM references
* established during initialization.
*/
export const state = {
db: null,
reports: [],
templatesCatalog: [],
templateDefinitions: new Map(),
lookups: new Map(),
imageRules: null,
appConfig: new Map(),
currentReportId: null,
currentAttachments: [],
selectedTemplateCode: null,
saveState: 'idle',
saveTimer: null,
autosaveIntervalId: null,
lastSyncAt: null,
/* Dirty flag: true when the current report has unsaved field changes (P6). */
dirty: false,
/* Search/filter state for the report list (F4). */
reportSearchQuery: '',
reportFilterStatus: ''
};
/* Cached DOM element references, populated once during init. */
export const elements = {};
/* ── State accessors ────────────────────────────────────────────────────── */
export function getCurrentReport() {
return state.reports.find((item) => item.id === state.currentReportId) || null;
}
export function getTemplateRecord(code, version) {
if (!code || version == null) {
return null;
}
const { makeTemplateKey } = stateHelpers;
return state.templateDefinitions.get(makeTemplateKey(code, version)) || null;
}
/*
* Externalizing makeTemplateKey as a helper avoids a circular import — utils.js
* cannot import state.js. Instead state.js imports nothing from utils; callers
* that need both can reference stateHelpers.makeTemplateKey.
*/
const stateHelpers = {
makeTemplateKey(code, version) {
return `${code}::${version}`;
}
};
export { stateHelpers };
+150
View File
@@ -0,0 +1,150 @@
/*
* 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);
};
}
+100
View File
@@ -0,0 +1,100 @@
/*
* Report and image-rules validation. These are pure functions with no DOM or
* state dependencies so they can be tested independently and kept in sync with
* server-side validation in src/routes/configRoutes.js (A7).
*/
/* ── Report validation ──────────────────────────────────────────────────── */
/*
* Returns an array of human-readable issue strings. The caller decides whether
* issues are informational or blocking.
*/
export function validateReport(report, template, attachments) {
const issues = [];
for (const section of template.definition.sections || []) {
for (const field of section.fields || []) {
const value = report.answers[field.id];
const required = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
if (field.type === 'attachment') {
const fieldAttachments = attachments.filter((item) => item.fieldId === field.id);
if (required && fieldAttachments.length === 0) {
issues.push(`${field.label}: at least one image is required.`);
}
continue;
}
if (required && isBlankValue(value, field.type)) {
issues.push(`${field.label}: value is required.`);
}
if (field.type === 'number' && value !== '' && value != null) {
if (Number.isNaN(Number(value))) {
issues.push(`${field.label}: number is invalid.`);
}
if (field.validation?.min !== undefined && Number(value) < field.validation.min) {
issues.push(`${field.label}: must be at least ${field.validation.min}.`);
}
}
}
}
return issues;
}
/* ── Image-rules validation (mirrors server-side logic in configRoutes) ── */
export function validateImageRulesPayload(payload) {
if (!payload.name) {
return 'Policy name is required';
}
if (!payload.allowedMimeTypes.length) {
return 'Add at least one MIME type';
}
if (!Number.isFinite(payload.maxFileSizeBytes) || payload.maxFileSizeBytes <= 0) {
return 'Max file size must be greater than 0';
}
if (!Number.isInteger(payload.maxWidthPx) || payload.maxWidthPx <= 0) {
return 'Max width must be a positive number';
}
if (!Number.isInteger(payload.maxHeightPx) || payload.maxHeightPx <= 0) {
return 'Max height must be a positive number';
}
if (!Number.isInteger(payload.jpegQuality) || payload.jpegQuality < 1 || payload.jpegQuality > 100) {
return 'JPEG quality must be between 1 and 100';
}
if (!Number.isInteger(payload.maxAttachmentsPerField) || payload.maxAttachmentsPerField <= 0) {
return 'Max attachments must be a positive number';
}
return null;
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
export function evaluateRequiredWhen(requiredWhen, answers) {
if (!requiredWhen?.field) {
return false;
}
return answers[requiredWhen.field] === requiredWhen.equals;
}
export function isBlankValue(value, type) {
if (type === 'checkbox') {
return false;
}
return value === '' || value == null;
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Check List Proof of Concept",
"short_name": "Check List",
"start_url": "/",
"display": "standalone",
"background_color": "#f3efe6",
"theme_color": "#f3efe6",
"description": "Offline-first proof of concept for template-driven quality inspection reports.",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+48
View File
@@ -0,0 +1,48 @@
<!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 Portal</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body class="portal-body">
<!--
The portal intentionally acts as a simple role chooser rather than a real
authentication page. It separates user and admin entry points in the PoC
without committing the project to a security model too early.
-->
<main class="portal-shell">
<section class="portal-hero panel">
<p class="eyebrow">Check List Access</p>
<h1>Choose workspace</h1>
<p class="portal-copy">
Use the operator workspace for quality reports and the administrator workspace for configuration.
</p>
</section>
<section class="portal-grid">
<!-- Direct operator entry for report creation and local draft work. -->
<a class="portal-card panel" href="/user">
<p class="summary-label">User area</p>
<h2>Operator workspace</h2>
<p class="portal-copy">
Create reports, work offline, attach images, and manage local drafts.
</p>
<span class="button button-primary portal-button">Open user area</span>
</a>
<!-- Direct administrator entry for centrally managed configuration. -->
<a class="portal-card panel" href="/admin">
<p class="summary-label">Admin area</p>
<h2>Administrator workspace</h2>
<p class="portal-copy">
Maintain image requirements and other centrally managed configuration.
</p>
<span class="button button-secondary portal-button">Open admin area</span>
</a>
</section>
</main>
</body>
</html>
+638
View File
@@ -0,0 +1,638 @@
:root {
/*
* The visual system uses a warm industrial palette instead of generic neutral
* SaaS colors. The goal is to make the PoC feel closer to an operational tool
* used in inspection environments than to a stock admin dashboard.
*/
--bg: #f3efe6;
--bg-strong: #e8dcc7;
--panel: rgba(255, 252, 247, 0.92);
--panel-border: rgba(93, 67, 35, 0.16);
--text: #1c1a18;
--muted: #685f53;
--accent: #9d3d2e;
--accent-strong: #7f2c20;
--accent-soft: #f4d2bf;
--success: #25624c;
--warning: #8a6119;
--danger: #8b2e34;
--shadow: 0 20px 50px rgba(77, 48, 18, 0.12);
--radius-lg: 24px;
--radius-md: 16px;
--radius-sm: 12px;
--font-ui: "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), transparent 35%),
linear-gradient(135deg, #f8f2e8 0%, #ead9bb 44%, #e9d8c5 100%);
color: var(--text);
font-family: var(--font-ui);
}
body {
min-height: 100vh;
}
.portal-body {
display: grid;
place-items: center;
padding: 24px;
}
button,
input,
select,
textarea {
font: inherit;
}
.app-shell {
/*
* The main layout keeps navigation and report context visible at the same time.
* This is important for a checklist workflow where users often switch reports
* and need immediate awareness of save state, sync state, and current draft.
*/
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
min-height: 100vh;
gap: 20px;
padding: 20px;
}
.panel {
border: 1px solid var(--panel-border);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.sidebar {
display: flex;
flex-direction: column;
padding: 24px;
gap: 22px;
}
.brand-block h1,
.hero h2,
.section-heading-row h2,
.empty-state h3,
.validation-block h3,
.attachment-policy h3 {
margin: 0;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent-strong);
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 0.74rem;
font-weight: 700;
}
.lede,
.hero-copy,
.panel-note,
.summary-note,
.policy-copy,
.empty-state p,
.meta-list dd,
.validation-list,
.report-list-item__meta {
color: var(--muted);
}
.sidebar-section {
display: grid;
gap: 12px;
}
.sidebar-link {
display: inline-flex;
justify-content: center;
text-decoration: none;
}
.sidebar-link.is-active {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff8f0;
}
.portal-shell {
/*
* The chooser page is intentionally sparse. Its only job is to separate entry
* points for operators and administrators without forcing a more complex auth
* design into the PoC before roles and identity are finalized.
*/
width: min(1100px, 100%);
display: grid;
gap: 24px;
}
.portal-hero {
padding: 32px;
}
.portal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.portal-card {
display: grid;
gap: 16px;
padding: 28px;
text-decoration: none;
color: inherit;
}
.portal-copy {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.portal-button {
width: fit-content;
}
.grow-section {
min-height: 0;
flex: 1;
}
.status-row,
.section-heading-row,
.hero-actions,
.field-header,
.attachment-toolbar,
.report-list-item__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-heading-row {
align-items: baseline;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
}
.badge-neutral {
background: rgba(28, 26, 24, 0.08);
color: var(--text);
}
.badge-online,
.status-in_progress,
.status-ready_for_export {
background: rgba(37, 98, 76, 0.12);
color: var(--success);
}
.badge-offline,
.status-draft,
.status-archived {
background: rgba(138, 97, 25, 0.12);
color: var(--warning);
}
.badge-error,
.status-exported {
background: rgba(139, 46, 52, 0.12);
color: var(--danger);
}
.button {
border: 0;
border-radius: 999px;
padding: 12px 16px;
font-weight: 700;
cursor: pointer;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.button:hover {
transform: translateY(-1px);
}
.button-primary {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff8f0;
box-shadow: 0 12px 24px rgba(157, 61, 46, 0.25);
}
.button-secondary {
background: rgba(28, 26, 24, 0.08);
color: var(--text);
}
.button-ghost {
background: transparent;
color: var(--danger);
border: 1px solid rgba(139, 46, 52, 0.18);
}
.button-small {
padding: 8px 12px;
font-size: 0.84rem;
}
.workspace {
display: grid;
gap: 20px;
}
.workspace-view {
/*
* User and admin workspaces share one HTML document. Hidden sections let the
* route control which workspace is visible while still reusing common styling
* and keeping asset delivery simple.
*/
display: none;
gap: 20px;
}
.workspace-view-active {
display: grid;
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 24px 28px;
overflow: hidden;
position: relative;
}
.hero::after {
content: "";
position: absolute;
inset: auto -80px -80px auto;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(157, 61, 46, 0.18), transparent 70%);
}
.hero-actions {
align-items: end;
flex-wrap: wrap;
}
.status-picker {
display: grid;
gap: 6px;
min-width: 220px;
}
.summary-grid,
.editor-grid {
/*
* These grids create a consistent rhythm between overview cards and working
* panels so the operator can scan status quickly before dropping into detail.
*/
display: grid;
gap: 20px;
}
.summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-card {
padding: 20px 22px;
}
.accent-card {
background: linear-gradient(145deg, rgba(157, 61, 46, 0.12), rgba(255, 252, 247, 0.96));
}
.summary-label,
.field-label,
.meta-list dt {
margin: 0 0 8px;
color: var(--muted);
font-size: 0.84rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.editor-grid {
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 380px);
}
.editor-panel,
.inspector-panel {
padding: 24px;
}
.admin-form {
margin-top: 16px;
}
.admin-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.report-form {
/*
* The form styles are generic on purpose because fields are generated from
* template JSON. The same primitives must support report editing and admin
* configuration without each field type needing a dedicated page-specific skin.
*/
display: grid;
gap: 20px;
margin-top: 16px;
}
.template-section {
display: grid;
gap: 16px;
padding: 20px;
border-radius: var(--radius-md);
background: rgba(243, 239, 230, 0.68);
border: 1px solid rgba(93, 67, 35, 0.12);
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field {
display: grid;
gap: 8px;
}
.field-full {
grid-column: 1 / -1;
}
.field-header {
align-items: baseline;
}
.required-pill {
color: var(--accent-strong);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.text-input,
.select-input,
.text-area,
.file-input {
width: 100%;
border: 1px solid rgba(93, 67, 35, 0.18);
background: rgba(255, 255, 255, 0.82);
color: var(--text);
border-radius: 14px;
padding: 12px 14px;
}
.text-input:focus,
.select-input:focus,
.text-area:focus,
.file-input:focus {
outline: 2px solid rgba(157, 61, 46, 0.22);
outline-offset: 2px;
}
.text-area {
min-height: 120px;
resize: vertical;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(93, 67, 35, 0.18);
}
.field-help {
margin: 0;
font-size: 0.86rem;
color: var(--muted);
}
.report-list {
display: grid;
gap: 10px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
/* F4 — Search and filter controls above the report list */
.report-filter-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.text-input-small,
.select-input-small {
padding: 8px 12px;
font-size: 0.88rem;
border-radius: 12px;
}
.select-input-small {
min-width: 120px;
}
.report-list-item {
width: 100%;
text-align: left;
border: 1px solid rgba(93, 67, 35, 0.12);
background: rgba(255, 255, 255, 0.74);
border-radius: 16px;
padding: 14px;
display: grid;
gap: 8px;
}
.report-list-item.is-active {
border-color: rgba(157, 61, 46, 0.36);
box-shadow: inset 0 0 0 1px rgba(157, 61, 46, 0.16);
background: rgba(244, 210, 191, 0.36);
}
.report-list-item__title {
font-size: 0.96rem;
}
.muted-count {
color: var(--muted);
font-size: 0.88rem;
}
.empty-state {
display: grid;
place-items: center;
min-height: 240px;
text-align: center;
padding: 24px;
border-radius: var(--radius-md);
background: rgba(243, 239, 230, 0.68);
border: 1px dashed rgba(93, 67, 35, 0.24);
}
.meta-list {
display: grid;
gap: 14px;
margin: 0;
}
.meta-list div {
display: grid;
gap: 4px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(93, 67, 35, 0.1);
}
.meta-list dd {
margin: 0;
}
.validation-block,
.attachment-policy {
margin-top: 26px;
}
.validation-list {
padding-left: 18px;
}
.validation-list li + li {
margin-top: 8px;
}
.attachment-list {
display: grid;
gap: 12px;
}
.attachment-card {
/*
* Attachments need enough visual weight to confirm that a photo is really tied
* to a report item. The card layout reserves space for preview, metadata, and
* removal action without requiring a modal or separate gallery screen.
*/
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(93, 67, 35, 0.12);
}
.attachment-preview {
width: 88px;
height: 88px;
border-radius: 12px;
object-fit: cover;
background: rgba(93, 67, 35, 0.08);
}
.attachment-card__copy {
display: grid;
gap: 4px;
min-width: 0;
}
.attachment-card__copy strong,
.attachment-card__copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 1180px) {
/*
* Tablet and narrow laptop layouts collapse the two-column structure into a
* single column so the editing surface remains usable without horizontal scroll.
*/
.app-shell,
.editor-grid,
.summary-grid,
.portal-grid {
grid-template-columns: 1fr;
}
.sidebar {
min-height: auto;
}
.report-list {
max-height: 260px;
}
}
@media (max-width: 760px) {
/*
* Mobile layout prioritizes single-column readability and larger preview areas.
* This matters because one of the project requirements is viable use on phones
* where camera capture and image attachment happen directly in the browser.
*/
.app-shell {
padding: 14px;
gap: 14px;
}
.hero,
.hero-actions,
.field-grid,
.attachment-card {
grid-template-columns: 1fr;
display: grid;
}
.field-grid {
gap: 14px;
}
.attachment-card {
grid-template-columns: 1fr;
}
.attachment-preview {
width: 100%;
height: 180px;
}
}
+118
View File
@@ -0,0 +1,118 @@
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());
}
}
+194
View File
@@ -0,0 +1,194 @@
<!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 — User</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
Operator workspace: report creation, local draft editing, validation,
image attachments, submission, and CSV export.
-->
<div class="app-shell">
<aside class="sidebar panel">
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
<button id="createReportButton" class="button button-primary" type="button">
Create new report
</button>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link is-active" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
</div>
<div class="sidebar-section grow-section">
<div class="section-heading-row">
<h2>Local reports</h2>
<span id="reportCount" class="muted-count">0</span>
</div>
<div class="report-filter-row">
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="select-input select-input-small">
<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 class="workspace">
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Proof of concept frontend</p>
<h2 id="heroTitle">No report selected</h2>
<p id="heroSubtitle" class="hero-copy">
Start by syncing templates and creating a local draft.
</p>
</div>
<div class="hero-actions">
<label class="status-picker">
<span>Status</span>
<select id="reportStatusSelect" class="select-input">
<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="button button-secondary" type="button">
Submit
</button>
<button id="exportReportButton" class="button button-secondary" type="button">
Export CSV
</button>
<button id="deleteReportButton" class="button button-ghost" type="button">
Delete report
</button>
</div>
</section>
<section class="summary-grid">
<article class="summary-card panel accent-card">
<p class="summary-label">Template</p>
<strong id="summaryTemplate">Not loaded</strong>
<span id="summaryVersion" class="summary-note">Version -</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Validation</p>
<strong id="validationHeadline">No report selected</strong>
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Offline cache</p>
<strong id="syncHeadline">No sync yet</strong>
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
</article>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Report editor</h2>
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
</div>
<form id="reportForm" class="report-form">
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Inspector view</h2>
<span class="panel-note">Local draft summary</span>
</div>
<dl id="reportMeta" class="meta-list">
<div>
<dt>Report ID</dt>
<dd>-</dd>
</div>
<div>
<dt>Template</dt>
<dd>-</dd>
</div>
<div>
<dt>Created</dt>
<dd>-</dd>
</div>
<div>
<dt>Updated</dt>
<dd>-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Validation issues</h3>
<ul id="validationList" class="validation-list">
<li>No report selected.</li>
</ul>
</div>
<div class="attachment-policy">
<h3>Image policy</h3>
<p id="imagePolicyText" class="policy-copy">
Load server configuration to see image limits and optimization rules.
</p>
</div>
</aside>
</section>
</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>