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:
+197
-160
@@ -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<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({
|
||||
data,
|
||||
onSelectProduct,
|
||||
@@ -31,6 +61,8 @@ export function BinsView({
|
||||
if (window.confirm(msg)) remove.mutate(binId);
|
||||
};
|
||||
|
||||
const grouped = groupBins(data.bins);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -65,176 +97,181 @@ export function BinsView({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
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;
|
||||
// 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 (
|
||||
<Card key={bin.id} padded={false} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "20px 22px 16px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
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",
|
||||
}}
|
||||
>
|
||||
{grouped.map(([groupKey, bins]) => (
|
||||
<div key={groupKey} style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(380px, 1fr))",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<Card key={bin.id} padded={false} style={{ display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ padding: "20px 22px 16px", borderBottom: "1px solid var(--line)" }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--serif)",
|
||||
fontSize: 18,
|
||||
color: "var(--ink-3)",
|
||||
width: 18,
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
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 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
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
width: `${Math.min(fillPct, 1) * 100}%`,
|
||||
height: "100%",
|
||||
background:
|
||||
fillPct > 0.9
|
||||
? "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>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user