Files
LabWise/components/Inventory.tsx

1154 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect } from "react";
import ExcelJS from "exceljs";
import { chemicalsApi } from "../lib/api";
import { validateCAS, validateNumber, validatePhoneOrEmail } from "../lib/validators";
import type { ChemicalInventory } from "../shared/types";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Badge } from "./ui/badge";
import {
Camera, Sparkles, Mail, Search, Plus, AlertTriangle,
ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical,
Table2, FileDown, FileSpreadsheet, Upload, ArrowRight,
} from "lucide-react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "./ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select";
// ── constants ──────────────────────────────────────────────────────────────
const storageDeviceOptions = [
"Aerosol Can","Ampule","Bulked Item","Fiber Box","Gas Cylinder",
"Glass Bottle","Metal Can","Metal Drum","Metal Open Drum","Pallet",
"Plastic Bag","Plastic Bottle","Plastic Drum","Plastic Open Drum",
];
const physicalStates = ["Solid", "Liquid", "Gas"];
const unitOptions = ["mL", "L", "g", "kg", "mg", "oz", "lb", "gal", "mol"];
const TABLE_COLUMNS: { label: string; key: keyof ChemicalInventory }[] = [
{ label: "PI First Name", key: "piFirstName" },
{ label: "Physical State", key: "physicalState" },
{ label: "Chemical Name", key: "chemicalName" },
{ label: "Bldg Code", key: "bldgCode" },
{ label: "LAB", key: "lab" },
{ label: "Storage Location", key: "storageLocation" },
{ label: "Storage Device", key: "storageDevice" },
{ label: "# of Containers", key: "numberOfContainers" },
{ label: "Amount per Container", key: "amountPerContainer" },
{ label: "Unit of Measure", key: "unitOfMeasure" },
{ label: "CAS #", key: "casNumber" },
{ label: "Chemical Formula", key: "chemicalFormula" },
{ label: "Molecular Weight", key: "molecularWeight" },
{ label: "Vendor", key: "vendor" },
{ label: "Catalog #", key: "catalogNumber" },
{ label: "Found in Catalog", key: "foundInCatalog" },
{ label: "PO#", key: "poNumber" },
{ label: "Receipt Date", key: "receiptDate" },
{ label: "Open Date", key: "openDate" },
{ label: "MAX on Hand", key: "maxOnHand" },
{ label: "Expiration Date", key: "expirationDate" },
{ label: "Contact", key: "contact" },
{ label: "Comments", key: "comments" },
{ label: "Date Entered", key: "dateEntered" },
{ label: "Permit #", key: "permitNumber" },
{ label: "Barcode", key: "barcode" },
{ label: "LAST_CHANGED", key: "lastChanged" },
{ label: "Concentration", key: "concentration" },
{ label: "Chemical Number", key: "chemicalNumber" },
{ label: "Lot Number", key: "lotNumber" },
{ label: "Multiple CAS (comma delimited)", key: "multipleCAS" },
];
// ── helpers ────────────────────────────────────────────────────────────────
function daysUntil(dateStr: string) {
const today = new Date(); today.setHours(0, 0, 0, 0);
return Math.ceil((new Date(dateStr).getTime() - today.getTime()) / 86400000);
}
function fillBarColor(pct: number) {
if (pct < 20) return "bg-red-500";
if (pct < 40) return "bg-amber-500";
return "bg-[#5a9584]";
}
type UserProfile = { pi_first_name: string; bldg_code: string; lab: string; contact?: string };
function emptyForm(profile?: UserProfile | null): Partial<ChemicalInventory> {
return {
piFirstName: profile?.pi_first_name ?? "",
bldgCode: profile?.bldg_code ?? "",
lab: profile?.lab ?? "",
contact: profile?.contact ?? "",
physicalState: "",
chemicalName: "",
storageLocation: "",
storageDevice: "Glass Bottle",
numberOfContainers: "1",
amountPerContainer: "",
unitOfMeasure: "",
casNumber: "",
chemicalFormula: "",
molecularWeight: "",
vendor: "",
catalogNumber: "",
lotNumber: "",
expirationDate: "",
concentration: "",
percentageFull: undefined,
comments: "",
};
}
// ── field helper ───────────────────────────────────────────────────────────
function Field({ label, value, required }: { label: string; value?: string | number; required?: boolean }) {
if (!value && value !== 0) return null;
return (
<div>
<p className={`text-xs mb-0.5 ${required ? "text-red-500" : "text-muted-foreground"}`}>{label}</p>
<p className="text-sm text-foreground">{value}</p>
</div>
);
}
// ── main component ─────────────────────────────────────────────────────────
export function Inventory() {
const [inventory, setInventory] = useState<ChemicalInventory[]>([]);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
// add/edit dialog
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ChemicalInventory | null>(null);
const [form, setForm] = useState<Partial<ChemicalInventory>>(emptyForm());
const [isSaving, setIsSaving] = useState(false);
const [formError, setFormError] = useState("");
const [showOptional, setShowOptional] = useState(false);
// table view dialog
const [tableViewOpen, setTableViewOpen] = useState(false);
// import dialog
const [importOpen, setImportOpen] = useState(false);
const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload");
const [importHeaders, setImportHeaders] = useState<string[]>([]);
const [importRows, setImportRows] = useState<string[][]>([]);
const [columnMapping, setColumnMapping] = useState<Record<string, keyof ChemicalInventory | "__skip__">>({});
const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState("");
const importFileRef = useRef<HTMLInputElement>(null);
// scan dialog
const [scanOpen, setScanOpen] = useState(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [extractedData, setExtractedData] = useState<Partial<ChemicalInventory> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
Promise.all([
chemicalsApi.list(),
fetch("/api/profile", { credentials: "include" }).then(r => r.ok ? r.json() : null),
]).then(([chems, prof]) => {
setInventory(chems);
setProfile(prof);
}).finally(() => setIsLoading(false));
}, []);
// ── form helpers ────────────────────────────────────────────────────────
function openAdd() {
setEditingItem(null);
setForm(emptyForm(profile));
setFormError("");
setShowOptional(false);
setDialogOpen(true);
}
function openEdit(item: ChemicalInventory) {
setEditingItem(item);
setForm({ ...item });
setFormError("");
setShowOptional(true);
setDialogOpen(true);
}
function setField(key: keyof ChemicalInventory, value: string | number | undefined) {
setForm(f => ({ ...f, [key]: value }));
}
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 || ""))) {
setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1).");
return;
}
if (!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 })) {
setFormError("Amount per container must be a number.");
return;
}
if (form.molecularWeight && !validateNumber(form.molecularWeight, { min: 0 })) {
setFormError("Molecular weight must be a number.");
return;
}
if (form.percentageFull != null && !validateNumber(form.percentageFull, { min: 0, max: 100 })) {
setFormError("% full must be between 0 and 100.");
return;
}
if (form.contact && !validatePhoneOrEmail(String(form.contact))) {
setFormError("Contact must be a valid phone number or email address.");
return;
}
setFormError("");
setIsSaving(true);
try {
if (editingItem) {
const updated = await chemicalsApi.update(editingItem.id, form);
setInventory(inv => inv.map(c => c.id === editingItem.id ? updated : c));
} else {
const saved = await chemicalsApi.create(form as ChemicalInventory);
setInventory(inv => [saved, ...inv]);
}
setDialogOpen(false);
} catch {
setFormError("Failed to save. Please try again.");
} finally {
setIsSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Remove this chemical from inventory?")) return;
await chemicalsApi.remove(id);
setInventory(inv => inv.filter(c => c.id !== id));
if (expandedId === id) setExpandedId(null);
}
// ── scan helpers ────────────────────────────────────────────────────────
function handlePhotoCapture(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
setCapturedImage(reader.result as string);
setIsProcessing(true);
setTimeout(() => {
setExtractedData({
chemicalName: "Methanol", casNumber: "67-56-1", physicalState: "Liquid",
vendor: "Sigma-Aldrich", lotNumber: "SLCD7890", catalogNumber: "34860",
amountPerContainer: "1", unitOfMeasure: "L", numberOfContainers: "1",
storageDevice: "Glass Bottle", chemicalFormula: "CH3OH", molecularWeight: "32.04",
expirationDate: "2026-03-15", concentration: "99.8%", percentageFull: 95,
needsManualEntry: ["piFirstName", "bldgCode", "lab", "storageLocation"],
scannedImage: reader.result as string,
piFirstName: profile?.pi_first_name ?? "",
bldgCode: profile?.bldg_code ?? "",
lab: profile?.lab ?? "",
});
setIsProcessing(false);
}, 2000);
};
reader.readAsDataURL(file);
}
async function handleAddFromScan() {
if (!extractedData) return;
try {
const saved = await chemicalsApi.create({
...extractedData,
piFirstName: extractedData.piFirstName || "",
physicalState: extractedData.physicalState || "",
chemicalName: extractedData.chemicalName || "",
bldgCode: extractedData.bldgCode || "",
lab: extractedData.lab || "",
storageLocation: extractedData.storageLocation || "",
storageDevice: extractedData.storageDevice || "Glass Bottle",
numberOfContainers: extractedData.numberOfContainers || "1",
amountPerContainer: extractedData.amountPerContainer || "",
unitOfMeasure: extractedData.unitOfMeasure || "",
casNumber: extractedData.casNumber || "",
});
setInventory(inv => [saved, ...inv]);
setCapturedImage(null); setExtractedData(null); setScanOpen(false);
} catch (err) { console.error(err); }
}
// ── export helpers ──────────────────────────────────────────────────────
function handleDownloadCSV() {
const headers = TABLE_COLUMNS.map(c => c.label);
const rows = filtered.map(item =>
TABLE_COLUMNS.map(c => String(item[c.key] ?? ""))
);
const csv = [headers, ...rows]
.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chemical_inventory.csv";
a.click();
URL.revokeObjectURL(url);
}
async function handleDownloadExcel() {
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 buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chemical_inventory.xlsx";
a.click();
URL.revokeObjectURL(url);
}
// ── import helpers ──────────────────────────────────────────────────────
function normalizeHeader(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function fuzzyMatchColumn(header: string): keyof ChemicalInventory | "__skip__" {
const norm = normalizeHeader(header);
if (!norm) return "__skip__";
for (const col of TABLE_COLUMNS) {
if (normalizeHeader(col.label) === norm) return col.key;
}
for (const col of TABLE_COLUMNS) {
const colNorm = normalizeHeader(col.label);
if (colNorm.startsWith(norm) || norm.startsWith(colNorm)) return col.key;
}
let bestKey: keyof ChemicalInventory | "__skip__" = "__skip__";
let bestScore = 0.55;
for (const col of TABLE_COLUMNS) {
const colNorm = normalizeHeader(col.label);
let matches = 0, ci = 0;
for (const ch of norm) {
while (ci < colNorm.length && colNorm[ci] !== ch) ci++;
if (ci < colNorm.length) { matches++; ci++; }
}
const score = matches / Math.max(norm.length, colNorm.length);
if (score > bestScore) { bestScore = score; bestKey = col.key; }
}
return bestKey;
}
function openImport() {
setImportStep("upload");
setImportHeaders([]);
setImportRows([]);
setColumnMapping({});
setImportResult(null);
setImportError("");
setImportOpen(true);
}
async function handleImportFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
console.log("[import] file selected:", file?.name, file?.type, file?.size);
if (!file) return;
e.target.value = "";
setImportError("");
try {
let allRows: string[][];
if (file.name.toLowerCase().endsWith(".csv")) {
console.log("[import] parsing as CSV");
const text = await file.text();
console.log("[import] CSV text length:", text.length);
allRows = text.split(/\r?\n/).map(line => {
const row: string[] = [];
let cur = "", inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; }
else inQuote = !inQuote;
} else if (ch === "," && !inQuote) {
row.push(cur); cur = "";
} else {
cur += ch;
}
}
row.push(cur);
return row;
});
} else {
console.log("[import] parsing as XLSX");
const buffer = await file.arrayBuffer();
console.log("[import] arrayBuffer size:", buffer.byteLength);
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buffer);
console.log("[import] workbook loaded, worksheets:", wb.worksheets.length);
const ws = wb.worksheets[0];
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 ?? "")));
});
console.log("[import] parsed rows:", allRows.length);
}
if (allRows.length < 2) {
setImportError("The file appears to be empty or has no data rows.");
return;
}
const headers = allRows[0];
const dataRows = allRows.slice(1).filter(r => r.some(c => c.trim() !== ""));
console.log("[import] headers:", headers, "data rows:", dataRows.length);
setImportHeaders(headers);
setImportRows(dataRows);
const mapping: Record<string, keyof ChemicalInventory | ""> = {};
for (const h of headers) mapping[h] = fuzzyMatchColumn(h);
setColumnMapping(mapping);
console.log("[import] advancing to map step");
setImportStep("map");
} catch (err) {
console.error("[import] error:", err);
setImportError(`Could not read file: ${(err as Error).message}`);
}
}
async function handleImportConfirm() {
setIsImporting(true);
try {
const rows = importRows.map(row => {
const chemical: Partial<ChemicalInventory> = {};
importHeaders.forEach((header, i) => {
const key = columnMapping[header];
if (key && key !== "__skip__") (chemical as Record<string, string>)[key] = row[i] ?? "";
});
return chemical;
});
const result = await chemicalsApi.import(rows);
setImportResult(result);
setImportStep("result");
if (result.imported > 0) {
const updated = await chemicalsApi.list();
setInventory(updated);
}
} catch (err) {
setImportResult({ imported: 0, errors: [(err as Error).message] });
setImportStep("result");
} finally {
setIsImporting(false);
}
}
// ── derived ─────────────────────────────────────────────────────────────
const filtered = inventory.filter(c =>
c.chemicalName.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.casNumber.includes(searchQuery) ||
c.lab.toLowerCase().includes(searchQuery.toLowerCase()) ||
(c.storageLocation || "").toLowerCase().includes(searchQuery.toLowerCase())
);
const lowStockCount = inventory.filter(c => c.percentageFull != null && c.percentageFull < 20).length;
// ── render ───────────────────────────────────────────────────────────────
return (
<div className="p-6 max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-foreground mb-1">Chemical Inventory</h1>
<p className="text-muted-foreground text-sm">Track, manage, and monitor your lab chemicals</p>
</div>
<div className="flex gap-2">
{/* Import Excel */}
<Button variant="outline" className="gap-2" onClick={openImport}>
<Upload className="w-4 h-4" /> Import Table
</Button>
{/* Add manually */}
<Button onClick={openAdd} className="gap-2 bg-primary hover:bg-primary/90">
<Plus className="w-4 h-4" /> Add Chemical
</Button>
{/* Scan */}
<Dialog open={scanOpen} onOpenChange={setScanOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<Camera className="w-4 h-4" /> Scan Label
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Camera className="w-5 h-5" /> Scan Chemical Label
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
{!capturedImage ? (
<div
className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary transition-colors cursor-pointer bg-muted/30"
onClick={() => fileInputRef.current?.click()}
>
<Camera className="w-14 h-14 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground mb-1">Click to capture or upload a label photo</p>
<p className="text-muted-foreground text-sm">AI will extract all available information</p>
<input ref={fileInputRef} type="file" accept="image/*" capture="environment"
className="hidden" onChange={handlePhotoCapture} />
</div>
) : (
<div className="space-y-4">
<img src={capturedImage} alt="Scanned label" className="w-full rounded-lg border border-border" />
{isProcessing ? (
<div className="bg-accent border border-border rounded-lg p-6 text-center">
<Sparkles className="w-8 h-8 text-primary mx-auto mb-2 animate-pulse" />
<p className="text-foreground">Scanning label</p>
<p className="text-muted-foreground text-sm">Reading all visible information with AI</p>
</div>
) : extractedData ? (
<div className="space-y-4">
<div className="bg-accent border border-border rounded-lg p-3 flex items-start gap-2">
<Sparkles className="w-4 h-4 text-primary mt-0.5" />
<p className="text-sm text-foreground">Information extracted. Yellow fields need manual entry.</p>
</div>
<div className="grid grid-cols-2 gap-3">
{(["piFirstName","bldgCode","lab","storageLocation"] as const).map(k => (
<div key={k} className={`space-y-1 ${extractedData.needsManualEntry?.includes(k) ? "bg-yellow-50 p-2 rounded" : ""}`}>
<Label className="text-red-600 text-xs">{k === "piFirstName" ? "PI First Name" : k === "bldgCode" ? "Building Code" : k === "lab" ? "Lab" : "Storage Location"} *</Label>
<Input value={(extractedData as Record<string,string>)[k] || ""} onChange={e => setExtractedData({ ...extractedData, [k]: e.target.value })} />
</div>
))}
<div className="space-y-1">
<Label className="text-red-600 text-xs">Chemical Name *</Label>
<Input value={extractedData.chemicalName || ""} onChange={e => setExtractedData({...extractedData, chemicalName: e.target.value})} />
</div>
<div className="space-y-1">
<Label className="text-red-600 text-xs">CAS # *</Label>
<Input value={extractedData.casNumber || ""} onChange={e => setExtractedData({...extractedData, casNumber: e.target.value})} />
</div>
<div className="space-y-1">
<Label className="text-red-600 text-xs">Physical State *</Label>
<Select value={extractedData.physicalState} onValueChange={v => setExtractedData({...extractedData, physicalState: v})}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{physicalStates.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-red-600 text-xs">Storage Device *</Label>
<Select value={extractedData.storageDevice} onValueChange={v => setExtractedData({...extractedData, storageDevice: v})}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{storageDeviceOptions.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Amount / Container</Label>
<Input value={extractedData.amountPerContainer || ""} onChange={e => setExtractedData({...extractedData, amountPerContainer: e.target.value})} />
</div>
<div className="space-y-1">
<Label className="text-xs">Unit</Label>
<Input value={extractedData.unitOfMeasure || ""} onChange={e => setExtractedData({...extractedData, unitOfMeasure: e.target.value})} />
</div>
<div className="space-y-1">
<Label className="text-xs">Expiration Date</Label>
<Input type="date" value={extractedData.expirationDate || ""} onChange={e => setExtractedData({...extractedData, expirationDate: e.target.value})} />
</div>
<div className="space-y-1">
<Label className="text-xs text-blue-600">% Full</Label>
<Input type="number" min={0} max={100} value={extractedData.percentageFull ?? ""} onChange={e => setExtractedData({...extractedData, percentageFull: parseFloat(e.target.value)})} />
</div>
</div>
<div className="flex gap-2">
<Button className="flex-1 bg-primary hover:bg-primary/90" onClick={handleAddFromScan}>Add to Inventory</Button>
<Button variant="outline" onClick={() => { setCapturedImage(null); setExtractedData(null); }}>Retake</Button>
</div>
</div>
) : null}
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
</div>
{/* Import Excel dialog */}
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" /> Import File
</DialogTitle>
</DialogHeader>
{importStep === "upload" && (
<div className="space-y-3 mt-2">
<div
className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary transition-colors cursor-pointer bg-muted/30"
onClick={() => importFileRef.current?.click()}
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add("border-primary"); }}
onDragLeave={e => e.currentTarget.classList.remove("border-primary")}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove("border-primary");
const file = e.dataTransfer.files[0];
if (file) handleImportFileSelect({ target: { files: e.dataTransfer.files, value: "" } } as unknown as React.ChangeEvent<HTMLInputElement>);
}}
>
<FileSpreadsheet className="w-14 h-14 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground mb-1">Click or drag a file here</p>
<p className="text-muted-foreground text-sm">Supports .xlsx and .csv columns will be automatically matched to your inventory fields</p>
<input ref={importFileRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={handleImportFileSelect} />
</div>
{importError && (
<p className="text-sm text-red-600 flex items-center gap-1">
<AlertTriangle className="w-4 h-4 shrink-0" /> {importError}
</p>
)}
</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 keyof ChemicalInventory | "__skip__" }))}
>
<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>
</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}
>
{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">
<div className={`rounded-lg p-4 border ${importResult.imported > 0 ? "bg-accent border-border" : "bg-red-50 border-red-200"}`}>
<p className="font-medium text-foreground">
{importResult.imported > 0
? `Successfully imported ${importResult.imported} chemical${importResult.imported !== 1 ? "s" : ""}.`
: "No rows were imported."}
</p>
</div>
{importResult.errors.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium text-red-600 flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> {importResult.errors.length} row{importResult.errors.length !== 1 ? "s" : ""} failed
</p>
<div className="border border-red-200 rounded-lg bg-red-50 p-3 max-h-40 overflow-y-auto space-y-1">
{importResult.errors.map((e, i) => (
<p key={i} className="text-xs text-red-700 font-mono">{e}</p>
))}
</div>
</div>
)}
<div className="flex gap-2">
<Button className="flex-1 bg-primary hover:bg-primary/90" onClick={() => setImportOpen(false)}>Done</Button>
<Button variant="outline" onClick={openImport}>Import Another File</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Email receipt banner */}
<Card className="p-4 mb-5 bg-gradient-to-r from-accent to-secondary border-border">
<div className="flex items-start gap-3">
<Mail className="w-4 h-4 text-primary mt-0.5 shrink-0" />
<p className="text-sm text-foreground">
Auto-import receipts: email them to{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-primary text-xs">inventory@labwise-auto.com</code>
{" "}and we'll extract and add chemicals automatically.
</p>
</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)}
/>
</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 ? (
<div className="flex justify-center py-16">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
{inventory.length === 0
? "No chemicals yet. Add one to get started."
: "No chemicals match your search."}
</div>
) : (
<div className="space-y-2">
{filtered.map(item => {
const isExpanded = expandedId === item.id;
const days = item.expirationDate ? daysUntil(item.expirationDate) : null;
const expired = days !== null && days < 0;
const expiringSoon = days !== null && days >= 0 && days <= 30;
const lowStock = item.percentageFull != null && item.percentageFull < 20;
return (
<Card key={item.id} className={`overflow-hidden transition-shadow ${isExpanded ? "shadow-md" : "hover:shadow-sm"}`}>
{/* 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-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-foreground">{item.chemicalName}</span>
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{item.physicalState}</span>
{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>}
</div>
<p className="text-sm text-muted-foreground mt-0.5">{item.lab} · {item.storageLocation || "—"}</p>
</div>
{/* Amount */}
<div className="text-sm text-muted-foreground shrink-0 hidden sm:block">
{item.numberOfContainers} × {item.amountPerContainer} {item.unitOfMeasure}
</div>
{/* Fill bar */}
{item.percentageFull != null && (
<div className="flex items-center gap-2 shrink-0 hidden md:flex">
<div className="w-20 h-1.5 bg-muted rounded-full overflow-hidden">
<div className={`h-full rounded-full ${fillBarColor(item.percentageFull)}`} style={{ width: `${item.percentageFull}%` }} />
</div>
<span className="text-xs text-muted-foreground w-8">{item.percentageFull}%</span>
</div>
)}
{/* Chevron */}
{isExpanded
? <ChevronUp className="w-4 h-4 text-muted-foreground shrink-0" />
: <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />}
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="border-t border-border px-5 py-4 bg-secondary/40">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-5">
{/* Storage */}
<div className="space-y-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Storage</p>
<Field label="Building" value={item.bldgCode} required />
<Field label="Lab" value={item.lab} required />
<Field label="Location" value={item.storageLocation} required />
<Field label="Device" value={item.storageDevice} required />
<Field label="Containers" value={item.numberOfContainers} required />
<Field label="Amount / Container" value={`${item.amountPerContainer} ${item.unitOfMeasure}`} required />
{item.percentageFull != null && (
<div>
<p className="text-xs text-muted-foreground mb-1">Fill level</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div className={`h-full rounded-full ${fillBarColor(item.percentageFull)}`} style={{ width: `${item.percentageFull}%` }} />
</div>
<span className="text-sm font-medium">{item.percentageFull}%</span>
</div>
</div>
)}
</div>
{/* Chemical info */}
<div className="space-y-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Chemical</p>
<Field label="CAS #" value={item.casNumber} required />
<Field label="Physical State" value={item.physicalState} required />
<Field label="Formula" value={item.chemicalFormula} />
<Field label="Molecular Weight" value={item.molecularWeight ? `${item.molecularWeight} g/mol` : undefined} />
<Field label="Concentration" value={item.concentration} />
<Field label="PI" value={item.piFirstName} />
<Field label="Contact" value={item.contact} />
</div>
{/* Procurement */}
<div className="space-y-3">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Procurement</p>
<Field label="Vendor" value={item.vendor} />
<Field label="Catalog #" value={item.catalogNumber} />
<Field label="Lot #" value={item.lotNumber} />
{item.expirationDate && (
<div>
<p className="text-xs text-muted-foreground mb-0.5">Expiration</p>
<p className={`text-sm font-medium ${expired ? "text-red-600" : expiringSoon ? "text-amber-600" : "text-foreground"}`}>
{item.expirationDate}
{expired && ` (expired ${Math.abs(days!)}d ago)`}
{expiringSoon && ` (${days}d left)`}
</p>
</div>
)}
<Field label="Barcode" value={item.barcode} />
{item.comments && (
<div>
<p className="text-xs text-muted-foreground mb-0.5">Comments</p>
<p className="text-sm text-foreground whitespace-pre-wrap">{item.comments}</p>
</div>
)}
</div>
</div>
<div className="flex gap-2 pt-2 border-t border-border">
<Button size="sm" variant="outline" onClick={() => openEdit(item)}>Edit</Button>
<Button size="sm" variant="ghost" className="text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => handleDelete(item.id)}>
<Trash2 className="w-4 h-4 mr-1" /> Delete
</Button>
</div>
</div>
)}
</Card>
);
})}
</div>
)}
{/* Table View dialog */}
<Dialog open={tableViewOpen} onOpenChange={setTableViewOpen}>
<DialogContent className="sm:max-w-[95vw] w-[95vw] h-[90vh] flex flex-col p-0">
<DialogHeader className="px-5 pt-5 pb-3 shrink-0 border-b border-border">
<div className="flex items-center justify-between">
<DialogTitle className="flex items-center gap-2">
<Table2 className="w-5 h-5" /> Inventory Table
<span className="text-sm font-normal text-muted-foreground ml-1">
({filtered.length} {filtered.length === 1 ? "chemical" : "chemicals"})
</span>
</DialogTitle>
<div className="flex gap-2 mr-6">
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadCSV}>
<FileDown className="w-4 h-4" /> CSV
</Button>
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadExcel}>
<FileSpreadsheet className="w-4 h-4" /> Excel
</Button>
</div>
</div>
</DialogHeader>
<div className="table-scroll flex-1">
<table className="text-xs border-collapse min-w-max">
<thead className="sticky top-0 z-10 bg-muted">
<tr>
{TABLE_COLUMNS.map(col => (
<th
key={col.key}
className="text-left px-3 py-2 font-semibold text-muted-foreground whitespace-nowrap border-b border-r border-border last:border-r-0"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((item, i) => (
<tr key={item.id} className={i % 2 === 0 ? "bg-background" : "bg-muted/30"}>
{TABLE_COLUMNS.map(col => (
<td
key={col.key}
className="px-3 py-1.5 whitespace-nowrap border-b border-r border-border/50 last:border-r-0 max-w-[200px] overflow-hidden text-ellipsis"
title={String(item[col.key] ?? "")}
>
{String(item[col.key] ?? "")}
</td>
))}
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={TABLE_COLUMNS.length} className="text-center py-10 text-muted-foreground">
No chemicals to display.
</td>
</tr>
)}
</tbody>
</table>
</div>
</DialogContent>
</Dialog>
{/* Add / Edit dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingItem ? "Edit Chemical" : "Add Chemical"}</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Required fields */}
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">Required fields</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Chemical Name <span className="text-red-500">*</span></Label>
<Input value={form.chemicalName || ""} onChange={e => setField("chemicalName", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">CAS # <span className="text-red-500">*</span></Label>
<Input
value={form.casNumber || ""}
onChange={e => setField("casNumber", e.target.value)}
placeholder="e.g. 67-56-1"
inputMode="numeric"
pattern="\d{2,7}-\d{2}-\d"
title="CAS format: digits-digits-digit (e.g. 67-56-1)"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Physical State <span className="text-red-500">*</span></Label>
<Select value={form.physicalState || ""} onValueChange={v => setField("physicalState", v)}>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>{physicalStates.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Storage Device <span className="text-red-500">*</span></Label>
<Select value={form.storageDevice || ""} onValueChange={v => setField("storageDevice", v)}>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>{storageDeviceOptions.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">PI First Name <span className="text-red-500">*</span></Label>
<Input value={form.piFirstName || ""} onChange={e => setField("piFirstName", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Building Code <span className="text-red-500">*</span></Label>
<Input value={form.bldgCode || ""} onChange={e => setField("bldgCode", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Lab <span className="text-red-500">*</span></Label>
<Input value={form.lab || ""} onChange={e => setField("lab", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Storage Location <span className="text-red-500">*</span></Label>
<Input value={form.storageLocation || ""} onChange={e => setField("storageLocation", e.target.value)} placeholder="e.g. Cabinet A-3" />
</div>
<div className="space-y-1">
<Label className="text-xs"># of Containers <span className="text-red-500">*</span></Label>
<Input type="number" min={1} value={form.numberOfContainers || ""} onChange={e => setField("numberOfContainers", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Amount / Container <span className="text-red-500">*</span></Label>
<Input
type="number"
min={0}
step="any"
value={form.amountPerContainer || ""}
onChange={e => setField("amountPerContainer", e.target.value)}
placeholder="e.g. 500"
/>
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">Unit of Measure <span className="text-red-500">*</span></Label>
<Select value={form.unitOfMeasure || ""} onValueChange={v => setField("unitOfMeasure", v)}>
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
<SelectContent>
{unitOptions.map(u => <SelectItem key={u} value={u}>{u}</SelectItem>)}
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Optional fields toggle */}
<button
type="button"
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
onClick={() => setShowOptional(v => !v)}
>
{showOptional ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{showOptional ? "Hide" : "Show"} optional fields
</button>
{showOptional && (
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-3">Optional fields</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Chemical Formula</Label>
<Input value={form.chemicalFormula || ""} onChange={e => setField("chemicalFormula", e.target.value)} placeholder="e.g. CH₃OH" />
</div>
<div className="space-y-1">
<Label className="text-xs">Molecular Weight</Label>
<Input
type="number"
min={0}
step="any"
value={form.molecularWeight || ""}
onChange={e => setField("molecularWeight", e.target.value)}
placeholder="g/mol"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Concentration</Label>
<Input value={form.concentration || ""} onChange={e => setField("concentration", e.target.value)} placeholder="e.g. 99.8%" />
</div>
<div className="space-y-1">
<Label className="text-xs">% Full</Label>
<Input type="number" min={0} max={100} value={form.percentageFull ?? ""} onChange={e => setField("percentageFull", e.target.value ? parseFloat(e.target.value) : undefined)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Vendor</Label>
<Input value={form.vendor || ""} onChange={e => setField("vendor", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Catalog #</Label>
<Input value={form.catalogNumber || ""} onChange={e => setField("catalogNumber", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Lot #</Label>
<Input value={form.lotNumber || ""} onChange={e => setField("lotNumber", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Expiration Date</Label>
<Input type="date" value={form.expirationDate || ""} onChange={e => setField("expirationDate", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Barcode</Label>
<Input value={form.barcode || ""} onChange={e => setField("barcode", e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs">Contact</Label>
<Input
value={form.contact || ""}
onChange={e => setField("contact", e.target.value)}
placeholder="Phone (e.g. 555-123-4567) or email"
/>
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">Comments</Label>
<Input value={form.comments || ""} onChange={e => setField("comments", e.target.value)} />
</div>
</div>
</div>
)}
{formError && <p className="text-sm text-red-600">{formError}</p>}
<div className="flex gap-2 pt-1">
<Button className="flex-1 bg-primary hover:bg-primary/90" onClick={handleSave} disabled={isSaving}>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : editingItem ? "Save Changes" : "Add to Inventory"}
</Button>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}