Files
HomeAssistantVS/custom_components/maintenance_supporter/frontend-src/helpers/worksheet.ts
T
2026-07-14 23:57:03 -04:00

161 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Task work sheet (v2.21) — a printable one-pager for a single task.
*
* Everything needed to actually DO the task, on one sheet of paper: object
* + task details, the checklist as real tick boxes, the notes, and a QR
* pair (open the task / complete it) so the paper links back to the panel.
* When the task has a linked PDF manual with a page hint, the manual
* excerpt pages are rendered INLINE (downscaled, two per row) via the
* vendored pdf.js legacy build — the whole work sheet prints as one
* document. The PDF link stays as the fallback when rendering fails.
*
* Mirrors helpers/report.ts: pure function building a self-contained HTML
* document; the panel opens it in a new tab where the user prints or saves
* as PDF. All user strings are escaped; labels arrive translated.
*/
import type { MaintenanceTask } from "../types";
export interface WorksheetLabels {
title: string; // "Work sheet"
object: string;
type: string;
interval: string;
nextDue: string;
lastDone: string;
priority: string;
checklist: string;
notes: string;
parts: string; // "Required parts"
scanView: string; // "Scan to open the task"
scanComplete: string; // "Scan to complete"
manualExcerpt: string; // "Manual excerpt"
pages: string; // "pages"
printedOn: string; // "Printed"
never: string;
typeLabel: (t: string) => string;
statusLabel: (s: string) => string;
}
export interface WorksheetExcerpt {
title: string;
startPage: number;
endPage: number;
url: string; // signed excerpt-endpoint URL (absolute)
/** Absolute base URL of the vendored pdf.js assets; when set, the sheet
* renders the excerpt pages inline (downscaled 2-up) via pdf.js. */
vendorBase?: string;
}
const esc = (v: unknown): string =>
String(v ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string);
export function buildTaskWorksheetHtml(
task: MaintenanceTask,
objectName: string,
L: WorksheetLabels,
formatDate: (iso: string) => string,
formatRecurrence: (task: MaintenanceTask) => string,
qrViewDataUri: string | null,
qrCompleteDataUri: string | null,
excerpt: WorksheetExcerpt | null,
nowIso: string,
partsLines: string[] = [],
): string {
const meta: Array<[string, string]> = [
[L.object, esc(objectName)],
[L.type, esc(L.typeLabel(task.type))],
[L.interval, esc(formatRecurrence(task))],
[L.nextDue, task.next_due ? esc(formatDate(task.next_due)) : "—"],
[L.lastDone, task.last_performed ? esc(formatDate(task.last_performed)) : esc(L.never)],
];
if (task.priority && task.priority !== "normal") {
meta.push([L.priority, esc(task.priority)]);
}
const checklist = (task.checklist || [])
.map((item) => `<li><span class="box"></span>${esc(item)}</li>`)
.join("");
const qr = (uri: string | null, caption: string) =>
uri
? `<figure class="qr"><img src="${uri}" alt="" /><figcaption>${esc(caption)}</figcaption></figure>`
: "";
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>${esc(task.name)}${esc(L.title)}</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { font: 13px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif; color: #111; margin: 0; }
header { display: flex; justify-content: space-between; align-items: flex-start;
border-bottom: 3px solid #111; padding-bottom: 8px; margin-bottom: 12px; }
h1 { font-size: 22px; margin: 0 0 2px; }
.obj { font-size: 14px; color: #444; }
.qr-row { display: flex; gap: 18px; }
.qr { margin: 0; text-align: center; }
.qr img { width: 88px; height: 88px; display: block; }
.qr figcaption { font-size: 9px; color: #555; max-width: 96px; }
table.meta { border-collapse: collapse; margin-bottom: 12px; }
table.meta td { padding: 2px 14px 2px 0; vertical-align: top; }
table.meta td:first-child { color: #555; white-space: nowrap; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
border-bottom: 1px solid #bbb; padding-bottom: 2px; margin: 14px 0 6px; }
ul.check { list-style: none; padding: 0; margin: 0; }
ul.check li { display: flex; align-items: flex-start; gap: 8px; padding: 4px 0; font-size: 14px; }
.box { width: 14px; height: 14px; border: 1.6px solid #111; border-radius: 2px;
flex: 0 0 auto; margin-top: 2px; }
.notes { white-space: pre-wrap; }
.excerpt a { color: #0b57d0; word-break: break-all; }
.excerpt-pages { display: flex; flex-wrap: wrap; gap: 3mm; margin-top: 4mm; }
.excerpt-pages canvas { width: calc(50% - 2mm); height: auto;
border: 0.4px solid #ccc; break-inside: avoid; }
footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 9px; color: #888;
border-top: 1px solid #ddd; padding-top: 3px; }
@media screen { body { max-width: 800px; margin: 24px auto; padding: 0 16px; } }
</style></head>
<body>
<header>
<div>
<h1>${esc(task.name)}</h1>
<div class="obj">${esc(objectName)}</div>
</div>
<div class="qr-row">
${qr(qrViewDataUri, L.scanView)}
${qr(qrCompleteDataUri, L.scanComplete)}
</div>
</header>
<table class="meta">
${meta.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v}</td></tr>`).join("")}
</table>
${checklist ? `<h2>${esc(L.checklist)}</h2><ul class="check">${checklist}</ul>` : ""}
${partsLines.length ? `<h2>${esc(L.parts)}</h2><ul class="check">${partsLines.map((line) => `<li><span class="box"></span>${esc(line)}</li>`).join("")}</ul>` : ""}
${task.notes ? `<h2>${esc(L.notes)}</h2><div class="notes">${esc(task.notes)}</div>` : ""}
${excerpt ? `<h2>${esc(L.manualExcerpt)}</h2>
<div class="excerpt">${esc(excerpt.title)}${esc(L.pages)} ${excerpt.startPage}${excerpt.endPage}:
<a href="${esc(excerpt.url)}" target="_blank" rel="noopener">PDF</a>
</div>
<div id="excerpt-pages" class="excerpt-pages"></div>
${excerpt.vendorBase ? `<script type="module">
// Render the excerpt pages inline (downscaled, two per row) so the
// whole work sheet prints as ONE document. The link above stays as
// the fallback if pdf.js or the fetch fails.
try {
const pdfjs = await import(${JSON.stringify(excerpt.vendorBase + "/pdf.min.mjs")});
pdfjs.GlobalWorkerOptions.workerSrc = ${JSON.stringify(excerpt.vendorBase + "/pdf.worker.min.mjs")};
const doc = await pdfjs.getDocument({ url: ${JSON.stringify(excerpt.url)} }).promise;
const host = document.getElementById("excerpt-pages");
for (let n = 1; n <= doc.numPages; n++) {
const page = await doc.getPage(n);
const viewport = page.getViewport({ scale: 1.4 }); // crisp at ~50% print width
const canvas = document.createElement("canvas");
canvas.width = viewport.width; canvas.height = viewport.height;
await page.render({ canvasContext: canvas.getContext("2d"), viewport }).promise;
host.appendChild(canvas);
}
} catch (e) { console.warn("excerpt inline render failed", e); }
</script>` : ""}` : ""}
<footer>${esc(objectName)} · ${esc(task.name)} · ${esc(L.printedOn)} ${esc(nowIso.slice(0, 10))}</footer>
</body></html>`;
}