Initial commit: Apothecary v0.4.0

This commit is contained in:
2026-05-03 20:19:26 -04:00
commit 027cf032be
55 changed files with 14678 additions and 0 deletions
@@ -0,0 +1,449 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Strain } from "../../types.js";
import { TYPES, TODAY_STR } from "../../types.js";
import { fmt } from "../../format.js";
import { api } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.js";
const NEW_BRAND = "__new_brand__";
const NEW_SHOP = "__new_shop__";
const NEW_BIN = "__new_bin__";
export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () => void }) {
const qc = useQueryClient();
const [form, setForm] = useState({
name: "",
brandId: data.brands[0]?.id ?? NEW_BRAND,
shopId: data.shops[0]?.id ?? NEW_SHOP,
type: "Flower",
weight: 3.5,
countOriginal: 1,
unitWeight: 0.7,
price: 45,
thc: 22,
cbd: 0.4,
totalCannabinoids: 26,
purchaseDate: TODAY_STR,
binId: data.bins[0]?.id ?? NEW_BIN,
sku: "",
assetTag: "",
});
const [newBrand, setNewBrand] = useState("");
const [newShopName, setNewShopName] = useState("");
const [newShopLocation, setNewShopLocation] = useState("");
const [newBinName, setNewBinName] = useState("");
const [newBinLocation, setNewBinLocation] = useState("");
const [newBinCapacity, setNewBinCapacity] = useState(10);
const [error, setError] = useState<string | null>(null);
// Track which cannabinoid fields the user has touched. Pre-fill from a
// matched strain only writes into untouched fields, so we never overwrite
// numbers the user just typed.
const [edited, setEdited] = useState({ thc: false, cbd: false, total: false });
const update = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((f) => ({ ...f, [k]: v }));
const cfg = TYPES.find((t) => t.id === form.type);
const isDiscrete = cfg?.kind === "discrete";
const cpg = !isDiscrete && form.weight > 0 ? form.price / form.weight : 0;
// Find an existing strain matching the current name + brand + type.
// Case-insensitive + trimmed, brand can be NEW_BRAND (no match) or null.
const matchedStrain: Strain | null = useMemo(() => {
const name = form.name.trim().toLowerCase();
if (!name) return null;
if (form.brandId === NEW_BRAND) return null;
return (
data.strains.find(
(s) =>
s.name.trim().toLowerCase() === name &&
(s.brandId ?? null) === (form.brandId ?? null) &&
s.type === form.type,
) ?? null
);
}, [data.strains, form.name, form.brandId, form.type]);
// Pre-fill cannabinoids from the matched strain into untouched fields.
useEffect(() => {
if (!matchedStrain) return;
setForm((f) => ({
...f,
thc: edited.thc ? f.thc : matchedStrain.defaultThc ?? f.thc,
cbd: edited.cbd ? f.cbd : matchedStrain.defaultCbd ?? f.cbd,
totalCannabinoids: edited.total
? f.totalCannabinoids
: matchedStrain.defaultTotalCannabinoids ?? f.totalCannabinoids,
}));
}, [matchedStrain]); // eslint-disable-line react-hooks/exhaustive-deps
const save = useMutation({
mutationFn: async () => {
let { brandId, shopId, binId } = form;
if (brandId === NEW_BRAND) {
if (!newBrand.trim()) throw new Error("New brand name required");
const b = await api.createBrand(newBrand.trim());
brandId = b.id;
}
if (shopId === NEW_SHOP) {
if (!newShopName.trim()) throw new Error("New shop name required");
const s = await api.createShop({ name: newShopName.trim(), location: newShopLocation.trim() });
shopId = s.id;
}
if (binId === NEW_BIN) {
if (!newBinName.trim()) throw new Error("New bin name required");
const b = await api.createBin({
name: newBinName.trim(),
location: newBinLocation.trim(),
capacity: newBinCapacity,
});
binId = b.id;
}
return api.createProduct({
...form,
brandId,
shopId,
binId,
kind: isDiscrete ? "discrete" : "bulk",
});
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
onError: (e: Error) => setError(e.message),
});
const isNewBrand = form.brandId === NEW_BRAND;
const isNewShop = form.shopId === NEW_SHOP;
const isNewBin = form.binId === NEW_BIN;
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(840px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title="Add a product" eyebrow="New entry" onClose={onClose} />
<div style={{ padding: 32 }}>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Identity</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 16, marginBottom: 28 }}>
<Field
label="Strain"
span={2}
hint={
matchedStrain
? "Matched existing strain — cannabinoid defaults pre-filled."
: undefined
}
>
<Input
value={form.name}
placeholder="e.g. Garden Ghost"
onChange={(e) => update("name", e.target.value)}
/>
</Field>
<Field label="Brand">
<Select value={form.brandId} onChange={(e) => update("brandId", e.target.value)}>
{data.brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
<option value={NEW_BRAND}>+ Add new brand</option>
</Select>
</Field>
<Field label="Shop">
<Select value={form.shopId} onChange={(e) => update("shopId", e.target.value)}>
{data.shops.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
<option value={NEW_SHOP}>+ Add new shop</option>
</Select>
</Field>
{isNewBrand && (
<Field label="New brand name" span={2}>
<Input value={newBrand} onChange={(e) => setNewBrand(e.target.value)} placeholder="e.g. Foxglove Farms" />
</Field>
)}
{isNewShop && (
<>
<Field label="New shop name">
<Input value={newShopName} onChange={(e) => setNewShopName(e.target.value)} placeholder="e.g. Greenleaf Co-op" />
</Field>
<Field label="Location (optional)">
<Input
value={newShopLocation}
onChange={(e) => setNewShopLocation(e.target.value)}
placeholder="e.g. Capitol Hill"
/>
</Field>
</>
)}
<Field label="Type">
<Select value={form.type} onChange={(e) => update("type", e.target.value)}>
{TYPES.map((t) => (
<option key={t.id} value={t.id}>{t.id} ({t.kind})</option>
))}
</Select>
</Field>
<Field label="Bin">
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
{data.bins.map((b) => (
<option key={b.id} value={b.id}>{b.name} {b.location}</option>
))}
<option value={NEW_BIN}>+ Add new bin</option>
</Select>
</Field>
{isNewBin && (
<>
<Field label="New bin name">
<Input
value={newBinName}
onChange={(e) => setNewBinName(e.target.value)}
placeholder="e.g. Top Drawer"
/>
</Field>
<Field label="Location (optional)">
<Input
value={newBinLocation}
onChange={(e) => setNewBinLocation(e.target.value)}
placeholder="e.g. Bedroom"
/>
</Field>
<Field label="Capacity">
<Input
type="number"
min={1}
step={1}
value={newBinCapacity}
onChange={(e) =>
setNewBinCapacity(Math.max(1, Math.floor(+e.target.value || 1)))
}
/>
</Field>
</>
)}
<Field label="SKU" hint="Leave blank — we'll generate one">
<Input value={form.sku} placeholder="SKU-…" onChange={(e) => update("sku", e.target.value)} />
</Field>
<Field label="Asset tag (optional)" hint="If you've physically tagged the item">
<Input
value={form.assetTag}
placeholder="AT-0000"
onChange={(e) => update("assetTag", e.target.value)}
/>
</Field>
</div>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 16 }}>Acquisition</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 8 }}>
{isDiscrete ? (
<>
<Field label={`Quantity (${cfg!.unit})`}>
<Input
type="number"
step="1"
value={form.countOriginal}
onChange={(e) => update("countOriginal", +e.target.value)}
/>
</Field>
<Field label="Per-unit weight (g)" hint="For grams stats">
<Input
type="number"
step="0.1"
value={form.unitWeight}
onChange={(e) => update("unitWeight", +e.target.value)}
/>
</Field>
</>
) : (
<Field label={`Size (${cfg?.unit ?? "g"})`} span={2}>
<Input
type="number"
step="0.1"
value={form.weight}
onChange={(e) => update("weight", +e.target.value)}
/>
</Field>
)}
<Field label="Price ($)">
<Input
type="number"
step="0.01"
value={form.price}
onChange={(e) => update("price", +e.target.value)}
/>
</Field>
<Field label="Purchase date">
<Input
type="date"
value={form.purchaseDate}
onChange={(e) => update("purchaseDate", e.target.value)}
/>
</Field>
</div>
{!isDiscrete && cpg > 0 && (
<div style={{ marginTop: 12, fontSize: 12, color: "var(--ink-3)" }}>
Cost per {cfg?.unit ?? "g"}:{" "}
<span className="mono" style={{ color: "var(--ink-2)" }}>{fmt.money(cpg)}</span>
</div>
)}
<div className="smallcaps" style={{ color: "var(--ink-3)", margin: "28px 0 16px" }}>Cannabinoid profile</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
<Field label="THC %">
<Input
type="number"
step="0.1"
value={form.thc}
onChange={(e) => {
setEdited((p) => ({ ...p, thc: true }));
update("thc", +e.target.value);
}}
/>
</Field>
<Field label="CBD %">
<Input
type="number"
step="0.1"
value={form.cbd}
onChange={(e) => {
setEdited((p) => ({ ...p, cbd: true }));
update("cbd", +e.target.value);
}}
/>
</Field>
<Field label="Total cannabinoids %">
<Input
type="number"
step="0.1"
value={form.totalCannabinoids}
onChange={(e) => {
setEdited((p) => ({ ...p, total: true }));
update("totalCannabinoids", +e.target.value);
}}
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{form.name
? `"${form.name}" → ${
isNewBin
? newBinName.trim() || "new bin"
: data.bins.find((b) => b.id === form.binId)?.name ?? "—"
}.`
: "Fill in the name to continue."}
</div>
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={!form.name || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Saving…" : "Save product"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
// ─── Shared modal chrome ──────────────────────────────────────────
export function ModalBackdrop({
children,
onClose,
}: {
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div
style={{
position: "fixed",
inset: 0,
background: "oklch(20% 0.02 60 / 0.4)",
zIndex: 50,
display: "flex",
justifyContent: "center",
alignItems: "flex-start",
overflow: "auto",
}}
onClick={onClose}
>
<div onClick={(e) => e.stopPropagation()} style={{ width: "100%", display: "flex", justifyContent: "center" }}>
{children}
</div>
</div>
);
}
export function ModalHeader({
title,
eyebrow,
eyebrowColor,
onClose,
}: {
title: string;
eyebrow: string;
eyebrowColor?: string;
onClose: () => void;
}) {
return (
<div
style={{
padding: "20px 32px",
borderBottom: "1px solid var(--line)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div>
<div className="smallcaps" style={{ color: eyebrowColor ?? "var(--ink-3)" }}>
{eyebrow}
</div>
<h2 className="serif" style={{ fontSize: 28, margin: "4px 0 0", fontWeight: 500 }}>
{title}
</h2>
</div>
<Btn variant="ghost" icon="close" onClick={onClose} />
</div>
);
}
export function ModalFooter({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: "16px 32px",
borderTop: "1px solid var(--line)",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "var(--bg-2)",
borderRadius: "0 0 var(--r-lg) var(--r-lg)",
}}
>
{children}
</div>
);
}
+254
View File
@@ -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>
);
}
+273
View File
@@ -0,0 +1,273 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../../api.js";
import { Btn, Field, Input } from "../primitives/index.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
export function AddBrandModal({ onClose }: { onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState("");
const create = useMutation({
mutationFn: () => api.createBrand(name.trim()),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(480px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title="Add a brand" eyebrow="Catalog" onClose={onClose} />
<div style={{ padding: 32 }}>
<Field label="Brand name">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Foxglove Farms"
/>
</Field>
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={!name.trim() || create.isPending}
onClick={() => create.mutate()}
>
{create.isPending ? "Saving…" : "Add brand"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
export function EditBinModal({
bin,
onClose,
}: {
bin: { id: string; name: string; location: string | null; capacity: number };
onClose: () => void;
}) {
const qc = useQueryClient();
const [name, setName] = useState(bin.name);
const [location, setLocation] = useState(bin.location ?? "");
const [capacity, setCapacity] = useState(bin.capacity);
const update = useMutation({
mutationFn: () =>
api.updateBin(bin.id, { name: name.trim(), location: location.trim(), capacity }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(560px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title="Edit bin" eyebrow="Storage" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gap: 16 }}>
<Field label="Bin name">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Top Drawer"
/>
</Field>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
<Field label="Location (optional)">
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Bedroom"
/>
</Field>
<Field label="Capacity">
<Input
type="number"
min={1}
step={1}
value={capacity}
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
/>
</Field>
</div>
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={!name.trim() || update.isPending}
onClick={() => update.mutate()}
>
{update.isPending ? "Saving…" : "Save changes"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
export function AddBinModal({ onClose }: { onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [capacity, setCapacity] = useState(10);
const create = useMutation({
mutationFn: () =>
api.createBin({ name: name.trim(), location: location.trim(), capacity }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(560px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title="Add a bin" eyebrow="Storage" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gap: 16 }}>
<Field label="Bin name">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Top Drawer"
/>
</Field>
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
<Field label="Location (optional)">
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Bedroom"
/>
</Field>
<Field label="Capacity">
<Input
type="number"
min={1}
step={1}
value={capacity}
onChange={(e) => setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))}
/>
</Field>
</div>
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={!name.trim() || create.isPending}
onClick={() => create.mutate()}
>
{create.isPending ? "Saving…" : "Add bin"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
export function AddShopModal({ onClose }: { onClose: () => void }) {
const qc = useQueryClient();
const [name, setName] = useState("");
const [location, setLocation] = useState("");
const create = useMutation({
mutationFn: () => api.createShop({ name: name.trim(), location: location.trim() }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(560px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader title="Add a shop" eyebrow="Catalog" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gap: 16 }}>
<Field label="Shop name">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Greenleaf Co-op"
/>
</Field>
<Field label="Location (optional)">
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g. Capitol Hill"
/>
</Field>
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={!name.trim() || create.isPending}
onClick={() => create.mutate()}
>
{create.isPending ? "Saving…" : "Add shop"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Product } from "../../types.js";
import { helpers, TODAY_STR } from "../../types.js";
import { remainingShort } from "../../stats.js";
import { fmt } from "../../format.js";
import { api } from "../../api.js";
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
import { ScanField } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
export function ConsumeFlow({
data,
onClose,
product: initialProduct,
}: {
data: Bootstrap;
onClose: () => void;
product: Product | null;
}) {
const qc = useQueryClient();
const active = data.products.filter((p) => p.status === "active");
const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? "");
const [rating, setRating] = useState(4);
const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR);
const product = data.products.find((p) => p.id === productId);
const finish = useMutation({
mutationFn: () => api.finishProduct(productId, { date, rating, notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
if (!product) return null;
const bin = data.bins.find((b) => b.id === product.binId);
const lifespan = Math.round((+new Date(date) - +new Date(product.purchaseDate)) / 86_400_000);
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="Mark as consumed" eyebrow="Archive · used up" onClose={onClose} />
<div style={{ padding: 32 }}>
<ScanField
products={active}
matchedProduct={product ?? null}
onMatch={setProductId}
/>
<div style={{ marginTop: 16 }}>
<Field label="Or pick from list">
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
{active.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
</option>
))}
</Select>
</Field>
</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 }}>
{product.name}
</div>
<div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{helpers.brandName(data, product.brandId)} · {bin?.name} · purchased{" "}
{fmt.dateShort(product.purchaseDate)}
</div>
</div>
<div style={{ textAlign: "right" }}>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>LASTED</div>
<div className="serif" style={{ fontSize: 24 }}>{lifespan} days</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 24 }}>
<Field label="Date finished">
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</Field>
<Field label="Rating">
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
padding: "10px 12px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setRating(n)}
style={{ border: "none", background: "transparent", cursor: "pointer", padding: 2 }}
>
<Icon
name="star"
size={20}
color={n <= rating ? "var(--amber)" : "var(--ink-4)"}
/>
</button>
))}
<span
style={{
marginLeft: "auto",
fontSize: 12,
color: "var(--ink-3)",
fontFamily: "var(--mono)",
}}
>
{rating}/5
</span>
</div>
</Field>
</div>
<div style={{ marginTop: 16 }}>
<Field label="Final notes" hint="Flavor, effects, would you rebuy">
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="What stood out?"
/>
</Field>
</div>
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn
variant="primary"
icon="check"
disabled={finish.isPending}
onClick={() => finish.mutate()}
>
{finish.isPending ? "Saving…" : "Mark consumed"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Bootstrap, Product } from "../../types.js";
import { helpers, TODAY_STR } from "../../types.js";
import { remainingShort } from "../../stats.js";
import { api } from "../../api.js";
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./AddProductFlow.js";
const REASONS: [string, string][] = [
["lost", "Lost / misplaced"],
["damaged", "Damaged"],
["expired", "Expired"],
["gifted", "Gifted away"],
["other", "Other"],
];
export function MarkGoneFlow({
data,
onClose,
product: initialProduct,
}: {
data: Bootstrap;
onClose: () => void;
product: Product | null;
}) {
const qc = useQueryClient();
const active = data.products.filter((p) => p.status === "active");
const [productId, setProductId] = useState(initialProduct?.id ?? active[0]?.id ?? "");
const [reason, setReason] = useState("lost");
const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR);
const product = data.products.find((p) => p.id === productId);
const mark = useMutation({
mutationFn: () => api.markGone(productId, { date, reason, notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose();
},
});
if (!product) return null;
return (
<ModalBackdrop onClose={onClose}>
<div
style={{
width: "min(640px, 96vw)",
margin: "40px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<ModalHeader
title="Mark as gone"
eyebrow="Archive · not consumed"
eyebrowColor="var(--terracotta)"
onClose={onClose}
/>
<div style={{ padding: 32 }}>
<div
style={{
fontSize: 13,
color: "var(--ink-2)",
marginBottom: 20,
padding: 14,
background: "var(--amber-soft)",
borderRadius: "var(--r-md)",
}}
>
Use this when an item is lost, damaged, expired, or gifted away. Counts as{" "}
<strong>spend</strong> but not as <strong>consumption</strong>, so daily averages stay accurate.
</div>
<Field label="Product">
<Select value={productId} onChange={(e) => setProductId(e.target.value)}>
{active.map((p) => (
<option key={p.id} value={p.id}>
{p.name} {helpers.brandName(data, p.brandId)} ({remainingShort(p)} left)
</option>
))}
</Select>
</Field>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 16 }}>
<Field label="Reason">
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
{REASONS.map(([k, l]) => (
<option key={k} value={k}>{l}</option>
))}
</Select>
</Field>
<Field label="Date">
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</Field>
</div>
<div style={{ marginTop: 16 }}>
<Field label="Notes (optional)" hint="What happened">
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="e.g. Pack went through the wash"
/>
</Field>
</div>
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn variant="danger" icon="bin" disabled={mark.isPending} onClick={() => mark.mutate()}>
{mark.isPending ? "Saving…" : "Mark gone"}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}