Add checkout/custody feature for tracking items in personal possession
Build and push image / build (push) Successful in 1m8s
Build and push image / build (push) Successful in 1m8s
Items can now be checked out of their bin into "my custody" and later checked back in or marked consumed. Adds checkout/checkin API endpoints, a My Custody sidebar page, CheckoutFlow and CheckinFlow modals, and updates ProductDetail, Inventory, ConsumeFlow, and MarkGoneFlow to handle the new checked-out status. Bulk items prompt for remaining weight on check-in. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { TYPES, helpers, TODAY_STR, enrichItems } from "../../types.js";
|
||||
import { fmt } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Input, Select } from "../primitives/index.js";
|
||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
import { useToast } from "../Toast.js";
|
||||
|
||||
export function CheckinFlow({
|
||||
data,
|
||||
onClose,
|
||||
item: initialItem,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
item: Item | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const allItems = enrichItems(data);
|
||||
const checkedOut = allItems.filter((i) => i.status === "checked-out");
|
||||
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||
const [binId, setBinId] = useState(data.bins[0]?.id ?? "");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [remaining, setRemaining] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const item = allItems.find((i) => i.id === itemId);
|
||||
const isBulk = item?.kind === "bulk";
|
||||
const cfg = item ? TYPES.find((t) => t.id === item.type) : undefined;
|
||||
const est = item ? helpers.estimatedRemaining(item, TODAY_STR) : 0;
|
||||
|
||||
const checkin = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: { date: string; binId: string; remainingWeight?: number } = {
|
||||
date,
|
||||
binId,
|
||||
};
|
||||
if (isBulk && remaining !== "") {
|
||||
body.remainingWeight = parseFloat(remaining);
|
||||
}
|
||||
return api.checkinInventoryItem(itemId, body);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
const binName = data.bins.find((b) => b.id === binId)?.name ?? "bin";
|
||||
toast(`Checked ${item?.name ?? "item"} back into ${binName}`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const handleScan = (result: ScanResult) => {
|
||||
if (result.kind === "item") {
|
||||
setItemId(result.item.id);
|
||||
const scanned = allItems.find((i) => i.id === result.item.id);
|
||||
if (scanned?.kind === "bulk") {
|
||||
setRemaining(helpers.estimatedRemaining(scanned, TODAY_STR).toFixed(2));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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="Check in" eyebrow="Return to bin" onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
items={checkedOut}
|
||||
products={[]}
|
||||
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||
onMatch={handleScan}
|
||||
mode="assetId"
|
||||
/>
|
||||
|
||||
{!item ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
textAlign: "center",
|
||||
color: "var(--ink-3)",
|
||||
fontSize: 13,
|
||||
fontStyle: "italic",
|
||||
padding: "24px 0",
|
||||
}}
|
||||
>
|
||||
Scan a checked-out asset ID to continue.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
}}
|
||||
>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
<span className="mono">{item.assetId}</span> ·{" "}
|
||||
{helpers.brandName(data, item.brandId)} · checked out{" "}
|
||||
{fmt.dateShort(item.checkoutDate)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: isBulk ? "1fr 1fr 1fr" : "1fr 1fr",
|
||||
gap: 16,
|
||||
marginTop: 24,
|
||||
}}
|
||||
>
|
||||
<Field label="Return to bin">
|
||||
<Select value={binId} onChange={(e) => setBinId(e.target.value)}>
|
||||
{data.bins.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Date">
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isBulk && (
|
||||
<Field
|
||||
label={`Weight now (${cfg?.unit ?? "g"})`}
|
||||
hint={`Was ~${est.toFixed(2)} ${cfg?.unit ?? "g"}`}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={remaining}
|
||||
onChange={(e) => setRemaining(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="check"
|
||||
disabled={checkin.isPending || !item || !binId}
|
||||
onClick={() => checkin.mutate()}
|
||||
>
|
||||
{checkin.isPending ? "Saving…" : "Check in"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Bootstrap, Item } from "../../types.js";
|
||||
import { helpers, TODAY_STR, enrichItems } from "../../types.js";
|
||||
import { fmt } from "../../format.js";
|
||||
import { api } from "../../api.js";
|
||||
import { Btn, Field, Icon, Input } from "../primitives/index.js";
|
||||
import { ScanField, type ScanResult } from "../ScanField.js";
|
||||
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
|
||||
import { useToast } from "../Toast.js";
|
||||
|
||||
export function CheckoutFlow({
|
||||
data,
|
||||
onClose,
|
||||
item: initialItem,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onClose: () => void;
|
||||
item: Item | null;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const allItems = enrichItems(data);
|
||||
const active = allItems.filter((i) => i.status === "active");
|
||||
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||
const [date, setDate] = useState(TODAY_STR);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const item = allItems.find((i) => i.id === itemId);
|
||||
|
||||
const checkout = useMutation({
|
||||
mutationFn: () => api.checkoutInventoryItem(itemId, { date }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["bootstrap"] });
|
||||
toast(`Checked out ${item?.name ?? "item"}`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const handleScan = (result: ScanResult) => {
|
||||
if (result.kind === "item") {
|
||||
setItemId(result.item.id);
|
||||
}
|
||||
};
|
||||
|
||||
const bin = item ? data.bins.find((b) => b.id === item.binId) : undefined;
|
||||
|
||||
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="Check out" eyebrow="" onClose={onClose} />
|
||||
|
||||
<div style={{ padding: 32 }}>
|
||||
<ScanField
|
||||
items={active}
|
||||
products={[]}
|
||||
matchedLabel={item ? `${item.assetId} · ${item.name}` : null}
|
||||
onMatch={handleScan}
|
||||
mode="assetId"
|
||||
/>
|
||||
|
||||
{!item ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
textAlign: "center",
|
||||
color: "var(--ink-3)",
|
||||
fontSize: 13,
|
||||
fontStyle: "italic",
|
||||
padding: "24px 0",
|
||||
}}
|
||||
>
|
||||
Scan an asset ID to continue.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
background: "var(--bg-2)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||||
{item.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
<span className="mono">{item.assetId}</span> ·{" "}
|
||||
{helpers.brandName(data, item.brandId)} · {bin?.name ?? "no bin"} ·
|
||||
purchased {fmt.dateShort(item.purchaseDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<Icon name="pocket" size={28} color="var(--ink-3)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24, maxWidth: 240 }}>
|
||||
<Field label="Date">
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<div />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<Btn variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Btn>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="pocket"
|
||||
disabled={checkout.isPending || !item}
|
||||
onClick={() => checkout.mutate()}
|
||||
>
|
||||
{checkout.isPending ? "Saving…" : "Check out"}
|
||||
</Btn>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function ConsumeFlow({
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const allItems = enrichItems(data);
|
||||
const active = allItems.filter((i) => i.status === "active");
|
||||
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||
const [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||
const [rating, setRating] = useState(4);
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
@@ -28,7 +28,7 @@ export function MarkGoneFlow({
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const allItems = enrichItems(data);
|
||||
const active = allItems.filter((i) => i.status === "active");
|
||||
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||||
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
||||
const [reason, setReason] = useState("lost");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
Reference in New Issue
Block a user