Files
threadcount-community/app/m/(app)/label/page.tsx
T
ThreadCount 8c86c988c1 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 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 04:04:31 +10:00

125 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/* Reprint a label. Short by design: it exists because a garment nobody can scan silently vanishes
from every count. Only sizes with a real supplier barcode can be reprinted — ThreadCount's
internal fallback code appears nowhere on a garment, so printing it would help nobody. */
import { useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, label, variantName } from "@/lib/compute";
import { isNative } from "@/lib/nativescan";
import MScan from "@/components/MScan";
import { INK, IconScan, MBar, MBody, MEmpty, MError, MNote, MRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
const REASONS = ["Worn off in the laundry", "Torn", "Never labelled", "Other"];
export default function MLabel() {
const { s } = useSnap();
const { byId, variants } = useDerived();
const [q, setQ] = useState("");
const [pick, setPick] = useState<string | null>(null);
const [reason, setReason] = useState("");
const [copies, setCopies] = useState(6);
const [scan, setScan] = useState(false);
const [err, setErr] = useState("");
/* The Android shell cannot print. Its WebView opens no second window, so the label sheet would
replace the app, and window.print() doesn't exist there — the button looked like it worked and
stranded the person on a page with nothing to do. Read after mount: the server render doesn't
know which shell it is being sent to. */
const [inApp, setInApp] = useState(false);
useEffect(() => { setInApp(isNative()); }, []);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return variants
.map((v) => ({ ...v, code: bcBound(s, v.item, v.si), name: `${variantName(byId[v.itemId], v.size)}` }))
.filter((r) => r.code)
.filter((r) => !needle || `${r.name} ${r.code} ${r.item.sku}`.toLowerCase().includes(needle));
}, [s, variants, byId, q]);
const chosen = rows.find((r) => r.key === pick);
const printable = chosen && chosen.code;
return (
<>
<MTop title="Reprint label" back right={chosen ? undefined : `${rows.length} labelled size${rows.length === 1 ? "" : "s"}`} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{!chosen ? (
<>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.03em", lineHeight: 1.05 }}>Barcode gone</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
A garment nobody can scan drops out of every count. Find it by code or description and print a fresh label.
</p>
</div>
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 8 }}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Code or description" autoFocus
aria-label="Find a garment" style={{ ...inputStyle, flex: 1 }} />
<button onClick={() => setScan(true)} aria-label="Scan a working label"
style={{ width: 56, minHeight: 48, border: "2px solid " + INK, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
<IconScan />
</button>
</div>
<MSection label="Sizes with a supplier barcode" />
{rows.length === 0
? <MEmpty title="Nothing matches" sub="Only sizes with a supplier barcode bound to them can be reprinted. Bind one by scanning the size on the desktop." />
: rows.slice(0, 40).map((r) => (
<MRow key={r.key} onClick={() => setPick(r.key)} mark="ink" title={r.name} sub={`${r.code}${r.item.sku ? ` · ${r.item.sku}` : ""}`} />
))}
</>
) : (
<>
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{chosen.name}</div>
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>{chosen.code}</div>
<button onClick={() => { setPick(null); setReason(""); }} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
</div>
<MSection label="Why is it being reprinted?" />
<div style={{ padding: 16, display: "flex", flexWrap: "wrap", gap: 8 }}>
{REASONS.map((r) => {
const on = reason === r;
return (
<button key={r} onClick={() => setReason(on ? "" : r)} aria-pressed={on}
style={{ minHeight: 48, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 14, fontWeight: 700, cursor: "pointer" }}>{r}</button>
);
})}
</div>
<MSection label="Copies" />
<div style={{ padding: 16, display: "flex", alignItems: "center", gap: 16 }}>
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)" }}>Six to an A4 sheet.</span>
<MStepper n={copies} onChange={setCopies} min={1} max={24} />
</div>
<MNote>The label carries the same barcode the supplier printed, so it scans identically to the ones still on the shelf.</MNote>
{inApp && (
<MNote tone="warn">
Printing is a desktop job the app can&rsquo;t open a label sheet. Open ThreadCount
on the desktop site, find <b>{chosen.name}</b> under {chosen.code}, and print it
from there.
</MNote>
)}
</>
)}
</MBody>
{chosen && (
<MBar label={inApp ? "Print it on the desktop" : `Print ${copies} label${copies === 1 ? "" : "s"}`} glyph="printer"
disabled={inApp}
onClick={() => {
if (!printable) { setErr("That size has no supplier barcode bound to it."); return; }
const url = `/print/labels?code=${encodeURIComponent(chosen.code)}&copies=${copies}&reason=${encodeURIComponent(reason)}`;
window.open(url, "_blank", "noopener");
}} />
)}
{scan && <MScan title="Scan a working label" onHit={(raw) => {
const k = s.barcodes[raw.trim()];
if (k) { setPick(k); setQ(""); } else setErr(`${raw.trim()} isnt a garment ThreadCount knows.`);
setScan(false);
}} onClose={() => setScan(false)} />}
</>
);
}