table import works now
Some checks failed
Deploy to Server / deploy (push) Failing after 1m7s

This commit is contained in:
2026-04-24 18:19:54 -05:00
parent 4098453c97
commit 24d126eeb9
6 changed files with 524 additions and 118 deletions

View File

@@ -12,6 +12,7 @@ import {
Camera, Sparkles, Mail, Search, Plus, AlertTriangle,
ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical,
Table2, FileDown, FileSpreadsheet, Upload, ArrowRight,
CheckSquare,
} from "lucide-react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
@@ -19,6 +20,7 @@ import {
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select";
import { Checkbox } from "./ui/checkbox";
// ── constants ──────────────────────────────────────────────────────────────
@@ -130,6 +132,11 @@ export function Inventory() {
const [searchQuery, setSearchQuery] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
// selection mode
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isDeleting, setIsDeleting] = useState(false);
// add/edit dialog
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ChemicalInventory | null>(null);
@@ -144,9 +151,12 @@ export function Inventory() {
// import dialog
const [importOpen, setImportOpen] = useState(false);
const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload");
const [fallbackReason, setFallbackReason] = useState("");
const [importHeaders, setImportHeaders] = useState<string[]>([]);
const [importRows, setImportRows] = useState<string[][]>([]);
const [columnMapping, setColumnMapping] = useState<Record<string, ImportColumnMapping>>({});
const [columnMapping, setColumnMapping] = useState<Record<string, keyof ChemicalInventory | "__skip__">>({});
const [columnMatchInfo, setColumnMatchInfo] = useState<Record<string, { confidence: number; reason: string }>>({});
const [isMatching, setIsMatching] = useState(false);
const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState("");
@@ -192,25 +202,15 @@ export function Inventory() {
}
async function handleSave() {
const required: (keyof ChemicalInventory)[] = [
"piFirstName","physicalState","chemicalName","bldgCode","lab",
"storageLocation","storageDevice","numberOfContainers","amountPerContainer",
"unitOfMeasure","casNumber",
];
const missing = required.filter(k => !form[k]);
if (missing.length) {
setFormError("Please fill in all required fields.");
return;
}
if (!validateCAS(String(form.casNumber || ""))) {
if (form.casNumber && !validateCAS(String(form.casNumber))) {
setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1).");
return;
}
if (!validateNumber(form.numberOfContainers, { min: 1, integer: true })) {
if (form.numberOfContainers && !validateNumber(form.numberOfContainers, { min: 1, integer: true })) {
setFormError("# of containers must be a whole number of 1 or more.");
return;
}
if (!validateNumber(form.amountPerContainer, { min: 0 })) {
if (form.amountPerContainer && !validateNumber(form.amountPerContainer, { min: 0 })) {
setFormError("Amount per container must be a number.");
return;
}
@@ -249,6 +249,31 @@ export function Inventory() {
await chemicalsApi.remove(id);
setInventory(inv => inv.filter(c => c.id !== id));
if (expandedId === id) setExpandedId(null);
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
async function handleDeleteSelected() {
if (selectedIds.size === 0) return;
if (!confirm(`Remove ${selectedIds.size} selected chemical(s) from inventory?`)) return;
setIsDeleting(true);
try {
const ids = Array.from(selectedIds);
await chemicalsApi.bulkRemove(ids);
setInventory(inv => inv.filter(c => !selectedIds.has(c.id)));
setExpandedId(null);
setSelectedIds(new Set());
setIsSelectionMode(false);
} catch (err) {
console.error("Failed to delete selected:", err);
alert("Failed to delete some or all items. Please refresh and try again.");
} finally {
setIsDeleting(false);
}
}
// ── scan helpers ────────────────────────────────────────────────────────
@@ -305,7 +330,10 @@ export function Inventory() {
function handleDownloadCSV() {
const headers = TABLE_COLUMNS.map(c => c.label);
const rows = filtered.map(item =>
const itemsToExport = isSelectionMode && selectedIds.size > 0
? filtered.filter(item => selectedIds.has(item.id))
: filtered;
const rows = itemsToExport.map(item =>
TABLE_COLUMNS.map(c => String(item[c.key] ?? ""))
);
const csv = [headers, ...rows]
@@ -324,7 +352,10 @@ export function Inventory() {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Chemical Inventory");
ws.addRow(TABLE_COLUMNS.map(c => c.label));
filtered.forEach(item => ws.addRow(TABLE_COLUMNS.map(c => item[c.key] ?? "")));
const itemsToExport = isSelectionMode && selectedIds.size > 0
? filtered.filter(item => selectedIds.has(item.id))
: filtered;
itemsToExport.forEach(item => ws.addRow(TABLE_COLUMNS.map(c => item[c.key] ?? "")));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
@@ -371,8 +402,10 @@ export function Inventory() {
setImportHeaders([]);
setImportRows([]);
setColumnMapping({});
setColumnMatchInfo({});
setImportResult(null);
setImportError("");
setFallbackReason("");
setImportOpen(true);
}
@@ -418,7 +451,15 @@ export function Inventory() {
if (!ws) throw new Error("No worksheets found in this file.");
allRows = [];
ws.eachRow(row => {
allRows.push((row.values as ExcelJS.CellValue[]).slice(1).map(v => String(v ?? "")));
allRows.push((row.values as ExcelJS.CellValue[]).slice(1).map(v => {
if (v instanceof Date) {
return v.toISOString().split('T')[0];
}
if (v && typeof v === 'object' && 'result' in v && v.result instanceof Date) {
return v.result.toISOString().split('T')[0];
}
return String(v ?? "");
}));
});
console.log("[import] parsed rows:", allRows.length);
}
@@ -432,11 +473,44 @@ export function Inventory() {
console.log("[import] headers:", headers, "data rows:", dataRows.length);
setImportHeaders(headers);
setImportRows(dataRows);
const mapping: Record<string, ImportColumnMapping> = {};
for (const h of headers) mapping[h] = fuzzyMatchColumn(h);
setColumnMapping(mapping);
console.log("[import] advancing to map step");
// Seed every header to skip while we wait for the semantic matcher.
const seeded: Record<string, keyof ChemicalInventory | "__skip__"> = {};
for (const h of headers) seeded[h] = "__skip__";
setColumnMapping(seeded);
setColumnMatchInfo({});
setImportStep("map");
// Run the server-side semantic matcher: Gemini API
setIsMatching(true);
try {
const sampleRows = dataRows.slice(0, 12);
const result = await chemicalsApi.columnMatch(headers, sampleRows);
const validKeys = new Set(TABLE_COLUMNS.map(c => c.key as string));
const next: Record<string, keyof ChemicalInventory | "__skip__"> = { ...seeded };
const info: Record<string, { confidence: number; reason: string }> = {};
for (const m of result.mappings) {
const key = m.key === "__skip__" || validKeys.has(m.key)
? (m.key as keyof ChemicalInventory | "__skip__")
: "__skip__";
next[m.header] = key;
info[m.header] = { confidence: m.confidence, reason: m.reason };
}
setColumnMapping(next);
setColumnMatchInfo(info);
} catch (matchErr) {
console.error("[import] semantic match failed:", matchErr);
setFallbackReason((matchErr as Error).message);
const mapping: Record<string, keyof ChemicalInventory | "__skip__"> = {};
const info: Record<string, { confidence: number; reason: string }> = {};
for (const h of headers) {
mapping[h] = fuzzyMatchColumn(h);
info[h] = { confidence: 0.6, reason: "Used fuzzy matching fallback." };
}
setColumnMapping(mapping);
setColumnMatchInfo(info);
} finally {
setIsMatching(false);
}
} catch (err) {
console.error("[import] error:", err);
setImportError(`Could not read file: ${(err as Error).message}`);
@@ -480,11 +554,32 @@ export function Inventory() {
const lowStockCount = inventory.filter(c => c.percentageFull != null && c.percentageFull < 20).length;
const isMissingKeyInfo = (c: ChemicalInventory) =>
!c.piFirstName || !c.physicalState || !c.chemicalName || !c.bldgCode || !c.lab ||
!c.storageLocation || !c.storageDevice || !c.numberOfContainers || !c.amountPerContainer ||
!c.unitOfMeasure || !c.casNumber;
const missingInfoCount = inventory.filter(isMissingKeyInfo).length;
// ── render ───────────────────────────────────────────────────────────────
return (
<div className="p-6 max-w-5xl mx-auto">
{missingInfoCount > 0 && (
<Card className="mb-4 p-4 bg-amber-50 border-amber-200">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800">Missing Key Information</p>
<p className="text-sm text-amber-700">
You have {missingInfoCount} {missingInfoCount === 1 ? "item" : "items"} missing important fields. Please review the highlighted items below and update them.
</p>
</div>
</div>
</Card>
)}
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
@@ -638,66 +733,144 @@ export function Inventory() {
</div>
)}
{importStep === "map" && (
<div className="space-y-4 mt-2">
<p className="text-sm text-muted-foreground">
Review the column mapping below. Adjust any mismatches before importing{" "}
<span className="font-medium text-foreground">{importRows.length} row{importRows.length !== 1 ? "s" : ""}</span>.
</p>
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">File Column</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium w-8"></th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Maps To</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Sample</th>
</tr>
</thead>
<tbody>
{importHeaders.map((header, idx) => (
<tr key={header} className="border-t border-border">
<td className="px-3 py-2 text-foreground font-mono text-xs">{header}</td>
<td className="px-1 py-2 text-muted-foreground">
<ArrowRight className="w-3 h-3" />
</td>
<td className="px-3 py-2">
<Select
value={columnMapping[header] ?? ""}
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as ImportColumnMapping }))}
>
<SelectTrigger className="h-7 text-xs w-48">
<SelectValue placeholder="— skip —" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__skip__"> skip </SelectItem>
{TABLE_COLUMNS.map(col => (
<SelectItem key={col.key} value={col.key}>{col.label}</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-3 py-2 text-muted-foreground text-xs truncate max-w-[120px]">
{importRows[0]?.[idx] ?? ""}
</td>
{importStep === "map" && (() => {
// Compute which target keys are claimed by more than one source header.
const targetCounts = new Map<string, string[]>();
for (const h of importHeaders) {
const k = columnMapping[h];
if (!k || k === "__skip__") continue;
const list = targetCounts.get(k) ?? [];
list.push(h);
targetCounts.set(k, list);
}
const duplicateTargets = new Map<string, string[]>(
Array.from(targetCounts.entries()).filter(([, hs]) => hs.length > 1),
);
const conflictHeaders = new Set<string>();
for (const hs of duplicateTargets.values()) for (const h of hs) conflictHeaders.add(h);
const labelByKey = new Map(TABLE_COLUMNS.map(c => [c.key as string, c.label]));
const blockImport = duplicateTargets.size > 0 || isMatching;
if (isMatching) {
return (
<div className="py-12 flex flex-col items-center justify-center space-y-4">
<Loader2 className="w-10 h-10 text-primary animate-spin" />
<p className="text-foreground font-medium">Matching columns with AI...</p>
<p className="text-muted-foreground text-sm">Please wait while Gemini analyzes your data.</p>
</div>
);
}
return (
<div className="space-y-4 mt-2">
{fallbackReason && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-1">
<p className="text-sm font-medium text-amber-800 flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> Gemini reasoning failed
</p>
<p className="text-xs text-amber-700">
Error: {fallbackReason}. Used fuzzy matching instead. Please verify the mappings.
</p>
</div>
)}
<p className="text-sm text-muted-foreground">
Review the column mapping below. Adjust any mismatches before importing{" "}
<span className="font-medium text-foreground">{importRows.length} row{importRows.length !== 1 ? "s" : ""}</span>.
</p>
{duplicateTargets.size > 0 && (
<div className="rounded-lg border border-red-300 bg-red-50 p-3 space-y-1">
<p className="text-sm font-medium text-red-700 flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> Each inventory field can only be filled by one file column.
</p>
<ul className="text-xs text-red-700 list-disc pl-5 space-y-0.5">
{Array.from(duplicateTargets.entries()).map(([key, hs]) => (
<li key={key}>
<span className="font-medium">{labelByKey.get(key) ?? key}</span> is mapped from:{" "}
{hs.map(h => `${h}`).join(", ")} pick one and set the others to skip .
</li>
))}
</ul>
</div>
)}
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">File Column</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium w-8"></th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Maps To</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Sample</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{importHeaders.map((header, idx) => {
const isConflict = conflictHeaders.has(header);
const info = columnMatchInfo[header];
const lowConfidence = info && info.confidence > 0 && info.confidence < 0.5
&& columnMapping[header] !== "__skip__";
return (
<tr
key={header}
className={`border-t border-border ${isConflict ? "bg-red-50" : ""}`}
>
<td className="px-3 py-2 text-foreground font-mono text-xs">{header}</td>
<td className="px-1 py-2 text-muted-foreground">
<ArrowRight className="w-3 h-3" />
</td>
<td className="px-3 py-2">
<Select
value={columnMapping[header] ?? "__skip__"}
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as keyof ChemicalInventory | "__skip__" }))}
>
<SelectTrigger
className={`h-7 text-xs w-48 ${isConflict ? "border-red-400 ring-1 ring-red-300" : ""}`}
title={info?.reason || undefined}
>
<SelectValue placeholder="— skip —" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__skip__"> skip </SelectItem>
{TABLE_COLUMNS.map(col => (
<SelectItem key={col.key} value={col.key}>{col.label}</SelectItem>
))}
</SelectContent>
</Select>
{lowConfidence && !isConflict && (
<p className="text-[10px] text-amber-600 mt-0.5">
Low confidence please verify.
</p>
)}
</td>
<td className="px-3 py-2 text-muted-foreground text-xs truncate max-w-[120px]">
{importRows[0]?.[idx] ?? ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex gap-2 pt-1">
<Button
className="flex-1 bg-primary hover:bg-primary/90 gap-2"
onClick={handleImportConfirm}
disabled={isImporting || blockImport}
>
{isImporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{isImporting
? "Importing…"
: duplicateTargets.size > 0
? "Resolve duplicate mappings to import"
: `Import ${importRows.length} row${importRows.length !== 1 ? "s" : ""}`}
</Button>
<Button variant="outline" onClick={() => setImportStep("upload")}>Back</Button>
</div>
</div>
<div className="flex gap-2 pt-1">
<Button
className="flex-1 bg-primary hover:bg-primary/90 gap-2"
onClick={handleImportConfirm}
disabled={isImporting}
>
{isImporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{isImporting ? "Importing…" : `Import ${importRows.length} row${importRows.length !== 1 ? "s" : ""}`}
</Button>
<Button variant="outline" onClick={() => setImportStep("upload")}>Back</Button>
</div>
</div>
)}
);
})()}
{importStep === "result" && importResult && (
<div className="space-y-4 mt-2">
@@ -741,33 +914,76 @@ export function Inventory() {
</div>
</Card>
{/* Search + stats */}
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search by name, CAS, lab, or location…"
className="pl-9"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
{/* Search + stats / Selection Action Bar */}
{isSelectionMode ? (
<Card className="mb-5 p-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-primary/20 bg-primary/5">
<div className="flex items-center gap-4 pl-1">
<div className="flex items-center gap-2">
<Checkbox
id="select-all"
checked={filtered.length > 0 && selectedIds.size === filtered.length}
onCheckedChange={(checked) => {
if (checked) {
setSelectedIds(new Set(filtered.map(c => c.id)));
} else {
setSelectedIds(new Set());
}
}}
/>
<Label htmlFor="select-all" className="cursor-pointer">Select All</Label>
</div>
<span className="text-sm font-medium text-primary">
{selectedIds.size} selected
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button size="sm" variant="ghost" onClick={() => { setIsSelectionMode(false); setSelectedIds(new Set()); }}>
Cancel
</Button>
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadCSV} disabled={selectedIds.size === 0}>
<FileDown className="w-4 h-4" /> CSV
</Button>
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadExcel} disabled={selectedIds.size === 0}>
<FileSpreadsheet className="w-4 h-4" /> Excel
</Button>
<Button size="sm" variant="destructive" className="gap-1.5" onClick={handleDeleteSelected} disabled={selectedIds.size === 0 || isDeleting}>
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
{isDeleting ? "Deleting..." : "Delete Selected"}
</Button>
</div>
</Card>
) : (
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search by name, CAS, lab, or location…"
className="pl-9"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex items-center gap-2 shrink-0 text-sm text-muted-foreground">
<FlaskConical className="w-4 h-4 text-[#5a9584]" />
<span className="hidden sm:inline">{inventory.length} chemicals</span>
{lowStockCount > 0 && (
<>
<span className="text-border hidden sm:inline">·</span>
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-amber-600 hidden sm:inline">{lowStockCount} low stock</span>
</>
)}
<span className="text-border">·</span>
<Button variant="ghost" size="sm" className="gap-1.5 h-7 px-2 text-muted-foreground bg-background hover:bg-background/80 hover:text-foreground rounded-md" onClick={() => setIsSelectionMode(true)}>
<CheckSquare className="w-3.5 h-3.5" /> Select
</Button>
<span className="text-border">·</span>
<Button variant="ghost" size="sm" className="gap-1.5 h-7 px-2 text-muted-foreground bg-background hover:bg-background/80 hover:text-foreground rounded-md" onClick={() => setTableViewOpen(true)}>
<Table2 className="w-3.5 h-3.5" /> Table
</Button>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 text-sm text-muted-foreground">
<FlaskConical className="w-4 h-4 text-[#5a9584]" />
<span>{inventory.length} chemicals</span>
{lowStockCount > 0 && (
<>
<span className="text-border">·</span>
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-amber-600">{lowStockCount} low stock</span>
</>
)}
<span className="text-border">·</span>
<Button variant="ghost" size="sm" className="gap-1.5 h-7 px-2 text-muted-foreground bg-background hover:bg-background/80 hover:text-foreground rounded-md" onClick={() => setTableViewOpen(true)}>
<Table2 className="w-3.5 h-3.5" /> View / Export Table
</Button>
</div>
</div>
)}
{/* List */}
{isLoading ? (
@@ -788,15 +1004,39 @@ export function Inventory() {
const expired = days !== null && days < 0;
const expiringSoon = days !== null && days >= 0 && days <= 30;
const lowStock = item.percentageFull != null && item.percentageFull < 20;
const missingInfo = isMissingKeyInfo(item);
const isSelected = selectedIds.has(item.id);
return (
<Card key={item.id} className={`overflow-hidden transition-shadow ${isExpanded ? "shadow-md" : "hover:shadow-sm"}`}>
<Card key={item.id} className={`overflow-hidden transition-shadow ${isExpanded ? "shadow-md" : "hover:shadow-sm"} ${missingInfo && !isExpanded ? "border-amber-300 bg-amber-50/30" : ""} ${isSelected ? "border-primary/50 bg-primary/5" : ""}`}>
{/* Summary row */}
<button
className="w-full text-left px-5 py-4 flex items-center gap-4"
onClick={() => setExpandedId(isExpanded ? null : item.id)}
>
{/* Name + state */}
<div className="flex items-stretch w-full">
{isSelectionMode && (
<div
className="flex items-center justify-center px-4 cursor-pointer hover:bg-muted/50 border-r border-border/50"
onClick={(e) => {
e.stopPropagation();
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
>
<Checkbox
checked={isSelected}
className="w-5 h-5 pointer-events-none"
onCheckedChange={() => {}} // handled by parent div
/>
</div>
)}
<button
className="flex-1 text-left px-5 py-4 flex items-center gap-4"
onClick={() => setExpandedId(isExpanded ? null : item.id)}
>
{/* Name + state */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-foreground">{item.chemicalName}</span>
@@ -804,6 +1044,7 @@ export function Inventory() {
{expired && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Expired</Badge>}
{expiringSoon && !expired && <Badge className="bg-amber-100 text-amber-700 border-amber-200 border text-xs py-0">Exp. {days}d</Badge>}
{lowStock && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Low stock</Badge>}
{missingInfo && <Badge className="bg-amber-100 text-amber-800 border-amber-300 border text-xs py-0 gap-1"><AlertTriangle className="w-3 h-3" /> Missing info</Badge>}
</div>
<p className="text-sm text-muted-foreground mt-0.5">{item.lab} · {item.storageLocation || "—"}</p>
</div>
@@ -827,7 +1068,8 @@ export function Inventory() {
{isExpanded
? <ChevronUp className="w-4 h-4 text-muted-foreground shrink-0" />
: <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />}
</button>
</button>
</div>
{/* Expanded detail */}
{isExpanded && (