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 f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
/* The catalogue on the phone.
|
||||
*
|
||||
* This used to be one of the rows under "On the desktop" — listed, greyed out, untappable, with
|
||||
* the note that adding garments is a sit-down job. It genuinely is, for a bulk import of two
|
||||
* hundred lines. It is not for the thing that actually happens in a linen room: a new garment
|
||||
* turns up at the counter and needs to exist before it can be scanned in.
|
||||
*
|
||||
* So this is the whole catalogue, not just what's on the shelf — /m/stock deliberately shows only
|
||||
* variants with history, which means a garment created five minutes ago wouldn't appear there. */
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { label, type Item } from "@/lib/compute";
|
||||
import { INK, IconPlus, MBody, MEmpty, MNav, MNote, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MCatalogue() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return s.catalog
|
||||
.filter((i: Item) => (showArchived ? i.archived : !i.archived))
|
||||
.map((i: Item) => ({
|
||||
...i,
|
||||
name: label(byId[i.id] ?? i),
|
||||
sub: [i.sizes.length ? `${i.sizes.length} size${i.sizes.length === 1 ? "" : "s"}` : "No sizes yet", i.supplier || "No supplier", i.sku].filter(Boolean).join(" · "),
|
||||
}))
|
||||
.filter((i) => !needle || `${i.name} ${i.sku} ${i.supplier} ${i.type} ${i.group}`.toLowerCase().includes(needle))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [s.catalog, byId, q, showArchived]);
|
||||
|
||||
const archivedCount = s.catalog.filter((i: Item) => i.archived).length;
|
||||
const addLink: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px",
|
||||
border: "2px solid " + INK, color: INK, textDecoration: "none",
|
||||
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Catalogue" right={`${rows.length} item${rows.length === 1 ? "" : "s"}`} back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Garment, code, supplier" aria-label="Filter the catalogue" style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div style={{ padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<Link href="/m/catalogue/new" style={addLink}>
|
||||
<IconPlus /><span style={{ flex: 1 }}>New garment</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MSection label={showArchived ? "Archived" : "Garments"} right={isAdmin ? "Tap to edit" : undefined} />
|
||||
{rows.length === 0
|
||||
? <MEmpty
|
||||
title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
|
||||
sub={q ? "Try a shorter search." : isAdmin ? "Add the first one and it can be scanned in straight away." : "An admin sets the catalogue up."} />
|
||||
: rows.slice(0, 300).map((i) => (
|
||||
<MRow
|
||||
key={i.id}
|
||||
href={isAdmin ? `/m/catalogue/${i.id}` : undefined}
|
||||
mark={i.archived ? "mute" : "ink"}
|
||||
title={i.name}
|
||||
sub={i.sub}
|
||||
right={<span style={{ fontSize: 12, color: "var(--color-neutral-600)" }}>{i.cost ? `$${i.cost.toFixed(2)}` : ""}</span>}
|
||||
/>
|
||||
))}
|
||||
|
||||
{archivedCount > 0 && (
|
||||
<div style={{ padding: 16 }}>
|
||||
<button
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>
|
||||
{showArchived ? "Back to the current catalogue" : `Show ${archivedCount} archived`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAdmin && <MNote>Only an admin can change the catalogue. You can still see what exists.</MNote>}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user