diff --git a/server/src/routes/bootstrap.ts b/server/src/routes/bootstrap.ts index 27f3b81..70a35a8 100644 --- a/server/src/routes/bootstrap.ts +++ b/server/src/routes/bootstrap.ts @@ -59,7 +59,7 @@ bootstrapRouter.get("/bootstrap", (_req, res) => { .all(); const shops = db.prepare("SELECT * FROM shops 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 .prepare<[], StrainRow>("SELECT * FROM strains ORDER BY name COLLATE NOCASE") .all(); diff --git a/server/src/routes/catalog.ts b/server/src/routes/catalog.ts index 72ea2ec..b2173b2 100644 --- a/server/src/routes/catalog.ts +++ b/server/src/routes/catalog.ts @@ -104,52 +104,32 @@ catalogRouter.delete("/shops/:id", (req, res) => { }); catalogRouter.post("/bins", (req, res) => { - const { name, location, capacity } = req.body as { - name: string; - location?: string; - capacity?: number; - }; + const { name, capacity } = req.body as { name: string; capacity?: number }; if (!name?.trim()) return res.status(400).json({ error: "name required" }); const id = nextId("bin", "bins"); 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( - id, - name.trim(), - location?.trim() ?? null, - cap, - ); - res.json({ id, name: name.trim(), location: location?.trim() ?? null, capacity: cap }); + db.prepare("INSERT INTO bins (id, name, capacity) VALUES (?, ?, ?)").run(id, name.trim(), cap); + res.json({ id, name: name.trim(), capacity: cap }); }); catalogRouter.patch("/bins/:id", (req, res) => { const { id } = req.params; - const { name, location, capacity } = req.body as { - name?: string; - location?: string | null; - capacity?: number; - }; + const { name, capacity } = req.body as { name?: string; capacity?: number }; const existing = db - .prepare<[string], { id: string; name: string; location: string | null; capacity: number }>( - "SELECT id, name, location, capacity FROM bins WHERE id = ?", + .prepare<[string], { id: string; name: string; capacity: number }>( + "SELECT id, name, capacity FROM bins WHERE id = ?", ) .get(id); if (!existing) return res.status(404).json({ error: "bin not found" }); const nextName = name?.trim() ? name.trim() : existing.name; - const nextLocation = - location === undefined ? existing.location : location?.toString().trim() || null; const nextCapacity = Number.isFinite(capacity) && (capacity as number) > 0 ? Math.floor(capacity as number) : existing.capacity; - db.prepare("UPDATE bins SET name = ?, location = ?, capacity = ? WHERE id = ?").run( - nextName, - nextLocation, - nextCapacity, - id, - ); - res.json({ id, name: nextName, location: nextLocation, capacity: nextCapacity }); + db.prepare("UPDATE bins SET name = ?, capacity = ? WHERE id = ?").run(nextName, nextCapacity, id); + res.json({ id, name: nextName, capacity: nextCapacity }); }); // Deleting a bin unassigns any products that reference it (bin_id → NULL), diff --git a/web/src/api.ts b/web/src/api.ts index a014103..45f2126 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -108,20 +108,17 @@ export const api = { deleteShop: (id: string) => request<{ ok: true }>(`/shops/${id}`, { method: "DELETE" }), - createBin: (body: { name: string; location?: string; capacity?: number }) => - request<{ id: string; name: string; location: string | null; capacity: number }>("/bins", { + createBin: (body: { name: string; capacity?: number }) => + request<{ id: string; name: string; capacity: number }>("/bins", { method: "POST", body: JSON.stringify(body), }), - updateBin: ( - id: string, - body: { name?: string; location?: string | null; capacity?: number }, - ) => - request<{ id: string; name: string; location: string | null; capacity: number }>( - `/bins/${id}`, - { method: "PATCH", body: JSON.stringify(body) }, - ), + updateBin: (id: string, body: { name?: string; capacity?: number }) => + request<{ id: string; name: string; capacity: number }>(`/bins/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }), deleteBin: (id: string) => request<{ ok: true }>(`/bins/${id}`, { method: "DELETE" }), diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index ef37061..806f570 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -45,7 +45,7 @@ export function ProductDetail({ ["Shop", helpers.shopName(data, product.shopId)], ["Total cannabinoids", `${product.totalCannabinoids.toFixed(1)}%`], ["Purchase date", fmt.date(product.purchaseDate)], - ["Bin", bin ? `${bin.name} — ${bin.location}` : ], + ["Bin", bin ? bin.name : ], ["Audit cadence", `Every ${cfg?.cadenceDays ?? "—"} days · ${cfg?.auditMode ?? "—"}`], [ "Cost per gram", diff --git a/web/src/components/modals/AddProductFlow.tsx b/web/src/components/modals/AddProductFlow.tsx index e8a42c1..2cc7f27 100644 --- a/web/src/components/modals/AddProductFlow.tsx +++ b/web/src/components/modals/AddProductFlow.tsx @@ -34,7 +34,6 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () 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(null); @@ -98,7 +97,6 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () 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; @@ -200,7 +198,7 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () @@ -211,14 +209,7 @@ export function AddProductFlow({ data, onClose }: { data: Bootstrap; onClose: () setNewBinName(e.target.value)} - placeholder="e.g. Top Drawer" - /> - - - setNewBinLocation(e.target.value)} - placeholder="e.g. Bedroom" + placeholder="e.g. A1" /> diff --git a/web/src/components/modals/CatalogModals.tsx b/web/src/components/modals/CatalogModals.tsx index 2f6d00e..19c3afc 100644 --- a/web/src/components/modals/CatalogModals.tsx +++ b/web/src/components/modals/CatalogModals.tsx @@ -125,16 +125,14 @@ export function EditBinModal({ bin, onClose, }: { - bin: { id: string; name: string; location: string | null; capacity: number }; + bin: { id: string; name: string; 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 }), + mutationFn: () => api.updateBin(bin.id, { name: name.trim(), capacity }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); onClose(); @@ -145,7 +143,7 @@ export function EditBinModal({
-
+
setName(e.target.value)} - placeholder="e.g. Top Drawer" + placeholder="e.g. A1" + /> + + + setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))} /> -
- - setLocation(e.target.value)} - placeholder="e.g. Bedroom" - /> - - - setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))} - /> - -
@@ -204,11 +193,9 @@ export function EditBinModal({ 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 }), + mutationFn: () => api.createBin({ name: name.trim(), capacity }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); onClose(); @@ -219,7 +206,7 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
void }) { }} > -
+
setName(e.target.value)} - placeholder="e.g. Top Drawer" + placeholder="e.g. A1" + /> + + + setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))} /> -
- - setLocation(e.target.value)} - placeholder="e.g. Bedroom" - /> - - - setCapacity(Math.max(1, Math.floor(+e.target.value || 1)))} - /> - -
diff --git a/web/src/components/modals/EditProductFlow.tsx b/web/src/components/modals/EditProductFlow.tsx index c33b2eb..46f5b3c 100644 --- a/web/src/components/modals/EditProductFlow.tsx +++ b/web/src/components/modals/EditProductFlow.tsx @@ -48,7 +48,6 @@ export function EditProductFlow({ 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(null); @@ -79,7 +78,6 @@ export function EditProductFlow({ 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; @@ -195,7 +193,7 @@ export function EditProductFlow({ @@ -213,14 +211,7 @@ export function EditProductFlow({ setNewBinName(e.target.value)} - placeholder="e.g. Top Drawer" - /> - - - setNewBinLocation(e.target.value)} - placeholder="e.g. Bedroom" + placeholder="e.g. A1" /> diff --git a/web/src/types.ts b/web/src/types.ts index 9de1749..0fa0139 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -64,7 +64,6 @@ export interface Shop { export interface Bin { id: string; name: string; - location: string | null; capacity: number; } diff --git a/web/src/views/BinsView.tsx b/web/src/views/BinsView.tsx index d1bd056..99a04df 100644 --- a/web/src/views/BinsView.tsx +++ b/web/src/views/BinsView.tsx @@ -6,6 +6,36 @@ import { fmt, TYPE_GLYPHS } from "../format.js"; import { api } from "../api.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(); + 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({ data, onSelectProduct, @@ -31,6 +61,8 @@ export function BinsView({ if (window.confirm(msg)) remove.mutate(binId); }; + const grouped = groupBins(data.bins); + return (
)} -
- {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; - // bulk products take one slot per jar/container. - const slotsUsed = items.reduce( - (s, p) => - s + (p.kind === "discrete" ? (p.countLastAudit ?? p.countOriginal) : 1), - 0, - ); - const fillPct = slotsUsed / bin.capacity; - const totalValue = items.reduce( - (s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR), - 0, - ); - return ( - -
-
-

- {bin.name} -

-
- {slotsUsed} / {bin.capacity} - - -
-
-
- {bin.location} - {fmt.money(totalValue)} -
-
-
0.9 - ? "var(--terracotta)" - : fillPct > 0.7 - ? "var(--amber)" - : "var(--sage)", - }} - /> -
-
-
- {items.length === 0 && ( -
- Empty -
- )} - {items.map((p) => ( -
onSelectProduct(p)} - style={{ - display: "flex", - alignItems: "center", - gap: 10, - padding: "8px 14px", - borderRadius: "var(--r-sm)", - cursor: "pointer", - }} - > + {grouped.map(([groupKey, bins]) => ( +
+
+ {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; + // bulk products take one slot per jar/container. + const slotsUsed = items.reduce( + (s, p) => + s + (p.kind === "discrete" ? (p.countLastAudit ?? p.countOriginal) : 1), + 0, + ); + const fillPct = slotsUsed / bin.capacity; + const totalValue = items.reduce( + (s, p) => s + p.price * helpers.pctRemaining(p, TODAY_STR), + 0, + ); + return ( + +
- {TYPE_GLYPHS[p.type]} +

+ {bin.name} +

+
+ {slotsUsed} / {bin.capacity} + + +
-
+
+ {fmt.money(totalValue)} +
+
0.9 + ? "var(--terracotta)" + : fillPct > 0.7 + ? "var(--amber)" + : "var(--sage)", }} - > - {p.name} -
-
- {helpers.brandName(data, p.brandId)} -
-
-
- {remainingShort(p)} + />
- ))} -
-
- ); - })} -
+
+ {items.length === 0 && ( +
+ Empty +
+ )} + {items.map((p) => ( +
onSelectProduct(p)} + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "8px 14px", + borderRadius: "var(--r-sm)", + cursor: "pointer", + }} + > +
+ {TYPE_GLYPHS[p.type]} +
+
+
+ {p.name} +
+
+ {helpers.brandName(data, p.brandId)} +
+
+
+ {remainingShort(p)} +
+
+ ))} +
+ + ); + })} +
+
+ ))}
); }