import { useState, useRef, useEffect } from "react"; import ExcelJS from "exceljs"; import { chemicalsApi } from "../lib/api"; 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, } 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); // 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; } 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); } // ── 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

{/* Table view */} {/* 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}
)}
{/* 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" />
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)} />
setField("comments", e.target.value)} />
)} {formError &&

{formError}

}
); }