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:
@@ -13,10 +13,19 @@ db.pragma("foreign_keys = ON");
|
|||||||
|
|
||||||
archiveLegacyIfPresent();
|
archiveLegacyIfPresent();
|
||||||
archiveV1IfPresent();
|
archiveV1IfPresent();
|
||||||
|
migrateAddCheckoutDate();
|
||||||
|
|
||||||
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
const schema = readFileSync(join(__dirname, "schema.sql"), "utf8");
|
||||||
db.exec(schema);
|
db.exec(schema);
|
||||||
|
|
||||||
|
function migrateAddCheckoutDate(): void {
|
||||||
|
const cols = db
|
||||||
|
.prepare(`PRAGMA table_info(inventory_items)`)
|
||||||
|
.all() as { name: string }[];
|
||||||
|
if (cols.length === 0 || cols.some((c) => c.name === "checkout_date")) return;
|
||||||
|
db.exec(`ALTER TABLE inventory_items ADD COLUMN checkout_date TEXT`);
|
||||||
|
}
|
||||||
|
|
||||||
// One-shot migration: the original schema put per-instance fields (weight,
|
// One-shot migration: the original schema put per-instance fields (weight,
|
||||||
// bin_id, etc.) directly on `products`. The split schema separates products
|
// bin_id, etc.) directly on `products`. The split schema separates products
|
||||||
// (catalog) from inventory_items (instance). When we detect the old shape,
|
// (catalog) from inventory_items (instance). When we detect the old shape,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type InventoryRow = {
|
|||||||
status: string;
|
status: string;
|
||||||
consumed_date: string | null;
|
consumed_date: string | null;
|
||||||
gone_date: string | null;
|
gone_date: string | null;
|
||||||
|
checkout_date: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
@@ -108,6 +109,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
|
|||||||
status: i.status,
|
status: i.status,
|
||||||
consumedDate: i.consumed_date,
|
consumedDate: i.consumed_date,
|
||||||
goneDate: i.gone_date,
|
goneDate: i.gone_date,
|
||||||
|
checkoutDate: i.checkout_date,
|
||||||
rating: i.rating,
|
rating: i.rating,
|
||||||
notes: i.notes,
|
notes: i.notes,
|
||||||
audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({
|
audits: (auditsByInventory.get(i.id) ?? []).map((a) => ({
|
||||||
|
|||||||
@@ -212,6 +212,67 @@ inventoryRouter.patch("/inventory/:id", (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
inventoryRouter.post("/inventory/:id/checkout", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { date } = req.body as { date: string };
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE inventory_items
|
||||||
|
SET status = 'checked-out', checkout_date = ?, bin_id = NULL
|
||||||
|
WHERE id = ? AND status = 'active'`,
|
||||||
|
)
|
||||||
|
.run(date, id);
|
||||||
|
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
inventoryRouter.post("/inventory/:id/checkin", (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { date, binId, remainingWeight } = req.body as {
|
||||||
|
date: string;
|
||||||
|
binId: string;
|
||||||
|
remainingWeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const item = db
|
||||||
|
.prepare<
|
||||||
|
[string],
|
||||||
|
{ product_id: string; last_audit_weight: number | null; weight: number }
|
||||||
|
>(
|
||||||
|
`SELECT product_id, last_audit_weight, weight
|
||||||
|
FROM inventory_items WHERE id = ? AND status = 'checked-out'`,
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
|
if (!item) return res.status(404).json({ error: "not found or not checked-out" });
|
||||||
|
|
||||||
|
const product = db
|
||||||
|
.prepare<[string], { kind: string }>(`SELECT kind FROM products WHERE id = ?`)
|
||||||
|
.get(item.product_id);
|
||||||
|
const isBulk = product?.kind === "bulk";
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE inventory_items
|
||||||
|
SET status = 'active', bin_id = ?, checkout_date = NULL
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(binId, id);
|
||||||
|
|
||||||
|
if (isBulk && remainingWeight != null && Number.isFinite(remainingWeight)) {
|
||||||
|
const prev = item.last_audit_weight ?? item.weight;
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO audits (inventory_id, date, mode, value, prev_value, confirmed_by)
|
||||||
|
VALUES (?, ?, 'weigh', ?, ?, 'checkin')`,
|
||||||
|
).run(id, date, remainingWeight, prev);
|
||||||
|
db.prepare(`UPDATE inventory_items SET last_audit_weight = ? WHERE id = ?`).run(
|
||||||
|
remainingWeight,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
inventoryRouter.post("/inventory/:id/finish", (req, res) => {
|
inventoryRouter.post("/inventory/:id/finish", (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { date, rating, notes } = req.body as {
|
const { date, rating, notes } = req.body as {
|
||||||
@@ -222,8 +283,8 @@ inventoryRouter.post("/inventory/:id/finish", (req, res) => {
|
|||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE inventory_items
|
`UPDATE inventory_items
|
||||||
SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL
|
SET status = 'consumed', consumed_date = ?, rating = ?, notes = ?, bin_id = NULL, checkout_date = NULL
|
||||||
WHERE id = ? AND status = 'active'`,
|
WHERE id = ? AND status IN ('active', 'checked-out')`,
|
||||||
)
|
)
|
||||||
.run(date, rating ?? null, notes ?? null, id);
|
.run(date, rating ?? null, notes ?? null, id);
|
||||||
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
|
if (result.changes === 0) return res.status(404).json({ error: "not found or not active" });
|
||||||
@@ -242,8 +303,8 @@ inventoryRouter.post("/inventory/:id/gone", (req, res) => {
|
|||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE inventory_items
|
`UPDATE inventory_items
|
||||||
SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL
|
SET status = 'gone', gone_date = ?, notes = ?, bin_id = NULL, checkout_date = NULL
|
||||||
WHERE id = ? AND status = 'active'`,
|
WHERE id = ? AND status IN ('active', 'checked-out')`,
|
||||||
)
|
)
|
||||||
.run(date, combinedNotes, id);
|
.run(date, combinedNotes, id);
|
||||||
if (result.changes === 0) throw new Error("not found");
|
if (result.changes === 0) throw new Error("not found");
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS inventory_items (
|
|||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
consumed_date TEXT,
|
consumed_date TEXT,
|
||||||
gone_date TEXT,
|
gone_date TEXT,
|
||||||
|
checkout_date TEXT,
|
||||||
rating INTEGER,
|
rating INTEGER,
|
||||||
notes TEXT
|
notes TEXT
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import { EditInventoryFlow } from "./components/modals/EditInventoryFlow.js";
|
|||||||
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
|
import { ConsumeFlow } from "./components/modals/ConsumeFlow.js";
|
||||||
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js";
|
import { MarkGoneFlow } from "./components/modals/MarkGoneFlow.js";
|
||||||
import { AuditFlow } from "./components/modals/AuditFlow.js";
|
import { AuditFlow } from "./components/modals/AuditFlow.js";
|
||||||
|
import { CheckoutFlow } from "./components/modals/CheckoutFlow.js";
|
||||||
|
import { CheckinFlow } from "./components/modals/CheckinFlow.js";
|
||||||
|
import { CustodyView } from "./views/CustodyView.js";
|
||||||
import {
|
import {
|
||||||
AddBinModal,
|
AddBinModal,
|
||||||
AddBrandModal,
|
AddBrandModal,
|
||||||
@@ -35,6 +38,8 @@ type ModalKey =
|
|||||||
| "consume"
|
| "consume"
|
||||||
| "gone"
|
| "gone"
|
||||||
| "audit"
|
| "audit"
|
||||||
|
| "checkout"
|
||||||
|
| "checkin"
|
||||||
| "addBrand"
|
| "addBrand"
|
||||||
| "addShop"
|
| "addShop"
|
||||||
| "addBin"
|
| "addBin"
|
||||||
@@ -96,6 +101,16 @@ export function App() {
|
|||||||
setModalItem(i ?? null);
|
setModalItem(i ?? null);
|
||||||
setModal("audit");
|
setModal("audit");
|
||||||
};
|
};
|
||||||
|
const openCheckout = (i?: Item) => {
|
||||||
|
setModalItem(i ?? null);
|
||||||
|
setSelected(null);
|
||||||
|
setModal("checkout");
|
||||||
|
};
|
||||||
|
const openCheckin = (i?: Item) => {
|
||||||
|
setModalItem(i ?? null);
|
||||||
|
setSelected(null);
|
||||||
|
setModal("checkin");
|
||||||
|
};
|
||||||
const openEdit = (i: Item) => {
|
const openEdit = (i: Item) => {
|
||||||
setModalItem(i);
|
setModalItem(i);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -157,6 +172,7 @@ export function App() {
|
|||||||
onAddProduct={openAdd}
|
onAddProduct={openAdd}
|
||||||
onMarkFinished={() => openConsume()}
|
onMarkFinished={() => openConsume()}
|
||||||
onAudit={() => openAudit()}
|
onAudit={() => openAudit()}
|
||||||
|
onCheckout={() => openCheckout()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="main parchment" style={{ minWidth: 0 }}>
|
<main className="main parchment" style={{ minWidth: 0 }}>
|
||||||
@@ -167,6 +183,9 @@ export function App() {
|
|||||||
<Route path="/inventory" element={
|
<Route path="/inventory" element={
|
||||||
<Inventory data={data} onSelectItem={setSelected} onAddInventory={openAdd} onAuditNew={() => openAudit()} />
|
<Inventory data={data} onSelectItem={setSelected} onAddInventory={openAdd} onAuditNew={() => openAudit()} />
|
||||||
} />
|
} />
|
||||||
|
<Route path="/custody" element={
|
||||||
|
<CustodyView data={data} onSelectItem={setSelected} onCheckin={openCheckin} onConsume={openConsume} onMarkGone={openMarkGone} />
|
||||||
|
} />
|
||||||
<Route path="/bins" element={
|
<Route path="/bins" element={
|
||||||
<BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} />
|
<BinsView data={data} onSelectItem={setSelected} onAddBin={() => setModal("addBin")} onEditBin={(bin) => { setModalBin(bin); setModal("editBin"); }} />
|
||||||
} />
|
} />
|
||||||
@@ -192,6 +211,8 @@ export function App() {
|
|||||||
onMarkGone={openMarkGone}
|
onMarkGone={openMarkGone}
|
||||||
onAudit={openAudit}
|
onAudit={openAudit}
|
||||||
onEdit={openEdit}
|
onEdit={openEdit}
|
||||||
|
onCheckout={openCheckout}
|
||||||
|
onCheckin={openCheckin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -208,6 +229,12 @@ export function App() {
|
|||||||
{modal === "audit" && (
|
{modal === "audit" && (
|
||||||
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
||||||
)}
|
)}
|
||||||
|
{modal === "checkout" && (
|
||||||
|
<CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
||||||
|
)}
|
||||||
|
{modal === "checkin" && (
|
||||||
|
<CheckinFlow data={data} onClose={() => setModal(null)} item={modalItem} />
|
||||||
|
)}
|
||||||
{modal === "addBrand" && <AddBrandModal onClose={() => setModal(null)} />}
|
{modal === "addBrand" && <AddBrandModal onClose={() => setModal(null)} />}
|
||||||
{modal === "addShop" && <AddShopModal onClose={() => setModal(null)} />}
|
{modal === "addShop" && <AddShopModal onClose={() => setModal(null)} />}
|
||||||
{modal === "addBin" && <AddBinModal onClose={() => setModal(null)} />}
|
{modal === "addBin" && <AddBinModal onClose={() => setModal(null)} />}
|
||||||
|
|||||||
@@ -119,6 +119,24 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
checkoutInventoryItem: (
|
||||||
|
id: string,
|
||||||
|
body: { date: string },
|
||||||
|
) =>
|
||||||
|
request<{ ok: true }>(`/inventory/${id}/checkout`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
|
checkinInventoryItem: (
|
||||||
|
id: string,
|
||||||
|
body: { date: string; binId: string; remainingWeight?: number },
|
||||||
|
) =>
|
||||||
|
request<{ ok: true }>(`/inventory/${id}/checkin`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
|
||||||
auditInventoryItem: (
|
auditInventoryItem: (
|
||||||
id: string,
|
id: string,
|
||||||
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
|
body: { date: string; mode: AuditMode; value: number; confirmedBy?: string },
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export function ProductDetail({
|
|||||||
onMarkGone,
|
onMarkGone,
|
||||||
onAudit,
|
onAudit,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onCheckout,
|
||||||
|
onCheckin,
|
||||||
}: {
|
}: {
|
||||||
item: Item;
|
item: Item;
|
||||||
data: Bootstrap;
|
data: Bootstrap;
|
||||||
@@ -23,6 +25,8 @@ export function ProductDetail({
|
|||||||
onMarkGone: (i: Item) => void;
|
onMarkGone: (i: Item) => void;
|
||||||
onAudit: (i: Item) => void;
|
onAudit: (i: Item) => void;
|
||||||
onEdit: (i: Item) => void;
|
onEdit: (i: Item) => void;
|
||||||
|
onCheckout: (i: Item) => void;
|
||||||
|
onCheckin: (i: Item) => void;
|
||||||
}) {
|
}) {
|
||||||
const bin = data.bins.find((b) => b.id === item.binId);
|
const bin = data.bins.find((b) => b.id === item.binId);
|
||||||
const cfg = TYPES.find((t) => t.id === item.type);
|
const cfg = TYPES.find((t) => t.id === item.type);
|
||||||
@@ -34,6 +38,7 @@ export function ProductDetail({
|
|||||||
const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR);
|
const sinceCheck = helpers.daysSinceCheck(item, TODAY_STR);
|
||||||
|
|
||||||
const isActive = item.status === "active";
|
const isActive = item.status === "active";
|
||||||
|
const isCheckedOut = item.status === "checked-out";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -58,7 +63,7 @@ export function ProductDetail({
|
|||||||
["Shop", helpers.shopName(data, item.shopId)],
|
["Shop", helpers.shopName(data, item.shopId)],
|
||||||
["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`],
|
["Total cannabinoids", `${item.totalCannabinoids.toFixed(1)}%`],
|
||||||
["Purchase date", fmt.date(item.purchaseDate)],
|
["Purchase date", fmt.date(item.purchaseDate)],
|
||||||
["Bin", bin ? bin.name : <span style={{ color: "var(--ink-3)" }}>—</span>],
|
["Bin", isCheckedOut ? "In your custody" : bin ? bin.name : <span style={{ color: "var(--ink-3)" }}>—</span>],
|
||||||
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
|
["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`],
|
||||||
[
|
[
|
||||||
"Cost per gram",
|
"Cost per gram",
|
||||||
@@ -69,6 +74,9 @@ export function ProductDetail({
|
|||||||
: "—",
|
: "—",
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
if (item.status === "checked-out") {
|
||||||
|
detailRows.push(["Checked out", fmt.date(item.checkoutDate)]);
|
||||||
|
}
|
||||||
if (item.status === "consumed") {
|
if (item.status === "consumed") {
|
||||||
detailRows.push(
|
detailRows.push(
|
||||||
["Date finished", fmt.date(item.consumedDate)],
|
["Date finished", fmt.date(item.consumedDate)],
|
||||||
@@ -130,17 +138,27 @@ export function ProductDetail({
|
|||||||
Inventory · <span className="mono">{item.assetId}</span>
|
Inventory · <span className="mono">{item.assetId}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||||
|
{isActive && (
|
||||||
|
<Btn variant="ghost" icon="pocket" onClick={() => onCheckout(item)}>
|
||||||
|
Check out
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
|
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}>
|
||||||
Audit
|
Audit
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
{isActive && (
|
{isCheckedOut && (
|
||||||
|
<Btn variant="sage" icon="check" onClick={() => onCheckin(item)}>
|
||||||
|
Check in
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
{(isActive || isCheckedOut) && (
|
||||||
<Btn variant="secondary" icon="check" onClick={() => onConsume(item)}>
|
<Btn variant="secondary" icon="check" onClick={() => onConsume(item)}>
|
||||||
Mark consumed
|
Mark consumed
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
{isActive && (
|
{(isActive || isCheckedOut) && (
|
||||||
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)} />
|
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)} />
|
||||||
)}
|
)}
|
||||||
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)} />
|
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)} />
|
||||||
@@ -159,6 +177,9 @@ export function ProductDetail({
|
|||||||
{item.status === "gone" && (
|
{item.status === "gone" && (
|
||||||
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate)}</Pill>
|
<Pill tone="amber">Gone · {fmt.daysAgo(item.goneDate)}</Pill>
|
||||||
)}
|
)}
|
||||||
|
{isCheckedOut && (
|
||||||
|
<Pill tone="outline">Checked out · {fmt.daysAgo(item.checkoutDate)}</Pill>
|
||||||
|
)}
|
||||||
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
|
{isActive && overdue && <Pill tone="amber">Audit overdue · {sinceCheck}d</Pill>}
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
@@ -214,7 +235,7 @@ export function ProductDetail({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isActive && (
|
{(isActive || isCheckedOut) && (
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Icon } from "./primitives/index.js";
|
|||||||
export type ViewKey =
|
export type ViewKey =
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "inventory"
|
| "inventory"
|
||||||
|
| "custody"
|
||||||
| "bins"
|
| "bins"
|
||||||
| "shops"
|
| "shops"
|
||||||
| "brands"
|
| "brands"
|
||||||
@@ -13,6 +14,7 @@ export type ViewKey =
|
|||||||
const NAV: { path: string; label: string; icon: string }[] = [
|
const NAV: { path: string; label: string; icon: string }[] = [
|
||||||
{ path: "/", label: "Dashboard", icon: "home" },
|
{ path: "/", label: "Dashboard", icon: "home" },
|
||||||
{ path: "/inventory", label: "Inventory", icon: "box" },
|
{ path: "/inventory", label: "Inventory", icon: "box" },
|
||||||
|
{ path: "/custody", label: "My Custody", icon: "pocket" },
|
||||||
{ path: "/bins", label: "Bins", icon: "bin" },
|
{ path: "/bins", label: "Bins", icon: "bin" },
|
||||||
{ path: "/shops", label: "Shops", icon: "shop" },
|
{ path: "/shops", label: "Shops", icon: "shop" },
|
||||||
{ path: "/brands", label: "Brands", icon: "tag" },
|
{ path: "/brands", label: "Brands", icon: "tag" },
|
||||||
@@ -42,10 +44,12 @@ export function Sidebar({
|
|||||||
onAddProduct,
|
onAddProduct,
|
||||||
onMarkFinished,
|
onMarkFinished,
|
||||||
onAudit,
|
onAudit,
|
||||||
|
onCheckout,
|
||||||
}: {
|
}: {
|
||||||
onAddProduct: () => void;
|
onAddProduct: () => void;
|
||||||
onMarkFinished: () => void;
|
onMarkFinished: () => void;
|
||||||
onAudit: () => void;
|
onAudit: () => void;
|
||||||
|
onCheckout: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
@@ -88,6 +92,9 @@ export function Sidebar({
|
|||||||
<button className="nav-link" onClick={onAudit} title="Audit">
|
<button className="nav-link" onClick={onAudit} title="Audit">
|
||||||
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
|
<Icon name="search" size={16} /> <span className="nav-label">Audit</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button className="nav-link" onClick={onCheckout} title="Check out">
|
||||||
|
<Icon name="pocket" size={16} /> <span className="nav-label">Check out</span>
|
||||||
|
</button>
|
||||||
<button className="nav-link" onClick={onMarkFinished} title="Mark consumed">
|
<button className="nav-link" onClick={onMarkFinished} title="Mark consumed">
|
||||||
<Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span>
|
<Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 qc = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const allItems = enrichItems(data);
|
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 [itemId, setItemId] = useState(initialItem?.id ?? "");
|
||||||
const [rating, setRating] = useState(4);
|
const [rating, setRating] = useState(4);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function MarkGoneFlow({
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const allItems = enrichItems(data);
|
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 [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
|
||||||
const [reason, setReason] = useState("lost");
|
const [reason, setReason] = useState("lost");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const ICON_PATHS: Record<string, string> = {
|
|||||||
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
|
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
|
||||||
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
|
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
|
||||||
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01",
|
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01",
|
||||||
|
pocket: "M5 4h14v5l-2 3v5a3 3 0 01-3 3h-4a3 3 0 01-3-3v-5L5 9V4zM9 12v5M15 12v5",
|
||||||
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
|
shop: "M4 9V4h16v5M4 9v11h16V9M4 9h16M10 20v-6h4v6",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -31,6 +31,7 @@ export interface Stats {
|
|||||||
series30: { date: string; grams: number }[];
|
series30: { date: string; grams: number }[];
|
||||||
series90: { date: string; grams: number }[];
|
series90: { date: string; grams: number }[];
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
|
checkedOutCount: number;
|
||||||
consumedCount: number;
|
consumedCount: number;
|
||||||
goneCount: number;
|
goneCount: number;
|
||||||
archivedCount: number;
|
archivedCount: number;
|
||||||
@@ -53,7 +54,7 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
const items = enrichItems(data);
|
const items = enrichItems(data);
|
||||||
const dayKey = (d: Date) => d.toISOString().slice(0, 10);
|
const dayKey = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
const active = items.filter((p) => p.status === "active");
|
const active = items.filter((p) => p.status === "active" || p.status === "checked-out");
|
||||||
const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate);
|
const consumed = items.filter((p) => p.status === "consumed" && p.consumedDate);
|
||||||
const gone = items.filter((p) => p.status === "gone");
|
const gone = items.filter((p) => p.status === "gone");
|
||||||
|
|
||||||
@@ -271,7 +272,8 @@ export function computeStats(data: Bootstrap): Stats {
|
|||||||
series7,
|
series7,
|
||||||
series30,
|
series30,
|
||||||
series90,
|
series90,
|
||||||
activeCount: active.length,
|
activeCount: items.filter((p) => p.status === "active").length,
|
||||||
|
checkedOutCount: items.filter((p) => p.status === "checked-out").length,
|
||||||
consumedCount: consumed.length,
|
consumedCount: consumed.length,
|
||||||
goneCount: gone.length,
|
goneCount: gone.length,
|
||||||
archivedCount: consumed.length + gone.length,
|
archivedCount: consumed.length + gone.length,
|
||||||
|
|||||||
+4
-3
@@ -1,4 +1,4 @@
|
|||||||
export type ProductStatus = "active" | "consumed" | "gone";
|
export type ProductStatus = "active" | "consumed" | "gone" | "checked-out";
|
||||||
export type ProductKind = "bulk" | "discrete";
|
export type ProductKind = "bulk" | "discrete";
|
||||||
export type AuditMode = "weigh" | "estimate" | "presence";
|
export type AuditMode = "weigh" | "estimate" | "presence";
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ export interface InventoryItem {
|
|||||||
status: ProductStatus;
|
status: ProductStatus;
|
||||||
consumedDate: string | null;
|
consumedDate: string | null;
|
||||||
goneDate: string | null;
|
goneDate: string | null;
|
||||||
|
checkoutDate: string | null;
|
||||||
rating: number | null;
|
rating: number | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
audits: Audit[];
|
audits: Audit[];
|
||||||
@@ -197,13 +198,13 @@ export const helpers = {
|
|||||||
return Math.floor((+new Date(today) - +new Date(last)) / 86_400_000);
|
return Math.floor((+new Date(today) - +new Date(last)) / 86_400_000);
|
||||||
},
|
},
|
||||||
auditOverdue(p: Item, today = TODAY_STR): boolean {
|
auditOverdue(p: Item, today = TODAY_STR): boolean {
|
||||||
if (p.status !== "active") return false;
|
if (p.status !== "active" && p.status !== "checked-out") return false;
|
||||||
const cfg = TYPES.find((t) => t.id === p.type);
|
const cfg = TYPES.find((t) => t.id === p.type);
|
||||||
if (!cfg) return false;
|
if (!cfg) return false;
|
||||||
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
return this.daysSinceCheck(p, today) >= cfg.cadenceDays;
|
||||||
},
|
},
|
||||||
estimatedRemaining(p: Item, today = TODAY_STR): number {
|
estimatedRemaining(p: Item, today = TODAY_STR): number {
|
||||||
if (p.status !== "active") return 0;
|
if (p.status !== "active" && p.status !== "checked-out") return 0;
|
||||||
if (p.kind === "discrete") {
|
if (p.kind === "discrete") {
|
||||||
return p.countLastAudit ?? p.countOriginal;
|
return p.countLastAudit ?? p.countOriginal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { fmt, TYPE_GLYPHS } from "../format.js";
|
||||||
import { Btn, Card, Pill, Icon, Select, inputStyle } from "../components/primitives/index.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 SortKey = "recent" | "name" | "thc" | "remaining" | "price" | "audit";
|
||||||
type ViewKey = "flat" | "grouped";
|
type ViewKey = "flat" | "grouped";
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ export function Inventory({
|
|||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let out = items;
|
let out = items;
|
||||||
if (filter === "active") out = out.filter((i) => i.status === "active");
|
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 === "consumed") out = out.filter((i) => i.status === "consumed");
|
||||||
else if (filter === "gone") out = out.filter((i) => i.status === "gone");
|
else if (filter === "gone") out = out.filter((i) => i.status === "gone");
|
||||||
if (typeFilter !== "all") out = out.filter((i) => i.type === typeFilter);
|
if (typeFilter !== "all") out = out.filter((i) => i.type === typeFilter);
|
||||||
@@ -139,6 +140,7 @@ export function Inventory({
|
|||||||
value={filter}
|
value={filter}
|
||||||
options={[
|
options={[
|
||||||
["active", "Active"],
|
["active", "Active"],
|
||||||
|
["checked-out", "Checked out"],
|
||||||
["consumed", "Consumed"],
|
["consumed", "Consumed"],
|
||||||
["gone", "Gone"],
|
["gone", "Gone"],
|
||||||
["all", "All"],
|
["all", "All"],
|
||||||
@@ -435,7 +437,7 @@ function ItemRow({
|
|||||||
const overdue = helpers.auditOverdue(i, TODAY_STR);
|
const overdue = helpers.auditOverdue(i, TODAY_STR);
|
||||||
const sinceCheck = helpers.daysSinceCheck(i, TODAY_STR);
|
const sinceCheck = helpers.daysSinceCheck(i, TODAY_STR);
|
||||||
const last = helpers.lastAudit(i);
|
const last = helpers.lastAudit(i);
|
||||||
const isInactive = i.status !== "active";
|
const isInactive = i.status !== "active" && i.status !== "checked-out";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => onSelect(i)}
|
onClick={() => onSelect(i)}
|
||||||
@@ -480,6 +482,9 @@ function ItemRow({
|
|||||||
{i.status === "gone" && (
|
{i.status === "gone" && (
|
||||||
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Gone</Pill>
|
<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 && (
|
{i.status === "active" && overdue && (
|
||||||
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Audit due</Pill>
|
<Pill tone="amber" style={{ marginLeft: 6, fontSize: 10 }}>Audit due</Pill>
|
||||||
)}
|
)}
|
||||||
@@ -531,7 +536,9 @@ function ItemRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: "var(--ink-3)", display: "flex", alignItems: "center", gap: 6 }}>
|
<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>
|
<span className="inv-row-chevron" style={{ color: "var(--ink-3)", marginLeft: "auto", fontSize: 14 }}>›</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user