New apps Added
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/** 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) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user