v0.22.0 — Inventory search + Location/Label grouping; Suggestions styling fix
Adds a search box (matches name, location, and labels) and a Location/Label grouping toggle to Inventory, and fixes the unstyled Suggestions page. (Renumbered from 0.19.0 → 0.22.0: Pantry work took 0.19–0.21 in parallel.) Hardening from a max-effort code review of the new grid: - Contained items resolve to their container's effective location, so search finds them and the flat view groups them under the right place (not "No location"). - Location/label groups key on id, not name, so same-named places/labels stay separate; synthetic bucket renamed "No labels" to avoid colliding with a real label; fixed duplicate React keys and two empty-state regressions. - deleteItem now reparents a deleted container's contents into its own spot (its place or parent item) instead of orphaning them; the grid also treats any still-orphaned child as top-level so nothing silently vanishes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,9 @@ export default async function SuggestionsPage() {
|
|||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
// The panel uses light-gray (neutral-*) text that assumes a white backdrop, so
|
// The panel uses light-gray (neutral-*) text that assumes a white backdrop, so
|
||||||
// render it on a white card for readable contrast in light or dark theme.
|
// render it on a white card for readable contrast in light or dark theme.
|
||||||
|
// color-scheme:light keeps its native inputs/textarea light-filled too.
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-white text-neutral-900 p-4 md:p-6 shadow-sm">
|
<div className="rounded-lg border bg-white text-neutral-900 p-4 md:p-6 shadow-sm [color-scheme:light]">
|
||||||
<SuggestionsPanel />
|
<SuggestionsPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
|
/* Force native form controls (input/textarea/select) to the light theme so
|
||||||
|
* they don't pick up the OS dark fill — otherwise an unstyled <input> renders
|
||||||
|
* with a dark system background in a dark-mode browser, hiding placeholder
|
||||||
|
* text. .dark overrides this below. */
|
||||||
|
color-scheme: light;
|
||||||
--dew: 120 18% 94%; /* #EEF3EE — pale sage background */
|
--dew: 120 18% 94%; /* #EEF3EE — pale sage background */
|
||||||
--dew-deep: 120 20% 97%; /* slightly lighter for cards */
|
--dew-deep: 120 20% 97%; /* slightly lighter for cards */
|
||||||
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
|
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
|
||||||
@@ -58,6 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
--dew: 140 18% 10%;
|
--dew: 140 18% 10%;
|
||||||
--dew-deep: 140 18% 12%;
|
--dew-deep: 140 18% 12%;
|
||||||
--soil: 120 18% 88%;
|
--soil: 120 18% 88%;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
|
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
|
||||||
|
Search, Tag,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { warrantyStatus } from "@/lib/inventory/value";
|
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>;
|
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 }: {
|
function ItemRow({ item, childrenByParent, depth }: {
|
||||||
item: GridItem;
|
item: GridItem;
|
||||||
childrenByParent: Map<string, GridItem[]>;
|
childrenByParent: Map<string, GridItem[]>;
|
||||||
@@ -55,14 +81,7 @@ function ItemRow({ item, childrenByParent, depth }: {
|
|||||||
</button>
|
</button>
|
||||||
) : <span className="w-4 shrink-0" />}
|
) : <span className="w-4 shrink-0" />}
|
||||||
|
|
||||||
{item.imageUrl ? (
|
<ItemThumb item={item} hasKids={kids.length > 0} />
|
||||||
// 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
|
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
|
||||||
<span className="font-medium text-sm">{item.name}</span>
|
<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>}
|
{kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<ItemBadges item={item} />
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
{open && kids.map((k) => (
|
{open && kids.map((k) => (
|
||||||
<ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} />
|
<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[] }) {
|
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) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-16 text-muted-foreground">
|
<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[]>();
|
const childrenByParent = new Map<string, GridItem[]>();
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
if (it.parentItemId) {
|
if (it.parentItemId) {
|
||||||
@@ -105,8 +261,11 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level items (not inside another item) grouped by location.
|
// Top-level = items with no parent, plus "orphans" whose parent isn't in the
|
||||||
const topLevel = items.filter((i) => !i.parentItemId);
|
// 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 groups = new Map<string, { name: string; items: GridItem[] }>();
|
||||||
const noLoc: GridItem[] = [];
|
const noLoc: GridItem[] = [];
|
||||||
for (const it of topLevel) {
|
for (const it of topLevel) {
|
||||||
@@ -115,30 +274,91 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|||||||
g.items.push(it);
|
g.items.push(it);
|
||||||
groups.set(it.location.id, g);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{sorted.map((g) => (
|
{sorted.map(([id, g]) => (
|
||||||
<div key={g.name} className="space-y-1.5">
|
<Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
|
||||||
<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} />)}
|
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||||
</div>
|
</Group>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
{noLoc.length > 0 &&
|
||||||
{noLoc.length > 0 && (
|
(sorted.length > 0 ? (
|
||||||
<div className="space-y-1.5">
|
<Group icon={MapPin} name="No location" count={noLoc.length}>
|
||||||
{sorted.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location</p>}
|
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
<div className="divide-y border rounded-lg overflow-hidden">
|
<div className="divide-y border rounded-lg overflow-hidden">
|
||||||
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.22.0",
|
||||||
|
date: "2026-07-04",
|
||||||
|
changes: [
|
||||||
|
"Inventory can now be searched and regrouped — there's a search box (matches item names, locations, and labels) and a Location / Label toggle to group your stuff either way.",
|
||||||
|
"Fixed the Suggestions page — the boxes and the \"Send suggestion\" button were rendering unstyled (dark, unreadable inputs and an invisible button). They now look right and are readable.",
|
||||||
|
"Searching now finds things stored inside containers too — search \"garage\" and you'll see the drill inside the garage toolbox, not just the toolbox.",
|
||||||
|
"Deleting a container (like a toolbox) now leaves its contents in its spot — a wrench in a deleted garage toolbox becomes a wrench loose in the garage, instead of disappearing.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.21.1",
|
version: "0.21.1",
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
|
|||||||
@@ -164,11 +164,29 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate
|
|||||||
|
|
||||||
export async function deleteItem(ctx: AuthContext, id: string) {
|
export async function deleteItem(ctx: AuthContext, id: string) {
|
||||||
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items");
|
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items");
|
||||||
const item = await db.item.findUnique({ where: { id }, select: { active: true, name: true } });
|
const item = await db.item.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { active: true, name: true, locationId: true, parentItemId: true },
|
||||||
|
});
|
||||||
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
||||||
await db.item.update({ where: { id }, data: { active: false } });
|
// Deleting a container drops its contents into the container's own spot (its
|
||||||
|
// place, or the item it was nested in) instead of orphaning them — a wrench in
|
||||||
|
// a deleted garage toolbox becomes a wrench loose in the garage.
|
||||||
|
const reparented = await db.$transaction(async (tx) => {
|
||||||
|
const { count } = await tx.item.updateMany({
|
||||||
|
where: { parentItemId: id, active: true },
|
||||||
|
data: { locationId: item.locationId, parentItemId: item.parentItemId },
|
||||||
|
});
|
||||||
|
await tx.item.update({ where: { id }, data: { active: false } });
|
||||||
|
return count;
|
||||||
|
});
|
||||||
await db.auditEvent.create({
|
await db.auditEvent.create({
|
||||||
data: { action: "item.delete", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
|
data: {
|
||||||
|
action: "item.delete",
|
||||||
|
entityId: id,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
payload: { name: item.name, reparentedChildren: reparented, via: ctx.via },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||||
export const APP_VERSION = "0.21.1";
|
export const APP_VERSION = "0.22.0";
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const config: Config = {
|
|||||||
"./components/**/*.{ts,tsx}",
|
"./components/**/*.{ts,tsx}",
|
||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
"./src/**/*.{ts,tsx}",
|
"./src/**/*.{ts,tsx}",
|
||||||
|
// The shared @otm/account-panel ships its own Tailwind classes (Suggestions,
|
||||||
|
// self-update, etc.). Scan its source so those utilities actually compile —
|
||||||
|
// otherwise the panel renders nearly unstyled (e.g. a bg-less Send button).
|
||||||
|
"./node_modules/@otm/account-panel/src/**/*.{ts,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
Reference in New Issue
Block a user