ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
"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’t open a label sheet. Open ThreadCount
|
||||
at threadcount.tech, 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()} isn’t a garment ThreadCount knows.`);
|
||||
setScan(false);
|
||||
}} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user