Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Product } from "../../types.js";
|
||||
import { TYPES, helpers, TODAY_STR } from "../../types.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||
import { ScanField } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
|
||||
|
||||
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
|
||||
weigh: {
|
||||
title: "Reweigh on a scale",
|
||||
desc: "Place the jar (minus tare) and record the new weight.",
|
||||
},
|
||||
estimate: {
|
||||
title: "Visual estimate",
|
||||
desc: "Eyeball the remaining amount — quick and approximate.",
|
||||
},
|
||||
presence: {
|
||||
title: "Confirm presence",
|
||||
desc: "Verify the item is still where you left it. Count units if applicable.",
|
||||
},
|
||||
};
|
||||
|
||||
export function AuditFlow({
|
||||
data,
|
||||
onClose,
|
||||
product: initialProduct,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
product: Product | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const overdueFirst = [...data.products]
|
||||
.filter((p) => p.status === "active")
|
||||
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
|
||||
|
||||
const [productId, setProductId] = useState(initialProduct?.id ?? overdueFirst[0]?.id ?? "");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [confirmedBy, setConfirmedBy] = useState<"SKU" | "asset" | "visual">("SKU");
|
||||
|
||||
const product = data.products.find((p) => p.id === productId);
|
||||
const cfg = product ? TYPES.find((t) => t.id === product.type) : undefined;
|
||||
|
||||
const initialValueFor = (p: Product | undefined): string => {
|
||||
if (!p) return "0";
|
||||
if (p.kind === "discrete") {
|
||||
return String(p.countLastAudit ?? p.countOriginal);
|
||||
}
|
||||
return helpers.estimatedRemaining(p, TODAY_STR).toFixed(2);
|
||||
};
|
||||
const [value, setValue] = useState<string>(initialValueFor(product));
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValueFor(product));
|
||||
}, [productId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const audit = useMutation({
|
||||
mutationFn: () =>
|
||||
api.auditProduct(productId, {
|
||||
date,
|
||||
mode: cfg?.auditMode ?? "weigh",
|
||||
value: Number(value),
|
||||
confirmedBy: cfg?.auditMode === "presence" ? confirmedBy : undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
if (!product) return null;
|
||||
const auditMode = cfg?.auditMode ?? "weigh";
|
||||
const ml = AUDIT_MODE_LABELS[auditMode] ?? AUDIT_MODE_LABELS.weigh!;
|
||||
|
||||
const last = helpers.lastAudit(product);
|
||||
const prevValue =
|
||||
product.kind === "discrete"
|
||||
? product.countLastAudit ?? product.countOriginal
|
||||
: last
|
||||
? last.value
|
||||
: product.weight;
|
||||
|
||||
const delta = Number(value) - prevValue;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<div
|
||||
style={{
|
||||
width: "min(720px, 96vw)",
|
||||
margin: "40px 20px",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<ModalHeader title={ml.title} eyebrow="Audit" onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
products={overdueFirst}
|
||||
matchedProduct={product ?? null}
|
||||
onMatch={setProductId}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Field label="Or pick from list">
|
||||
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
|
||||
{overdueFirst.map((p) => {
|
||||
const od = helpers.auditOverdue(p);
|
||||
const sc = helpers.daysSinceCheck(p);
|
||||
return (
|
||||
<option key={p.id} value={p.id}>
|
||||
{od ? "⚠ " : ""}
|
||||
{p.name} — {helpers.brandName(data, p.brandId)} · {sc}d since check
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 20, fontWeight: 500 }}>
|
||||
{product.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{product.type} · {product.kind} · cadence every {cfg?.cadenceDays}d
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LAST CHECKED</div>
|
||||
<div className="serif" style={{ fontSize: 18 }}>
|
||||
{last ? `${helpers.daysSinceCheck(product)}d ago` : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-2)", marginTop: 10, fontStyle: "italic" }}>
|
||||
{ml.desc}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: auditMode === "presence" ? "1fr 1fr 1fr" : "1fr 1fr",
|
||||
gap: 16,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
label={
|
||||
product.kind === "discrete"
|
||||
? `Count now (${cfg?.unit})`
|
||||
: auditMode === "weigh"
|
||||
? `Weight now (${cfg?.unit})`
|
||||
: `Estimate now (${cfg?.unit})`
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
step={product.kind === "discrete" ? "1" : "0.1"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Date">
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</Field>
|
||||
{auditMode === "presence" && (
|
||||
<Field label="Confirmed by">
|
||||
<Select
|
||||
value={confirmedBy}
|
||||
onChange={(e) => setConfirmedBy(e.target.value as typeof confirmedBy)}
|
||||
>
|
||||
<option value="SKU">SKU label</option>
|
||||
<option value="asset">Asset tag</option>
|
||||
<option value="visual">Visual ID</option>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 20,
|
||||
padding: 14,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr 1fr",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Was</div>
|
||||
<div className="serif" style={{ fontSize: 22 }}>
|
||||
{prevValue} {cfg?.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Now</div>
|
||||
<div className="serif" style={{ fontSize: 22, color: "var(--sage)" }}>
|
||||
{value} {cfg?.unit}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>Δ since last</div>
|
||||
<div
|
||||
className="serif"
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: delta < 0 ? "var(--terracotta)" : "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{delta.toFixed(product.kind === "discrete" ? 0 : 2)} {cfg?.unit}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
Next audit due in {cfg?.cadenceDays}d
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={audit.isPending}
|
||||
onClick={() => audit.mutate()}
|
||||
>
|
||||
{audit.isPending ? "Saving…" : "Save audit"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user