lxc-keyreg/public/report.js
2026-08-24 15:21:24 +01:00

235 lines
11 KiB
JavaScript

/* Key register — printable reports.
Everything renders to plain HTML so the browser's own Print / Save as PDF
produces the document. No PDF library, nothing to keep up to date. */
"use strict";
let S = null, view = "out", whoId = null;
const $out = document.getElementById("out");
const $view = document.getElementById("view");
const $who = document.getElementById("who");
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const fmt = (d) => { if (!d) return "—"; const [y, m, dd] = d.split("-"); return `${dd} ${T._months[+m - 1]} ${y}`; };
const days = (a, b) => Math.round((new Date(b) - new Date(a)) / 864e5);
const person = (id) => S.people.find((p) => p.id === +id);
const door = (id) => S.doors.find((d) => d.id === +id);
const cab = (id) => S.cabinets.find((c) => c.id === id) || { name: "—", full_name: "—" };
const doorName = (i) => (i.door_id ? door(i.door_id).name : i.zone);
const isLate = (i) => i.loan && i.loan.due_date && i.loan.due_date < S.today;
/* Hook order — the audit sheet is meant to be read down the cabinet. */
const byHook = (a, b) =>
(a.hook || "").localeCompare(b.hook || "", undefined, { numeric: true }) ||
a.tag.localeCompare(b.tag, undefined, { numeric: true });
function head(title, sub) {
const now = new Date();
return `<div class="rep-head">
<p class="eyebrow">${esc(T.brand)}</p>
<h1>${esc(title)}</h1>
<p class="rep-meta">${esc(T.repGenerated(fmt(S.today), now.toTimeString().slice(0, 5)))}${
sub ? " · " + esc(sub) : ""}</p></div>`;
}
const stat = (n, label) => `<div class="rep-stat"><b>${n}</b><span>${esc(label)}</span></div>`;
/* ---------------------------------------------------------------- issued */
function viewOut(onlyLate) {
const out = S.items.filter((i) => i.status === "out" && (!onlyLate || isLate(i)));
const byPerson = {};
out.forEach((i) => (byPerson[i.loan.person_id] = byPerson[i.loan.person_id] || []).push(i));
Object.values(byPerson).forEach((l) => l.sort(byHook));
const ids = Object.keys(byPerson).sort((a, b) =>
person(a).name.localeCompare(person(b).name, "pt"));
if (!out.length) return head(onlyLate ? T.repOverdue : T.repOut) + `<p>${esc(T.repNothing)}</p>`;
return head(onlyLate ? T.repOverdue : T.repOut) +
`<div class="rep-stats">
${stat(out.length, T.stKeysOut)}
${stat(ids.length, T.stPeople)}
${stat(S.items.filter(isLate).length, T.stOverdue)}
</div><div style="height:22px"></div>` +
ids.map((pid) => {
const p = person(pid), list = byPerson[pid];
return `<div class="rep-group">
<h2>${esc(p.name)}</h2>
<p class="who">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}${
p.phone ? " · " + esc(p.phone) : ""}</p>
<table class="rep"><thead><tr>
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
<th>${esc(T.cCabinet)}</th><th>${esc(T.cOut)}</th><th>${esc(T.cDue)}</th>
</tr></thead><tbody>
${list.map((i) => {
const late = isLate(i);
return `<tr>
<td class="num">${esc(i.tag)}</td>
<td>${esc(doorName(i))}</td>
<td>${esc(i.building)}</td>
<td>${esc(cab(i.cabinet_id).name)}</td>
<td>${fmt(i.loan.out_date)}</td>
<td class="${late ? "od" : ""}">${i.loan.due_date ? fmt(i.loan.due_date) : "—"}${
late ? ` (+${days(i.loan.due_date, S.today)})` : ""}</td></tr>`;
}).join("")}
</tbody></table></div>`;
}).join("") + signOff();
}
/* ------------------------------------------------- receipt for one person */
async function viewPerson(id) {
const p = person(id);
if (!p) return head(T.repReceiptTitle) + `<p>${esc(T.repChoosePerson)}</p>`;
let rows = [];
try { rows = await fetch("/api/report/person/" + id).then((r) => r.json()); } catch (e) { /* offline */ }
if (!rows.length) return head(T.repReceiptTitle, p.name) + `<p>${esc(T.repNothing)}</p>`;
return head(T.repReceiptTitle, `${p.name}${p.dept ? " · " + p.dept : ""}`) +
`<p style="font-size:14px;max-width:62ch">${esc(T.repReceiptIntro(rows.length))}</p>
<table class="rep" style="margin-top:14px"><thead><tr>
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
<th>${esc(T.cCabinet)}</th><th>${esc(T.cOut)}</th><th>${esc(T.cDue)}</th>
</tr></thead><tbody>
${rows.map((h) => {
const late = h.due_date && h.due_date < S.today;
return `<tr>
<td class="num">${esc(h.tag)}</td><td>${esc(h.door || "")}</td>
<td>${esc(h.building)}</td><td>${esc(cab(h.cabinet_id).name)}</td>
<td>${fmt(h.out_date)}</td>
<td class="${late ? "od" : ""}">${h.due_date ? fmt(h.due_date) : "—"}</td></tr>`;
}).join("")}
</tbody></table>
<div class="sec" style="margin-top:24px">${esc(T.signature)}</div>
${rows.filter((h) => h.signature).map((h) => `<div class="sig-block">
<div class="sig-cap">${esc(h.tag)} · ${esc(T.repSignedOn(fmt(h.out_date)))}</div>
<img src="${esc(h.signature)}" alt=""></div>`).join("")
|| `<p style="font-size:13px;color:var(--slate)">${esc(T.repNoSig)}</p>`}
${signOff(p.name)}`;
}
/* ------------------------------------------------------------- inventory */
function viewInventory() {
const live = S.items.filter((i) => i.status !== "retired");
const groups = {};
S.cabinets.forEach((c) => {
const list = live.filter((i) => i.cabinet_id === c.id).sort(byHook);
if (list.length) groups[c.id] = list;
});
const statusWord = (i) => (T.statuses && T.statuses[i.status]) || i.status;
return head(T.repInventory) +
`<div class="rep-stats">
${stat(S.items.length, T.stTotal)}
${stat(S.items.filter((i) => i.status === "in").length, T.stInCab)}
${stat(S.items.filter((i) => i.status === "out").length, T.stKeysOut)}
${stat(S.items.filter((i) => i.status === "reserve").length, T.stReserve)}
</div><div style="height:22px"></div>` +
S.cabinets.filter((c) => groups[c.id]).map((c) => `<div class="rep-group">
<h2>${esc(c.full_name)}</h2>
<p class="who">${esc(c.note || "")}</p>
<table class="rep"><thead><tr>
<th>${esc(T.cTag)}</th><th>${esc(T.cDoor)}</th><th>${esc(T.cBuilding)}</th>
<th>${esc(T.cHook)}</th><th>${esc(T.cStatus)}</th><th>${esc(T.cPerson)}</th>
</tr></thead><tbody>
${groups[c.id].map((i) => `<tr>
<td class="num">${esc(i.tag)}</td><td>${esc(doorName(i))}</td>
<td>${esc(i.building)}</td><td>${esc(i.hook)}</td>
<td class="${isLate(i) ? "od" : ""}">${esc(statusWord(i))}</td>
<td>${i.loan && i.status === "out" ? esc(person(i.loan.person_id).name) : ""}</td></tr>`).join("")}
</tbody></table></div>`).join("");
}
/* ------------------------------------------- audit sheet, one per cabinet */
function viewAudit() {
const cabs = whoId && whoId !== "ALL" ? S.cabinets.filter((c) => c.id === whoId) : S.cabinets;
return head(T.repAudit) +
`<p style="font-size:13.5px;max-width:62ch">${esc(T.repAuditIntro)}</p>` +
cabs.map((c) => {
const list = S.items.filter((i) => i.cabinet_id === c.id && i.status !== "retired").sort(byHook);
if (!list.length) return "";
return `<div class="rep-group">
<h2>${esc(c.full_name)}</h2>
<p class="who">${esc(c.note || "")}</p>
<table class="rep"><thead><tr>
<th style="width:34px">${esc(T.cCheck)}</th><th>${esc(T.cTag)}</th>
<th>${esc(T.cHook)}</th><th>${esc(T.cDoor)}</th>
<th>${esc(T.cStatus)}</th><th>${esc(T.cPerson)}</th>
</tr></thead><tbody>
${list.map((i) => `<tr>
<td><span class="tick"></span></td>
<td class="num">${esc(i.tag)}</td><td>${esc(i.hook)}</td>
<td>${esc(doorName(i))}</td>
<td>${esc((T.statuses && T.statuses[i.status]) || i.status)}</td>
<td>${i.loan && i.status === "out" ? esc(person(i.loan.person_id).name) : ""}</td></tr>`).join("")}
</tbody></table></div>`;
}).join("") +
`<div class="sign-off">
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repAuditBy)}</div></div>
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repDate)}</div></div>
</div>
<div style="margin-top:20px"><div class="sign-lab">${esc(T.repNotes)}</div>
<div class="sign-line"></div><div class="sign-line"></div></div>`;
}
function signOff(holder) {
return `<div class="sign-off">
${holder ? `<div><div class="sign-line"></div>
<div class="sign-lab">${esc(T.repHolder)} — ${esc(holder)}</div></div>` : ""}
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repResponsible)}</div></div>
<div><div class="sign-line"></div><div class="sign-lab">${esc(T.repDate)}</div></div>
</div>`;
}
/* ------------------------------------------------------------------ shell */
async function render() {
$out.innerHTML = "";
const isPerson = view === "person";
const isAudit = view === "audit";
$who.style.display = isPerson || isAudit ? "" : "none";
if (isPerson) {
$who.innerHTML = `<option value="">${esc(T.repChoosePerson)}</option>` +
S.people.map((p) => `<option value="${p.id}"${String(whoId) === String(p.id) ? " selected" : ""}>${esc(p.name)}</option>`).join("");
$out.innerHTML = await viewPerson(whoId);
} else if (isAudit) {
$who.innerHTML = `<option value="ALL">${esc(T.repAllCabinets)}</option>` +
S.cabinets.map((c) => `<option value="${esc(c.id)}"${whoId === c.id ? " selected" : ""}>${esc(c.full_name)}</option>`).join("");
$out.innerHTML = viewAudit();
} else if (view === "overdue") {
$out.innerHTML = viewOut(true);
} else if (view === "inventory") {
$out.innerHTML = viewInventory();
} else {
$out.innerHTML = viewOut(false);
}
document.title = `${T.brand} — ${$view.selectedOptions[0].textContent}`;
}
(async () => {
try {
S = await fetch("/api/state").then((r) => r.json());
} catch (e) {
$out.innerHTML = `<p>${esc(T ? T.noServer : "offline")}</p>`;
return;
}
setLang(S.lang);
document.getElementById("printbtn").textContent = T.printPdf;
document.getElementById("backlink").title = T.back;
$view.innerHTML = [["out", T.repOut], ["overdue", T.repOverdue], ["person", T.repPerson],
["inventory", T.repInventory], ["audit", T.repAudit]]
.map(([v, l]) => `<option value="${v}">${esc(l)}</option>`).join("");
/* deep links: /report?view=person&id=3 */
const q = new URLSearchParams(location.search);
if (q.get("view")) view = q.get("view");
if (q.get("id")) whoId = q.get("id");
$view.value = view;
$view.onchange = () => { view = $view.value; whoId = view === "audit" ? "ALL" : null; render(); };
$who.onchange = () => { whoId = $who.value; render(); };
document.getElementById("printbtn").onclick = () => window.print();
await render();
})();