Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/helpers/report.ts
T
2026-07-08 10:43:39 -04:00

111 lines
4.7 KiB
TypeScript

/** Self-contained printable maintenance report for one object.
*
* Produces a full HTML document (inline print CSS) that the panel opens in a new
* tab via a Blob URL — the user then prints or "Save as PDF" from the browser.
* No PDF dependency, works offline. All user content is HTML-escaped.
*/
import type { MaintenanceObject, MaintenanceTask } from "../types";
export interface ReportLabels {
title: string;
generated: string;
manufacturer: string;
model: string;
serial: string;
installed: string;
warranty: string;
area: string;
notes: string;
tasksHeading: string;
colTask: string;
colType: string;
colStatus: string;
colSchedule: string;
colLastDone: string;
colNextDue: string;
colCost: string;
colTimes: string;
totalCost: string;
/** Human schedule label for a task — pass formatRecurrence so calendar
* kinds (weekdays/nth_weekday/…) render properly, not just intervals. */
scheduleLabel: (t: MaintenanceTask) => string;
statusLabel: (s: string) => string;
typeLabel: (t: string) => string;
none: string;
}
function esc(v: unknown): string {
return String(v ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] as string));
}
export function buildObjectReportHtml(
obj: MaintenanceObject,
tasks: MaintenanceTask[],
labels: ReportLabels,
fmtDate: (iso: string | null | undefined) => string,
currencySymbol: string,
nowIso: string,
): string {
const meta = ([
[labels.manufacturer, obj.manufacturer],
[labels.model, obj.model],
[labels.serial, obj.serial_number],
[labels.installed, obj.installation_date ? fmtDate(obj.installation_date) : null],
[labels.warranty, obj.warranty_expiry ? fmtDate(obj.warranty_expiry) : null],
] as Array<[string, string | null | undefined]>).filter(([, v]) => !!v);
const rows = tasks.map((t) => {
const schedule = labels.scheduleLabel(t);
return `<tr>
<td>${esc(t.name)}</td>
<td>${esc(labels.typeLabel(t.type))}</td>
<td>${esc(labels.statusLabel(t.status))}</td>
<td>${esc(schedule)}</td>
<td>${esc(t.last_performed ? fmtDate(t.last_performed) : labels.none)}</td>
<td>${esc(t.next_due ? fmtDate(t.next_due) : labels.none)}</td>
<td class="num">${t.times_performed ?? 0}</td>
<td class="num">${(t.total_cost ?? 0).toFixed(2)} ${esc(currencySymbol)}</td>
</tr>`;
}).join("");
const totalCost = tasks.reduce((n, t) => n + (t.total_cost ?? 0), 0);
return `<!DOCTYPE html><html><head><meta charset="utf-8">
<title>${esc(labels.title)}${esc(obj.name)}</title>
<style>
* { box-sizing: border-box; }
body { font: 13px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; margin: 32px; }
h1 { font-size: 22px; margin: 0 0 2px; }
.sub { color: #666; margin: 0 0 20px; }
.meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 24px; margin-bottom: 20px; }
.meta div { border-bottom: 1px solid #eee; padding: 4px 0; }
.meta .k { color: #888; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
h2 { font-size: 15px; margin: 24px 0 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid #eee; vertical-align: top; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #888; border-bottom: 2px solid #ccc; }
td.num, th.num { text-align: right; }
tfoot td { font-weight: 600; border-top: 2px solid #ccc; border-bottom: none; }
.notes { margin-top: 16px; white-space: pre-wrap; color: #333; }
@media print { body { margin: 0; } @page { margin: 16mm; } }
</style></head><body>
<h1>${esc(obj.name)}</h1>
<p class="sub">${esc(labels.title)} · ${esc(labels.generated)}: ${esc(fmtDate(nowIso))}</p>
${meta.length ? `<div class="meta">${meta.map(([k, v]) =>
`<div><div class="k">${esc(k)}</div>${esc(v)}</div>`).join("")}</div>` : ""}
<h2>${esc(labels.tasksHeading)} (${tasks.length})</h2>
<table>
<thead><tr>
<th>${esc(labels.colTask)}</th><th>${esc(labels.colType)}</th><th>${esc(labels.colStatus)}</th>
<th>${esc(labels.colSchedule)}</th><th>${esc(labels.colLastDone)}</th><th>${esc(labels.colNextDue)}</th>
<th class="num">${esc(labels.colTimes)}</th><th class="num">${esc(labels.colCost)}</th>
</tr></thead>
<tbody>${rows || `<tr><td colspan="8">${esc(labels.none)}</td></tr>`}</tbody>
<tfoot><tr><td colspan="7">${esc(labels.totalCost)}</td><td class="num">${totalCost.toFixed(2)} ${esc(currencySymbol)}</td></tr></tfoot>
</table>
${obj.notes ? `<div class="notes"><strong>${esc(labels.notes)}:</strong>\n${esc(obj.notes)}</div>` : ""}
</body></html>`;
}