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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
/* The green line at the top of Today after something finished elsewhere: a shelf count committed
|
||||
* (?flash=counted&loc=<name>&gaps=<n>, or the older ?counted=1) or a facility just created
|
||||
* (?flash=created). Dismissing it drops the query so a reload does not bring it back. */
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { OK } from "@/components/m";
|
||||
|
||||
export function bannerText(q: URLSearchParams): string {
|
||||
const flash = q.get("flash");
|
||||
if (flash === "created") return "Facility created";
|
||||
if (flash === "counted" || q.get("counted")) {
|
||||
const loc = (q.get("loc") || "").slice(0, 80).trim();
|
||||
const gaps = Math.max(0, parseInt(q.get("gaps") || "0", 10) || 0);
|
||||
return `${loc || "Shelf"} counted${gaps > 0 ? ` · ${gaps} ${gaps === 1 ? "gap" : "gaps"}` : ""}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export default function TodayBanner() {
|
||||
const sp = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [gone, setGone] = useState(false);
|
||||
const text = bannerText(new URLSearchParams(sp.toString()));
|
||||
if (!text || gone) return null;
|
||||
return (
|
||||
<div role="status" style={{ background: OK, color: "#ffffff", padding: "12px 14px", margin: "-16px -16px 12px", fontWeight: 700, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
|
||||
<span>{text}</span>
|
||||
<button type="button" aria-label="Dismiss" onClick={() => { setGone(true); router.replace("/m"); }}
|
||||
style={{ background: "none", border: 0, color: "#ffffff", fontSize: 22, width: 44, height: 44, flex: "none", cursor: "pointer", padding: 0, fontFamily: "inherit" }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
/* Today's figures, worked out once from the snapshot: the To do rows (each only when there is
|
||||
* something to do), Your day, and Recent. Membership comes from the shared selectors
|
||||
* (lib/portalcounts.ts through lib/today.ts and lib/workcount.ts), so Today, the Work badge and the
|
||||
* Work segments cannot disagree about what is waiting. */
|
||||
import { useMemo } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { formatInZone, onhand, reorderAt, staffName, variantName } from "@/lib/compute";
|
||||
import { PICKUP_LATE_DAYS, atReorderVariants } from "@/lib/portalcounts";
|
||||
import { collectRows, countRows, dayMonth, plural, receiveRows } from "@/lib/today";
|
||||
import { PICK_STATUSES } from "@/lib/workcount";
|
||||
import { useRequests } from "@/components/requests/RequestList";
|
||||
|
||||
export type TodoRow = { key: string; n: string; accent: boolean; title: string; sub: string; href: string };
|
||||
export type RecentRow = { key: string; title: string; sub: string; right: string; href?: string };
|
||||
export type TodayView = {
|
||||
kicker: string;
|
||||
todo: TodoRow[];
|
||||
day: { issued: number; back: number; counted: number };
|
||||
recent: RecentRow[];
|
||||
};
|
||||
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
|
||||
/** "Wed 16 Sep" from a facility date, without the locale's comma or four-letter "Sept". */
|
||||
export function shortDate(iso: string): string {
|
||||
const y = +iso.slice(0, 4), m = +iso.slice(5, 7) - 1, d = +iso.slice(8, 10);
|
||||
if (!y || m < 0 || !d) return "";
|
||||
return `${WEEKDAYS[new Date(Date.UTC(y, m, d)).getUTCDay()]} ${d} ${MONTHS[m]}`;
|
||||
}
|
||||
|
||||
/** Names, at most four, then "+n". */
|
||||
function names(list: string[]): string {
|
||||
const uniq = [...new Set(list.filter(Boolean))];
|
||||
return uniq.slice(0, 4).join(", ") + (uniq.length > 4 ? ` +${uniq.length - 4}` : "");
|
||||
}
|
||||
const first = (full: string) => full.trim().split(/\s+/)[0] || "";
|
||||
|
||||
export function useToday(): TodayView {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, staffById } = useDerived();
|
||||
const { data } = useRequests();
|
||||
|
||||
return useMemo(() => {
|
||||
const today = s.today;
|
||||
const todo: TodoRow[] = [];
|
||||
|
||||
// 1. Requests to pick.
|
||||
if (data) {
|
||||
const picks = data.requests.filter((r) => PICK_STATUSES.has(r.status));
|
||||
if (picks.length) {
|
||||
todo.push({
|
||||
key: "picks", n: String(picks.length), accent: false, title: "Requests to pick",
|
||||
sub: names(picks.map((r) => staffById[r.staffId]?.first || first(r.staffName))), href: "/m/work?seg=picks",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. The most overdue shelf count (countsDue sorts never-counted first, then oldest).
|
||||
const due = countRows(s, byId)[0];
|
||||
if (due) {
|
||||
const never = due.age === "—";
|
||||
const days = never ? 0 : parseInt(due.age, 10) || 0;
|
||||
const what = due.garments.join(", ");
|
||||
todo.push({
|
||||
key: "count", n: never ? "New" : `${days}d`, accent: true, title: `Count ${due.trail || due.loc.name}`,
|
||||
sub: [what, never ? "never counted" : `not counted for ${plural(days, "day")}`].filter(Boolean).join(" · "),
|
||||
href: `/m/count/${encodeURIComponent(due.loc.id)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Deliveries to receive.
|
||||
const inn = receiveRows(s, staffById);
|
||||
if (inn.length) {
|
||||
const o = inn[0].o;
|
||||
todo.push({
|
||||
key: "in", n: String(inn.length), accent: false, title: inn.length === 1 ? "Delivery to receive" : "Deliveries to receive",
|
||||
sub: [o.code, o.supplier].filter(Boolean).join(" · "), href: "/m/work?seg=in",
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Pickups waiting past the late threshold.
|
||||
const late = collectRows(s, byId, staffById, { includeRound: true }).filter((r) => r.late);
|
||||
if (late.length) {
|
||||
todo.push({
|
||||
key: "pickups", n: String(late.length), accent: true,
|
||||
title: `${late.length === 1 ? "Pickup" : "Pickups"} waiting ${PICKUP_LATE_DAYS}+ days`,
|
||||
sub: names(late.map((r) => r.name)), href: "/m/work?seg=pickups",
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Lines at or below par, the worst one named (lowest on hand against par).
|
||||
const low = atReorderVariants(s, L);
|
||||
if (low.length) {
|
||||
let worst = low[0], worstRatio = Infinity, worstOh = 0;
|
||||
for (const v of low) {
|
||||
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
|
||||
const ratio = par > 0 ? oh / par : oh;
|
||||
if (ratio < worstRatio) { worst = v; worstRatio = ratio; worstOh = oh; }
|
||||
}
|
||||
todo.push({
|
||||
key: "below", n: String(low.length), accent: false, title: "Lines below par",
|
||||
sub: `Worst: ${variantName(worst.item, worst.size)}, ${worstOh <= 0 ? "none on the shelf" : `${worstOh} on the shelf`}`,
|
||||
href: "/m/stock?seg=below",
|
||||
});
|
||||
}
|
||||
|
||||
// Your day.
|
||||
let issued = 0, back = 0;
|
||||
for (const i of s.issues) {
|
||||
if (i.date === today) issued += i.qty;
|
||||
if (i.returned?.date === today || (!i.returned && i.handedIn === today)) back += i.qty;
|
||||
}
|
||||
const countsToday = s.stocktakes.filter((t) => t.date === today && t.mode !== "preloved");
|
||||
|
||||
// Recent: one row per person per kind per day, plus today's counts and deliveries. Newest first.
|
||||
type R = RecentRow & { sort: string };
|
||||
const groups: Record<string, { staffId: string; kind: "Issued" | "Handed back"; at: string; qty: number; last: string }> = {};
|
||||
for (const i of s.issues.slice(-200)) {
|
||||
const add = (kind: "Issued" | "Handed back", at: string, stamp: string) => {
|
||||
const g = (groups[`${i.staffId}|${kind}|${at}`] ||= { staffId: i.staffId, kind, at, qty: 0, last: "" });
|
||||
g.qty += i.qty;
|
||||
if (stamp > g.last) g.last = stamp;
|
||||
};
|
||||
add("Issued", i.date, i.createdAt);
|
||||
if (i.returned) add("Handed back", i.returned.date, "");
|
||||
else if (i.handedIn) add("Handed back", i.handedIn, "");
|
||||
}
|
||||
const time = (iso: string) => formatInZone(iso, s.tz, { hour: "numeric", minute: "2-digit", hourCycle: "h23" }).replace(/^0(\d)/, "$1");
|
||||
const rows: R[] = Object.entries(groups).map(([k, g]) => ({
|
||||
key: k, title: staffName(staffById[g.staffId], "Staff"), sub: `${g.kind} ${plural(g.qty, "item")}`,
|
||||
right: g.at === today ? (g.last ? time(g.last) : "Today") : dayMonth(g.at, s.tz),
|
||||
href: `/m/person/${g.staffId}`,
|
||||
// A record with a time today sorts by it; a date with no time sorts to the end of that day.
|
||||
sort: g.last && g.at === today ? g.last : `${g.at}T23:59:59`,
|
||||
}));
|
||||
const locName = (id: string | null) => (id ? s.locations.find((l) => l.id === id)?.name || "Shelf" : "Whole room");
|
||||
for (const t of countsToday) {
|
||||
rows.push({
|
||||
key: `st|${t.id}`, title: locName(t.locationId),
|
||||
sub: t.variances > 0 ? `Counted, ${plural(t.variances, "gap")}` : "Counted, all lines match",
|
||||
right: "Today", sort: `${today}T23:59:59`,
|
||||
});
|
||||
}
|
||||
for (const o of s.orders) {
|
||||
for (const r of o.receipts) {
|
||||
if (r.date !== today) continue;
|
||||
const n = r.lines.reduce((a, l) => a + l.qty, 0);
|
||||
rows.push({ key: `rc|${r.id}`, title: `Delivery ${o.code}`, sub: `Received ${plural(n, "item")}`, right: "Today", sort: `${today}T23:59:59` });
|
||||
}
|
||||
}
|
||||
const recent = rows
|
||||
.sort((a, b) => (a.sort < b.sort ? 1 : a.sort > b.sort ? -1 : 0))
|
||||
.slice(0, 4)
|
||||
.map(({ key, title, sub, right, href }) => ({ key, title, sub, right, href }));
|
||||
|
||||
const kicker = [shortDate(today), s.settings.facility, s.settings.location].filter(Boolean).join(" · ");
|
||||
return { kicker, todo, day: { issued, back, counted: countsToday.length }, recent };
|
||||
}, [s, L, byId, staffById, data]);
|
||||
}
|
||||
Reference in New Issue
Block a user