|
|
|
|
@@ -1,10 +1,11 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
import { useMemo, useState } from "react";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import {
|
|
|
|
|
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
|
|
|
|
|
Search, Tag,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { warrantyStatus } from "@/lib/inventory/value";
|
|
|
|
|
|
|
|
|
|
@@ -37,6 +38,31 @@ function WarrantyBadge({ expiry }: { expiry: string | Date | null }) {
|
|
|
|
|
return <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ItemThumb({ item, hasKids }: { item: GridItem; hasKids: boolean }) {
|
|
|
|
|
if (item.imageUrl) {
|
|
|
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
|
|
|
return <img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />;
|
|
|
|
|
}
|
|
|
|
|
return (
|
|
|
|
|
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
|
|
|
|
|
{hasKids ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ItemBadges({ item }: { item: GridItem }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
|
|
|
{item.labels.slice(0, 3).map((l) => (
|
|
|
|
|
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
|
|
|
|
|
))}
|
|
|
|
|
<WarrantyBadge expiry={item.warrantyExpiry} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tree row — used when grouping by location with no active search. Shows nested
|
|
|
|
|
// container contents under an expand toggle.
|
|
|
|
|
function ItemRow({ item, childrenByParent, depth }: {
|
|
|
|
|
item: GridItem;
|
|
|
|
|
childrenByParent: Map<string, GridItem[]>;
|
|
|
|
|
@@ -55,14 +81,7 @@ function ItemRow({ item, childrenByParent, depth }: {
|
|
|
|
|
</button>
|
|
|
|
|
) : <span className="w-4 shrink-0" />}
|
|
|
|
|
|
|
|
|
|
{item.imageUrl ? (
|
|
|
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
|
|
|
<img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />
|
|
|
|
|
) : (
|
|
|
|
|
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
|
|
|
|
|
{kids.length > 0 ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<ItemThumb item={item} hasKids={kids.length > 0} />
|
|
|
|
|
|
|
|
|
|
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
|
|
|
|
|
<span className="font-medium text-sm">{item.name}</span>
|
|
|
|
|
@@ -70,12 +89,7 @@ function ItemRow({ item, childrenByParent, depth }: {
|
|
|
|
|
{kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>}
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
|
|
|
{item.labels.slice(0, 3).map((l) => (
|
|
|
|
|
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
|
|
|
|
|
))}
|
|
|
|
|
<WarrantyBadge expiry={item.warrantyExpiry} />
|
|
|
|
|
</div>
|
|
|
|
|
<ItemBadges item={item} />
|
|
|
|
|
</div>
|
|
|
|
|
{open && kids.map((k) => (
|
|
|
|
|
<ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} />
|
|
|
|
|
@@ -84,7 +98,101 @@ function ItemRow({ item, childrenByParent, depth }: {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Flat row — used for search results and label grouping, where the container
|
|
|
|
|
// tree doesn't apply. Shows a location/container hint instead of nesting.
|
|
|
|
|
function FlatRow({ item, hint }: { item: GridItem; hint: string | null }) {
|
|
|
|
|
const qty = Number(item.quantity);
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-accent transition-colors">
|
|
|
|
|
<span className="w-4 shrink-0" />
|
|
|
|
|
<ItemThumb item={item} hasKids={item._count.containedItems > 0} />
|
|
|
|
|
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
|
|
|
|
|
<span className="font-medium text-sm">{item.name}</span>
|
|
|
|
|
{qty !== 1 && <span className="text-xs text-muted-foreground"> · {qty}{item.unit ? ` ${item.unit}` : ""}</span>}
|
|
|
|
|
{hint && <span className="block text-xs text-muted-foreground truncate">{hint}</span>}
|
|
|
|
|
</Link>
|
|
|
|
|
<ItemBadges item={item} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Group({ icon: Icon, name, count, children }: {
|
|
|
|
|
icon: typeof MapPin; name: string; count: number; children: React.ReactNode;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-sm font-medium">
|
|
|
|
|
<Icon className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
|
|
|
|
<span className="font-display">{name}</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground font-normal">· {count}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="divide-y border rounded-lg overflow-hidden">{children}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type GroupBy = "location" | "label";
|
|
|
|
|
|
|
|
|
|
// The place an item physically sits in. An item lives directly in a Place, or
|
|
|
|
|
// inside another Item that does — so for contained items (own location null) we
|
|
|
|
|
// walk up the container chain to the nearest ancestor that has a location. The
|
|
|
|
|
// `seen` guard keeps a corrupt cycle from looping forever.
|
|
|
|
|
function effectiveLocation(
|
|
|
|
|
it: GridItem,
|
|
|
|
|
byId: Map<string, GridItem>,
|
|
|
|
|
): { id: string; name: string } | null {
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
let cur: GridItem | undefined = it;
|
|
|
|
|
while (cur) {
|
|
|
|
|
if (cur.location) return cur.location;
|
|
|
|
|
if (!cur.parentItemId || seen.has(cur.id)) break;
|
|
|
|
|
seen.add(cur.id);
|
|
|
|
|
cur = byId.get(cur.parentItemId);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|
|
|
|
const [query, setQuery] = useState("");
|
|
|
|
|
const [groupBy, setGroupBy] = useState<GroupBy>("location");
|
|
|
|
|
|
|
|
|
|
const q = query.trim().toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Item lookup by id — powers the "inside X" hint and the container-chain walk
|
|
|
|
|
// that resolves a contained item's effective location.
|
|
|
|
|
const byId = useMemo(() => {
|
|
|
|
|
const m = new Map<string, GridItem>();
|
|
|
|
|
for (const it of items) m.set(it.id, it);
|
|
|
|
|
return m;
|
|
|
|
|
}, [items]);
|
|
|
|
|
|
|
|
|
|
// Match name, effective location, or any label so search finds things by where
|
|
|
|
|
// they live (including inside a container) or how they're tagged, not just by name.
|
|
|
|
|
const matched = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
items.filter((it) => {
|
|
|
|
|
if (!q) return true;
|
|
|
|
|
const loc = effectiveLocation(it, byId);
|
|
|
|
|
return (
|
|
|
|
|
it.name.toLowerCase().includes(q) ||
|
|
|
|
|
(loc?.name.toLowerCase().includes(q) ?? false) ||
|
|
|
|
|
it.labels.some((l) => l.label.name.toLowerCase().includes(q))
|
|
|
|
|
);
|
|
|
|
|
}),
|
|
|
|
|
[items, q, byId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Container nesting only makes sense in the unfiltered location tree; a search
|
|
|
|
|
// or a label view shows a flat list, so we tree only in that one case.
|
|
|
|
|
const treeView = groupBy === "location" && !q;
|
|
|
|
|
|
|
|
|
|
const hintFor = (it: GridItem): string | null => {
|
|
|
|
|
const parent = it.parentItemId ? byId.get(it.parentItemId) : undefined;
|
|
|
|
|
if (parent) return `in ${parent.name}`;
|
|
|
|
|
return it.location ? it.location.name : null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Zero inventory: show just the empty state, no search/toggle chrome to operate on.
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="text-center py-16 text-muted-foreground">
|
|
|
|
|
@@ -95,7 +203,55 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Items contained inside another item are rendered under their parent.
|
|
|
|
|
const empty = (
|
|
|
|
|
<div className="text-center py-16 text-muted-foreground">
|
|
|
|
|
<Package className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
|
|
|
|
<p className="font-medium">Nothing matches</p>
|
|
|
|
|
<p className="text-sm mt-1">Try a different search or grouping.</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<input
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
placeholder="Search items, locations, or labels…"
|
|
|
|
|
className="w-full rounded-md border bg-background pl-9 pr-3 py-2 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center rounded-md border p-0.5 text-sm shrink-0">
|
|
|
|
|
{(["location", "label"] as const).map((g) => (
|
|
|
|
|
<button
|
|
|
|
|
key={g}
|
|
|
|
|
onClick={() => setGroupBy(g)}
|
|
|
|
|
className={`flex items-center gap-1.5 rounded px-3 py-1.5 transition-colors ${
|
|
|
|
|
groupBy === g ? "bg-[hsl(var(--leaf))] text-white" : "text-muted-foreground hover:text-foreground"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{g === "location" ? <MapPin className="h-3.5 w-3.5" /> : <Tag className="h-3.5 w-3.5" />}
|
|
|
|
|
{g === "location" ? "Location" : "Label"}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{matched.length === 0 ? empty : treeView ? (
|
|
|
|
|
<LocationTree items={matched} />
|
|
|
|
|
) : groupBy === "location" ? (
|
|
|
|
|
<FlatByLocation items={matched} byId={byId} hintFor={hintFor} />
|
|
|
|
|
) : (
|
|
|
|
|
<FlatByLabel items={matched} hintFor={hintFor} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default view: top-level items grouped by location, container contents nested.
|
|
|
|
|
function LocationTree({ items }: { items: GridItem[] }) {
|
|
|
|
|
const childrenByParent = new Map<string, GridItem[]>();
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
if (it.parentItemId) {
|
|
|
|
|
@@ -105,8 +261,11 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Top-level items (not inside another item) grouped by location.
|
|
|
|
|
const topLevel = items.filter((i) => !i.parentItemId);
|
|
|
|
|
// Top-level = items with no parent, plus "orphans" whose parent isn't in the
|
|
|
|
|
// set (e.g. a soft-deleted container) — otherwise those still-active children
|
|
|
|
|
// would render nowhere, since nothing left recurses into them.
|
|
|
|
|
const present = new Set(items.map((i) => i.id));
|
|
|
|
|
const topLevel = items.filter((i) => !i.parentItemId || !present.has(i.parentItemId));
|
|
|
|
|
const groups = new Map<string, { name: string; items: GridItem[] }>();
|
|
|
|
|
const noLoc: GridItem[] = [];
|
|
|
|
|
for (const it of topLevel) {
|
|
|
|
|
@@ -115,30 +274,91 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|
|
|
|
g.items.push(it);
|
|
|
|
|
groups.set(it.location.id, g);
|
|
|
|
|
}
|
|
|
|
|
const sorted = Array.from(groups.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{sorted.map((g) => (
|
|
|
|
|
<div key={g.name} className="space-y-1.5">
|
|
|
|
|
<div className="flex items-center gap-1.5 text-sm font-medium">
|
|
|
|
|
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
|
|
|
|
<span className="font-display">{g.name}</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground font-normal">· {g.items.length}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="divide-y border rounded-lg overflow-hidden">
|
|
|
|
|
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{sorted.map(([id, g]) => (
|
|
|
|
|
<Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
|
|
|
|
|
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
|
|
|
|
</Group>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{noLoc.length > 0 && (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{sorted.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location</p>}
|
|
|
|
|
{noLoc.length > 0 &&
|
|
|
|
|
(sorted.length > 0 ? (
|
|
|
|
|
<Group icon={MapPin} name="No location" count={noLoc.length}>
|
|
|
|
|
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
|
|
|
|
</Group>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="divide-y border rounded-lg overflow-hidden">
|
|
|
|
|
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Flat results grouped by location (every matching item is its own row). Groups
|
|
|
|
|
// key on the effective location id — so a contained item shows under its
|
|
|
|
|
// container's place (matching the tree view), and two same-named places stay
|
|
|
|
|
// separate instead of merging.
|
|
|
|
|
function FlatByLocation({ items, byId, hintFor }: {
|
|
|
|
|
items: GridItem[];
|
|
|
|
|
byId: Map<string, GridItem>;
|
|
|
|
|
hintFor: (i: GridItem) => string | null;
|
|
|
|
|
}) {
|
|
|
|
|
const groups = new Map<string, { name: string; items: GridItem[] }>();
|
|
|
|
|
const noLoc: GridItem[] = [];
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
const loc = effectiveLocation(it, byId);
|
|
|
|
|
if (!loc) { noLoc.push(it); continue; }
|
|
|
|
|
const g = groups.get(loc.id) ?? { name: loc.name, items: [] };
|
|
|
|
|
g.items.push(it);
|
|
|
|
|
groups.set(loc.id, g);
|
|
|
|
|
}
|
|
|
|
|
const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{sorted.map(([id, g]) => (
|
|
|
|
|
<Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
|
|
|
|
|
{g.items.map((it) => <FlatRow key={it.id} item={it} hint={it.parentItemId ? hintFor(it) : null} />)}
|
|
|
|
|
</Group>
|
|
|
|
|
))}
|
|
|
|
|
{noLoc.length > 0 && (
|
|
|
|
|
<Group icon={MapPin} name="No location" count={noLoc.length}>
|
|
|
|
|
{noLoc.map((it) => <FlatRow key={it.id} item={it} hint={it.parentItemId ? hintFor(it) : null} />)}
|
|
|
|
|
</Group>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Flat results grouped by label. An item with multiple labels appears under each;
|
|
|
|
|
// items with no labels collect under a synthetic "No labels" group. Real-label
|
|
|
|
|
// groups key on label id, so two same-named labels stay separate and a user label
|
|
|
|
|
// named "No labels" can't merge into the synthetic bucket.
|
|
|
|
|
function FlatByLabel({ items, hintFor }: { items: GridItem[]; hintFor: (i: GridItem) => string | null }) {
|
|
|
|
|
const groups = new Map<string, { name: string; items: GridItem[] }>();
|
|
|
|
|
const unlabeled: GridItem[] = [];
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
if (it.labels.length === 0) { unlabeled.push(it); continue; }
|
|
|
|
|
for (const { label } of it.labels) {
|
|
|
|
|
const g = groups.get(label.id) ?? { name: label.name, items: [] };
|
|
|
|
|
g.items.push(it);
|
|
|
|
|
groups.set(label.id, g);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{sorted.map(([id, g]) => (
|
|
|
|
|
<Group key={id} icon={Tag} name={g.name} count={g.items.length}>
|
|
|
|
|
{g.items.map((it) => <FlatRow key={it.id} item={it} hint={hintFor(it)} />)}
|
|
|
|
|
</Group>
|
|
|
|
|
))}
|
|
|
|
|
{unlabeled.length > 0 && (
|
|
|
|
|
<Group icon={Tag} name="No labels" count={unlabeled.length}>
|
|
|
|
|
{unlabeled.map((it) => <FlatRow key={it.id} item={it} hint={hintFor(it)} />)}
|
|
|
|
|
</Group>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|