Group bins by letter, sort by number, drop location
Build and push image / build (push) Successful in 46s

Bins follow an A1/A2/B1 naming convention, so the Bins page now parses
the leading letter prefix as a row group and the trailing number as the
within-row order. Each letter starts a fresh grid section; bins whose
names don't match the pattern fall into a trailing "Other" bucket
sorted alphabetically.

Removes the optional location field from bins end to end: the API
client signatures, server POST/PATCH routes, both product-flow inline
creates, the dropdown labels, the ProductDetail bin row, and the
BinsView header line. The bootstrap query explicitly projects only
id/name/capacity so the dead column doesn't leak through.

The location column stays in the bins table on disk to avoid a
migration on existing deployments — it just isn't read or written.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 22:07:12 -04:00
parent cd7aeb9d09
commit d335525073
9 changed files with 245 additions and 272 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => {
.all(); .all();
const shops = db.prepare("SELECT * FROM shops ORDER BY id").all(); const shops = db.prepare("SELECT * FROM shops ORDER BY id").all();
const brands = db.prepare("SELECT * FROM brands ORDER BY id").all(); const brands = db.prepare("SELECT * FROM brands ORDER BY id").all();
const bins = db.prepare("SELECT * FROM bins ORDER BY id").all(); const bins = db.prepare("SELECT id, name, capacity FROM bins ORDER BY id").all();
const strains = db const strains = db
.prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE") .prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE")
.all(); .all();
+8 -28
View File
@@ -104,52 +104,32 @@ catalogRouter.delete("/shops/:id", (req, res) => {
}); });
catalogRouter.post("/bins", (req, res) => { catalogRouter.post("/bins", (req, res) => {
const { name, location, capacity } = req.body as { const { name, capacity } = req.body as { name: string; capacity?: number };
name: string;
location?: string;
capacity?: number;
};
if (!name?.trim()) return res.status(400).json({ error: "name required" }); if (!name?.trim()) return res.status(400).json({ error: "name required" });
const id = nextId("bin", "bins"); const id = nextId("bin", "bins");
const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10; const cap = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : 10;
db.prepare("INSERT INTO bins (id, name, location, capacity) VALUES (?, ?, ?, ?)").run( db.prepare("INSERT INTO bins (id, name, capacity) VALUES (?, ?, ?)").run(id, name.trim(), cap);
id, res.json({ id, name: name.trim(), capacity: cap });
name.trim(),
location?.trim() ?? null,
cap,
);
res.json({ id, name: name.trim(), location: location?.trim() ?? null, capacity: cap });
}); });
catalogRouter.patch("/bins/:id", (req, res) => { catalogRouter.patch("/bins/:id", (req, res) => {
const { id } = req.params; const { id } = req.params;
const { name, location, capacity } = req.body as { const { name, capacity } = req.body as { name?: string; capacity?: number };
name?: string;
location?: string | null;
capacity?: number;
};
const existing = db const existing = db
.prepare<[string], { id: string; name: string; location: string | null; capacity: number }>( .prepare<[string], { id: string; name: string; capacity: number }>(
"SELECT id, name, location, capacity FROM bins WHERE id = ?", "SELECT id, name, capacity FROM bins WHERE id = ?",
) )
.get(id); .get(id);
if (!existing) return res.status(404).json({ error: "bin not found" }); if (!existing) return res.status(404).json({ error: "bin not found" });
const nextName = name?.trim() ? name.trim() : existing.name; const nextName = name?.trim() ? name.trim() : existing.name;
const nextLocation =
location === undefined ? existing.location : location?.toString().trim() || null;
const nextCapacity = const nextCapacity =
Number.isFinite(capacity) && (capacity as number) > 0 Number.isFinite(capacity) && (capacity as number) > 0
? Math.floor(capacity as number) ? Math.floor(capacity as number)
: existing.capacity; : existing.capacity;
db.prepare("UPDATE bins SET name = ?, location = ?, capacity = ? WHERE id = ?").run( db.prepare("UPDATE bins SET name = ?, capacity = ? WHERE id = ?").run(nextName, nextCapacity, id);
nextName, res.json({ id, name: nextName, capacity: nextCapacity });
nextLocation,
nextCapacity,
id,
);
res.json({ id, name: nextName, location: nextLocation, capacity: nextCapacity });
}); });
// Deleting a bin unassigns any products that reference it (bin_id → NULL), // Deleting a bin unassigns any products that reference it (bin_id → NULL),
+7 -10
View File
@@ -108,20 +108,17 @@ export const api = {
deleteShop: (id: string) => deleteShop: (id: string) =>
request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }), request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }),
createBin: (body: { name: string; location?: string; capacity?: number }) => createBin: (body: { name: string; capacity?: number }) =>
request<{ id: string; name: string; location: string | null; capacity: number }>("/bins", { request<{ id: string; name: string; capacity: number }>("/bins", {
method: "POST", method: "POST",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
updateBin: ( updateBin: (id: string, body: { name?: string; capacity?: number }) =>
id: string, request<{ id: string; name: string; capacity: number }>(`/bins/${id}`, {
body: { name?: string; location?: string | null; capacity?: number }, method: "PATCH",
) => body: JSON.stringify(body),
request<{ id: string; name: string; location: string | null; capacity: number }>( }),
`/bins/${id}`,
{ method: "PATCH", body: JSON.stringify(body) },
),
deleteBin: (id: string) => deleteBin: (id: string) =>
request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }), request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }),
+1 -1
View File
@@ -45,7 +45,7 @@ export function ProductDetail({
["Shop", helpers.shopName(data, product.shopId)], ["Shop", helpers.shopName(data, product.shopId)],
["Total cannabinoids", `${product.totalCannabinoids.toFixed(1)}%`], ["Total cannabinoids", `${product.totalCannabinoids.toFixed(1)}%`],
["Purchase date", fmt.date(product.purchaseDate)], ["Purchase date", fmt.date(product.purchaseDate)],
["Bin", bin ? `${bin.name}${bin.location}` : <span style={{ color: "var(--ink-3)" }}></span>], ["Bin", 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",
+2 -11
View File
@@ -34,7 +34,6 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
const [newShopName, setNewShopName] = useState(""); const [newShopName, setNewShopName] = useState("");
const [newShopLocation, setNewShopLocation] = useState(""); const [newShopLocation, setNewShopLocation] = useState("");
const [newBinName, setNewBinName] = useState(""); const [newBinName, setNewBinName] = useState("");
const [newBinLocation, setNewBinLocation] = useState("");
const [newBinCapacity, setNewBinCapacity] = useState(10); const [newBinCapacity, setNewBinCapacity] = useState(10);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -98,7 +97,6 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
if (!newBinName.trim()) throw new Error("New bin name required"); if (!newBinName.trim()) throw new Error("New bin name required");
const b = await api.createBin({ const b = await api.createBin({
name: newBinName.trim(), name: newBinName.trim(),
location: newBinLocation.trim(),
capacity: newBinCapacity, capacity: newBinCapacity,
}); });
binId = b.id; binId = b.id;
@@ -200,7 +198,7 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
<Field label="Bin"> <Field label="Bin">
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}> <Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
{data.bins.map((b) => ( {data.bins.map((b) => (
<option key={b.id} value={b.id}>{b.name} {b.location}</option> <option key={b.id} value={b.id}>{b.name}</option>
))} ))}
<option value={NEW_BIN}>+ Add new bin</option> <option value={NEW_BIN}>+ Add new bin</option>
</Select> </Select>
@@ -211,14 +209,7 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: ()
<Input <Input
value={newBinName} value={newBinName}
onChange={(e) => setNewBinName(e.target.value)} onChange={(e) => setNewBinName(e.target.value)}
placeholder="e.g. Top Drawer" placeholder="e.g. A1"
/>
</Field>
<Field label="Location (optional)">
<Input
value={newBinLocation}
onChange={(e) => setNewBinLocation(e.target.value)}
placeholder="e.g. Bedroom"
/> />
</Field> </Field>
<Field label="Capacity"> <Field label="Capacity">
+27 -49
View File
@@ -125,16 +125,14 @@ export function EditBinModal({
bin, bin,
onClose, onClose,
}: { }: {
bin: { id: string; name: string; location: string | null; capacity: number }; bin: { id: string; name: string; capacity: number };
onClose: () => void; onClose: () => void;
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(bin.name); const [name, setName] = useState(bin.name);
const [location, setLocation] = useState(bin.location ?? "");
const [capacity, setCapacity] = useState(bin.capacity); const [capacity, setCapacity] = useState(bin.capacity);
const update = useMutation({ const update = useMutation({
mutationFn: () => mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity }),
api.updateBin(bin.id, { name: name.trim(), location: location.trim(), capacity }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose(); onClose();
@@ -145,7 +143,7 @@ export function EditBinModal({
<ModalBackdrop onClose={onClose}> <ModalBackdrop onClose={onClose}>
<div <div
style={{ style={{
width: "min(560px, 96vw)", width: "min(480px, 96vw)",
margin: "40px 20px", margin: "40px 20px",
background: "var(--bg)", background: "var(--bg)",
border: "1px solid var(--line)", border: "1px solid var(--line)",
@@ -154,33 +152,24 @@ export function EditBinModal({
}} }}
> >
<ModalHeader title="Edit bin" eyebrow="Storage" onClose={onClose} /> <ModalHeader title="Edit bin" eyebrow="Storage" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gap: 16 }}> <div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
<Field label="Bin name"> <Field label="Bin name">
<Input <Input
autoFocus autoFocus
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Top Drawer" placeholder="e.g. A1"
/>
</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> </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> </div>
<ModalFooter> <ModalFooter>
<div /> <div />
@@ -204,11 +193,9 @@ export function EditBinModal({
export function AddBinModal({ onClose }: { onClose: () => void }) { export function AddBinModal({ onClose }: { onClose: () => void }) {
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [location, setLocation] = useState("");
const [capacity, setCapacity] = useState(10); const [capacity, setCapacity] = useState(10);
const create = useMutation({ const create = useMutation({
mutationFn: () => mutationFn: () => api.createBin({ name: name.trim(), capacity }),
api.createBin({ name: name.trim(), location: location.trim(), capacity }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
onClose(); onClose();
@@ -219,7 +206,7 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
<ModalBackdrop onClose={onClose}> <ModalBackdrop onClose={onClose}>
<div <div
style={{ style={{
width: "min(560px, 96vw)", width: "min(480px, 96vw)",
margin: "40px 20px", margin: "40px 20px",
background: "var(--bg)", background: "var(--bg)",
border: "1px solid var(--line)", border: "1px solid var(--line)",
@@ -228,33 +215,24 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
}} }}
> >
<ModalHeader title="Add a bin" eyebrow="Storage" onClose={onClose} /> <ModalHeader title="Add a bin" eyebrow="Storage" onClose={onClose} />
<div style={{ padding: 32, display: "grid", gap: 16 }}> <div style={{ padding: 32, display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
<Field label="Bin name"> <Field label="Bin name">
<Input <Input
autoFocus autoFocus
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="e.g. Top Drawer" placeholder="e.g. A1"
/>
</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> </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> </div>
<ModalFooter> <ModalFooter>
<div /> <div />
+2 -11
View File
@@ -48,7 +48,6 @@ export function EditProductFlow({
const [newShopName, setNewShopName] = useState(""); const [newShopName, setNewShopName] = useState("");
const [newShopLocation, setNewShopLocation] = useState(""); const [newShopLocation, setNewShopLocation] = useState("");
const [newBinName, setNewBinName] = useState(""); const [newBinName, setNewBinName] = useState("");
const [newBinLocation, setNewBinLocation] = useState("");
const [newBinCapacity, setNewBinCapacity] = useState(10); const [newBinCapacity, setNewBinCapacity] = useState(10);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -79,7 +78,6 @@ export function EditProductFlow({
if (!newBinName.trim()) throw new Error("New bin name required"); if (!newBinName.trim()) throw new Error("New bin name required");
const b = await api.createBin({ const b = await api.createBin({
name: newBinName.trim(), name: newBinName.trim(),
location: newBinLocation.trim(),
capacity: newBinCapacity, capacity: newBinCapacity,
}); });
binId = b.id; binId = b.id;
@@ -195,7 +193,7 @@ export function EditProductFlow({
<Field label="Bin"> <Field label="Bin">
<Select value={form.binId} onChange={(e) => update("binId", e.target.value)}> <Select value={form.binId} onChange={(e) => update("binId", e.target.value)}>
{data.bins.map((b) => ( {data.bins.map((b) => (
<option key={b.id} value={b.id}>{b.name} {b.location}</option> <option key={b.id} value={b.id}>{b.name}</option>
))} ))}
<option value={NEW_BIN}>+ Add new bin</option> <option value={NEW_BIN}>+ Add new bin</option>
</Select> </Select>
@@ -213,14 +211,7 @@ export function EditProductFlow({
<Input <Input
value={newBinName} value={newBinName}
onChange={(e) => setNewBinName(e.target.value)} onChange={(e) => setNewBinName(e.target.value)}
placeholder="e.g. Top Drawer" placeholder="e.g. A1"
/>
</Field>
<Field label="Location (optional)">
<Input
value={newBinLocation}
onChange={(e) => setNewBinLocation(e.target.value)}
placeholder="e.g. Bedroom"
/> />
</Field> </Field>
<Field label="Capacity"> <Field label="Capacity">
-1
View File
@@ -64,7 +64,6 @@ export interface Shop {
export interface Bin { export interface Bin {
id: string; id: string;
name: string; name: string;
location: string | null;
capacity: number; capacity: number;
} }
+197 -160
View File
@@ -6,6 +6,36 @@ import { fmt, TYPE_GLYPHS } from "../format.js";
import { api } from "../api.js"; import { api } from "../api.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js"; import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
// Bins follow a "letter + number" naming convention (A1, A2, B1, …).
// Group by the letter prefix so each letter starts a new visual row,
// and sort by the trailing number left-to-right within the row.
const NAME_RE = /^([A-Za-z]+)(\d+)$/;
function groupBins(bins: Bin[]): [string, Bin[]][] {
const groups = new Map<string, Bin[]>();
for (const bin of bins) {
const m = bin.name.trim().match(NAME_RE);
const key = m ? m[1]!.toUpperCase() : "Other";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(bin);
}
for (const list of groups.values()) {
list.sort((a, b) => {
const am = a.name.trim().match(NAME_RE);
const bm = b.name.trim().match(NAME_RE);
const an = am ? parseInt(am[2]!, 10) : Number.POSITIVE_INFINITY;
const bn = bm ? parseInt(bm[2]!, 10) : Number.POSITIVE_INFINITY;
if (an !== bn) return an - bn;
return a.name.localeCompare(b.name);
});
}
return [...groups.entries()].sort(([a], [b]) => {
if (a === "Other") return 1;
if (b === "Other") return -1;
return a.localeCompare(b);
});
}
export function BinsView({ export function BinsView({
data, data,
onSelectProduct, onSelectProduct,
@@ -31,6 +61,8 @@ export function BinsView({
if (window.confirm(msg)) remove.mutate(binId); if (window.confirm(msg)) remove.mutate(binId);
}; };
const grouped = groupBins(data.bins);
return ( return (
<div <div
style={{ style={{
@@ -65,176 +97,181 @@ export function BinsView({
</Card> </Card>
)} )}
<div {grouped.map(([groupKey, bins]) => (
style={{ <div key={groupKey} style={{ marginBottom: 24 }}>
display: "grid", <div
gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))", style={{
gap: 14, display: "grid",
}} gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))",
> gap: 14,
{data.bins.map((bin) => { }}
const items = data.products.filter((p) => p.binId === bin.id && p.status === "active"); >
// Discrete products (pre-rolls, edibles, vapes) take a slot per unit; {bins.map((bin) => {
// bulk products take one slot per jar/container. const items = data.products.filter(
const slotsUsed = items.reduce( (p) => p.binId === bin.id && p.status === "active",
(s, p) => );
s + (p.kind === "discrete" ? (p.countLastAudit ?? p.countOriginal) : 1), // Discrete products (pre-rolls, edibles, vapes) take a slot per unit;
0, // bulk products take one slot per jar/container.
); const slotsUsed = items.reduce(
const fillPct = slotsUsed / bin.capacity; (s, p) =>
const totalValue = items.reduce( s + (p.kind === "discrete" ? (p.countLastAudit ?? p.countOriginal) : 1),
(s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR), 0,
0, );
); const fillPct = slotsUsed / bin.capacity;
return ( const totalValue = items.reduce(
<Card key={bin.id} padded={false} style={{ display: "flex", flexDirection: "column" }}> (s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR),
<div style={{ padding: "20px 22px 16px", borderBottom: "1px solid var(--line)" }}> 0,
<div );
style={{ return (
display: "flex", <Card key={bin.id} padded={false} style={{ display: "flex", flexDirection: "column" }}>
alignItems: "baseline", <div style={{ padding: "20px 22px 16px", borderBottom: "1px solid var(--line)" }}>
justifyContent: "space-between",
marginBottom: 4,
gap: 8,
}}
>
<h3 className="serif" style={{ fontSize: 24, margin: 0, fontWeight: 500 }}>
{bin.name}
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<Pill tone="outline">{slotsUsed} / {bin.capacity}</Pill>
<button
onClick={() => onEditBin(bin)}
title="Edit bin"
aria-label={`Edit bin ${bin.name}`}
style={{
background: "transparent",
border: "none",
padding: 4,
borderRadius: "var(--r-sm)",
cursor: "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(bin.id, bin.name, items.length)}
title="Remove bin"
aria-label={`Remove bin ${bin.name}`}
disabled={remove.isPending}
style={{
background: "transparent",
border: "none",
padding: 4,
borderRadius: "var(--r-sm)",
cursor: remove.isPending ? "wait" : "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="bin" size={14} />
</button>
</div>
</div>
<div
style={{
fontSize: 12,
color: "var(--ink-3)",
display: "flex",
justifyContent: "space-between",
}}
>
<span>{bin.location}</span>
<span className="mono">{fmt.money(totalValue)}</span>
</div>
<div
style={{
marginTop: 12,
height: 4,
background: "var(--bg-3)",
borderRadius: 2,
overflow: "hidden",
}}
>
<div
style={{
width: `${Math.min(fillPct, 1) * 100}%`,
height: "100%",
background:
fillPct > 0.9
? "var(--terracotta)"
: fillPct > 0.7
? "var(--amber)"
: "var(--sage)",
}}
/>
</div>
</div>
<div style={{ padding: 8, flex: 1 }}>
{items.length === 0 && (
<div
style={{
padding: 30,
textAlign: "center",
fontSize: 12,
color: "var(--ink-3)",
fontStyle: "italic",
}}
>
Empty
</div>
)}
{items.map((p) => (
<div
key={p.id}
onClick={() => onSelectProduct(p)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 14px",
borderRadius: "var(--r-sm)",
cursor: "pointer",
}}
>
<div <div
style={{ style={{
fontFamily: "var(--serif)", display: "flex",
fontSize: 18, alignItems: "baseline",
color: "var(--ink-3)", justifyContent: "space-between",
width: 18, marginBottom: 4,
gap: 8,
}} }}
> >
{TYPE_GLYPHS[p.type]} <h3 className="serif" style={{ fontSize: 24, margin: 0, fontWeight: 500 }}>
{bin.name}
</h3>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<Pill tone="outline">{slotsUsed} / {bin.capacity}</Pill>
<button
onClick={() => onEditBin(bin)}
title="Edit bin"
aria-label={`Edit bin ${bin.name}`}
style={{
background: "transparent",
border: "none",
padding: 4,
borderRadius: "var(--r-sm)",
cursor: "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="edit" size={14} />
</button>
<button
onClick={() => handleDelete(bin.id, bin.name, items.length)}
title="Remove bin"
aria-label={`Remove bin ${bin.name}`}
disabled={remove.isPending}
style={{
background: "transparent",
border: "none",
padding: 4,
borderRadius: "var(--r-sm)",
cursor: remove.isPending ? "wait" : "pointer",
color: "var(--ink-3)",
display: "inline-flex",
}}
>
<Icon name="bin" size={14} />
</button>
</div>
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div
style={{
fontSize: 12,
color: "var(--ink-3)",
display: "flex",
justifyContent: "flex-end",
}}
>
<span className="mono">{fmt.money(totalValue)}</span>
</div>
<div
style={{
marginTop: 12,
height: 4,
background: "var(--bg-3)",
borderRadius: 2,
overflow: "hidden",
}}
>
<div <div
style={{ style={{
fontSize: 13, width: `${Math.min(fillPct, 1) * 100}%`,
fontWeight: 500, height: "100%",
whiteSpace: "nowrap", background:
overflow: "hidden", fillPct > 0.9
textOverflow: "ellipsis", ? "var(--terracotta)"
: fillPct > 0.7
? "var(--amber)"
: "var(--sage)",
}} }}
> />
{p.name}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
{helpers.brandName(data, p.brandId)}
</div>
</div>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-2)" }}>
{remainingShort(p)}
</div> </div>
</div> </div>
))} <div style={{ padding: 8, flex: 1 }}>
</div> {items.length === 0 && (
</Card> <div
); style={{
})} padding: 30,
</div> textAlign: "center",
fontSize: 12,
color: "var(--ink-3)",
fontStyle: "italic",
}}
>
Empty
</div>
)}
{items.map((p) => (
<div
key={p.id}
onClick={() => onSelectProduct(p)}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 14px",
borderRadius: "var(--r-sm)",
cursor: "pointer",
}}
>
<div
style={{
fontFamily: "var(--serif)",
fontSize: 18,
color: "var(--ink-3)",
width: 18,
}}
>
{TYPE_GLYPHS[p.type]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{p.name}
</div>
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
{helpers.brandName(data, p.brandId)}
</div>
</div>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-2)" }}>
{remainingShort(p)}
</div>
</div>
))}
</div>
</Card>
);
})}
</div>
</div>
))}
</div> </div>
); );
} }