390 lines
14 KiB
JavaScript
390 lines
14 KiB
JavaScript
/*
|
|
* 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;
|
|
}
|