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,169 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Bootstrap, Item } from "../types.js";
|
||||
import { helpers, TODAY_STR, enrichItems } from "../types.js";
|
||||
import { remainingShort } from "../stats.js";
|
||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||
import { Btn, Card, Icon } from "../components/primitives/index.js";
|
||||
|
||||
export function CustodyView({
|
||||
data,
|
||||
onSelectItem,
|
||||
onCheckin,
|
||||
onConsume,
|
||||
onMarkGone,
|
||||
}: {
|
||||
data: Bootstrap;
|
||||
onSelectItem: (i: Item) => void;
|
||||
onCheckin: (i: Item) => void;
|
||||
onConsume: (i: Item) => void;
|
||||
onMarkGone: (i: Item) => void;
|
||||
}) {
|
||||
const items = useMemo(() => enrichItems(data), [data]);
|
||||
const checkedOut = useMemo(
|
||||
() =>
|
||||
items
|
||||
.filter((i) => i.status === "checked-out")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
+new Date(b.checkoutDate ?? b.purchaseDate) -
|
||||
+new Date(a.checkoutDate ?? a.purchaseDate),
|
||||
),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "40px 48px", maxWidth: 1100 }}>
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<h1
|
||||
className="serif"
|
||||
style={{ fontSize: 44, margin: 0, fontWeight: 500, letterSpacing: "-0.02em" }}
|
||||
>
|
||||
My Custody
|
||||
</h1>
|
||||
<div style={{ fontSize: 13, color: "var(--ink-3)", marginTop: 6 }}>
|
||||
{checkedOut.length} item{checkedOut.length === 1 ? "" : "s"} checked out
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checkedOut.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "80px 20px",
|
||||
color: "var(--ink-3)",
|
||||
}}
|
||||
>
|
||||
<Icon name="pocket" size={40} color="var(--ink-4)" />
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontStyle: "italic",
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
Nothing checked out right now.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card padded={false}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "32px 2fr 1fr 0.8fr 0.8fr auto",
|
||||
padding: "10px 16px",
|
||||
fontSize: 11,
|
||||
color: "var(--ink-3)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
}}
|
||||
>
|
||||
<div />
|
||||
<div>Item</div>
|
||||
<div>Brand</div>
|
||||
<div>Remaining</div>
|
||||
<div>Checked out</div>
|
||||
<div />
|
||||
</div>
|
||||
|
||||
{checkedOut.map((item) => (
|
||||
<CustodyRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
data={data}
|
||||
onSelect={() => onSelectItem(item)}
|
||||
onCheckin={() => onCheckin(item)}
|
||||
onConsume={() => onConsume(item)}
|
||||
onMarkGone={() => onMarkGone(item)}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustodyRow({
|
||||
item,
|
||||
data,
|
||||
onSelect,
|
||||
onCheckin,
|
||||
onConsume,
|
||||
onMarkGone,
|
||||
}: {
|
||||
item: Item;
|
||||
data: Bootstrap;
|
||||
onSelect: () => void;
|
||||
onCheckin: () => void;
|
||||
onConsume: () => void;
|
||||
onMarkGone: () => void;
|
||||
}) {
|
||||
const glyph = TYPE_GLYPHS[item.type] ?? "·";
|
||||
const pct = helpers.pctRemaining(item, TODAY_STR);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "32px 2fr 1fr 0.8fr 0.8fr auto",
|
||||
padding: "12px 16px",
|
||||
alignItems: "center",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div style={{ fontSize: 16, textAlign: "center", opacity: 0.6 }}>{glyph}</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 14 }}>{item.name}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||||
{item.assetId}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "var(--ink-2)" }}>
|
||||
{helpers.brandName(data, item.brandId)}
|
||||
</div>
|
||||
<div style={{ fontFamily: "var(--mono)", fontSize: 12 }}>
|
||||
{remainingShort(item)}
|
||||
<span style={{ color: "var(--ink-3)", marginLeft: 6 }}>
|
||||
{Math.round(pct * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||||
{fmt.daysAgo(item.checkoutDate)}
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", gap: 4 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Btn variant="sage" icon="check" onClick={onCheckin}>
|
||||
Check in
|
||||
</Btn>
|
||||
<Btn variant="secondary" icon="check" onClick={onConsume}>
|
||||
Consume
|
||||
</Btn>
|
||||
<Btn variant="ghost" icon="bin" onClick={onMarkGone} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { remainingShort } from "../stats.js";
|
||||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||||
import { Btn, Card, Pill, Icon, Select, inputStyle } from "../components/primitives/index.js";
|
||||
|
||||
type FilterKey = "active" | "consumed" | "gone" | "all";
|
||||
type FilterKey = "active" | "checked-out" | "consumed" | "gone" | "all";
|
||||
type SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
||||
type ViewKey = "flat" | "grouped";
|
||||
|
||||
@@ -50,6 +50,7 @@ export function Inventory({
|
||||
const filtered = useMemo(() => {
|
||||
let out = items;
|
||||
if (filter === "active") out = out.filter((i) => i.status === "active");
|
||||
else if (filter === "checked-out") out = out.filter((i) => i.status === "checked-out");
|
||||
else if (filter === "consumed") out = out.filter((i) => i.status === "consumed");
|
||||
else if (filter === "gone") out = out.filter((i) => i.status === "gone");
|
||||
if (typeFilter !== "all") out = out.filter((i) => i.type === typeFilter);
|
||||
@@ -139,6 +140,7 @@ export function Inventory({
|
||||
value={filter}
|
||||
options={[
|
||||
["active", "Active"],
|
||||
["checked-out", "Checked out"],
|
||||
["consumed", "Consumed"],
|
||||
["gone", "Gone"],
|
||||
["all", "All"],
|
||||
@@ -435,7 +437,7 @@ function ItemRow({
|
||||
const overdue = helpers.auditOverdue(i, TODAY_STR);
|
||||
const sinceCheck = helpers.daysSinceCheck(i, TODAY_STR);
|
||||
const last = helpers.lastAudit(i);
|
||||
const isInactive = i.status !== "active";
|
||||
const isInactive = i.status !== "active" && i.status !== "checked-out";
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(i)}
|
||||
@@ -480,6 +482,9 @@ function ItemRow({
|
||||
{i.status === "gone" && (
|
||||
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Gone</Pill>
|
||||
)}
|
||||
{i.status === "checked-out" && (
|
||||
<Pill tone="outline" style={{ marginLeft: 6, fontSize: 10 }}>Checked out</Pill>
|
||||
)}
|
||||
{i.status === "active" && overdue && (
|
||||
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Audit due</Pill>
|
||||
)}
|
||||
@@ -531,7 +536,9 @@ function ItemRow({
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--ink-3)", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{bin ? bin.name : <span style={{ fontStyle: "italic" }}>—</span>}
|
||||
{i.status === "checked-out" ? (
|
||||
<span style={{ fontStyle: "italic", color: "var(--sage)" }}>Custody</span>
|
||||
) : bin ? bin.name : <span style={{ fontStyle: "italic" }}>—</span>}
|
||||
<span className="inv-row-chevron" style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}>›</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user