595 lines
32 KiB
JavaScript
595 lines
32 KiB
JavaScript
/* Key register — front end. Talks to the API, holds no truth of its own. */
|
|
"use strict";
|
|
|
|
let S = null; // server state
|
|
let tab = "board", cabinet = "ALL", filter = "all", query = "", pending = null;
|
|
|
|
const $scroll = document.getElementById("scroll");
|
|
const $sheet = document.getElementById("sheet");
|
|
const $scrim = document.getElementById("scrim");
|
|
const $toast = document.getElementById("toast");
|
|
|
|
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
|
const fmt = (d) => { if (!d) return "—"; const [y, m, dd] = d.split("-"); return `${dd} ${T._months[+m - 1]} ${y}`; };
|
|
const short = (d) => { if (!d) return ""; const [y, m, dd] = d.split("-"); return `${dd}/${m}/${y.slice(2)}`; };
|
|
const initials = (n) => n.split(" ").filter((w) => w.length > 2).slice(0, 2).map((w) => w[0]).join("") || n[0];
|
|
const days = (a, b) => Math.round((new Date(b) - new Date(a)) / 864e5);
|
|
|
|
const item = (id) => S.items.find((i) => i.id === +id);
|
|
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) || { id: "", name: "—", full_name: "Unassigned", note: "" };
|
|
const copiesOf = (did) => S.items.filter((i) => i.door_id === +did);
|
|
const kindOf = (id) => (S.kinds || []).find((k) => k.id === id)
|
|
|| { id, name: id, shape: "circle", colour: "#2E6E60" };
|
|
/* one place that decides how any item is drawn */
|
|
const kindClass = (i) => `shape-${kindOf(i.kind).shape}`;
|
|
const kindStyle = (i) => `--k:${kindOf(i.kind).colour}`;
|
|
const isLate = (i) => i.loan && i.loan.due_date && i.loan.due_date < S.today;
|
|
/* Hook order, so the board reads in the same order as the physical hooks and
|
|
you can count along during an audit. Numeric-aware, so R2 sorts before R10.
|
|
Falls back to the tag when hooks are not filled in. */
|
|
const byHook = (a, b) =>
|
|
(a.hook || "").localeCompare(b.hook || "", undefined, { numeric: true }) ||
|
|
a.tag.localeCompare(b.tag, undefined, { numeric: true });
|
|
|
|
let toastTimer;
|
|
function toast(msg, err) {
|
|
$toast.textContent = msg;
|
|
$toast.className = "toast on" + (err ? " err" : "");
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => ($toast.className = "toast"), 2600);
|
|
}
|
|
|
|
async function api(url, opts) {
|
|
const r = await fetch(url, opts);
|
|
const body = await r.json().catch(() => ({}));
|
|
if (!r.ok) throw new Error(body.error || r.statusText);
|
|
return body;
|
|
}
|
|
async function load() {
|
|
try {
|
|
S = await api("/api/state");
|
|
setLang(S.lang);
|
|
chrome();
|
|
counts();
|
|
render();
|
|
} catch (e) {
|
|
$scroll.innerHTML = `<div class="pane"><div class="empty">${esc(T.noServer)}<br>${esc(e.message)}</div></div>`;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ header */
|
|
function counts() {
|
|
const live = S.items.filter((i) => i.status !== "reserve" && i.status !== "retired");
|
|
const reserve = S.items.filter((i) => i.status === "reserve").length;
|
|
const late = S.items.filter(isLate).length;
|
|
const out = S.items.filter((i) => i.status === "out").length;
|
|
document.getElementById("counts").innerHTML = `
|
|
<span class="count">${T.cabinetsN(S.cabinets.length)}</span>
|
|
<span class="count"><span class="swatch in"></span>${T.keysIn(live.filter((i) => i.status === "in").length, live.length)}</span>
|
|
<span class="count"><span class="swatch out"></span><b>${out}</b> ${T.issuedN}</span>
|
|
${late ? `<span class="count warn"><b>${late}</b> ${T.overdueN}</span>` : ""}
|
|
${reserve ? `<span class="count"><span class="swatch res"></span>${reserve} ${T.reserveN}</span>` : ""}`;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- board */
|
|
function peg(i) {
|
|
const late = isLate(i);
|
|
return `<button class="peg ${kindClass(i)} st-${i.status}${late ? " late" : ""}"
|
|
style="${kindStyle(i)}" data-item="${i.id}"
|
|
aria-label="${esc(i.tag)}, ${esc(i.door_id ? door(i.door_id).name : i.zone)}, ${esc(kindOf(i.kind).name)}, ${late ? "overdue" : i.status}">
|
|
${esc(i.tag)}${i.is_master ? "<small>MASTER</small>" : ""}
|
|
${late ? '<span class="flag"></span>' : ""}${i.notes ? '<span class="memo">✎</span>' : ""}</button>`;
|
|
}
|
|
|
|
function viewBoard() {
|
|
const pass = (i) =>
|
|
filter === "all" ? i.status !== "retired" :
|
|
filter === "out" ? i.status === "out" :
|
|
filter === "late" ? isLate(i) :
|
|
filter.startsWith("k:") ? i.kind === filter.slice(2) && i.status !== "retired" :
|
|
true;
|
|
const show = S.items.filter((i) => pass(i) && (cabinet === "ALL" || i.cabinet_id === cabinet));
|
|
|
|
const tiles = [{ id: "ALL", name: T.allTile }].concat(S.cabinets).map((c) => {
|
|
const list = c.id === "ALL" ? S.items : S.items.filter((i) => i.cabinet_id === c.id);
|
|
return `<button class="cab" data-cab="${esc(c.id)}" aria-pressed="${cabinet === c.id}">
|
|
<span class="cn">${esc(c.name)}</span>
|
|
<span class="cs">${T.aItemsCount(list.length)} · <span class="o">${T.outCount(list.filter((i) => i.status === "out").length)}</span></span></button>`;
|
|
}).join("");
|
|
|
|
/* Section order comes from the cabinets themselves, not from the order keys
|
|
happen to appear in. Grouping by insertion order used to put a cabinet
|
|
wherever its lowest-numbered key fell, ignoring its sort value entirely. */
|
|
let sections;
|
|
if (cabinet === "ALL") {
|
|
sections = S.cabinets
|
|
.map((c) => [c.full_name, show.filter((i) => i.cabinet_id === c.id).sort(byHook)])
|
|
.filter(([, list]) => list.length);
|
|
const orphans = show.filter((i) => !S.cabinets.some((c) => c.id === i.cabinet_id));
|
|
if (orphans.length) sections.push(["—", orphans.sort(byHook)]);
|
|
} else {
|
|
/* Inside one cabinet, group by building in the order buildings appear in
|
|
the door list, so it is stable rather than dependent on key numbering. */
|
|
const order = [];
|
|
S.doors.forEach((d) => { if (!order.includes(d.building)) order.push(d.building); });
|
|
show.forEach((i) => { if (i.building && !order.includes(i.building)) order.push(i.building); });
|
|
sections = order
|
|
.map((b) => [b, show.filter((i) => i.building === b).sort(byHook)])
|
|
.filter(([, list]) => list.length);
|
|
}
|
|
|
|
const rails = sections.map(([label, list]) => `<div class="rail">
|
|
<div class="rail-label">${esc(label)} <span>${T.outCount(list.filter((i) => i.status === "out").length)} · ${list.length}</span></div>
|
|
<div class="pegs">${list.map(peg).join("")}</div></div>`).join("")
|
|
|| `<div class="empty" style="background:transparent;border-color:#3A4C46;color:#8A968F">
|
|
${S.items.length ? T.noMatch : T.noKeysYet + " <a href='/admin' style='color:#9FADA7'>" + T.admin + "</a>."}</div>`;
|
|
|
|
$scroll.innerHTML = `<div class="board">
|
|
<div class="cabs">${tiles}</div>
|
|
${cabinet !== "ALL" && cab(cabinet).note ? `<p class="where">${esc(cab(cabinet).note)}</p>` : ""}
|
|
<div class="filters">${[["all", T.fEverything]]
|
|
.concat((S.kinds || []).map((k) => ["k:" + k.id, k.name]))
|
|
.concat([["out", T.fOut], ["late", T.fLate]])
|
|
.map(([k, l]) => `<button class="chip" data-filter="${esc(k)}" aria-pressed="${filter === k}">${esc(l)}</button>`).join("")}</div>
|
|
${rails}
|
|
<div class="legend">
|
|
<span><i class="lg-in"></i>${T.lgIn}</span><span><i class="lg-out"></i>${T.lgOut}</span>
|
|
<span><i class="lg-res"></i>${T.lgRes}</span><span><i class="lg-late"></i>${T.lgLate}</span>
|
|
${(S.kinds || []).map((k) => `<span><i class="lg-kind" style="background:${esc(k.colour)};border-color:${esc(k.colour)};${
|
|
k.shape === "circle" ? "border-radius:50%" : k.shape === "square" ? "border-radius:0" : ""}"></i>${esc(k.name)}</span>`).join("")}
|
|
</div>
|
|
<p class="note">${T.boardNote}</p></div>`;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- rows */
|
|
function itemRow(i) {
|
|
const late = isLate(i), l = i.loan;
|
|
const name = i.door_id ? door(i.door_id).name : i.zone;
|
|
return `<button class="row${late ? " late" : ""}" data-item="${i.id}">
|
|
<span class="badge ${kindClass(i)} st-${i.status}" style="${kindStyle(i)}">${esc(i.tag)}</span>
|
|
<span class="row-main"><span class="row-t">${esc(name)}</span>
|
|
<span class="row-s">${l && i.status === "out"
|
|
? `<span class="${late ? "od" : "held"}">${esc(person(l.person_id) ? person(l.person_id).name : "?")}</span>${late ? ` · ${days(l.due_date, S.today)}d` : ""}`
|
|
: i.status === "reserve" ? T.reserveCopy : i.status === "lost" ? T.lost
|
|
: i.status === "retired" ? T.retired : T.onHook}</span></span>
|
|
<span class="row-n">${esc(cab(i.cabinet_id).name)}</span></button>`;
|
|
}
|
|
|
|
function doorRow(d) {
|
|
const c = copiesOf(d.id).filter((i) => i.status !== "retired");
|
|
const out = c.filter((i) => i.status === "out");
|
|
const late = c.filter(isLate).length;
|
|
const who = out.map((i) => { const p = person(i.loan.person_id); return p ? p.name.split(" ")[0] : "?"; });
|
|
return `<button class="row${late ? " late" : ""}" data-door="${d.id}">
|
|
<span class="badge door">${esc(d.num)}</span>
|
|
<span class="row-main"><span class="row-t">${esc(d.name)}</span>
|
|
<span class="row-s">${esc(d.building)}${who.length ? ` · <span class="${late ? "od" : "held"}">${esc(who.join(", "))}</span>` : " · " + T.allIn}</span></span>
|
|
<span class="row-n">${T.copies(c.length)}
|
|
<span class="dots">${c.map((i) => `<span class="dot ${isLate(i) ? "out" : i.status}" style="${kindStyle(i)}"></span>`).join("")}</span></span></button>`;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- doors */
|
|
function viewDoors() {
|
|
const q = query.trim().toLowerCase();
|
|
const hay = (d) => {
|
|
const c = copiesOf(d.id);
|
|
const who = c.map((i) => { if (!i.loan) return ""; const p = person(i.loan.person_id); return p ? p.name + " " + p.dept : ""; }).join(" ");
|
|
return (d.num + " " + d.name + " " + d.building + " " + d.lock_group + " " + who + " " +
|
|
c.map((i) => i.tag + " " + i.notes).join(" ")).toLowerCase();
|
|
};
|
|
const match = S.doors.filter((d) => !q || hay(d).includes(q));
|
|
/* items not attached to a door — alarm tags, remotes, anything zone-based */
|
|
const tags = S.items.filter((i) => !i.door_id &&
|
|
(!q || (i.tag + " " + i.zone + " " + i.building + " " + kindOf(i.kind).name).toLowerCase().includes(q)));
|
|
const groups = {};
|
|
match.forEach((d) => (groups[d.building] = groups[d.building] || []).push(d));
|
|
|
|
$scroll.innerHTML = `<div class="pane">
|
|
<input class="search" id="q" placeholder="${esc(T.searchPlaceholder)}" value="${esc(query)}">
|
|
${match.length || tags.length
|
|
? Object.entries(groups).map(([b, list]) =>
|
|
`<div class="sec">${esc(b)} · ${T.doorsN(list.length)}</div>${list.map(doorRow).join("")}`).join("")
|
|
+ (tags.length ? `<div class="sec">${T.tagsSection} · ${tags.length}</div>${tags.map(itemRow).join("")}` : "")
|
|
: `<div class="empty">${esc(T.nothingFound(query))}</div>`}
|
|
<p class="note">${T.doorsNote}</p></div>`;
|
|
|
|
const el = document.getElementById("q");
|
|
el.oninput = (e) => {
|
|
query = e.target.value; viewDoors();
|
|
const f = document.getElementById("q"); f.focus(); f.setSelectionRange(query.length, query.length);
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ people */
|
|
function viewPeople() {
|
|
const rows = S.people.map((p) => {
|
|
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out");
|
|
return { p, held, late: held.filter(isLate).length };
|
|
}).sort((a, b) => b.held.length - a.held.length);
|
|
const row = (r) => `<button class="row${r.late ? " late" : ""}" data-person="${r.p.id}">
|
|
<span class="badge person">${esc(initials(r.p.name))}</span>
|
|
<span class="row-main"><span class="row-t">${esc(r.p.name)}${r.p.active ? "" : ` (${T.inactive})`}</span>
|
|
<span class="row-s">${esc(r.p.dept)}${r.p.role ? " · " + esc(r.p.role) : ""}${r.late ? ` · <span class="od">${r.late} ${T.overdueN}</span>` : ""}</span></span>
|
|
<span class="row-n">${r.held.length ? T.itemsN(r.held.length) : "—"}</span></button>`;
|
|
const holding = rows.filter((r) => r.held.length), free = rows.filter((r) => !r.held.length);
|
|
$scroll.innerHTML = `<div class="pane">
|
|
${holding.length ? `<div class="sec">${T.holdingNow}</div>${holding.map(row).join("")}` : ""}
|
|
${free.length ? `<div class="sec">${T.nothingOut}</div>${free.map(row).join("")}` : ""}
|
|
${rows.length ? "" : `<div class="empty">${T.noPeopleYet} <a href="/admin">${T.admin}</a>.</div>`}</div>`;
|
|
}
|
|
|
|
/* --------------------------------------------------------------------- log */
|
|
function viewLog() {
|
|
const ev = [];
|
|
S.handovers.forEach((h) => {
|
|
ev.push({ d: h.out_date, dir: "out", h });
|
|
if (h.in_date) ev.push({ d: h.in_date, dir: "in", h });
|
|
});
|
|
ev.sort((a, b) => b.d.localeCompare(a.d));
|
|
$scroll.innerHTML = `<div class="pane"><div class="sec">${T.logTitle}</div>
|
|
${ev.length ? `<div class="log">${ev.map((e) => {
|
|
const it = item(e.h.item_id), p = person(e.h.person_id);
|
|
return `<div class="log-row">
|
|
<span class="log-d">${short(e.d)}</span>
|
|
<span class="log-b"><span class="log-tag">${esc(it ? it.tag : "?")}</span>
|
|
${e.dir === "out" ? T.toWord : T.backFrom} ${esc(p ? p.name : "?")}
|
|
${e.h.has_sig ? ` <span style="color:var(--verdigris)">✓ ${T.signedMark}</span>` : ""}
|
|
${e.h.note ? `<div class="memo-line">${esc(e.h.note)}</div>` : ""}</span>
|
|
<span class="dir ${e.dir}">${e.dir === "out" ? T.dirOut : T.dirIn}</span></div>`;
|
|
}).join("")}</div>` : `<div class="empty">${T.noHandovers}</div>`}
|
|
<p class="note">${T.logNote}</p></div>`;
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- journal */
|
|
let requests = [];
|
|
|
|
function requestCard(r) {
|
|
const done = r.status === "handled";
|
|
return `<div class="req-card${done ? " done" : ""}">
|
|
<h4>${esc(r.name)}</h4>
|
|
<div class="meta">${esc(r.dept || "")}${r.dept && r.contact ? " · " : ""}${esc(r.contact || "")}</div>
|
|
<div class="meta">${esc(done ? T.reqHandledOn(short(r.handled)) : T.reqOn(short(r.created.slice(0, 10))))}</div>
|
|
${r.areas ? `<p class="areas-line">${esc(r.areas)}</p>` : ""}
|
|
${r.detail ? `<p class="detail">${esc(r.detail)}</p>` : ""}
|
|
${r.note ? `<p class="detail" style="font-style:italic">${esc(r.note)}</p>` : ""}
|
|
${done ? "" : `<div style="margin-top:10px">
|
|
<input class="search" id="rn${r.id}" placeholder="${esc(T.reqNotePh)}" style="margin-bottom:7px">
|
|
<button class="mini" data-handle="${r.id}">${T.reqMarkDone}</button></div>`}
|
|
</div>`;
|
|
}
|
|
|
|
async function viewJournal() {
|
|
try { requests = await api("/api/requests"); } catch (e) { requests = []; }
|
|
const pending = requests.filter((r) => r.status === "pending");
|
|
const done = requests.filter((r) => r.status === "handled").slice(0, 8);
|
|
|
|
/* State of the public form, read from the server rather than from memory —
|
|
a form that is public with no PIN is the one thing worth noticing. */
|
|
const rf = S.reqForm || { public: false, pin: false };
|
|
const status = !rf.public
|
|
? `<div class="status-line in" style="font-size:12.5px">${T.reqFormPrivate}</div>`
|
|
: rf.pin
|
|
? `<div class="status-line in" style="font-size:12.5px">${T.reqFormPublic}
|
|
· <a href="/pedido" style="color:inherit">${T.reqFormOpenIt}</a></div>`
|
|
: `<div class="status-line late" style="font-size:12.5px"><b>${T.reqFormOpen}</b>
|
|
· <a href="/pedido" style="color:inherit">${T.reqFormOpenIt}</a></div>`;
|
|
|
|
$scroll.innerHTML = `<div class="pane">
|
|
<div class="sec">${T.reqInbox}${pending.length ? `<span class="badge-count">${pending.length}</span>` : ""}</div>
|
|
${status}
|
|
${pending.length ? pending.map(requestCard).join("")
|
|
: `<div class="empty">${T.reqNone}</div>`}
|
|
${done.length ? `<div class="sec">${T.reqHandled}</div>${done.map(requestCard).join("")}` : ""}
|
|
|
|
<div class="sec">${T.newEntry}</div>
|
|
<div class="note-box">
|
|
<textarea id="jt" placeholder="${esc(T.journalPlaceholder)}"></textarea>
|
|
<div style="text-align:right;margin-top:8px"><button class="mini" id="jadd">${T.addJournal}</button></div></div>
|
|
<div class="sec">${T.journalTitle}</div>
|
|
${S.journal.length ? S.journal.map((j) => `<div class="row" style="align-items:flex-start;cursor:default">
|
|
<span class="row-n" style="width:52px;text-align:left;padding-top:2px">${short(j.date)}</span>
|
|
<span class="row-main" style="white-space:normal">
|
|
<span style="font-size:14px;line-height:1.5">${esc(j.text)}</span>
|
|
${j.author ? `<span class="row-s">${esc(j.author)}</span>` : ""}</span></div>`).join("")
|
|
: `<div class="empty">${T.journalEmpty}</div>`}
|
|
<p class="note">${T.journalNote}</p></div>`;
|
|
|
|
document.getElementById("jadd").onclick = async () => {
|
|
const t = document.getElementById("jt").value.trim();
|
|
if (!t) return;
|
|
try {
|
|
await api("/api/journal", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: t }) });
|
|
await load(); toast(T.tJournalAdded);
|
|
} catch (e) { toast(errMsg(e.message), true); }
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ sheets */
|
|
function openSheet(html) {
|
|
$sheet.innerHTML = '<div class="grab"></div>' + html;
|
|
$sheet.classList.add("on"); $scrim.classList.add("on"); $sheet.scrollTop = 0;
|
|
}
|
|
function closeSheet() { $sheet.classList.remove("on"); $scrim.classList.remove("on"); pending = null; }
|
|
$scrim.onclick = closeSheet;
|
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSheet(); });
|
|
|
|
function sheetDoor(did) {
|
|
const d = door(did), c = copiesOf(did).filter((i) => i.status !== "retired");
|
|
const out = c.filter((i) => i.status === "out");
|
|
const sameLock = d.lock_group ? S.doors.filter((x) => x.lock_group === d.lock_group && x.id !== d.id) : [];
|
|
openSheet(`<h2>${esc(d.num)} · ${esc(d.name)}</h2><p class="sub">${esc(d.building)}</p>
|
|
<div class="status-line ${out.length ? "out" : "in"}">
|
|
${T.doorSummary(c.length, out.length, c.filter((i) => i.status === "in").length,
|
|
c.filter((i) => i.status === "reserve").length, c.filter((i) => i.status === "lost").length)}</div>
|
|
<dl class="facts">
|
|
<div class="fact"><dt>${T.fLockGroup}</dt><dd class="mono">${esc(d.lock_group || "—")}</dd></div>
|
|
<div class="fact"><dt>${T.fCopiesLiveIn}</dt><dd>${esc([...new Set(c.map((i) => cab(i.cabinet_id).name))].join(", ") || "—")}</dd></div></dl>
|
|
${d.notes ? `<div class="note-box"><span class="cap">${T.noteOnDoor}</span><div style="font-size:14px">${esc(d.notes)}</div></div>` : ""}
|
|
<div class="sec">${T.everyCopy}</div>${c.map(itemRow).join("")}
|
|
${sameLock.length ? `<div class="sec">${T.sameLock}</div>${sameLock.map(doorRow).join("")}` : ""}
|
|
<div class="actions" style="margin-top:16px"><button class="btn btn-g" data-close>${T.close}</button></div>`);
|
|
}
|
|
|
|
async function sheetItem(id) {
|
|
const i = item(id);
|
|
if (!i) return;
|
|
const l = i.loan, late = isLate(i), c = cab(i.cabinet_id);
|
|
const d = i.door_id ? door(i.door_id) : null;
|
|
let hist = [];
|
|
try { hist = await api(`/api/items/${i.id}/history`); } catch (e) { /* non-fatal */ }
|
|
|
|
const status = late
|
|
? `<div class="status-line late">${T.withSince(esc(person(l.person_id).name), fmt(l.out_date))}${T.dueWas(fmt(l.due_date), days(l.due_date, S.today))}</div>`
|
|
: i.status === "out"
|
|
? `<div class="status-line out">${T.issuedToOn(esc(person(l.person_id) ? person(l.person_id).name : "?"), fmt(l.out_date))}${
|
|
l.due_date ? T.dueBack(fmt(l.due_date)) : T.noDueDate}</div>`
|
|
: i.status === "reserve"
|
|
? `<div class="status-line reserve">${T.reserveLine}</div>`
|
|
: i.status === "lost" ? `<div class="status-line lost">${T.lostLine}</div>`
|
|
: i.status === "retired" ? `<div class="status-line retired">${T.retiredLine}</div>`
|
|
: `<div class="status-line in">${T.inCabinet(`<b>${esc(c.full_name)}</b>`)}${i.hook ? T.atHook(esc(i.hook)) : ""}</div>`;
|
|
|
|
openSheet(`<h2>${esc(i.tag)}</h2>
|
|
<p class="sub">${esc(d ? d.name : i.zone)}${i.building ? " · " + esc(i.building) : ""}</p>${status}
|
|
${l && l.note && i.status === "out" ? `<div class="note-box"><span class="cap">${T.noteOnHandover}</span>
|
|
<div style="font-size:14px">${esc(l.note)}</div></div>` : ""}
|
|
<dl class="facts">
|
|
<div class="fact"><dt>${T.fType}</dt><dd>${esc(kindOf(i.kind).name)}</dd></div>
|
|
<div class="fact"><dt>${T.fKeptIn}</dt><dd>${esc(c.full_name)}${c.note ? `<span class="tiny">${esc(c.note)}</span>` : ""}</dd></div>
|
|
${i.hook ? `<div class="fact"><dt>${T.fHook}</dt><dd class="mono">${esc(i.hook)}</dd></div>` : ""}
|
|
${d ? `<div class="fact"><dt>${T.fLockGroup}</dt><dd class="mono">${esc(d.lock_group || "—")}</dd></div>` : ""}
|
|
${i.uid ? `<div class="fact"><dt>${T.fChipUid}</dt><dd class="mono">${esc(i.uid)}</dd></div>` : ""}
|
|
${i.is_master ? `<div class="fact"><dt>${T.fMaster}</dt><dd>${T.yes}</dd></div>` : ""}</dl>
|
|
${d ? `<button class="row" data-door="${d.id}" style="margin-bottom:14px">
|
|
<span class="badge door">${esc(d.num)}</span>
|
|
<span class="row-main"><span class="row-t">${T.seeAllCopies(copiesOf(d.id).length)}</span>
|
|
<span class="row-s">${esc(d.name)}</span></span></button>` : ""}
|
|
<div class="note-box"><span class="cap">${T.noteOnKey}</span>
|
|
<textarea id="inote" placeholder="${esc(T.notePlaceholder)}">${esc(i.notes)}</textarea>
|
|
<div style="text-align:right;margin-top:6px"><button class="mini" data-savenote="${i.id}">${T.saveNote}</button></div></div>
|
|
<div class="actions">
|
|
${i.status === "in" || i.status === "reserve"
|
|
? `<button class="btn btn-o" data-issue="${i.id}">${i.status === "reserve" ? T.breakSeal : T.issueTo}</button>`
|
|
: i.status === "out" ? `<button class="btn btn-p" data-return="${i.id}">${T.takeBack}</button>` : ""}
|
|
<button class="btn btn-g" data-close>${T.close}</button></div>
|
|
<div class="sec">${T.history}</div>
|
|
${hist.length ? `<div class="log">${hist.map((h) => {
|
|
const p = person(h.person_id);
|
|
const hLate = !h.in_date && h.due_date && h.due_date < S.today;
|
|
return `<div class="log-row"><span class="log-d">${short(h.out_date)}</span>
|
|
<span class="log-b">${esc(p ? p.name : "?")}
|
|
<div style="color:var(--slate);font-size:12px">${h.in_date ? T.returnedOn(fmt(h.in_date)) : T.stillHolding}</div>
|
|
${h.note ? `<div class="memo-line">${esc(h.note)}</div>` : ""}
|
|
${h.signature ? `<div class="sigshot"><img src="${esc(h.signature)}" alt="Signature"></div>` : ""}</span>
|
|
<span class="dir ${h.in_date ? "in" : hLate ? "late" : "out"}">${h.in_date ? T.stClosed : hLate ? T.stLate : T.stOpen}</span></div>`;
|
|
}).join("")}</div>` : `<div class="empty">${T.neverIssued}</div>`}
|
|
<div style="height:8px"></div>`);
|
|
}
|
|
|
|
function sheetPerson(id) {
|
|
const p = person(id);
|
|
const held = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out");
|
|
const past = S.handovers.filter((h) => h.person_id === p.id && h.in_date);
|
|
openSheet(`<h2>${esc(p.name)}</h2><p class="sub">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}</p>
|
|
<dl class="facts">
|
|
<div class="fact"><dt>${T.fHoldingNow}</dt><dd>${T.itemsN(held.length)}${
|
|
held.length ? `<span class="tiny">${esc(T.fromCabinets([...new Set(held.map((i) => cab(i.cabinet_id).name))].join(", ")))}</span>` : ""}</dd></div>
|
|
<div class="fact"><dt>${T.fReturnedPast}</dt><dd>${past.length}</dd></div>
|
|
${p.phone ? `<div class="fact"><dt>${T.fPhone}</dt><dd class="mono">${esc(p.phone)}</dd></div>` : ""}</dl>
|
|
${held.length ? `<div class="sec">${T.currentlyHolds}</div>${held.map(itemRow).join("")}
|
|
<a class="btn btn-g" style="display:block;text-decoration:none;margin:10px 0 4px"
|
|
href="/report?view=person&id=${p.id}">${T.printPdf} — ${T.repReceiptTitle}</a>`
|
|
: `<div class="empty">${T.holdsNothing}</div>`}
|
|
${past.length ? `<div class="sec">${T.returnedSection}</div><div class="log">${past.map((h) => {
|
|
const it = item(h.item_id);
|
|
return `<div class="log-row"><span class="log-d">${short(h.in_date)}</span>
|
|
<span class="log-b"><span class="log-tag">${esc(it ? it.tag : "?")}</span>
|
|
<div style="color:var(--slate);font-size:12px">${T.heldFromTo(fmt(h.out_date), fmt(h.in_date))}</div></span></div>`;
|
|
}).join("")}</div>` : ""}
|
|
<div class="actions" style="margin-top:16px"><button class="btn btn-g" data-close>${T.close}</button></div>`);
|
|
}
|
|
|
|
let pickQuery = "";
|
|
|
|
function personPickList(itemId) {
|
|
const q = pickQuery.trim().toLowerCase();
|
|
const active = S.people.filter((p) => p.active &&
|
|
(!q || (p.name + " " + p.dept + " " + p.role).toLowerCase().includes(q)));
|
|
if (!active.length) {
|
|
return `<div class="empty">${q ? esc(T.nothingFound(pickQuery))
|
|
: T.noActivePeople + ` <a href="/admin">${T.admin}</a>.`}</div>`;
|
|
}
|
|
return active.map((p) => {
|
|
const holds = S.items.filter((i) => i.loan && i.loan.person_id === p.id && i.status === "out").length;
|
|
return `<button class="row" data-give="${p.id}" data-on="${itemId}">
|
|
<span class="badge person">${esc(initials(p.name))}</span>
|
|
<span class="row-main"><span class="row-t">${esc(p.name)}</span>
|
|
<span class="row-s">${esc(p.dept)}${p.role ? " · " + esc(p.role) : ""}</span></span>
|
|
<span class="row-n">${holds ? T.itemsN(holds) : ""}</span></button>`;
|
|
}).join("");
|
|
}
|
|
|
|
function bindPicker(itemId) {
|
|
const el = document.getElementById("pq");
|
|
if (!el) return;
|
|
el.oninput = (e) => {
|
|
pickQuery = e.target.value;
|
|
document.getElementById("plist").innerHTML = personPickList(itemId);
|
|
};
|
|
}
|
|
|
|
function sheetIssue(id) {
|
|
const i = item(id);
|
|
pickQuery = "";
|
|
openSheet(`<h2>${esc(T.issueTitle(i.tag))}</h2>
|
|
<p class="sub">${esc(i.door_id ? door(i.door_id).name : i.zone)} · ${esc(T.fromThe(cab(i.cabinet_id).full_name))}</p>
|
|
${i.status === "reserve" ? `<div class="status-line reserve">${T.reserveWarn}</div>` : ""}
|
|
<div class="sec">${T.whoTakes}</div>
|
|
<input class="search" id="pq" placeholder="${esc(T.searchPerson)}" autocomplete="off">
|
|
<div id="plist">${personPickList(i.id)}</div>
|
|
<div class="actions" style="margin-top:14px"><button class="btn btn-g" data-close>${T.cancel}</button></div>`);
|
|
bindPicker(i.id);
|
|
}
|
|
|
|
const addDays = (d, k) => { const t = new Date(d); t.setDate(t.getDate() + k); return t.toISOString().slice(0, 10); };
|
|
|
|
function sheetConfirm() {
|
|
const i = item(pending.item), p = person(pending.person);
|
|
openSheet(`<h2>${T.handover}</h2>
|
|
<p class="sub">${esc(i.tag)} · ${esc(i.door_id ? door(i.door_id).name : i.zone)} → ${esc(p.name)}</p>
|
|
<div class="sec">${T.backBy}</div>
|
|
<div class="due-opts">${[["none", T.dNone], ["7", T.d7], ["30", T.d30], ["90", T.d90]]
|
|
.map(([k, l]) => `<button class="due" data-due="${k}" aria-pressed="${pending.dueKey === k}">${l}</button>`).join("")}</div>
|
|
<div class="note-box"><span class="cap">${T.whyNote}</span>
|
|
<textarea id="hnote" placeholder="${esc(T.whyPlaceholder)}">${esc(pending.note)}</textarea></div>
|
|
<div class="sec">${T.signature}</div>
|
|
<div class="sigpad"><canvas id="sig"></canvas><div class="sigline"></div>
|
|
${pending.sig ? "" : `<div class="hint" id="sighint">${T.signHint}</div>`}</div>
|
|
<div style="text-align:right;margin-bottom:14px">
|
|
<button class="mini" id="sigclear" style="background:var(--card);color:var(--slate);border:1px solid var(--line)">${T.clearSig}</button></div>
|
|
<div class="actions">
|
|
<button class="btn btn-o" id="confirm">${T.confirmHandover}</button>
|
|
<button class="btn btn-g" data-close>${T.cancel}</button></div>`);
|
|
|
|
const c = document.getElementById("sig"), ctx = c.getContext("2d"), dpr = window.devicePixelRatio || 1;
|
|
requestAnimationFrame(() => {
|
|
c.width = c.offsetWidth * dpr; c.height = c.offsetHeight * dpr; ctx.scale(dpr, dpr);
|
|
ctx.lineWidth = 2.2; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "#131E1B";
|
|
if (pending && pending.sig) {
|
|
const im = new Image();
|
|
im.onload = () => ctx.drawImage(im, 0, 0, c.offsetWidth, c.offsetHeight);
|
|
im.src = pending.sig;
|
|
}
|
|
});
|
|
let drawing = false;
|
|
const pos = (e) => { const r = c.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };
|
|
c.addEventListener("pointerdown", (e) => {
|
|
drawing = true; c.setPointerCapture(e.pointerId);
|
|
const h = document.getElementById("sighint"); if (h) h.remove();
|
|
const [x, y] = pos(e); ctx.beginPath(); ctx.moveTo(x, y);
|
|
});
|
|
c.addEventListener("pointermove", (e) => {
|
|
if (!drawing) return;
|
|
const [x, y] = pos(e); ctx.lineTo(x, y); ctx.stroke();
|
|
pending.sig = c.toDataURL("image/png");
|
|
});
|
|
c.addEventListener("pointerup", () => { drawing = false; });
|
|
document.getElementById("sigclear").onclick = () => { ctx.clearRect(0, 0, c.width, c.height); pending.sig = null; };
|
|
|
|
document.getElementById("confirm").onclick = async (ev) => {
|
|
ev.target.disabled = true;
|
|
const note = document.getElementById("hnote").value.trim();
|
|
const due = pending.dueKey === "none" ? null : addDays(S.today, +pending.dueKey);
|
|
const id = pending.item;
|
|
try {
|
|
await api(`/api/items/${id}/issue`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ person_id: pending.person, due_date: due, note, signature: pending.sig }),
|
|
});
|
|
pending = null;
|
|
await load();
|
|
toast(T.tHandedOver);
|
|
sheetItem(id);
|
|
} catch (e) { toast(errMsg(e.message), true); ev.target.disabled = false; }
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ events */
|
|
document.addEventListener("click", async (e) => {
|
|
const t = e.target.closest("[data-handle],[data-item],[data-door],[data-person],[data-filter],[data-tab],[data-cab],[data-issue],[data-return],[data-give],[data-close],[data-savenote],[data-due]");
|
|
if (!t) return;
|
|
|
|
if (t.dataset.close !== undefined) return closeSheet();
|
|
|
|
if (t.dataset.savenote) {
|
|
const id = +t.dataset.savenote, notes = document.getElementById("inote").value.trim();
|
|
try {
|
|
await api(`/api/items/${id}/notes`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ notes }) });
|
|
await load(); toast(T.tNoteSaved); sheetItem(id);
|
|
} catch (err) { toast(errMsg(err.message), true); }
|
|
return;
|
|
}
|
|
if (t.dataset.due) {
|
|
pending.note = document.getElementById("hnote").value;
|
|
pending.dueKey = t.dataset.due;
|
|
return sheetConfirm();
|
|
}
|
|
if (t.dataset.give) {
|
|
pending = { item: +t.dataset.on, person: +t.dataset.give, dueKey: "none", note: "", sig: null };
|
|
return sheetConfirm();
|
|
}
|
|
if (t.dataset.door !== undefined && t.dataset.door !== "") return sheetDoor(+t.dataset.door);
|
|
if (t.dataset.item) return sheetItem(+t.dataset.item);
|
|
if (t.dataset.person) return sheetPerson(+t.dataset.person);
|
|
if (t.dataset.issue) return sheetIssue(+t.dataset.issue);
|
|
if (t.dataset.return) {
|
|
const id = +t.dataset.return;
|
|
try {
|
|
await api(`/api/items/${id}/return`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
|
|
await load(); toast(T.tBackIn); sheetItem(id);
|
|
} catch (err) { toast(errMsg(err.message), true); }
|
|
return;
|
|
}
|
|
if (t.dataset.handle) {
|
|
const id = +t.dataset.handle;
|
|
const note = (document.getElementById("rn" + id) || {}).value || "";
|
|
try {
|
|
await api(`/api/requests/${id}/handle`, { method: "POST",
|
|
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note }) });
|
|
await load(); toast(T.tSaved);
|
|
} catch (err) { toast(errMsg(err.message), true); }
|
|
return;
|
|
}
|
|
if (t.dataset.cab) { cabinet = t.dataset.cab; return render(); }
|
|
if (t.dataset.filter) { filter = t.dataset.filter; return render(); }
|
|
if (t.dataset.tab) {
|
|
tab = t.dataset.tab;
|
|
document.querySelectorAll(".tab").forEach((x) => x.setAttribute("aria-selected", x.dataset.tab === tab));
|
|
$scroll.scrollTop = 0;
|
|
return render();
|
|
}
|
|
});
|
|
|
|
function chrome() {
|
|
document.getElementById("brand").textContent = T.brand;
|
|
document.getElementById("apptitle").textContent = T.appTitle;
|
|
[["gear", T.admin], ["reports", T.reports], ["askform", T.reqHandPhone]].forEach(([id, label]) => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.title = label;
|
|
el.setAttribute("aria-label", label);
|
|
});
|
|
document.querySelectorAll("[data-t]").forEach((el) => (el.textContent = T[el.dataset.t]));
|
|
/* pending requests are easy to miss on a tab you rarely open */
|
|
const jt = document.querySelector('[data-tab="journal"] [data-t]');
|
|
if (jt && S.pending) jt.innerHTML = `${T.tabJournal}<span class="badge-count">${S.pending}</span>`;
|
|
document.title = T.brand;
|
|
}
|
|
|
|
function render() {
|
|
({ board: viewBoard, doors: viewDoors, people: viewPeople, log: viewLog, journal: viewJournal })[tab]();
|
|
}
|
|
load();
|