ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildSnapshot } from "@/lib/snapshot";
|
||||
import { bcBound, itemMap, key, ledger, onhand } from "@/lib/compute";
|
||||
import AutoPrint from "@/components/AutoPrint";
|
||||
import { barcodeKind, barcodeSvg } from "@/lib/barcode";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SP = Record<string, string | undefined>;
|
||||
|
||||
/* Garment labels, 6 to an A4 sheet.
|
||||
*
|
||||
* Two ways in. `?code=` prints one bound code a chosen number of times — the reprint, for a label
|
||||
* that has worn off. `?item=` prints a whole garment: every size that carries a code, once per
|
||||
* garment on the shelf, which is what labelling a rack of stock that arrived unlabelled actually
|
||||
* needs. Six size-14s in the cupboard means six size-14 labels, because each of those six shirts is
|
||||
* going to have one stuck on it.
|
||||
*
|
||||
* Only ever the code that is really bound to the size. ThreadCount's internal 93XXXXXXX fallback
|
||||
* appears on no garment, so printing it would put a barcode into the linen room that no scanner has
|
||||
* ever seen. */
|
||||
export default async function LabelSheet({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/auth");
|
||||
const q = await searchParams;
|
||||
const code = String(q.code || "").trim().slice(0, 64);
|
||||
const itemId = String(q.item || "").trim().slice(0, 64);
|
||||
const copies = Math.min(24, Math.max(1, parseInt(q.copies || "6", 10) || 6));
|
||||
const reason = String(q.reason || "").slice(0, 60);
|
||||
if (!code && !itemId) redirect("/app/stock");
|
||||
|
||||
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId } });
|
||||
const label = (it: { item: string; gender: string; sku: string }) =>
|
||||
it.item + (it.gender !== "Unisex" ? ` — ${it.gender}` : "");
|
||||
|
||||
/** One entry per label to be printed, in size order. */
|
||||
type Row = { code: string; name: string; size: string; sku: string };
|
||||
const rows: Row[] = [];
|
||||
let heading = "";
|
||||
|
||||
if (code) {
|
||||
const bc = await prisma.barcode.findUnique({
|
||||
where: { facilityId_code: { facilityId: user.facilityId, code } },
|
||||
include: { item: { select: { item: true, gender: true, sizes: true, sku: true } } },
|
||||
});
|
||||
if (!bc) redirect("/app/stock");
|
||||
const size = String(bc.item.sizes[bc.sizeIndex] ?? bc.sizeIndex);
|
||||
for (let i = 0; i < copies; i++) rows.push({ code, name: label(bc.item), size, sku: bc.item.sku });
|
||||
heading = `${label(bc.item)} · size ${size}`;
|
||||
} else {
|
||||
const it = await prisma.catalogItem.findFirst({
|
||||
where: { id: itemId, facilityId: user.facilityId },
|
||||
select: { id: true, item: true, gender: true, sizes: true, sku: true, barcodes: { select: { code: true, sizeIndex: true } } },
|
||||
});
|
||||
if (!it) redirect("/app/stock");
|
||||
/* How many of each. Read through the same ledger the rest of the product uses rather than
|
||||
summing stock rows here: a second on-hand expression is how a screen ends up disagreeing with
|
||||
the shelf, and this one decides how much paper comes out of the printer. */
|
||||
const snap = await buildSnapshot(user);
|
||||
const L = ledger(snap);
|
||||
/* Go size by size, not code by code. A size can end up carrying more than one Barcode row — the
|
||||
supplier re-labels a range, the new code is scanned onto a size that already carries one and
|
||||
the old row survives — so walking the rows printed that size twice over, half of it in the
|
||||
code the linen room had just replaced. One garment on the shelf gets one label. The code that
|
||||
prints is the one bcBound() picks, which is the code the size's own screens show, so the
|
||||
sheet and the screen can never name different numbers for the same size. */
|
||||
const snapItem = itemMap(snap)[it.id];
|
||||
const bySize = new Map<number, string>();
|
||||
for (const b of it.barcodes) if (!bySize.has(b.sizeIndex)) bySize.set(b.sizeIndex, b.code);
|
||||
for (const si of [...bySize.keys()].sort((a, z) => a - z)) {
|
||||
const code = (snapItem ? bcBound(snap, snapItem, si) : "") || bySize.get(si)!;
|
||||
const n = Math.max(0, onhand(snap, L, key(it.id, si)));
|
||||
const size = String(it.sizes[si] ?? si);
|
||||
for (let i = 0; i < n; i++) rows.push({ code, name: label(it), size, sku: it.sku });
|
||||
}
|
||||
heading = label(it);
|
||||
// Nothing on the shelf, or no size labelled yet: say which, rather than printing a blank sheet.
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div style={{ background: "#fff", color: "#201e1d", padding: 32, fontFamily: "var(--font-body)" }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 800, margin: 0 }}>{heading}</h1>
|
||||
<p style={{ marginTop: 12, maxWidth: "60ch", lineHeight: 1.6 }}>
|
||||
{it.barcodes.length === 0
|
||||
? "No size on this garment has a barcode yet, so there is nothing to print. Generate barcodes for it first."
|
||||
: "There is nothing on the shelf to label — every size with a barcode is showing zero on hand. Count some in, or print a single size from its own page."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const X_NOMINAL = 0.33; // EAN-13 at 100% magnification
|
||||
const X_MIN = 0.25; // below this a hand scanner starts refusing the symbol
|
||||
const LABEL_MM = 62; // printable width inside one cell, see below
|
||||
|
||||
/* barcodeSvg sizes itself in CSS pixels at whatever module width it was handed, which says nothing
|
||||
about how wide the symbol lands on paper. A 12-digit UPC-A or an alphanumeric supplier asset tag
|
||||
is not a valid EAN-13, so it falls through to Code 128 — half as wide again as the EAN-13 this
|
||||
sheet was laid out around — and at a fixed pixel width it printed past the edge of its label and
|
||||
pushed the second column off the page. So the sheet works the other way round: read back how
|
||||
many modules the symbol actually has, then pick the widest X-dimension that still fits the label,
|
||||
capped at the 0.33mm EAN-13 nominal so a short code isn't blown up to fill the cell.
|
||||
|
||||
LABEL_MM is the printable width inside one cell: A4 210mm, less the 12mm @page margins, less the
|
||||
sheet's 10mm side padding, less the 6mm gutter, halved, less the 8mm padding inside the label —
|
||||
rounded down to leave the border somewhere to sit. */
|
||||
const drawn = new Map<string, { svg: string; printMm: number; xDim: number }>();
|
||||
for (const r of rows) {
|
||||
if (drawn.has(r.code)) continue;
|
||||
const svg = barcodeSvg(r.code, { module: 2, height: 58 });
|
||||
const modules = Math.round(parseFloat(/width="([\d.]+)"/.exec(svg)?.[1] || "0") / 2);
|
||||
const xDim = modules > 0 ? Math.min(X_NOMINAL, LABEL_MM / modules) : X_NOMINAL;
|
||||
drawn.set(r.code, { svg, printMm: modules * xDim, xDim });
|
||||
}
|
||||
const tooSmall = [...drawn.entries()].filter(([, d]) => d.xDim < X_MIN).length;
|
||||
|
||||
return (
|
||||
<div style={{ background: "#fff", color: "#201e1d", fontFamily: "var(--font-body)", minHeight: "100vh" }}>
|
||||
<style>{`@page{size:A4;margin:12mm} html,body{background:#fff !important} .sheet{display:grid;grid-template-columns:1fr 1fr;gap:6mm;padding:6mm 10mm 10mm} .lbl{-webkit-print-color-adjust:exact;print-color-adjust:exact} .lbl svg{width:100%;height:auto;display:block} @media print{.no-print{display:none !important}}`}</style>
|
||||
<div className="no-print" style={{ padding: "10px 16px", borderBottom: "2px solid #201e1d", display: "flex", gap: 12, alignItems: "center", fontSize: 13, flexWrap: "wrap" }}>
|
||||
<b>{rows.length} label{rows.length === 1 ? "" : "s"}</b>
|
||||
<span>{heading}</span>
|
||||
{code && <span style={{ color: "#57534f" }}>{code} · {barcodeKind(code)}</span>}
|
||||
{itemId && <span style={{ color: "#57534f" }}>one per garment on hand, {drawn.size} size{drawn.size === 1 ? "" : "s"}</span>}
|
||||
{reason && <span style={{ color: "#57534f" }}>Reason: {reason}</span>}
|
||||
{/* A symbol squeezed below about 0.25mm per module is printed but not reliably readable, and
|
||||
finding that out at the shelf with a scanner in your hand is the wrong place to find out. */}
|
||||
{tooSmall > 0 && <b style={{ color: "#b8240e" }}>{tooSmall === 1 ? "One code is" : `${tooSmall} codes are`} too long to print at a scannable size on this label — bind a shorter code, or print on a wider label from your own stationery.</b>}
|
||||
<AutoPrint />
|
||||
</div>
|
||||
<div className="sheet">
|
||||
{rows.map((r, i) => {
|
||||
const d = drawn.get(r.code)!;
|
||||
return (
|
||||
<div key={i} className="lbl" style={{ border: "1.5px solid #201e1d", padding: "10mm 8mm", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 6, breakInside: "avoid", pageBreakInside: "avoid" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, letterSpacing: "-0.01em", textAlign: "center", lineHeight: 1.2 }}>{r.name}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "#57534f" }}>Size {r.size}{r.sku ? ` · ${r.sku}` : ""}</div>
|
||||
<div style={{ width: `${d.printMm}mm`, maxWidth: "100%" }} dangerouslySetInnerHTML={{ __html: d.svg }} />
|
||||
<div style={{ fontSize: 9, letterSpacing: "0.08em", textTransform: "uppercase", color: "#7a7573" }}>{fac.slipOrg || fac.name}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildSnapshot } from "@/lib/snapshot";
|
||||
import { CASUAL_SETS, FTE_CASUAL, FTE_OPTIONS, ccOf, fmtDate, initialSets, isKit, isNursing, setsForFte, staffName } from "@/lib/compute";
|
||||
import { SET_GARMENTS, setsCap, setsHeld, setsOnStart } from "@/lib/sets";
|
||||
import AutoPrint from "@/components/AutoPrint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SP = Record<string, string | undefined>;
|
||||
|
||||
const lb: React.CSSProperties = { fontSize: 8.5, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: "#201e1d" };
|
||||
/** A field is a label over a rule to write on. Filled or blank, the rule is the same height, so a
|
||||
* form printed from a staff record and one printed from a request line up sheet for sheet. */
|
||||
const F = ({ label, v, big }: { label: string; v?: string; big?: boolean }) => (
|
||||
<div>
|
||||
<div style={lb}>{label}</div>
|
||||
<div style={{ borderBottom: "1.2px solid #201e1d", minHeight: big ? 24 : 21, fontSize: big ? 13 : 11, fontWeight: big ? 700 : 600, padding: "1px 2px 0", display: "flex", alignItems: "flex-end" }}>{v || " "}</div>
|
||||
</div>
|
||||
);
|
||||
const CB = ({ label }: { label: string }) => (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
<span style={{ width: 11, height: 11, border: "1.2px solid #201e1d", display: "inline-block" }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600 }}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
/** Sets, said the way the form says them out loud. Three different rules quote a number of sets in
|
||||
* three different sentences, and the plural is not worth writing out three times. */
|
||||
const nSets = (n: number) => `${n} set${n === 1 ? "" : "s"}`;
|
||||
const H = ({ t, note }: { t: string; note?: string }) => (
|
||||
<div style={{ marginTop: 9, borderBottom: "2px solid #201e1d", paddingBottom: 2, display: "flex", alignItems: "baseline", gap: 8 }}>
|
||||
<span style={{ fontSize: 9.5, fontWeight: 800, letterSpacing: ".1em", textTransform: "uppercase" }}>{t}</span>
|
||||
{note && <span style={{ fontSize: 8.5, fontWeight: 600, color: "#57534f" }}>{note}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* The uniform order form — the one the manager signs.
|
||||
*
|
||||
* It is the paper half of a mechanism the app already has: an Approval records what came back
|
||||
* signed, and this prints the thing that goes out to be signed. Three ways in, one document.
|
||||
* `?staff=` gives blank garment rows for somebody to fill in at the ward; `?request=` gives the same
|
||||
* form with a request that already exists in ThreadCount written onto it, so the signature and the
|
||||
* record are about the same garments; `?approval=` reprints one approval that was already recorded,
|
||||
* as it was recorded.
|
||||
*
|
||||
* That last one is a copy of history and is held to history's rules. An approval row keeps the sets,
|
||||
* the FTE and the name as signed, and nothing else about the day it was signed — so those three
|
||||
* print as they stand and every other field the form asks for prints blank. Filling the ward, the
|
||||
* role or the cost centre in from today's record would put this year's answers on last year's
|
||||
* decision, which is exactly the question somebody looking up March came here to ask. The sheet says
|
||||
* which is which in its own words rather than leaving a reader to guess.
|
||||
*
|
||||
* Every word that identifies a hospital — the name across the top, the org line under it, the logo,
|
||||
* the contacts in the footer — comes out of that facility's own settings, exactly as the collection
|
||||
* slip's do. None of it is written down here. This form was copied off a real health service's
|
||||
* paperwork, and shipping any of theirs to every other customer is the one mistake this file cannot
|
||||
* make, so an unset field prints an empty space and never a stand-in that reads like a real address.
|
||||
*
|
||||
* The rule printed at the bottom is the one that belongs to the person named at the top, and only
|
||||
* that one. There are three — the FTE table, a fixed starting kit, and manager approval a set at a
|
||||
* time — all ending at the same six sets held. Which one a person is on is their group's, as the
|
||||
* facility put it in its own settings, and every number in them is read out of those settings
|
||||
* through lib/sets rather than written down here. The words on the page name the route, never a job
|
||||
* title: one employer's names for its roles printed on another's paperwork is the same mistake as
|
||||
* printing its address. Somebody signs under that sentence, so a sentence describing an entitlement
|
||||
* that is not theirs is worse on this page than no sentence at all.
|
||||
*
|
||||
* One A4 page, always. A form that runs onto a second sheet gets thrown away and hand-written, which
|
||||
* is exactly the paperwork this replaces — so the blocks are sized to fit together and a long
|
||||
* request spills into a counted note rather than pushing the signature over the fold. */
|
||||
export default async function OrderForm({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/auth");
|
||||
const q = await searchParams;
|
||||
const staffId = String(q.staff || "").trim().slice(0, 64);
|
||||
const requestId = String(q.request || "").trim().slice(0, 64);
|
||||
const approvalId = String(q.approval || "").trim().slice(0, 64);
|
||||
if (!staffId && !requestId && !approvalId) redirect("/app/staff");
|
||||
|
||||
const [fac, snap] = await Promise.all([
|
||||
prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId } }),
|
||||
buildSnapshot(user),
|
||||
]);
|
||||
|
||||
/** The garments on the form: one row per live request line, or nothing at all when the form is
|
||||
* printed blank for the ward to fill in. */
|
||||
type Row = { garment: string; size: string; qty: number };
|
||||
let rows: Row[] = [];
|
||||
let subjectId = staffId;
|
||||
let meta = "";
|
||||
let requestSets = 0;
|
||||
/** The whole of what an approval remembers about the day it was signed. Four facts, and the page
|
||||
* below prints these four and leaves the rest of the form empty. */
|
||||
let recorded: { date: string; by: string; sets: number; fte: string; photo: boolean } | null = null;
|
||||
|
||||
if (requestId) {
|
||||
const req = await prisma.request.findFirst({
|
||||
where: { id: requestId, facilityId: user.facilityId },
|
||||
include: { lines: { include: { item: { select: { item: true, gender: true, type: true, sizes: true } } }, orderBy: { sort: "asc" } } },
|
||||
});
|
||||
if (!req) redirect("/app/requests");
|
||||
subjectId = req.subjectId;
|
||||
// A declined line is not being ordered, so it has no business on the order. It stays on the
|
||||
// request in the app, where the wearer is told which garment was knocked back and why.
|
||||
const live = req.lines.filter((l) => l.status !== "declined");
|
||||
rows = live.map((l) => ({
|
||||
garment: l.item.item + (l.item.gender !== "Unisex" ? ` — ${l.item.gender}` : ""),
|
||||
size: String(l.item.sizes[l.sizeIndex] ?? l.sizeIndex),
|
||||
qty: l.qty,
|
||||
}));
|
||||
requestSets = setsHeld(live.map((l) => ({ item: { type: l.item.type, item: l.item.item }, qty: l.qty })));
|
||||
meta = `Request ${req.code}${req.reason ? ` · ${req.reason}` : ""}`;
|
||||
} else if (approvalId) {
|
||||
const a = await prisma.approval.findFirst({ where: { id: approvalId, facilityId: user.facilityId } });
|
||||
if (!a) redirect("/app/staff");
|
||||
subjectId = a.staffId;
|
||||
// byName is the signature as it was written, kept as a copy on purpose — a manager who has since
|
||||
// married or left still signed this form under the name that is on the paper.
|
||||
recorded = { date: a.date, by: a.byName, sets: a.sets, fte: a.fte, photo: !!a.photoId };
|
||||
meta = `Approval recorded ${fmtDate(a.date)}`;
|
||||
}
|
||||
|
||||
const st = snap.staff.find((x) => x.id === subjectId);
|
||||
if (!st) redirect(requestId ? "/app/requests" : "/app/staff");
|
||||
|
||||
/* Which of the three allowances this person is on, asked of the facility's own two lists through
|
||||
lib/compute, so the paper and the counter screen cannot answer it differently. A group on
|
||||
neither list is on manager approval. Both figures go through the helpers rather than straight
|
||||
off the settings: a coordinator who has never opened the box, or who cleared it, still gets the
|
||||
standing number printed and not a zero. */
|
||||
const nursing = isNursing(snap, st);
|
||||
const onKit = isKit(snap, st);
|
||||
const startKit = setsOnStart(snap.settings.initialSets);
|
||||
const cap = setsCap(snap.settings.capSets);
|
||||
const proposed = initialSets(snap, st);
|
||||
/* What the rule says, printed beside what is being asked for. The number is a proposal and never
|
||||
a refusal — a manager may write a larger one — but the basis has to be on the page next to it, or
|
||||
the deviation is invisible the moment the form is filed. */
|
||||
const casual = st.fte.trim().toLowerCase() === FTE_CASUAL.toLowerCase();
|
||||
/* A reprint quotes the decision, not the rule. What a manager signed for may well be above what
|
||||
any table proposes — that discretion is the point of the form — so printing today's proposal
|
||||
under a decision already made would read as a correction nobody has asked for. */
|
||||
const basis = recorded
|
||||
? `Recorded approval: ${nSets(recorded.sets)}${recorded.fte ? ` at ${recorded.fte} FTE` : " · no FTE was recorded with it"} · signed by ${recorded.by || "a name that was not recorded"} on ${fmtDate(recorded.date)}`
|
||||
: nursing
|
||||
? proposed !== null
|
||||
? `FTE table: ${proposed} set${proposed === 1 ? "" : "s"} at ${st.fte} FTE`
|
||||
: casual
|
||||
? `Casual — ${CASUAL_SETS.join(" / ")} sets at the manager's discretion`
|
||||
: "No FTE recorded — the table proposes nothing until one is"
|
||||
: onKit
|
||||
? `Starting kit for this role: ${nSets(startKit)}`
|
||||
: `Ceiling for this role: ${nSets(cap)} — ${cap * SET_GARMENTS} garments — each one approved by the manager`;
|
||||
|
||||
/* The rule in words, under whichever block prints below. Written for the folder this ends up in
|
||||
rather than for the wearer: the staff app tells somebody what their manager will approve, a
|
||||
signed form has to say who approves what. */
|
||||
const rule = nursing
|
||||
? `The table proposes the initial kit; a manager may sign above it, up to ${nSets(cap)} — ${cap * SET_GARMENTS} garments — held at any time.`
|
||||
: onKit
|
||||
? `${nSets(startKit)} are issued on starting, and more as needed, up to ${nSets(cap)} — ${cap * SET_GARMENTS} garments — held at any time. Nothing has to be handed back first.`
|
||||
: `This role is capped at ${nSets(cap)} — ${cap * SET_GARMENTS} garments — held at any time, and every set needs the manager's approval.`;
|
||||
|
||||
/* The last clause of what the delegate is putting their name to. It has to point at a rule that
|
||||
is both on this page and this person's: the FTE table only prints for the groups on it, so
|
||||
pointing at it on anybody else's form would be asking for a signature against somebody else's
|
||||
numbers. */
|
||||
const authorises = nursing
|
||||
? "Where that number is above the table below, I authorise the additional sets."
|
||||
: onKit
|
||||
? `Where that number is above the ${nSets(startKit)} issued on starting, I authorise the additional sets.`
|
||||
: `I authorise each of those sets against this facility's ceiling of ${nSets(cap)}.`;
|
||||
|
||||
/* The bands off the signed form, read back out of the table in lib/compute.ts rather than typed
|
||||
again here, and shown only to the groups on the FTE table. Two FTEs that propose the same number share a column,
|
||||
which is how the paper form groups them, and a change to the table changes what prints without
|
||||
anybody having to remember that this page exists. */
|
||||
const bands: { ftes: string[]; sets: number }[] = [];
|
||||
for (const f of FTE_OPTIONS) {
|
||||
if (f === FTE_CASUAL) continue;
|
||||
const n = setsForFte(f);
|
||||
if (n === null) continue;
|
||||
const last = bands[bands.length - 1];
|
||||
if (last && last.sets === n) last.ftes.push(f);
|
||||
else bands.push({ ftes: [f], sets: n });
|
||||
}
|
||||
|
||||
const org = fac.slipOrg;
|
||||
const blank = rows.length === 0;
|
||||
const shown = rows.slice(0, 8);
|
||||
const spilled = rows.length - shown.length;
|
||||
const garments = rows.reduce((n, r) => n + r.qty, 0);
|
||||
// Blank rules are there to be written on at the ward: enough of them to be worth signing, few
|
||||
// enough to leave the office-use block on the page. A copy of a decision already made gets none
|
||||
// of them — rules to write on are an invitation to fill this in and hand it back as a second
|
||||
// approval, and the garments it does not know are said in words underneath instead.
|
||||
const empties = recorded ? 0 : blank ? 5 : Math.max(0, 5 - shown.length);
|
||||
// A blank form pre-fills the proposal, but only where a rule actually proposes something. The
|
||||
// groups on manager approval have a limit and no proposal, so the rule stays empty for the manager
|
||||
// to write in: printing the starting kit there would offer them a number nobody agreed.
|
||||
const setsAsked = recorded
|
||||
? String(recorded.sets)
|
||||
: blank
|
||||
? (nursing || onKit) && proposed !== null ? String(proposed) : ""
|
||||
: requestSets > 0 ? `${requestSets} (${garments} garment${garments === 1 ? "" : "s"})` : `${garments} garment${garments === 1 ? "" : "s"}`;
|
||||
|
||||
const contacts = [snap.settings.coordinator, snap.settings.coordinatorEmail, snap.settings.coordinatorPhone].map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div style={{ background: "#fff", color: "#201e1d", fontFamily: "var(--font-body)", minHeight: "100vh" }}>
|
||||
<style>{`@page{size:A4;margin:0} html,body{background:#fff !important} .sheet{width:210mm;padding:10mm 14mm 8mm;margin:0 auto;-webkit-print-color-adjust:exact;print-color-adjust:exact} .t{width:100%;border-collapse:collapse} .t th{font-size:8.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;text-align:left;padding:2px 4px;border-bottom:1.5px solid #201e1d} .t td{height:8.4mm;padding:0 4px;font-size:11px;font-weight:600;vertical-align:bottom;border-bottom:1.2px solid #201e1d} .fte{width:100%;border-collapse:collapse;margin-top:3px} .fte th,.fte td{border:1.2px solid #201e1d;padding:2px 4px;font-size:9.5px;text-align:center} .fte th{font-weight:700;background:#f1efee} .fte td{font-weight:700} @media print{.no-print{display:none !important}}`}</style>
|
||||
|
||||
<div className="no-print" style={{ padding: "10px 16px", borderBottom: "2px solid #201e1d", display: "flex", gap: 12, alignItems: "center", fontSize: 13, flexWrap: "wrap" }}>
|
||||
<b>Uniform order form</b>
|
||||
<span style={{ color: "#57534f" }}>{staffName(st)}{meta ? ` · ${meta}` : " · blank rows for the ward to fill in"}</span>
|
||||
{recorded && <b>A copy of what was recorded — not a form to sign.</b>}
|
||||
{/* The footer is the only thing telling a ward where to send the signed form back to. If
|
||||
nobody has filled the contacts in, say so here rather than printing an empty line and
|
||||
letting the forms come back to nobody. */}
|
||||
{contacts.length === 0 && <b style={{ color: "#b8240e" }}>No linen room contacts are set — add the coordinator's name, e-mail and phone in Settings so this form carries them.</b>}
|
||||
<AutoPrint />
|
||||
</div>
|
||||
|
||||
<div className="sheet">
|
||||
<div style={{ height: 7, background: "linear-gradient(90deg,#201e1d 72%,#9ACBD8 72%)" }} />
|
||||
<div style={{ marginTop: 9, display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 17, fontWeight: 800, letterSpacing: "-0.01em", textTransform: "uppercase", lineHeight: 1.1 }}>Uniform order form</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, marginTop: 2 }}>{snap.settings.facility}</div>
|
||||
{org && <div style={{ fontSize: 9.5, fontWeight: 600, color: "#57534f" }}>{org}</div>}
|
||||
{meta && <div style={{ fontSize: 9.5, fontWeight: 700, marginTop: 2 }}>{meta}</div>}
|
||||
</div>
|
||||
{fac.logoData && <img src={fac.logoData} alt={org || snap.settings.facility} style={{ width: 110, height: 34, objectFit: "contain", objectPosition: "right top" }} />}
|
||||
</div>
|
||||
{/* Said on the paper, not only on the screen it was printed from, because the paper is what
|
||||
ends up in the folder. Somebody pulling this out in a year has to be able to tell at a
|
||||
glance which figures are the decision and which fields nobody ever recorded — otherwise
|
||||
an empty ward or cost centre reads as a form filled in badly rather than as a fact the
|
||||
record never held. */}
|
||||
{recorded && (
|
||||
<div style={{ marginTop: 8, border: "1.5px solid #201e1d", padding: "5px 7px", fontSize: 9, fontWeight: 600, lineHeight: 1.4 }}>
|
||||
A copy of the approval recorded on {fmtDate(recorded.date)} — not a form to sign. The uniform sets, the FTE and the name as signed are printed as they were recorded that day. The staff member is named from the register as it reads today. Every other field the form asks for — the garments, the contact details, the ward, the role, the cost centre — was never recorded against this approval, so it is left blank rather than filled in from today's record.
|
||||
{recorded.photo ? " The signed sheet itself was photographed and is on their record in ThreadCount." : " No photograph of the signed sheet was kept."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<H t="Staff details" />
|
||||
<div style={{ marginTop: 6, display: "grid", gridTemplateColumns: "2fr 1fr 1fr", columnGap: 14 }}>
|
||||
<F label="Staff name" v={staffName(st)} big />
|
||||
<F label="Payroll number" v={st.num} big />
|
||||
<F label={recorded ? "Date approved" : "Date"} v={fmtDate(recorded ? recorded.date : snap.today)} />
|
||||
</div>
|
||||
<div style={{ marginTop: 7, display: "grid", gridTemplateColumns: "1.7fr 1fr", columnGap: 14 }}>
|
||||
{/* Only an e-mail the staff member gave us themselves, on their own self-service account.
|
||||
Blank is the right answer when they have never claimed one — the ward writes it in. */}
|
||||
<F label="E-mail" v={recorded ? "" : st.selfEmail} />
|
||||
<F label="Mobile" v={recorded ? "" : st.phone} />
|
||||
</div>
|
||||
|
||||
<H t="Uniforms requested" note={recorded ? "not recorded against this approval — the garments were written on the signed sheet" : blank ? "one garment to a line" : `${rows.length} line${rows.length === 1 ? "" : "s"} · ${garments} garment${garments === 1 ? "" : "s"}`} />
|
||||
<table className="t" style={{ marginTop: 3 }}>
|
||||
<thead>
|
||||
<tr><th style={{ width: "6%" }}>No.</th><th style={{ width: "44%" }}>Garment</th><th style={{ width: "14%" }}>Size</th><th style={{ width: "10%" }}>Qty</th><th>Notes</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((r, i) => (
|
||||
<tr key={i}><td>{i + 1}</td><td>{r.garment}</td><td>{r.size}</td><td>{r.qty}</td><td> </td></tr>
|
||||
))}
|
||||
{Array.from({ length: empties }).map((_, i) => (
|
||||
<tr key={`b${i}`}><td>{shown.length + i + 1}</td><td> </td><td> </td><td> </td><td> </td></tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* The full count is printed in the heading above, so a form that could not fit every line
|
||||
says how many it is short rather than quietly ending a garment early. */}
|
||||
{spilled > 0 && <div style={{ fontSize: 9.5, fontWeight: 700, marginTop: 3 }}>+{spilled} more line{spilled === 1 ? "" : "s"} — see the request in ThreadCount.</div>}
|
||||
{recorded && (
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: "#57534f", marginTop: 3, lineHeight: 1.35 }}>
|
||||
The garments were written on the signed sheet by hand. What was recorded here is the number of sets, not the garments, so there is nothing to print on these lines — the sets are in the approval below{recorded.photo ? ", and the photographed sheet on their record has the handwriting" : ""}.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 8, display: "grid", gridTemplateColumns: "auto 1.4fr 0.9fr", alignItems: "end", columnGap: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingBottom: 3 }}>
|
||||
<span style={lb}>Fitting completed</span><CB label="Yes" /><CB label="No" />
|
||||
</div>
|
||||
<F label="Staff member signature" />
|
||||
<F label="Date" />
|
||||
</div>
|
||||
|
||||
<H t="Manager / financial delegate approval" note={recorded ? "as recorded — a blank field was never part of the record" : undefined} />
|
||||
<div style={{ marginTop: 6, display: "grid", gridTemplateColumns: "1.5fr 0.8fr 1fr", columnGap: 14 }}>
|
||||
<F label={recorded ? "Manager / delegate name (as signed)" : "Manager / delegate name"} v={recorded ? recorded.by : undefined} />
|
||||
{/* The cost centre is read off the person's ward as it stands today and wards do change.
|
||||
On a reprint that makes it a guess at which budget carried last year's garments, so it
|
||||
is left for whoever is reconciling to read off the paper. */}
|
||||
<F label="Cost centre" v={recorded ? "" : ccOf(snap, st)} />
|
||||
<F label="Contact number" />
|
||||
</div>
|
||||
<div style={{ marginTop: 7, display: "grid", gridTemplateColumns: "1.5fr 0.7fr 0.6fr 0.8fr", columnGap: 14 }}>
|
||||
<F label="Ward / Department" v={recorded ? "" : st.dept} />
|
||||
<F label="Role" v={recorded ? "" : st.group} />
|
||||
<F label="Combined FTE" v={recorded ? recorded.fte : st.fte} />
|
||||
<F label="Uniform sets" v={setsAsked} />
|
||||
</div>
|
||||
<div style={{ fontSize: 8.5, fontWeight: 600, color: "#57534f", marginTop: 2 }}>{basis}</div>
|
||||
<div style={{ fontSize: 9.5, fontWeight: 600, marginTop: 5, lineHeight: 1.35 }}>
|
||||
{recorded
|
||||
? `${recorded.by || "Somebody whose name was not recorded"} approved ${nSets(recorded.sets)} for the staff member named above${recorded.fte ? `, at ${recorded.fte} FTE` : ""}. That is the whole of the decision as ThreadCount holds it — the declaration it was signed under is on the signed sheet.`
|
||||
: `I confirm this staff member is employed in the role and at the FTE shown above, and I approve the uniform sets requested against the cost centre shown. ${authorises}`}
|
||||
</div>
|
||||
<div style={{ marginTop: 6, display: "grid", gridTemplateColumns: "1.6fr 1fr 0.9fr", columnGap: 14 }}>
|
||||
{/* The signature rule stays empty on a reprint. The ink is on the sheet that was signed,
|
||||
and a copy carrying a signature would be a second decision to file. */}
|
||||
<F label={recorded ? "Signature (on the signed sheet)" : "Signature"} />
|
||||
<F label="Position" />
|
||||
<F label="Date" v={recorded ? fmtDate(recorded.date) : undefined} />
|
||||
</div>
|
||||
|
||||
{/* The FTE table is the rule only for the groups the facility put on it, so it prints only on
|
||||
their forms. The other two routes have no table to print — a starting kit and a ceiling
|
||||
are each one sentence — and the page they save is why a long request still fits above
|
||||
the signature.
|
||||
|
||||
A reprint gets none of the three. Which allowance somebody is on is read from their role,
|
||||
an approval never recorded the role, and a rule quoted off today's role would be this
|
||||
year's entitlement printed underneath last year's decision. */}
|
||||
{recorded ? (
|
||||
<>
|
||||
<H t="Uniform sets" note="the decision, not the rule behind it" />
|
||||
<div style={{ fontSize: 8.5, fontWeight: 600, color: "#57534f", marginTop: 3 }}>
|
||||
A set is one top and one pair of trousers. The allowance this was signed against is not part of what was recorded, so no entitlement rule is printed on a copy — the sets above are the decision itself.
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{nursing ? (
|
||||
<>
|
||||
<H t="Uniform sets by combined FTE" note="for this role · a proposal, not a limit" />
|
||||
<table className="fte">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: "left" }}>Total combined FTE</th>
|
||||
{bands.map((b) => <th key={b.sets}>{b.ftes.join(" / ")}</th>)}
|
||||
<th>{FTE_CASUAL}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style={{ textAlign: "left" }}>Uniform sets</td>
|
||||
{bands.map((b) => <td key={b.sets}>{b.sets}</td>)}
|
||||
<td>{CASUAL_SETS.join(" / ")} at manager's discretion</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<H t="Uniform sets for this role" note={onKit ? "starting kit · issued by the linen room" : "each set approved by the manager"} />
|
||||
)}
|
||||
<div style={{ fontSize: 8.5, fontWeight: 600, color: "#57534f", marginTop: 3 }}>
|
||||
A set is one top and one pair of trousers. {rule}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Never filled in by the app. The counter writes these on the paper as the order goes out and
|
||||
the garments come back, and ThreadCount does not yet know a PO number or an invoice value
|
||||
— printing a guess at one would be worse than the empty rule it replaced. On a copy they
|
||||
are blank for the same reason every other unrecorded field is: whatever the counter wrote
|
||||
is on the original, and this page has never been told any of it. */}
|
||||
<H t="Office use only" note={recorded ? "written on the original, not held in ThreadCount" : undefined} />
|
||||
<div style={{ marginTop: 6, display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", columnGap: 14, rowGap: 7 }}>
|
||||
<F label="Date ordered" />
|
||||
<F label="PO number" />
|
||||
<F label="Value" />
|
||||
<F label="Invoice number" />
|
||||
<F label="Stock received" />
|
||||
<F label="Date collected" />
|
||||
<F label="Staff signature" />
|
||||
<div>
|
||||
<div style={lb}>Alterations</div>
|
||||
<div style={{ borderBottom: "1.2px solid #201e1d", minHeight: 21, display: "flex", alignItems: "flex-end", gap: 10, paddingBottom: 2 }}><CB label="Yes" /><CB label="No" /></div>
|
||||
</div>
|
||||
<div style={{ gridColumn: "span 2" }}>
|
||||
<div style={lb}>Staff notified</div>
|
||||
<div style={{ borderBottom: "1.2px solid #201e1d", minHeight: 21, display: "flex", alignItems: "flex-end", gap: 10, paddingBottom: 2 }}><CB label="Phone" /><CB label="E-mail" /><CB label="Date" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 9, paddingTop: 5, borderTop: "2px solid #201e1d", fontSize: 9, fontWeight: 600, color: "#57534f" }}>
|
||||
{contacts.length > 0 ? `Return the signed form to ${contacts.join(" · ")}` : " "}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import AutoPrint from "@/components/AutoPrint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SP = Record<string, string | undefined>;
|
||||
|
||||
const lb: React.CSSProperties = { fontSize: 10, fontWeight: 600, letterSpacing: ".08em", textTransform: "uppercase" };
|
||||
const val = (big?: boolean): React.CSSProperties => ({ borderBottom: big ? "2px solid #201e1d" : "1.5px solid #201e1d", minHeight: big ? 30 : 24, fontSize: big ? 17 : 13, fontWeight: big ? 700 : 600, padding: "2px 2px 0", display: "flex", alignItems: "flex-end" });
|
||||
const F = ({ label, v, big }: { label: string; v?: string; big?: boolean }) => (
|
||||
<div><div style={lb}>{label}</div><div style={val(big)}>{v || " "}</div></div>
|
||||
);
|
||||
const CB = ({ label, on }: { label: string; on: boolean }) => (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
<span style={{ width: 12, height: 12, border: "1.5px solid #201e1d", display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 9, fontWeight: 800, lineHeight: 1 }}>{on ? "✕" : " "}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 600 }}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default async function PrintPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/auth");
|
||||
const fac = await prisma.facility.findUniqueOrThrow({ where: { id: user.facilityId } });
|
||||
const q = await searchParams;
|
||||
const type = q.type === "delivery" ? "delivery" : "collection";
|
||||
const copies = Math.min(3, Math.max(1, parseInt(q.copies || "1", 10) || 1));
|
||||
const org = fac.slipOrg || fac.name;
|
||||
/* What is actually in the bag, one garment to a line.
|
||||
*
|
||||
* A ward request now covers several garments under one code, so a slip that only carried a total
|
||||
* would be signed for without anybody being able to check it. The caller passes the approved
|
||||
* lines already worded ("2 × Tunic — 16"), newline separated; the counter's own issue slip passes
|
||||
* nothing and the block simply doesn't appear. Capped so a very long request can't push the
|
||||
* signature block off the page — the number is still printed in full beside it. */
|
||||
const garments = String(q.lines || "").split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
const shown = garments.slice(0, 10);
|
||||
const spilled = garments.length - shown.length;
|
||||
const title = type === "delivery" ? "Uniform ward delivery" : "Uniform ready for collection";
|
||||
const foot = type === "delivery" ? fac.slipDeliveryFooter : fac.slipCollectionFooter;
|
||||
|
||||
const Slip = () => (
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", justifyContent: "flex-start" }}>
|
||||
<div style={{ height: 8, background: "linear-gradient(90deg,#201e1d 72%,#9ACBD8 72%)" }} />
|
||||
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16 }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 800, letterSpacing: "-0.01em", textTransform: "uppercase" }}>{title}</div>
|
||||
{fac.logoData ? <img src={fac.logoData} alt={org} style={{ width: 110, height: 34, objectFit: "contain", objectPosition: "right center" }} /> : <div style={{ width: 150, textAlign: "right", fontSize: 12, fontWeight: 800, letterSpacing: "0.02em", lineHeight: 1.15 }}>{org}</div>}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: q.code ? "1fr 130px" : "1fr", columnGap: 18 }}>
|
||||
<F label="Staff name" v={q.staffName} big />
|
||||
{/* One code, one bag. It sits beside the name because that is the pair the counter matches. */}
|
||||
{q.code && <F label="Collection code" v={q.code} big />}
|
||||
</div>
|
||||
{shown.length > 0 && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={lb}>Garments — tick each one as it goes in the bag</div>
|
||||
<div style={{ border: "1.5px solid #201e1d", marginTop: 3, padding: "5px 8px", display: "grid", gridTemplateColumns: garments.length > 4 ? "1fr 1fr" : "1fr", columnGap: 18, rowGap: 3 }}>
|
||||
{shown.map((g, i) => <div key={i} style={{ fontSize: 12, fontWeight: 600 }}><CB label={g} on={false} /></div>)}
|
||||
</div>
|
||||
{spilled > 0 && <div style={{ fontSize: 10, fontWeight: 600, marginTop: 3 }}>+{spilled} more line{spilled === 1 ? "" : "s"} — see the request in ThreadCount.</div>}
|
||||
</div>
|
||||
)}
|
||||
{type === "delivery" ? (
|
||||
<>
|
||||
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1.4fr 1.4fr 0.5fr", columnGap: 18 }}><F label="Ward / Department" v={q.dept} /><F label="Deliver to (location on ward)" v={q.deliverTo} /><F label="Garments" v={q.sets} /></div>
|
||||
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1fr 1fr 1.4fr", columnGap: 18 }}><F label="PO / Order no." v={q.po} /><F label="Date received" v={q.dateReceived} /><F label="Requested by (manager / staff)" v={q.requestedBy} /></div>
|
||||
<div style={{ marginTop: 12, paddingTop: 8, borderTop: "2px solid #201e1d", display: "grid", gridTemplateColumns: "1fr 0.8fr 1.4fr", columnGap: 18 }}><F label="Delivered by" v={q.deliveredBy} /><F label="Date / time" v={q.dateTime} /><F label="Received on ward by (name + sign)" /></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "1.5fr 1fr 1fr", columnGap: 18 }}><F label="Ward / Department" v={q.dept} /><F label="Date received" v={q.dateReceived} /><F label="PO / Order no." v={q.po} /></div>
|
||||
<div style={{ marginTop: 10, display: "grid", gridTemplateColumns: "0.5fr auto 1fr", alignItems: "end", columnGap: 18 }}>
|
||||
<F label="Garments" v={q.sets} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingBottom: 5 }}><span style={lb}>Staff notified</span><CB label="Phone" on={q.notifiedPhone === "1"} /><CB label="Email" on={false} /></div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8 }}><span style={{ ...lb, whiteSpace: "nowrap" }}>Date notified</span><div style={{ ...val(), flex: 1 }}>{q.dateNotified || " "}</div></div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, paddingTop: 8, borderTop: "2px solid #201e1d", display: "grid", gridTemplateColumns: "1.6fr 1fr", columnGap: 18 }}><F label="Collected by (signature)" /><F label="Date collected" /></div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: 8, fontSize: 9.5, fontWeight: 600, color: "#57534f" }}>{foot}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ background: "#fff", color: "#201e1d", fontFamily: "var(--font-body)", minHeight: "100vh" }}>
|
||||
<style>{`@page{size:A4;margin:0} html,body{background:#fff !important} .sheet{width:210mm;min-height:${copies > 1 ? "297mm" : "auto"};padding:28px 44px 24px;margin:0 auto;display:flex;flex-direction:column;-webkit-print-color-adjust:exact;print-color-adjust:exact} .cut{display:flex;align-items:center;gap:8px;color:#a8a4a1;margin:10px 0} .cut div{flex:1;border-top:2px dashed #a8a4a1} @media print{.no-print{display:none !important}}`}</style>
|
||||
<div className="no-print" style={{ padding: "10px 16px", borderBottom: "2px solid #201e1d", display: "flex", gap: 12, alignItems: "center", fontSize: 13 }}>
|
||||
<b>{title}</b><span style={{ color: "#57534f" }}>Print preview — use your browser's print dialog if it didn't open.</span>
|
||||
<AutoPrint />
|
||||
</div>
|
||||
<div className="sheet">
|
||||
{Array.from({ length: copies }).map((_, i) => (
|
||||
<div key={i} style={{ display: "contents" }}>
|
||||
{i > 0 && <div className="cut"><span style={{ fontSize: 13 }}>✂</span><div /></div>}
|
||||
<Slip />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadOrderDoc } from "@/lib/orderdoc";
|
||||
import { fmtDate, money } from "@/lib/compute";
|
||||
import AutoPrint from "@/components/AutoPrint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* The purchase order as an A4 sheet: the supplier's own product codes, a tick box per line to
|
||||
* work down while keying it into the supplier's site, the account number, the order number, a
|
||||
* space for the supplier's reference, and a signature. Black on white, nothing that will not
|
||||
* print. Rendering it stamps printedAt on the order. */
|
||||
export default async function SupplierOrderSheet({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/auth");
|
||||
if (user.role !== "ADMIN") redirect("/app/orders");
|
||||
const q = await searchParams;
|
||||
const id = String(q.id || "").trim().slice(0, 64);
|
||||
if (!id) redirect("/app/orders");
|
||||
const d = await loadOrderDoc(user.facilityId, id).catch(() => null);
|
||||
if (!d) redirect("/app/orders");
|
||||
await prisma.order.update({ where: { id }, data: { printedAt: new Date() } }).catch(() => undefined);
|
||||
|
||||
const ink = "#201e1d";
|
||||
const lb: React.CSSProperties = { fontSize: 9, fontWeight: 700, letterSpacing: ".08em", textTransform: "uppercase", color: ink };
|
||||
const cell: React.CSSProperties = { padding: "6px 6px", borderBottom: `1px solid ${ink}`, fontSize: 11.5, verticalAlign: "top" };
|
||||
const r: React.CSSProperties = { ...cell, textAlign: "right", fontVariantNumeric: "tabular-nums" };
|
||||
const box = <span style={{ display: "inline-block", width: 11, height: 11, border: `1.2px solid ${ink}` }} />;
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: "system-ui, -apple-system, Segoe UI, sans-serif", color: ink, background: "#fff", padding: "14mm", maxWidth: 800, margin: "0 auto" }}>
|
||||
<style>{`@page{size:A4;margin:14mm}@media print{.noprint{display:none}}`}</style>
|
||||
<div className="noprint" style={{ display: "flex", gap: 8, marginBottom: 12 }}><a className="btn btn-ghost" href="/app/orders">← Orders</a><AutoPrint /></div>
|
||||
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", borderBottom: `2px solid ${ink}`, paddingBottom: 8 }}>
|
||||
<div>
|
||||
<div style={lb}>Purchase order</div>
|
||||
<h1 style={{ fontSize: 22, margin: "2px 0 0", letterSpacing: "-0.01em" }}>{d.order.code}{d.order.ref ? <span style={{ fontWeight: 400 }}> · {d.order.ref}</span> : null}</h1>
|
||||
</div>
|
||||
<div style={{ textAlign: "right", fontSize: 11.5, lineHeight: 1.5 }}>
|
||||
<b>{d.facility.name}</b><br />{d.facility.location}{d.facility.slipOrg ? <><br />{d.facility.slipOrg}</> : null}
|
||||
</div>
|
||||
</header>
|
||||
<section style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "6px 24px", margin: "12px 0", fontSize: 11.5, lineHeight: 1.6 }}>
|
||||
<div><span style={lb}>Supplier</span><br /><b>{d.supplier.name || "—"}</b>{d.supplier.contact ? ` · ${d.supplier.contact}` : ""}{d.supplier.phone ? ` · ${d.supplier.phone}` : ""}{d.supplier.email ? <><br />{d.supplier.email}</> : null}</div>
|
||||
<div><span style={lb}>Date</span><br /><b>{fmtDate(d.order.date)}</b>{d.order.expected ? ` · expected ${fmtDate(d.order.expected)}` : ""}</div>
|
||||
<div><span style={lb}>Our account with supplier</span><br /><b>{d.supplier.account || "—"}</b></div>
|
||||
<div><span style={lb}>Supplier's order reference</span><br />{d.order.ref ? <b>{d.order.ref}</b> : <span style={{ display: "inline-block", width: 180, borderBottom: `1.2px solid ${ink}`, height: 16 }} />}</div>
|
||||
{d.staff && <div style={{ gridColumn: "1 / -1" }}><span style={lb}>Ordered for</span><br /><b>{d.staff.name}</b>{d.staff.dept ? ` · ${d.staff.dept}` : ""}{d.staff.cc ? ` · cost centre ${d.staff.cc}` : ""}</div>}
|
||||
</section>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", marginTop: 6 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...cell, ...lb, borderBottom: `2px solid ${ink}`, width: 18 }}></th>
|
||||
<th style={{ ...cell, ...lb, borderBottom: `2px solid ${ink}`, textAlign: "left" }}>Supplier code</th>
|
||||
<th style={{ ...cell, ...lb, borderBottom: `2px solid ${ink}`, textAlign: "left" }}>Description</th>
|
||||
<th style={{ ...cell, ...lb, borderBottom: `2px solid ${ink}`, textAlign: "left" }}>Size</th>
|
||||
<th style={{ ...r, ...lb, borderBottom: `2px solid ${ink}` }}>Qty</th>
|
||||
<th style={{ ...r, ...lb, borderBottom: `2px solid ${ink}` }}>Unit</th>
|
||||
<th style={{ ...r, ...lb, borderBottom: `2px solid ${ink}` }}>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{d.lines.map((l, i) => (
|
||||
<tr key={i}>
|
||||
<td style={cell}>{box}</td>
|
||||
<td style={{ ...cell, fontFamily: "ui-monospace, Menlo, monospace", fontWeight: 700 }}>{l.code || "—"}</td>
|
||||
<td style={cell}>{l.description}</td>
|
||||
<td style={cell}>{l.size}</td>
|
||||
<td style={{ ...r, fontWeight: 700 }}>{l.qty}</td>
|
||||
<td style={r}>{l.unit ? money(l.unit) : "—"}</td>
|
||||
<td style={r}>{l.unit ? money(l.total) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 8, fontSize: 12 }}>
|
||||
<div>{d.lines.reduce((t, l) => t + l.qty, 0)} units · {d.lines.length} line{d.lines.length === 1 ? "" : "s"}</div>
|
||||
<div><b>Total {money(d.total)}</b> <span style={{ fontSize: 10 }}>ex tax, at last known unit costs</span></div>
|
||||
</div>
|
||||
{d.order.notes && <div style={{ marginTop: 10, fontSize: 11 }}>Notes: {d.order.notes}</div>}
|
||||
<div style={{ marginTop: 28, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 24, fontSize: 11 }}>
|
||||
<div><span style={lb}>Ordered by</span><div style={{ borderBottom: `1.2px solid ${ink}`, height: 22 }} /></div>
|
||||
<div><span style={lb}>Date placed</span><div style={{ borderBottom: `1.2px solid ${ink}`, height: 22 }} /></div>
|
||||
</div>
|
||||
<div style={{ marginTop: 14, fontSize: 10, color: "#57534f" }}>Please quote order {d.order.code} on the invoice.{d.facility.coordinator ? ` Questions: ${d.facility.coordinator}${d.facility.coordinatorPhone ? ` · ${d.facility.coordinatorPhone}` : ""}${d.facility.coordinatorEmail ? ` · ${d.facility.coordinatorEmail}` : ""}.` : ""}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user