Group bins by letter, sort by number, drop location
Build and push image / build (push) Successful in 46s
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:
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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" }),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,21 +152,13 @@ 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>
|
|
||||||
<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>
|
||||||
<Field label="Capacity">
|
<Field label="Capacity">
|
||||||
@@ -181,7 +171,6 @@ export function EditBinModal({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div />
|
<div />
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
@@ -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,21 +215,13 @@ 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>
|
|
||||||
<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>
|
||||||
<Field label="Capacity">
|
<Field label="Capacity">
|
||||||
@@ -255,7 +234,6 @@ export function AddBinModal({ onClose }: { onClose: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div />
|
<div />
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +97,8 @@ export function BinsView({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{grouped.map(([groupKey, bins]) => (
|
||||||
|
<div key={groupKey} style={{ marginBottom: 24 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -72,8 +106,10 @@ export function BinsView({
|
|||||||
gap: 14,
|
gap: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.bins.map((bin) => {
|
{bins.map((bin) => {
|
||||||
const items = data.products.filter((p) => p.binId === bin.id && p.status === "active");
|
const items = data.products.filter(
|
||||||
|
(p) => p.binId === bin.id && p.status === "active",
|
||||||
|
);
|
||||||
// Discrete products (pre-rolls, edibles, vapes) take a slot per unit;
|
// Discrete products (pre-rolls, edibles, vapes) take a slot per unit;
|
||||||
// bulk products take one slot per jar/container.
|
// bulk products take one slot per jar/container.
|
||||||
const slotsUsed = items.reduce(
|
const slotsUsed = items.reduce(
|
||||||
@@ -143,10 +179,9 @@ export function BinsView({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "var(--ink-3)",
|
color: "var(--ink-3)",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "flex-end",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{bin.location}</span>
|
|
||||||
<span className="mono">{fmt.money(totalValue)}</span>
|
<span className="mono">{fmt.money(totalValue)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -236,5 +271,7 @@ export function BinsView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user