538e5079ab
Build and push image / build (push) Successful in 54s
Comprehensive UX audit covering modals, drawers, dashboard, and inventory. Key changes: confirmation steps before destructive actions, undo via toast for consume/gone/checkout, back-navigation across entity drawers, optional ratings, discrete item count field, audit progress bar, and sortable column affordance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import { useEffect } from "react";
|
||
import type { Bootstrap, Brand, Product, Item } from "../types.js";
|
||
import { TYPES, helpers, enrichItems } from "../types.js";
|
||
import { getToday, getStoredTimezone } from "../tz.js";
|
||
import { fmt, TYPE_GLYPHS } from "../format.js";
|
||
import { Btn, Pill, Icon } from "./primitives/index.js";
|
||
import { remainingShort } from "../stats.js";
|
||
import { useExitAnimation } from "../hooks/useExitAnimation.js";
|
||
import { useFocusTrap } from "../hooks/useFocusTrap.js";
|
||
|
||
export function BrandDetail({
|
||
brand,
|
||
data,
|
||
onClose,
|
||
onEdit,
|
||
onDelete,
|
||
onSelectSku,
|
||
onSelectItem,
|
||
backLabel,
|
||
onBack,
|
||
}: {
|
||
brand: Brand;
|
||
data: Bootstrap;
|
||
onClose: () => void;
|
||
onEdit: () => void;
|
||
onDelete: () => void;
|
||
onSelectSku: (p: Product) => void;
|
||
onSelectItem: (i: Item) => void;
|
||
backLabel?: string;
|
||
onBack?: () => void;
|
||
}) {
|
||
const products = data.products.filter((p) => p.brandId === brand.id);
|
||
const strainMap = new Map(data.strains.map((s) => [s.id, s]));
|
||
const allItems = enrichItems(data).filter((i) => i.brandId === brand.id);
|
||
const hasItems = allItems.length > 0;
|
||
|
||
const active = allItems.filter((i) => i.status === "active" || i.status === "checked-out");
|
||
const consumed = allItems.filter((i) => i.status === "consumed");
|
||
const gone = allItems.filter((i) => i.status === "gone");
|
||
|
||
const totalSpend = allItems.reduce((s, i) => s + i.price, 0);
|
||
const avgPrice = hasItems ? totalSpend / allItems.length : 0;
|
||
|
||
const rated = allItems.filter((i) => i.rating != null);
|
||
const avgRating =
|
||
rated.length > 0 ? rated.reduce((s, i) => s + i.rating!, 0) / rated.length : null;
|
||
|
||
const sortedItems = [...allItems].sort(
|
||
(a, b) => +new Date(b.purchaseDate) - +new Date(a.purchaseDate),
|
||
);
|
||
const recentItems = sortedItems.slice(0, 20);
|
||
|
||
const todayStr = getToday(getStoredTimezone());
|
||
const tz = getStoredTimezone();
|
||
|
||
const { closing, triggerClose } = useExitAnimation(220, onClose);
|
||
const trapRef = useFocusTrap<HTMLDivElement>();
|
||
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") triggerClose();
|
||
};
|
||
document.addEventListener("keydown", handleKeyDown);
|
||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||
}, [triggerClose]);
|
||
|
||
const statCards: [string, React.ReactNode][] = [
|
||
["SKUs", String(products.length)],
|
||
["Purchases", String(allItems.length)],
|
||
["Total spent", hasItems ? fmt.money(totalSpend) : "—"],
|
||
["Avg price", hasItems ? fmt.money(avgPrice) : "—"],
|
||
];
|
||
|
||
return (
|
||
<div
|
||
ref={trapRef}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
style={{
|
||
position: "fixed",
|
||
inset: 0,
|
||
background: "oklch(20% 0.02 60 / 0.4)",
|
||
zIndex: 50,
|
||
display: "flex",
|
||
justifyContent: "flex-end",
|
||
animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
|
||
}}
|
||
onClick={triggerClose}
|
||
>
|
||
<div
|
||
onClick={(e) => e.stopPropagation()}
|
||
style={{
|
||
width: "min(720px, 100vw)",
|
||
height: "100%",
|
||
animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
|
||
background: "var(--bg)",
|
||
borderLeft: "1px solid var(--line)",
|
||
overflow: "auto",
|
||
boxShadow: "var(--shadow-lg)",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
padding: "20px 32px",
|
||
borderBottom: "1px solid var(--line)",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: onBack ? 8 : 0,
|
||
position: "sticky",
|
||
top: 0,
|
||
background: "var(--bg)",
|
||
zIndex: 1,
|
||
}}
|
||
>
|
||
{onBack && backLabel && (
|
||
<button
|
||
onClick={onBack}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
background: "none",
|
||
border: "none",
|
||
padding: 0,
|
||
fontSize: 12,
|
||
color: "var(--sage)",
|
||
cursor: "pointer",
|
||
alignSelf: "flex-start",
|
||
}}
|
||
>
|
||
<span style={{ transform: "scaleX(-1)", display: "inline-flex" }}><Icon name="arrow" size={12} /></span> Back to {backLabel}
|
||
</button>
|
||
)}
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>
|
||
Brand
|
||
</div>
|
||
<div style={{ display: "flex", gap: 6 }}>
|
||
<Btn variant="ghost" icon="edit" onClick={onEdit} />
|
||
<Btn
|
||
variant="ghost"
|
||
icon="bin"
|
||
disabled={hasItems}
|
||
onClick={onDelete}
|
||
title={hasItems ? "Cannot delete — has inventory items" : undefined}
|
||
style={hasItems ? { opacity: 0.3, cursor: "not-allowed" } : undefined}
|
||
/>
|
||
<Btn variant="ghost" icon="close" onClick={triggerClose} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: "32px 32px 60px" }}>
|
||
<h1
|
||
className="serif"
|
||
style={{
|
||
fontSize: 48,
|
||
margin: "0 0 4px",
|
||
fontWeight: 500,
|
||
letterSpacing: "-0.02em",
|
||
lineHeight: 1.1,
|
||
}}
|
||
>
|
||
{brand.name}
|
||
</h1>
|
||
|
||
{hasItems && (
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: `repeat(${statCards.length}, 1fr)`,
|
||
gap: 1,
|
||
marginTop: 32,
|
||
background: "var(--line)",
|
||
border: "1px solid var(--line)",
|
||
borderRadius: "var(--r-md)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{statCards.map(([l, v], i) => (
|
||
<div key={i} style={{ padding: "18px 16px", background: "var(--surface)" }}>
|
||
<div className="smallcaps" style={{ color: "var(--ink-3)" }}>{l}</div>
|
||
<div className="serif" style={{ fontSize: 26, marginTop: 4, fontWeight: 500 }}>
|
||
{v}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{hasItems && (
|
||
<div style={{ marginTop: 28 }}>
|
||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||
Lifecycle
|
||
</div>
|
||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||
{active.length > 0 && <Pill tone="sage">{active.length} active</Pill>}
|
||
{consumed.length > 0 && <Pill tone="terra">{consumed.length} consumed</Pill>}
|
||
{gone.length > 0 && <Pill tone="amber">{gone.length} gone</Pill>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{avgRating != null && (
|
||
<div style={{ marginTop: 28 }}>
|
||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||
Ratings
|
||
</div>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||
<div style={{ display: "flex", gap: 2 }}>
|
||
{[1, 2, 3, 4, 5].map((n) => (
|
||
<Icon
|
||
key={n}
|
||
name="star"
|
||
size={18}
|
||
color={n <= Math.round(avgRating) ? "var(--amber)" : "var(--ink-4)"}
|
||
/>
|
||
))}
|
||
</div>
|
||
<span className="serif" style={{ fontSize: 22, fontWeight: 500 }}>
|
||
{avgRating.toFixed(1)}
|
||
</span>
|
||
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||
from {rated.length} review{rated.length === 1 ? "" : "s"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{products.length > 0 && (
|
||
<div style={{ marginTop: 28 }}>
|
||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||
SKUs ({products.length})
|
||
</div>
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--line)",
|
||
borderRadius: "var(--r-md)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{products.map((p, idx) => {
|
||
const strain = strainMap.get(p.strainId);
|
||
const itemCount = allItems.filter((i) => i.productId === p.id).length;
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
onClick={() => onSelectSku(p)}
|
||
className="inv-row"
|
||
style={{
|
||
padding: "12px 16px",
|
||
borderBottom: idx < products.length - 1 ? "1px solid var(--line)" : "none",
|
||
display: "grid",
|
||
gridTemplateColumns: "24px 1fr auto auto auto",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
background: "var(--surface)",
|
||
cursor: "pointer",
|
||
}}
|
||
>
|
||
<span style={{ fontFamily: "var(--serif)", fontSize: 16, color: "var(--ink-3)" }}>
|
||
{TYPE_GLYPHS[p.type]}
|
||
</span>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontWeight: 500,
|
||
fontSize: 13,
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
}}
|
||
>
|
||
{strain?.name ?? "(unknown)"}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: "var(--ink-3)" }}>
|
||
{p.type} · {p.kind}
|
||
</div>
|
||
</div>
|
||
<span className="mono" style={{ fontSize: 12 }}>
|
||
{p.sku}
|
||
</span>
|
||
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||
{itemCount} item{itemCount === 1 ? "" : "s"}
|
||
</span>
|
||
<span
|
||
className="inv-row-chevron"
|
||
style={{ color: "var(--ink-3)", fontSize: 14 }}
|
||
>
|
||
›
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{hasItems && (
|
||
<div style={{ marginTop: 28 }}>
|
||
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 12 }}>
|
||
Recent purchases ({allItems.length})
|
||
</div>
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--line)",
|
||
borderRadius: "var(--r-md)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{recentItems.map((item, idx) => {
|
||
const isInactive = item.status !== "active" && item.status !== "checked-out";
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
onClick={() => onSelectItem(item)}
|
||
className="inv-row"
|
||
style={{
|
||
padding: "12px 16px",
|
||
borderBottom: idx < recentItems.length - 1 ? "1px solid var(--line)" : "none",
|
||
display: "grid",
|
||
gridTemplateColumns: "auto 1fr auto auto auto",
|
||
alignItems: "center",
|
||
gap: 12,
|
||
background: "var(--surface)",
|
||
cursor: "pointer",
|
||
opacity: isInactive ? 0.55 : 1,
|
||
}}
|
||
>
|
||
<span className="mono" style={{ fontSize: 12 }}>{item.assetId}</span>
|
||
<span style={{ fontSize: 13 }}>
|
||
{item.status === "consumed" && <Pill tone="terra" style={{ fontSize: 10 }}>Consumed</Pill>}
|
||
{item.status === "gone" && <Pill tone="amber" style={{ fontSize: 10 }}>Gone</Pill>}
|
||
{item.status === "checked-out" && <Pill tone="outline" style={{ fontSize: 10 }}>Checked out</Pill>}
|
||
{item.status === "active" && helpers.auditOverdue(item, todayStr) && (
|
||
<Pill tone="amber" style={{ fontSize: 10 }}>Audit due</Pill>
|
||
)}
|
||
{item.status === "active" && !helpers.auditOverdue(item, todayStr) && (
|
||
<Pill tone="sage" style={{ fontSize: 10 }}>Active</Pill>
|
||
)}
|
||
</span>
|
||
<span className="mono" style={{ fontSize: 12 }}>{fmt.money(item.price)}</span>
|
||
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||
{fmt.dateShort(item.purchaseDate, tz)}
|
||
</span>
|
||
<span style={{ fontSize: 12, color: "var(--ink-3)" }}>
|
||
{(item.status === "active" || item.status === "checked-out")
|
||
? remainingShort(item)
|
||
: ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!hasItems && (
|
||
<div
|
||
style={{
|
||
marginTop: 36,
|
||
padding: 40,
|
||
textAlign: "center",
|
||
color: "var(--ink-3)",
|
||
background: "var(--bg-2)",
|
||
borderRadius: "var(--r-md)",
|
||
border: "1px solid var(--line)",
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 14, marginBottom: 4 }}>No inventory items yet</div>
|
||
<div style={{ fontSize: 12 }}>
|
||
Products from this brand will appear here.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{hasItems && (
|
||
<div style={{ marginTop: 12, fontSize: 11, color: "var(--ink-3)", fontStyle: "italic" }}>
|
||
Cannot delete this brand while it has associated inventory items.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|