/* * 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 = `

No report open

Choose a template and create a report to start editing locally.

`; 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 = `

Template missing

This draft is bound to template version ${report.templateVersion}, but that definition is not cached locally.

`; 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 = `

${escapeHtml(section.title)}

${section.fields.length} field(s)`; 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 = '
  • No report selected.
  • '; return; } const template = getTemplateRecord(report.templateCode, report.templateVersion); if (!template) { elements.validationHeadline.textContent = t('templateUnavailable'); elements.validationDetail.textContent = t('validationNeedsTemplate'); elements.validationList.innerHTML = `
  • ${t('templateMissing')}
  • `; return; } const issues = validateReport(report, template, state.currentAttachments); if (!issues.length) { elements.validationHeadline.textContent = t('readyForExport'); elements.validationDetail.textContent = t('noBlockingIssues'); elements.validationList.innerHTML = `
  • ${t('noValidationIssues')}
  • `; 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; }