Files
threadcount-community/app/m/(app)/reorder/page.tsx
T
ThreadCount 057da00fd2 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 e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:16:36 +10:00

108 lines
6.0 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";
/* Reorder draft — what fell below par, at quantities that bring each line back up. Adjust and raise.
This raises a draft on Ordering; nothing reaches a supplier until someone approves it there. */
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { flaggedNeeds, label, onhand, reorderAt, touched, variantName } from "@/lib/compute";
import { INK, MBar, MBody, MEmpty, MError, MRule, MStepper, MTop } from "@/components/m";
export default function MReorder() {
const { s, mutate, busy } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const [qty, setQty] = useState<Record<string, number>>({});
const [err, setErr] = useState("");
const [done, setDone] = useState<string | null>(null);
const needs = useMemo(() => flaggedNeeds(s, L, byId).map((n) => {
const k = `${n.itemId}:${n.si}`;
return { ...n, key: k, name: `${variantName(byId[n.itemId], n.size)}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
}), [s, L, byId]);
// /m/stock's "N below par" counts every line at or under its reorder point. flaggedNeeds nets off
// what is already on an open order and drops a line once that covers it, so the two figures
// legitimately differ — and a counter who taps "Reorder 8 lines" and is told nothing needs
// ordering stops believing the screen. Report both, and never call a short shelf healthy:
// stock on order is not stock on the shelf until somebody receipts it.
const belowPar = useMemo(
() => variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key)).length,
[s, L, variants],
);
const covered = belowPar - needs.length;
const q = (k: string, fallback: number) => qty[k] ?? fallback;
const total = needs.reduce((t, n) => t + q(n.key, n.qty), 0);
const suppliers = [...new Set(needs.map((n) => n.supplier))];
const raise = async () => {
const bySup: Record<string, typeof needs> = {};
for (const n of needs) if (q(n.key, n.qty) > 0) (bySup[n.supplier] ||= []).push(n);
const codes: string[] = [];
for (const sup of Object.keys(bySup)) {
const r = await mutate<{ code: string }>("order.create", {
orderFor: "Stock", supplier: sup, replenish: false, notes: "Raised from a stocktake on the app",
lines: bySup[sup].map((n) => ({ itemId: n.itemId, size: n.size, qty: q(n.key, n.qty) })),
});
if (!r.ok) { setErr(r.error); return; }
codes.push(r.result.code);
}
setDone(codes.join(" · "));
};
if (done) return (
<>
<MTop title="Reorder" />
<MRule />
<MBody><MEmpty title={`Draft ${done} raised`} sub="Its waiting on Ordering. Check the quantities and the supplier reference there, then send it." /></MBody>
<MBar label="Back to stock" href="/m/stock" />
</>
);
return (
<>
<MTop title="Reorder" back right={needs.length ? `${needs.length} line${needs.length === 1 ? "" : "s"}` : undefined} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>Below par</div>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05, marginTop: 8 }}>Draft order</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
{needs.length === 0
? belowPar === 0
? "Every line is at or above its par level. Nothing needs ordering."
: `${belowPar} line${belowPar === 1 ? "" : "s"} ${belowPar === 1 ? "is" : "are"} at or below par, and open orders already cover ${belowPar === 1 ? "it" : "them"}. Theres nothing more to raise — but the shelf stays short until the delivery is receipted.`
: covered > 0
? `${belowPar} lines are at or below par. Open orders cover ${covered}; the other ${needs.length} still need${needs.length === 1 ? "s" : ""} ordering, at quantities that bring ${needs.length === 1 ? "it" : "each one"} back up.`
: `${needs.length} line${needs.length === 1 ? "" : "s"} ${needs.length === 1 ? "is" : "are"} at or below par. Quantities bring each one back up.`}
</p>
</div>
{needs.length === 0 ? (
<MEmpty
title={belowPar ? "Already on order" : "Nothing to reorder"}
sub={belowPar
? "Every short line is on an open order. Receipt it on Ordering when it lands — until then those shelves are still short."
: "Come back after a count, or lower a par level on the desktop if a line should be carrying more."} />
) : needs.map((n) => (
<div key={n.key} style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16.5, fontWeight: 700, letterSpacing: "-0.01em" }}>{n.name}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 3 }}>{n.oh} on hand · par {n.par} · {n.supplier}</div>
</div>
<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} />
</div>
))}
{needs.length > 0 && (
<p style={{ padding: "18px 16px 26px", fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
Goes to {suppliers.length === 1 ? suppliers[0] : `${suppliers.length} suppliers`} as a draft. Nothing is sent until you approve it on Ordering.
</p>
)}
</MBody>
{needs.length > 0 && <MBar label={busy ? "Raising…" : `Raise the draft — ${total} item${total === 1 ? "" : "s"}`} onClick={raise} disabled={busy || total === 0} />}
</>
);
}