modified version
This commit is contained in:
+10
-3
@@ -655,15 +655,22 @@ function renderUserList() {
|
||||
container.innerHTML = '<div class="empty-state"><h3>No users</h3><p>Click "Add User" to create one.</p></div>';
|
||||
return;
|
||||
}
|
||||
const rows = admin.users.map((u) => `<tr>
|
||||
const rows = admin.users.map((u) => {
|
||||
const taskCount = admin.tasks.filter((t) => t.userId === u.id).length;
|
||||
const taskBadge = taskCount > 0
|
||||
? `<span class="badge bg-primary">${taskCount}</span>`
|
||||
: `<span class="badge bg-secondary">0</span>`;
|
||||
return `<tr>
|
||||
<td>${esc(u.email)}</td><td>${esc(u.name)}</td><td>${esc(u.familyName)}</td>
|
||||
<td>${esc(u.company || '-')}</td><td>${esc(u.role || '-')}</td>
|
||||
<td class="text-center">${taskBadge}</td>
|
||||
<td class="admin-table-actions">
|
||||
<button class="button button-small button-secondary" data-edit-user="${u.id}">Edit</button>
|
||||
<button class="button button-small button-ghost" data-delete-user="${u.id}">Delete</button>
|
||||
</td></tr>`).join('');
|
||||
</td></tr>`;
|
||||
}).join('');
|
||||
container.innerHTML = `<table class="admin-table"><thead><tr>
|
||||
<th>Email</th><th>Name</th><th>Family Name</th><th>Company</th><th>Role</th><th>Actions</th>
|
||||
<th>Email</th><th>Name</th><th>Family Name</th><th>Company</th><th>Role</th><th>Tasks</th><th>Actions</th>
|
||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
container.querySelectorAll('[data-edit-user]').forEach((b) => b.addEventListener('click', () => editUser(Number(b.dataset.editUser))));
|
||||
container.querySelectorAll('[data-delete-user]').forEach((b) => b.addEventListener('click', () => deleteUser(Number(b.dataset.deleteUser))));
|
||||
|
||||
+8
-12
@@ -1,13 +1,17 @@
|
||||
/*
|
||||
* API communication module. Centralizes fetch calls and service-worker
|
||||
* registration so network details stay out of rendering and state logic.
|
||||
* API communication module. Centralizes fetch calls so network details stay
|
||||
* out of rendering and state logic. All JSON traffic from the frontend goes
|
||||
* through `fetchJson()`, which prepends the versioned base path and unwraps
|
||||
* structured error responses into regular thrown errors.
|
||||
*/
|
||||
|
||||
import { API_BASE } from './constants.js';
|
||||
|
||||
/*
|
||||
* Generic JSON fetcher. All frontend API calls pass through this function so
|
||||
* error handling, header defaults, and base path are consistent everywhere.
|
||||
* Generic JSON fetcher. Prepends the API base path when the caller passes a
|
||||
* relative path, forwards headers, and parses the response body on success.
|
||||
* Non-2xx responses raise an `Error` whose message is the server's `message`
|
||||
* field when present, otherwise a generic status-code fallback.
|
||||
*/
|
||||
export async function fetchJson(path, options = {}) {
|
||||
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||
@@ -40,11 +44,3 @@ export async function fetchJson(path, options = {}) {
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function registerServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').catch((error) => {
|
||||
console.error('Service worker registration failed', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
/*
|
||||
* CSV and attachment export module (F2). Generates a CSV file from the current
|
||||
* report's answers and allows downloading individual attachments. XLSX and ZIP
|
||||
* export can be added by integrating SheetJS and JSZip libraries.
|
||||
*/
|
||||
|
||||
import { state, getCurrentReport, getTemplateRecord } from './state.js';
|
||||
import { dbGetAllByIndex } from './db.js';
|
||||
import { STORE_ATTACHMENTS } from './constants.js';
|
||||
|
||||
/*
|
||||
* Exports the active report as a CSV file. Columns are derived from the template
|
||||
* definition so field labels appear as headers and field values as the row.
|
||||
*/
|
||||
export async function exportReportCSV() {
|
||||
const report = getCurrentReport();
|
||||
|
||||
if (!report) {
|
||||
throw new Error('No report to export');
|
||||
}
|
||||
|
||||
const template = getTemplateRecord(report.templateCode, report.templateVersion);
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template definition needed for export');
|
||||
}
|
||||
|
||||
const headers = [];
|
||||
const values = [];
|
||||
|
||||
/* Meta columns. */
|
||||
headers.push('Report Number', 'Template', 'Version', 'Status', 'Created', 'Updated');
|
||||
values.push(
|
||||
report.reportNumber,
|
||||
report.templateCode,
|
||||
String(report.templateVersion),
|
||||
report.status,
|
||||
report.createdAt,
|
||||
report.updatedAt
|
||||
);
|
||||
|
||||
/* Dynamic field columns derived from the template definition. */
|
||||
for (const section of template.definition.sections || []) {
|
||||
for (const field of section.fields || []) {
|
||||
if (field.type === 'attachment') {
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.push(field.label);
|
||||
const raw = report.answers[field.id];
|
||||
values.push(raw === undefined || raw === null ? '' : String(raw));
|
||||
}
|
||||
}
|
||||
|
||||
const csvContent = [
|
||||
headers.map(csvEscape).join(','),
|
||||
values.map(csvEscape).join(',')
|
||||
].join('\r\n');
|
||||
|
||||
downloadBlob(
|
||||
new Blob([csvContent], { type: 'text/csv;charset=utf-8' }),
|
||||
`${report.reportNumber || 'report'}.csv`
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Exports all attachments for the active report as individual file downloads.
|
||||
* A future iteration could bundle these into a ZIP archive using JSZip.
|
||||
*/
|
||||
export async function exportReportAttachments() {
|
||||
const report = getCurrentReport();
|
||||
|
||||
if (!report) {
|
||||
throw new Error('No report to export');
|
||||
}
|
||||
|
||||
const attachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', report.id);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
downloadBlob(attachment.blob, attachment.generatedFilename);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function csvEscape(value) {
|
||||
const str = String(value).replace(/"/g, '""');
|
||||
return /[",\r\n]/.test(str) ? `"${str}"` : str;
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
/*
|
||||
* Dynamic form field creation. Each function returns a DOM node tree for a
|
||||
* single template field. The approach keeps template-driven rendering in one
|
||||
* module while the orchestrator (app.js) provides callbacks for state mutations.
|
||||
*
|
||||
* Callback contract:
|
||||
* onFieldChange(field, nextValue) — called when the user edits a field
|
||||
* onAttachFiles(field, report, files) — called when files are selected
|
||||
* onRemoveAttachment(attachmentId) — called to remove an attachment
|
||||
*/
|
||||
|
||||
import { evaluateRequiredWhen } from './validation.js';
|
||||
import { formatFileSize } from './utils.js';
|
||||
|
||||
export function createFieldNode(field, report, { state, onFieldChange, onAttachFiles, onRemoveAttachment }) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `field ${field.type === 'comment' || field.type === 'attachment' ? 'field-full' : ''}`;
|
||||
|
||||
const isRequired = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
|
||||
const currentValue = report.answers[field.id];
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="field-header">
|
||||
<label class="field-label" for="field-${field.id}">${escapeHtml(field.label)}</label>
|
||||
${isRequired ? '<span class="required-pill">Required</span>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
let inputNode;
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'date':
|
||||
case 'number':
|
||||
inputNode = document.createElement('input');
|
||||
inputNode.className = 'text-input';
|
||||
inputNode.id = `field-${field.id}`;
|
||||
inputNode.name = field.id;
|
||||
inputNode.type = field.type;
|
||||
inputNode.value = currentValue ?? '';
|
||||
inputNode.readOnly = Boolean(field.readOnly);
|
||||
|
||||
if (field.validation?.min !== undefined) {
|
||||
inputNode.min = String(field.validation.min);
|
||||
}
|
||||
|
||||
inputNode.addEventListener('input', (event) => {
|
||||
onFieldChange(field, event.target.value);
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case 'lookup': {
|
||||
inputNode = document.createElement('select');
|
||||
inputNode.className = 'select-input';
|
||||
inputNode.id = `field-${field.id}`;
|
||||
inputNode.name = field.id;
|
||||
|
||||
const emptyOption = document.createElement('option');
|
||||
emptyOption.value = '';
|
||||
emptyOption.textContent = 'Select an option';
|
||||
inputNode.append(emptyOption);
|
||||
|
||||
const lookup = state.lookups.get(field.lookupCode);
|
||||
|
||||
for (const optionData of lookup?.values || []) {
|
||||
const option = document.createElement('option');
|
||||
option.value = optionData.value;
|
||||
option.textContent = optionData.label;
|
||||
option.selected = optionData.value === currentValue;
|
||||
inputNode.append(option);
|
||||
}
|
||||
|
||||
inputNode.addEventListener('change', (event) => {
|
||||
onFieldChange(field, event.target.value);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'checkbox':
|
||||
inputNode = document.createElement('label');
|
||||
inputNode.className = 'checkbox-row';
|
||||
inputNode.innerHTML = `
|
||||
<input id="field-${field.id}" name="${field.id}" type="checkbox" ${currentValue ? 'checked' : ''} />
|
||||
<span>${escapeHtml(field.label)}</span>
|
||||
`;
|
||||
|
||||
inputNode.querySelector('input').addEventListener('change', (event) => {
|
||||
onFieldChange(field, event.target.checked);
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case 'comment':
|
||||
inputNode = document.createElement('textarea');
|
||||
inputNode.className = 'text-area';
|
||||
inputNode.id = `field-${field.id}`;
|
||||
inputNode.name = field.id;
|
||||
inputNode.maxLength = field.maxLength || 5000;
|
||||
inputNode.value = currentValue ?? '';
|
||||
|
||||
inputNode.addEventListener('input', (event) => {
|
||||
onFieldChange(field, event.target.value);
|
||||
});
|
||||
|
||||
break;
|
||||
|
||||
case 'attachment':
|
||||
inputNode = createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment });
|
||||
break;
|
||||
|
||||
default:
|
||||
inputNode = document.createElement('input');
|
||||
inputNode.className = 'text-input';
|
||||
inputNode.id = `field-${field.id}`;
|
||||
inputNode.name = field.id;
|
||||
inputNode.type = 'text';
|
||||
inputNode.value = currentValue ?? '';
|
||||
|
||||
inputNode.addEventListener('input', (event) => {
|
||||
onFieldChange(field, event.target.value);
|
||||
});
|
||||
}
|
||||
|
||||
wrapper.append(inputNode);
|
||||
|
||||
if (field.requiredWhen?.field) {
|
||||
const help = document.createElement('p');
|
||||
help.className = 'field-help';
|
||||
help.textContent = `Required when ${field.requiredWhen.field} is ${String(field.requiredWhen.equals)}.`;
|
||||
wrapper.append(help);
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/* ── Attachment field ───────────────────────────────────────────────────── */
|
||||
|
||||
function createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment }) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'attachment-list';
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'attachment-toolbar';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'file-input';
|
||||
input.id = `field-${field.id}`;
|
||||
input.type = 'file';
|
||||
input.accept = state.imageRules?.allowedMimeTypes?.join(',') || 'image/*';
|
||||
input.multiple = true;
|
||||
|
||||
input.addEventListener('change', async (event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
await onAttachFiles(field, report, files);
|
||||
event.target.value = '';
|
||||
});
|
||||
|
||||
toolbar.append(input);
|
||||
container.append(toolbar);
|
||||
|
||||
/*
|
||||
* P3 — Lazy-load attachment previews. Thumbnails are created from object URLs
|
||||
* on demand using IntersectionObserver. Only attachments scrolled into view
|
||||
* allocate a Blob URL, keeping memory use proportional to the visible area.
|
||||
*/
|
||||
const attachments = state.currentAttachments.filter((item) => item.fieldId === field.id);
|
||||
|
||||
if (!attachments.length) {
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'field-help';
|
||||
hint.textContent = 'No images attached yet.';
|
||||
container.append(hint);
|
||||
return container;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const img = entry.target;
|
||||
const attachment = attachments.find((a) => a.id === img.dataset.attachmentId);
|
||||
|
||||
if (attachment?.blob) {
|
||||
const objectUrl = URL.createObjectURL(attachment.blob);
|
||||
img.src = objectUrl;
|
||||
img.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true });
|
||||
}
|
||||
|
||||
observer.unobserve(img);
|
||||
}
|
||||
}, { rootMargin: '200px' });
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'attachment-card';
|
||||
|
||||
const preview = document.createElement('img');
|
||||
preview.className = 'attachment-preview';
|
||||
preview.alt = attachment.generatedFilename;
|
||||
preview.dataset.attachmentId = attachment.id;
|
||||
/* Actual src loaded lazily by the IntersectionObserver above. */
|
||||
|
||||
observer.observe(preview);
|
||||
|
||||
const copy = document.createElement('div');
|
||||
copy.className = 'attachment-card__copy';
|
||||
copy.innerHTML = `
|
||||
<strong>${escapeHtml(attachment.generatedFilename)}</strong>
|
||||
<span>${escapeHtml(attachment.originalFilename)}</span>
|
||||
<span>${attachment.width}x${attachment.height}px • ${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;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/*
|
||||
* Lightweight i18n module. All user-facing strings are collected here so the app
|
||||
* can be translated by swapping or extending the locale object. The current
|
||||
* implementation is English-only; a future iteration could load locale files
|
||||
* dynamically based on a user preference.
|
||||
*/
|
||||
|
||||
const en = {
|
||||
/* General */
|
||||
appTitle: 'Check List',
|
||||
appTagline: 'Offline-first proof of concept for template-driven quality reports.',
|
||||
checkingConnection: 'Checking connection',
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
noChanges: 'No changes',
|
||||
|
||||
/* Sync */
|
||||
syncingTemplates: 'Syncing templates',
|
||||
templatesSynced: 'Templates synced',
|
||||
syncFailed: 'Sync failed, using cache',
|
||||
offlineMode: 'Offline mode',
|
||||
|
||||
/* Reports */
|
||||
noReportSelected: 'No report selected',
|
||||
noReportSelectedHint: 'Start by syncing templates and creating a local draft.',
|
||||
noLocalReports: 'No local reports yet.',
|
||||
selectTemplate: 'Select a template first',
|
||||
templateNotAvailable: 'Template not available locally',
|
||||
newReportCreated: 'New report created',
|
||||
reportDeleted: 'Report deleted',
|
||||
deleteReportConfirm: 'Delete local report {0}?',
|
||||
draftUpdated: 'Draft updated',
|
||||
statusChanged: 'Status changed',
|
||||
saved: 'Saved {0}',
|
||||
|
||||
/* Submission */
|
||||
submitting: 'Submitting report…',
|
||||
submitted: 'Report submitted to server',
|
||||
submitFailed: 'Submission failed',
|
||||
goOnlineToSubmit: 'Go online to submit a report',
|
||||
|
||||
/* Validation */
|
||||
readyForExport: 'Ready for export validation',
|
||||
noBlockingIssues: 'No blocking validation issues detected for the current draft.',
|
||||
noValidationIssues: 'No validation issues.',
|
||||
issueCount: '{0} issue(s) to resolve',
|
||||
issueReadyWarning: 'The report is marked ready, but validation still has blocking items.',
|
||||
issueDraftHint: 'Draft save is still allowed, but export should be blocked until these issues are fixed.',
|
||||
templateUnavailable: 'Template unavailable',
|
||||
validationNeedsTemplate: 'Validation cannot run until the template is cached again.',
|
||||
required: 'value is required.',
|
||||
numberInvalid: 'number is invalid.',
|
||||
numberMin: 'must be at least {0}.',
|
||||
imageRequired: 'at least one image is required.',
|
||||
templateMissing: 'Template definition missing for this report version.',
|
||||
|
||||
/* Attachments */
|
||||
noImagesAttached: 'No images attached yet.',
|
||||
maxAttachments: 'Only {0} attachment(s) allowed',
|
||||
imageSkipped: 'Image skipped: {0}',
|
||||
imagesUpdated: 'Images updated',
|
||||
attachmentRemoved: 'Attachment removed',
|
||||
unsupportedFileType: 'Unsupported file type: {0}',
|
||||
fileExceedsLimit: 'File exceeds limit: {0}',
|
||||
optimizeFailed: 'Failed to optimize image: {0}',
|
||||
optimizedStillExceeds: 'Optimized image still exceeds limit: {0}',
|
||||
|
||||
/* Admin */
|
||||
savingImagePolicy: 'Saving image policy',
|
||||
imagePolicySaved: 'Image policy saved',
|
||||
goOnlineToSave: 'Go online to save admin settings',
|
||||
policyNameRequired: 'Policy name is required',
|
||||
addMimeType: 'Add at least one MIME type',
|
||||
maxFileSizePositive: 'Max file size must be greater than 0',
|
||||
maxWidthPositive: 'Max width must be a positive number',
|
||||
maxHeightPositive: 'Max height must be a positive number',
|
||||
jpegQualityRange: 'JPEG quality must be between 1 and 100',
|
||||
maxAttachmentsPositive: 'Max attachments must be a positive number',
|
||||
|
||||
/* Template management */
|
||||
publishingVersion: 'Publishing version…',
|
||||
versionPublished: 'Template version published',
|
||||
publishFailed: 'Failed to publish version',
|
||||
|
||||
/* Export */
|
||||
exportStarted: 'Preparing export…',
|
||||
exportComplete: 'Export ready — file downloaded',
|
||||
exportFailed: 'Export failed',
|
||||
noReportToExport: 'Open a report before exporting',
|
||||
noTemplateForExport: 'Template definition needed for export',
|
||||
|
||||
/* Search */
|
||||
searchPlaceholder: 'Search reports…',
|
||||
|
||||
/* Misc */
|
||||
noTemplatesCached: 'No cached templates available',
|
||||
liveConfig: 'Live server configuration',
|
||||
offlineCachedConfig: 'Offline cached configuration',
|
||||
noImageRulesLoaded: 'No image rules loaded',
|
||||
imagePolicyHint: 'Load server configuration to see image limits and optimization rules.'
|
||||
};
|
||||
|
||||
let currentLocale = en;
|
||||
|
||||
/*
|
||||
* Retrieve a translated string by key. Optional positional parameters replace
|
||||
* {0}, {1}, etc. placeholders.
|
||||
*/
|
||||
export function t(key, ...params) {
|
||||
let text = currentLocale[key] ?? key;
|
||||
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
text = text.replace(`{${i}}`, String(params[i]));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
export function setLocale(locale) {
|
||||
currentLocale = { ...en, ...locale };
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
/*
|
||||
* Rendering module. All functions that manipulate the visible DOM live here.
|
||||
* Each render function reads from the shared state and writes to the cached
|
||||
* element references. The module has no side effects beyond DOM mutation.
|
||||
*/
|
||||
|
||||
import { state, elements, getCurrentReport, getTemplateRecord } from './state.js';
|
||||
import { createFieldNode } from './forms.js';
|
||||
import { validateReport } from './validation.js';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
formatRelativeTime,
|
||||
formatFileSize,
|
||||
prettifyStatus,
|
||||
badgeClassForTone
|
||||
} from './utils.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/* ── Top-level render orchestrator ──────────────────────────────────────── */
|
||||
|
||||
export function render(fieldCallbacks) {
|
||||
renderTemplateSelector();
|
||||
|
||||
/* User workspace renders — only when the report editor DOM exists. */
|
||||
if (elements.reportForm) {
|
||||
renderReportList();
|
||||
renderTemplateSummary();
|
||||
renderCurrentReport(fieldCallbacks);
|
||||
renderSyncSummary();
|
||||
renderImagePolicy();
|
||||
}
|
||||
|
||||
/* Admin workspace renders — only when the admin form DOM exists. */
|
||||
if (elements.adminImageRulesForm) {
|
||||
renderAdminImageRules();
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Template selector ──────────────────────────────────────────────────── */
|
||||
|
||||
export function renderTemplateSelector() {
|
||||
const currentValue = state.selectedTemplateCode;
|
||||
elements.templateSelect.innerHTML = '';
|
||||
|
||||
if (!state.templatesCatalog.length) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = t('noTemplatesCached');
|
||||
elements.templateSelect.append(option);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const template of state.templatesCatalog) {
|
||||
const option = document.createElement('option');
|
||||
option.value = template.code;
|
||||
option.textContent = `${template.name} (v${template.activeVersion})`;
|
||||
option.selected = template.code === currentValue;
|
||||
elements.templateSelect.append(option);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Report list (F4 — with search & status filter) ─────────────────────── */
|
||||
|
||||
export function renderReportList() {
|
||||
if (!elements.reportList) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.reportList.innerHTML = '';
|
||||
|
||||
/* Apply search query and status filter. */
|
||||
let filtered = state.reports;
|
||||
|
||||
if (state.reportSearchQuery) {
|
||||
const q = state.reportSearchQuery.toLowerCase();
|
||||
filtered = filtered.filter((r) => {
|
||||
const num = (r.answers?.reportNumber || r.reportNumber || '').toLowerCase();
|
||||
const title = (r.title || '').toLowerCase();
|
||||
return num.includes(q) || title.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
if (state.reportFilterStatus) {
|
||||
filtered = filtered.filter((r) => r.status === state.reportFilterStatus);
|
||||
}
|
||||
|
||||
elements.reportCount.textContent = String(filtered.length);
|
||||
|
||||
if (!filtered.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'field-help';
|
||||
empty.textContent = state.reports.length ? 'No reports match the current filter.' : t('noLocalReports');
|
||||
elements.reportList.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const report of filtered) {
|
||||
const fragment = elements.reportListItemTemplate.content.cloneNode(true);
|
||||
const button = fragment.querySelector('.report-list-item');
|
||||
const title = fragment.querySelector('.report-list-item__title');
|
||||
const statusEl = fragment.querySelector('.report-list-item__status');
|
||||
const meta = fragment.querySelector('.report-list-item__meta');
|
||||
|
||||
title.textContent = report.answers.reportNumber || report.reportNumber;
|
||||
statusEl.textContent = prettifyStatus(report.status);
|
||||
statusEl.classList.add(`status-${report.status}`);
|
||||
meta.textContent = `${report.title} • Updated ${formatDateTime(report.updatedAt)}`;
|
||||
|
||||
if (report.id === state.currentReportId) {
|
||||
button.classList.add('is-active');
|
||||
}
|
||||
|
||||
button.dataset.reportId = report.id;
|
||||
elements.reportList.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Template summary cards ─────────────────────────────────────────────── */
|
||||
|
||||
export function renderTemplateSummary() {
|
||||
if (!elements.summaryTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const report = getCurrentReport();
|
||||
const templateCode = report?.templateCode || state.selectedTemplateCode;
|
||||
const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode);
|
||||
|
||||
if (!catalogEntry) {
|
||||
elements.summaryTemplate.textContent = 'Not loaded';
|
||||
elements.summaryVersion.textContent = 'Version -';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.summaryTemplate.textContent = catalogEntry.name;
|
||||
elements.summaryVersion.textContent = report
|
||||
? `Version ${report.templateVersion}`
|
||||
: `Version ${catalogEntry.activeVersion}`;
|
||||
}
|
||||
|
||||
export function renderSyncSummary() {
|
||||
if (!elements.syncHeadline) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.syncHeadline.textContent = state.lastSyncAt
|
||||
? `Last sync ${formatRelativeTime(state.lastSyncAt)}`
|
||||
: 'No sync yet';
|
||||
elements.syncDetail.textContent = state.lastSyncAt
|
||||
? `Cached template data from ${formatDateTime(state.lastSyncAt)}`
|
||||
: 'Templates are cached locally after the first successful sync.';
|
||||
}
|
||||
|
||||
/* ── Image policy ───────────────────────────────────────────────────────── */
|
||||
|
||||
export function renderImagePolicy() {
|
||||
if (!elements.imagePolicyText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.imageRules) {
|
||||
elements.imagePolicyText.textContent = t('imagePolicyHint');
|
||||
return;
|
||||
}
|
||||
|
||||
elements.imagePolicyText.textContent = `${state.imageRules.name}: ${state.imageRules.allowedMimeTypes.join(
|
||||
', '
|
||||
)}, max ${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, behavior ${state.imageRules.oversizeBehavior}.`;
|
||||
}
|
||||
|
||||
/* ── Admin image rules ──────────────────────────────────────────────────── */
|
||||
|
||||
export function renderAdminImageRules() {
|
||||
if (!elements.adminSyncState) {
|
||||
return;
|
||||
}
|
||||
|
||||
populateAdminImageRulesForm(state.imageRules);
|
||||
|
||||
if (!state.imageRules) {
|
||||
elements.adminSyncState.textContent = t('noImageRulesLoaded');
|
||||
elements.adminSyncState.className = 'badge badge-offline';
|
||||
elements.adminPolicyCode.textContent = '-';
|
||||
elements.adminPolicyMimeTypes.textContent = '-';
|
||||
elements.adminPolicyOptimization.textContent = '-';
|
||||
elements.adminPolicyLimits.textContent = '-';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.adminSyncState.textContent = navigator.onLine ? t('liveConfig') : t('offlineCachedConfig');
|
||||
elements.adminSyncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
|
||||
elements.adminPolicyCode.textContent = state.imageRules.code;
|
||||
elements.adminPolicyMimeTypes.textContent = state.imageRules.allowedMimeTypes.join(', ');
|
||||
elements.adminPolicyOptimization.textContent = `${state.imageRules.oversizeBehavior}, JPEG ${state.imageRules.jpegQuality}%`;
|
||||
elements.adminPolicyLimits.textContent = `${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, ${state.imageRules.maxAttachmentsPerField} attachment(s)`;
|
||||
}
|
||||
|
||||
export function populateAdminImageRulesForm(imageRules) {
|
||||
if (!imageRules || !elements.adminPolicyName) {
|
||||
return;
|
||||
}
|
||||
|
||||
elements.adminPolicyName.value = imageRules.name || '';
|
||||
elements.adminAllowedMimeTypes.value = (imageRules.allowedMimeTypes || []).join(', ');
|
||||
elements.adminMaxFileSizeMb.value = String((Number(imageRules.maxFileSizeBytes || 0) / (1024 * 1024)).toFixed(1));
|
||||
elements.adminMaxAttachmentsPerField.value = String(imageRules.maxAttachmentsPerField || 1);
|
||||
elements.adminMaxWidthPx.value = String(imageRules.maxWidthPx || '');
|
||||
elements.adminMaxHeightPx.value = String(imageRules.maxHeightPx || '');
|
||||
elements.adminJpegQuality.value = String(imageRules.jpegQuality || '');
|
||||
elements.adminOversizeBehavior.value = imageRules.oversizeBehavior || 'auto_optimize';
|
||||
}
|
||||
|
||||
/* ── Current report ─────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* `renderCurrentReport` accepts a `fieldCallbacks` object so the form module
|
||||
* can invoke actions (field change, attach, remove) owned by the orchestrator
|
||||
* without creating circular imports.
|
||||
*/
|
||||
export function renderCurrentReport(fieldCallbacks = {}) {
|
||||
if (!elements.reportForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const report = getCurrentReport();
|
||||
|
||||
if (!report) {
|
||||
elements.heroTitle.textContent = t('noReportSelected');
|
||||
elements.heroSubtitle.textContent = t('noReportSelectedHint');
|
||||
elements.reportStatusSelect.value = 'draft';
|
||||
elements.reportStatusSelect.disabled = true;
|
||||
elements.deleteReportButton.disabled = true;
|
||||
if (elements.submitReportButton) elements.submitReportButton.disabled = true;
|
||||
if (elements.exportReportButton) elements.exportReportButton.disabled = true;
|
||||
elements.editorHint.textContent = 'Dynamic form rendering from template JSON';
|
||||
elements.reportForm.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No report open</h3>
|
||||
<p>Choose a template and create a report to start editing locally.</p>
|
||||
</div>
|
||||
`;
|
||||
renderMeta(null);
|
||||
renderValidation();
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getTemplateRecord(report.templateCode, report.templateVersion);
|
||||
elements.reportStatusSelect.disabled = false;
|
||||
elements.reportStatusSelect.value = report.status;
|
||||
elements.deleteReportButton.disabled = false;
|
||||
if (elements.submitReportButton) elements.submitReportButton.disabled = false;
|
||||
if (elements.exportReportButton) elements.exportReportButton.disabled = false;
|
||||
elements.heroTitle.textContent = report.answers.reportNumber || report.reportNumber;
|
||||
elements.heroSubtitle.textContent = `${template?.name || report.templateCode} • Local draft bound to template version ${report.templateVersion}`;
|
||||
elements.editorHint.textContent = `${state.currentAttachments.length} attachment(s) stored in IndexedDB for this report`;
|
||||
|
||||
if (!template) {
|
||||
elements.reportForm.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>Template missing</h3>
|
||||
<p>This draft is bound to template version ${report.templateVersion}, but that definition is not cached locally.</p>
|
||||
</div>
|
||||
`;
|
||||
renderMeta(report);
|
||||
renderValidation();
|
||||
return;
|
||||
}
|
||||
|
||||
elements.reportForm.innerHTML = '';
|
||||
|
||||
for (const section of template.definition.sections || []) {
|
||||
const sectionNode = document.createElement('section');
|
||||
sectionNode.className = 'template-section';
|
||||
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'section-heading-row';
|
||||
heading.innerHTML = `<h3>${escapeHtml(section.title)}</h3><span class="panel-note">${section.fields.length} field(s)</span>`;
|
||||
sectionNode.append(heading);
|
||||
|
||||
const fieldGrid = document.createElement('div');
|
||||
fieldGrid.className = 'field-grid';
|
||||
|
||||
for (const field of section.fields || []) {
|
||||
fieldGrid.append(createFieldNode(field, report, {
|
||||
state,
|
||||
onFieldChange: fieldCallbacks.onFieldChange || (() => {}),
|
||||
onAttachFiles: fieldCallbacks.onAttachFiles || (() => {}),
|
||||
onRemoveAttachment: fieldCallbacks.onRemoveAttachment || (() => {})
|
||||
}));
|
||||
}
|
||||
|
||||
sectionNode.append(fieldGrid);
|
||||
elements.reportForm.append(sectionNode);
|
||||
}
|
||||
|
||||
renderMeta(report);
|
||||
renderValidation();
|
||||
}
|
||||
|
||||
/* ── Meta & validation ──────────────────────────────────────────────────── */
|
||||
|
||||
export function renderMeta(report) {
|
||||
if (!elements.reportMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = report
|
||||
? [
|
||||
report.id,
|
||||
`${report.templateCode} v${report.templateVersion}`,
|
||||
formatDateTime(report.createdAt),
|
||||
formatDateTime(report.updatedAt)
|
||||
]
|
||||
: ['-', '-', '-', '-'];
|
||||
|
||||
elements.reportMeta.querySelectorAll('dd').forEach((node, index) => {
|
||||
node.textContent = values[index];
|
||||
});
|
||||
}
|
||||
|
||||
export function renderValidation() {
|
||||
if (!elements.validationHeadline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const report = getCurrentReport();
|
||||
|
||||
if (!report) {
|
||||
elements.validationHeadline.textContent = t('noReportSelected');
|
||||
elements.validationDetail.textContent = 'Draft validation will appear here.';
|
||||
elements.validationList.innerHTML = '<li>No report selected.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getTemplateRecord(report.templateCode, report.templateVersion);
|
||||
|
||||
if (!template) {
|
||||
elements.validationHeadline.textContent = t('templateUnavailable');
|
||||
elements.validationDetail.textContent = t('validationNeedsTemplate');
|
||||
elements.validationList.innerHTML = `<li>${t('templateMissing')}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = validateReport(report, template, state.currentAttachments);
|
||||
|
||||
if (!issues.length) {
|
||||
elements.validationHeadline.textContent = t('readyForExport');
|
||||
elements.validationDetail.textContent = t('noBlockingIssues');
|
||||
elements.validationList.innerHTML = `<li>${t('noValidationIssues')}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.validationHeadline.textContent = t('issueCount', issues.length);
|
||||
elements.validationDetail.textContent =
|
||||
report.status === 'ready_for_export' ? t('issueReadyWarning') : t('issueDraftHint');
|
||||
elements.validationList.innerHTML = '';
|
||||
|
||||
for (const issue of issues) {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = issue;
|
||||
elements.validationList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Badge helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
export function updateConnectionBadge() {
|
||||
if (navigator.onLine) {
|
||||
elements.connectionBadge.textContent = t('online');
|
||||
elements.connectionBadge.className = 'badge badge-online';
|
||||
} else {
|
||||
elements.connectionBadge.textContent = t('offline');
|
||||
elements.connectionBadge.className = 'badge badge-offline';
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSaveBadge(text, tone) {
|
||||
elements.saveBadge.textContent = text;
|
||||
elements.saveBadge.className = `badge ${badgeClassForTone(tone)}`;
|
||||
}
|
||||
|
||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
+122
-8
@@ -53,11 +53,30 @@ const bulkImagesStore = new Map();
|
||||
|
||||
/*
|
||||
* Loads all admin entity data from the server bulk endpoint and caches in IndexedDB.
|
||||
*
|
||||
* Failure handling:
|
||||
* 401 — Session is gone (e.g. server restarted). Redirect to login so the
|
||||
* user can re-authenticate and return with a fresh session.
|
||||
* Other non-2xx — Transient server/network problem. Fall back to the last
|
||||
* successful snapshot stored in IndexedDB so the user can still see
|
||||
* their assigned tasks.
|
||||
*/
|
||||
async function loadFromServer() {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/admin/all', { headers: { Accept: 'application/json' } });
|
||||
if (!resp.ok) return;
|
||||
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401) {
|
||||
/* Session expired or server restarted — must re-authenticate. */
|
||||
window.location.href = '/login-user';
|
||||
return;
|
||||
}
|
||||
/* Other server error — use last cached snapshot so tasks remain visible. */
|
||||
console.warn('Server returned', resp.status, '— falling back to IndexedDB cache.');
|
||||
await loadFromCache();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data });
|
||||
userState.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
|
||||
@@ -68,7 +87,9 @@ async function loadFromServer() {
|
||||
userState.clTemplates = data.clTemplates || [];
|
||||
userState.tasks = data.tasks || [];
|
||||
} catch (err) {
|
||||
/* Network failure (offline, DNS, etc.) — fall back to cache. */
|
||||
console.warn('Failed to load data from server, using IndexedDB cache', err);
|
||||
await loadFromCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +149,30 @@ async function loadAllData() {
|
||||
userState.taskData = await loadTaskData();
|
||||
}
|
||||
|
||||
/*
|
||||
* Manually triggered synchronization with the server.
|
||||
* Called when the user presses the "Sync" button.
|
||||
* Fetches fresh data, re-applies the user filter, and re-renders task lists.
|
||||
*/
|
||||
async function forceSyncWithServer() {
|
||||
const btn = document.getElementById('syncBtn');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Syncing…';
|
||||
}
|
||||
try {
|
||||
await loadFromServer();
|
||||
filterTasksByUser();
|
||||
renderTaskListView();
|
||||
renderSidebarTasks();
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Sync';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterTasksByUser() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const userId = params.get('userId');
|
||||
@@ -142,6 +187,7 @@ function bindEvents() {
|
||||
document.getElementById('saveDraftBtn')?.addEventListener('click', saveDraft);
|
||||
document.getElementById('saveFinalBtn')?.addEventListener('click', saveFinal);
|
||||
document.getElementById('showSettingsBtn')?.addEventListener('click', showSettingsView);
|
||||
document.getElementById('syncBtn')?.addEventListener('click', forceSyncWithServer);
|
||||
document.getElementById('closeSettingsBtn')?.addEventListener('click', closeSettingsView);
|
||||
document.getElementById('recordSearchInput')?.addEventListener('input', onSearchInput);
|
||||
|
||||
@@ -203,16 +249,23 @@ async function showListView() {
|
||||
}
|
||||
|
||||
function showDetailView(taskId) {
|
||||
userState.currentTaskId = Number(taskId);
|
||||
/* Always work with a numeric ID. dataset attributes and sidebar event handlers
|
||||
may pass either a string or a number depending on the call site. Normalizing
|
||||
here keeps every downstream function consistent. */
|
||||
const id = Number(taskId);
|
||||
userState.currentTaskId = id;
|
||||
userState.activeCategory = null;
|
||||
userState.searchQuery = '';
|
||||
const searchInput = document.getElementById('recordSearchInput');
|
||||
if (searchInput) searchInput.value = '';
|
||||
hideAllViews();
|
||||
document.getElementById('taskDetailView').classList.add('workspace-view-active');
|
||||
/* If task was reopened and images were stripped, try to re-download from server */
|
||||
maybeDownloadImages(taskId).then(() => renderTaskDetail());
|
||||
highlightSidebarTask(taskId);
|
||||
/* If task was reopened and images were stripped, try to re-download from server.
|
||||
Chain: hydrate values first → then fetch image blobs → then render. */
|
||||
maybeHydrateFromServer(id)
|
||||
.then(() => maybeDownloadImages(id))
|
||||
.then(() => renderTaskDetail());
|
||||
highlightSidebarTask(id);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -1274,15 +1327,76 @@ function stripImageDataFromStorage(taskId) {
|
||||
updateStorageIndicator();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* Hydrate task data from server when IndexedDB has no local copy
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/*
|
||||
* If the browser's IndexedDB has no record data for this task (cleared storage,
|
||||
* first access on a new device, or fresh browser profile), fetch the last
|
||||
* submitted report from the server and seed the local state so previously
|
||||
* filled values are visible without the user having to re-enter them.
|
||||
*
|
||||
* Images are not re-embedded here — they are pointed to the server with
|
||||
* uploadedToServer:true so that maybeDownloadImages (called next in the chain)
|
||||
* can fetch the actual blobs.
|
||||
*/
|
||||
async function maybeHydrateFromServer(taskId) {
|
||||
const id = Number(taskId);
|
||||
|
||||
/* Skip if IndexedDB already has record data for this task. */
|
||||
const existing = userState.taskData[id];
|
||||
if (existing && Object.keys(existing.records || {}).length > 0) return;
|
||||
|
||||
if (!navigator.onLine) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/reports/${id}`, { headers: { Accept: 'application/json' } });
|
||||
if (!resp.ok) return; /* 404 = task has never been saved — empty form is correct */
|
||||
|
||||
const report = await resp.json();
|
||||
if (!report?.answers?.records) return;
|
||||
|
||||
/* Rebuild records, keeping all field values but replacing image dataUrls with
|
||||
uploadedToServer markers so the image-download step can restore them. */
|
||||
const hydratedRecords = {};
|
||||
for (const [recId, rd] of Object.entries(report.answers.records)) {
|
||||
hydratedRecords[recId] = {
|
||||
status: rd.status || '',
|
||||
handledBy: rd.handledBy || '',
|
||||
comment: rd.comment || '',
|
||||
images: (rd.images || []).map(img => ({
|
||||
name: img.name || '',
|
||||
size: img.size || 0,
|
||||
uploadedToServer: true
|
||||
/* dataUrl intentionally omitted — fetched by maybeDownloadImages */
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
userState.taskData[id] = {
|
||||
visitDate: report.answers.visitDate || '',
|
||||
records: hydratedRecords
|
||||
};
|
||||
|
||||
/* Persist to IndexedDB so future visits within the same browser are instant. */
|
||||
await saveOneTaskData(id, userState.taskData[id]);
|
||||
} catch (err) {
|
||||
console.warn('Could not hydrate task data from server:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* Download images from server when task is reopened
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
async function maybeDownloadImages(taskId) {
|
||||
const task = userState.tasks.find(t => t.id === taskId);
|
||||
/* Normalize to number — callers may pass a string from a dataset attribute. */
|
||||
const id = Number(taskId);
|
||||
const task = userState.tasks.find(t => t.id === id);
|
||||
if (!task) return;
|
||||
|
||||
const data = getTaskData(taskId);
|
||||
const data = getTaskData(id);
|
||||
if (!data.records) return;
|
||||
|
||||
/* Check if any images have uploadedToServer flag but no dataUrl */
|
||||
@@ -1299,7 +1413,7 @@ async function maybeDownloadImages(taskId) {
|
||||
|
||||
/* Fetch images (as dataUrls) from the server */
|
||||
try {
|
||||
const resp = await fetch(`/api/v1/reports/${taskId}/images`, {
|
||||
const resp = await fetch(`/api/v1/reports/${id}/images`, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* Utility functions used across multiple frontend modules. These are pure
|
||||
* functions with no side effects and no dependency on application state.
|
||||
*/
|
||||
|
||||
/* ── Formatting ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export function prettifyStatus(status) {
|
||||
return status
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function formatDateTime(value) {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
export function formatTime(value) {
|
||||
return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function formatRelativeTime(value) {
|
||||
const diffMs = Date.now() - new Date(value).getTime();
|
||||
const diffMinutes = Math.round(diffMs / 60000);
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes} minute(s) ago`;
|
||||
}
|
||||
|
||||
const diffHours = Math.round(diffMinutes / 60);
|
||||
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours} hour(s) ago`;
|
||||
}
|
||||
|
||||
return `${Math.round(diffHours / 24)} day(s) ago`;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/* ── Naming & Sanitization ──────────────────────────────────────────────── */
|
||||
|
||||
export function sanitizeForFilename(value) {
|
||||
return String(value || 'report')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-zA-Z0-9-_]/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
export function generateReportNumber() {
|
||||
const now = new Date();
|
||||
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
|
||||
now.getDate()
|
||||
).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(
|
||||
now.getMinutes()
|
||||
).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
|
||||
return `POC-${stamp}`;
|
||||
}
|
||||
|
||||
export function buildGeneratedFilename(report, field, sequence, extension) {
|
||||
const reportNumber = sanitizeForFilename(report.answers.reportNumber || report.reportNumber);
|
||||
const sectionCode = sanitizeForFilename(field.id).slice(0, 10).toUpperCase();
|
||||
return `${reportNumber}_${sectionCode}_${String(sequence).padStart(3, '0')}.${extension}`;
|
||||
}
|
||||
|
||||
/* ── Badge helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
export function badgeClassForTone(tone) {
|
||||
if (tone === 'success') {
|
||||
return 'badge-online';
|
||||
}
|
||||
|
||||
if (tone === 'error') {
|
||||
return 'badge-error';
|
||||
}
|
||||
|
||||
if (tone === 'warning') {
|
||||
return 'badge-offline';
|
||||
}
|
||||
|
||||
return 'badge-neutral';
|
||||
}
|
||||
|
||||
/* ── Template helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
export function makeTemplateKey(code, version) {
|
||||
return `${code}::${version}`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Multiple versions of the same template can exist in local cache because old
|
||||
* drafts remain bound to the version they started with. The catalog therefore
|
||||
* picks the newest version per template code for the creation UI while keeping
|
||||
* older records available in the version lookup map.
|
||||
*/
|
||||
export function deriveTemplateCatalog(templateRows) {
|
||||
const byCode = new Map();
|
||||
|
||||
for (const row of templateRows) {
|
||||
const existing = byCode.get(row.code);
|
||||
const shouldReplace = !existing || Number(row.version) > Number(existing.version);
|
||||
|
||||
if (shouldReplace) {
|
||||
byCode.set(row.code, row);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byCode.values())
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((item) => ({
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
activeVersion: item.version,
|
||||
publishedAt: item.publishedAt
|
||||
}));
|
||||
}
|
||||
|
||||
/* ── General ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* Debounce helper. Returns a wrapper that delays invocation until `ms`
|
||||
* milliseconds of inactivity have passed, preventing expensive operations
|
||||
* (such as a full form re-render) from running on every keystroke.
|
||||
*/
|
||||
export function debounce(fn, ms) {
|
||||
let timer = null;
|
||||
|
||||
return function debounced(...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), ms);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user