Files
threadcount-community/components/settings/CatalogueSection.tsx
T
ThreadCount a6f1059ddf 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 a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-15 22:54:09 +10:00

81 lines
5.5 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";
/* Catalogue & suppliers: default reorder level, barcode lookup, and the supplier directory. */
import { useState } from "react";
import { useSnap } from "@/lib/client";
import { Field } from "@/components/ui";
import { Panel } from "@/components/portal";
import { csvOf, type SupplierRec } from "@/lib/compute";
import { downloadCsv } from "@/lib/print";
import { Msg, SectionHead, TextField, useSaver, useSettingsFields } from "./common";
type SupKey = "contact" | "phone" | "account" | "email" | "lead";
const SUP_FIELDS: readonly [SupKey, string, string][] = [["contact", "Contact", "e.g. Dana R."], ["phone", "Phone", "e.g. 07 3xxx xxxx"], ["account", "Account no.", "e.g. ACC-2201"], ["email", "Order email", "e.g. orders@example.com"], ["lead", "Lead time (days)", "e.g. 14"]];
export default function CatalogueSection() {
const { s, isAdmin, mutate } = useSnap();
const saver = useSaver();
const { msg, say, draft, debounced } = saver;
const { val, setField } = useSettingsFields(saver);
const [lookupOn, setLookupOn] = useState<boolean | null>(null);
const [ns, setNs] = useState("");
const supField = (sup: SupplierRec, k: SupKey) => draft[`sup:${sup.id}:${k}`] ?? (sup[k] === null ? "" : String(sup[k]));
function exportSuppliers() {
const rows = s.supplierDir.map((sup) => [sup.name, supField(sup, "contact"), supField(sup, "phone"), supField(sup, "account"), supField(sup, "email"), supField(sup, "lead"), s.catalog.filter((it) => it.supplier === sup.name).length, s.orders.filter((o) => o.supplier === sup.name).length]);
downloadCsv(`threadcount-suppliers-${s.today}.csv`, csvOf(["Supplier", "Contact", "Phone", "Account no.", "Order email", "Lead time (days)", "Products", "Orders"], rows));
}
return (
<>
<SectionHead divider={false}>Catalogue</SectionHead>
<div className="tc-set-grid">
<TextField label="Default reorder level" hint="For sizes without their own." value={val("defaultReorder")} disabled={!isAdmin} onChange={(v) => setField("defaultReorder", v.replace(/[^0-9]/g, ""))} />
</div>
{isAdmin && (
<label style={{ display: "flex", gap: 10, alignItems: "center", fontSize: 14, cursor: "pointer" }}>
<input type="checkbox" style={{ width: 16, height: 16, accentColor: "var(--color-accent)" }} checked={lookupOn ?? s.settings.barcodeLookup}
onChange={async (e) => {
const v = e.target.checked; setLookupOn(v);
const r = await mutate("settings.update", { barcodeLookup: v });
if (!r.ok) setLookupOn(!v);
say("fields", r.ok ? (v ? "Barcode lookup on." : "Barcode lookup off.") : r.error);
}} />
Look up unknown barcodes in public databases
</label>
)}
<Msg text={msg.fields} />
<SectionHead meta="Suppliers with products or orders cant be removed."
right={<button className="btn btn-ghost" onClick={exportSuppliers} disabled={s.supplierDir.length === 0}>Export CSV</button>}>
Suppliers
</SectionHead>
{s.supplierDir.map((sp) => {
const nItems = s.catalog.filter((it) => it.supplier === sp.name).length, nOrds = s.orders.filter((o) => o.supplier === sp.name).length;
return (
<Panel key={sp.id} headingLevel={3}
title={<span style={{ textTransform: "none", letterSpacing: 0, fontFamily: "var(--font-heading)", fontSize: 15, fontWeight: 800 }}>{sp.name}</span>}
aside={<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<span className="tc-mono">{nItems}</span> product{nItems === 1 ? "" : "s"} · <span className="tc-mono">{nOrds}</span> order{nOrds === 1 ? "" : "s"}
{isAdmin && nItems + nOrds === 0 && <button className="btn btn-ghost btn-icon" title="Remove this supplier" aria-label={`Remove ${sp.name}`} onClick={async () => { const r = await mutate("supplier.remove", { id: sp.id }); say("sup", r.ok ? `${sp.name} removed.` : r.error); }}>×</button>}
</span>}>
<div className="tc-set-grid" style={{ padding: "12px 16px" }}>
{SUP_FIELDS.map(([k, lbl, ph]) => (
<Field key={k} label={<>{lbl}<span className="sr-only"> for {sp.name}</span></>}>{(c) => <input {...c} className={"input" + (k === "lead" || k === "account" ? " tc-mono" : "")} placeholder={ph} value={supField(sp, k)} disabled={!isAdmin}
onChange={(e) => { const v = k === "lead" ? e.target.value.replace(/[^0-9]/g, "") : e.target.value; debounced(`sup:${sp.id}:${k}`, v, "supplier.update", { id: sp.id, [k]: v }, "sup"); }} />}</Field>
))}
</div>
</Panel>
);
})}
{!s.supplierDir.length && <div className="tc-meta-line">No suppliers yet.</div>}
{isAdmin && (
<div style={{ display: "flex", gap: 10, alignItems: "flex-end", flexWrap: "wrap" }}>
<Field label="New supplier" style={{ width: 280, maxWidth: "100%" }}>{(c) => <input {...c} className="input" value={ns} onChange={(e) => setNs(e.target.value)} placeholder="e.g. Northline Workwear" onKeyDown={async (e) => { if (e.key === "Enter" && ns.trim()) { const r = await mutate("supplier.add", { name: ns }); say("sup", r.ok ? "Added." : r.error); if (r.ok) setNs(""); } }} />}</Field>
<button className="btn btn-secondary" disabled={!ns.trim()} onClick={async () => { const r = await mutate("supplier.add", { name: ns }); say("sup", r.ok ? "Added." : r.error); if (r.ok) setNs(""); }}>Add supplier</button>
</div>
)}
<Msg text={msg.sup} />
</>
);
}