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 { 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 (

{label}

{value}

); } // ── main component ───────────────────────────────────────────────────────── export function Inventory() { const [inventory, setInventory] = useState([]); const [profile, setProfile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); const [expandedId, setExpandedId] = useState(null); // add/edit dialog const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); const [form, setForm] = useState>(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([]); const [importRows, setImportRows] = useState([]); const [columnMapping, setColumnMapping] = useState>({}); const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null); const [isImporting, setIsImporting] = useState(false); const [importError, setImportError] = useState(""); const importFileRef = useRef(null); // scan dialog const [scanOpen, setScanOpen] = useState(false); const [capturedImage, setCapturedImage] = useState(null); const [isProcessing, setIsProcessing] = useState(false); const [extractedData, setExtractedData] = useState | null>(null); const fileInputRef = useRef(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) { 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) { 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 = {}; 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 = {}; importHeaders.forEach((header, i) => { const key = columnMapping[header]; if (key && key !== "__skip__") (chemical as Record)[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 (
{/* Header */}

Chemical Inventory

Track, manage, and monitor your lab chemicals

{/* Import Excel */} {/* Add manually */} {/* Scan */} Scan Chemical Label
{!capturedImage ? (
fileInputRef.current?.click()} >

Click to capture or upload a label photo

AI will extract all available information

) : (
Scanned label {isProcessing ? (

Scanning label…

Reading all visible information with AI

) : extractedData ? (

Information extracted. Yellow fields need manual entry.

{(["piFirstName","bldgCode","lab","storageLocation"] as const).map(k => (
)[k] || ""} onChange={e => setExtractedData({ ...extractedData, [k]: e.target.value })} />
))}
setExtractedData({...extractedData, chemicalName: e.target.value})} />
setExtractedData({...extractedData, casNumber: e.target.value})} />
setExtractedData({...extractedData, amountPerContainer: e.target.value})} />
setExtractedData({...extractedData, unitOfMeasure: e.target.value})} />
setExtractedData({...extractedData, expirationDate: e.target.value})} />
setExtractedData({...extractedData, percentageFull: parseFloat(e.target.value)})} />
) : null}
)}
{/* Import Excel dialog */} Import File {importStep === "upload" && (
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); }} >

Click or drag a file here

Supports .xlsx and .csv — columns will be automatically matched to your inventory fields

{importError && (

{importError}

)}
)} {importStep === "map" && (

Review the column mapping below. Adjust any mismatches before importing{" "} {importRows.length} row{importRows.length !== 1 ? "s" : ""}.

{importHeaders.map((header, idx) => ( ))}
File Column Maps To Sample
{header} {importRows[0]?.[idx] ?? ""}
)} {importStep === "result" && importResult && (
0 ? "bg-accent border-border" : "bg-red-50 border-red-200"}`}>

{importResult.imported > 0 ? `Successfully imported ${importResult.imported} chemical${importResult.imported !== 1 ? "s" : ""}.` : "No rows were imported."}

{importResult.errors.length > 0 && (

{importResult.errors.length} row{importResult.errors.length !== 1 ? "s" : ""} failed

{importResult.errors.map((e, i) => (

{e}

))}
)}
)}
{/* Email receipt banner */}

Auto-import receipts: email them to{" "} inventory@labwise-auto.com {" "}and we'll extract and add chemicals automatically.

{/* Search + stats */}
setSearchQuery(e.target.value)} />
{inventory.length} chemicals {lowStockCount > 0 && ( <> · {lowStockCount} low stock )} ·
{/* List */} {isLoading ? (
) : filtered.length === 0 ? (
{inventory.length === 0 ? "No chemicals yet. Add one to get started." : "No chemicals match your search."}
) : (
{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 ( {/* Summary row */} {/* Expanded detail */} {isExpanded && (
{/* Storage */}

Storage

{item.percentageFull != null && (

Fill level

{item.percentageFull}%
)}
{/* Chemical info */}

Chemical

{/* Procurement */}

Procurement

{item.expirationDate && (

Expiration

{item.expirationDate} {expired && ` (expired ${Math.abs(days!)}d ago)`} {expiringSoon && ` (${days}d left)`}

)} {item.comments && (

Comments

{item.comments}

)}
)} ); })}
)} {/* Table View dialog */}
Inventory Table ({filtered.length} {filtered.length === 1 ? "chemical" : "chemicals"})
{TABLE_COLUMNS.map(col => ( ))} {filtered.map((item, i) => ( {TABLE_COLUMNS.map(col => ( ))} ))} {filtered.length === 0 && ( )}
{col.label}
{String(item[col.key] ?? "")}
No chemicals to display.
{/* Add / Edit dialog */} {editingItem ? "Edit Chemical" : "Add Chemical"}
{/* Required fields */}

Required fields

setField("chemicalName", e.target.value)} />
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)" />
setField("piFirstName", e.target.value)} />
setField("bldgCode", e.target.value)} />
setField("lab", e.target.value)} />
setField("storageLocation", e.target.value)} placeholder="e.g. Cabinet A-3" />
setField("numberOfContainers", e.target.value)} />
setField("amountPerContainer", e.target.value)} placeholder="e.g. 500" />
{/* Optional fields toggle */} {showOptional && (

Optional fields

setField("chemicalFormula", e.target.value)} placeholder="e.g. CH₃OH" />
setField("molecularWeight", e.target.value)} placeholder="g/mol" />
setField("concentration", e.target.value)} placeholder="e.g. 99.8%" />
setField("percentageFull", e.target.value ? parseFloat(e.target.value) : undefined)} />
setField("vendor", e.target.value)} />
setField("catalogNumber", e.target.value)} />
setField("lotNumber", e.target.value)} />
setField("expirationDate", e.target.value)} />
setField("barcode", e.target.value)} />
setField("contact", e.target.value)} placeholder="Phone (e.g. 555-123-4567) or email" />
setField("comments", e.target.value)} />
)} {formError &&

{formError}

}
); }