a6f1059ddf
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).
108 lines
6.0 KiB
TypeScript
108 lines
6.0 KiB
TypeScript
"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="It’s 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"}. There’s 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} />}
|
||
</>
|
||
);
|
||
}
|