Files
LabWise/components/Inventory.tsx

1154 lines
56 KiB
TypeScript
Raw Normal View History

import { useState, useRef, useEffect } from "react";
2026-04-01 20:12:19 -05:00
import ExcelJS from "exceljs";
import { chemicalsApi } from "../lib/api";
2026-04-04 23:11:51 -05:00
import { validateCAS, validateNumber, validatePhoneOrEmail } from "../lib/validators";
import type { ChemicalInventory } from "../shared/types";
2026-03-18 17:10:16 -05:00
import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
2026-03-18 17:10:16 -05:00
import { Badge } from "./ui/badge";
import {
Camera, Sparkles, Mail, Search, Plus, AlertTriangle,
ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical,
2026-04-01 20:39:34 -05:00
Table2, FileDown, FileSpreadsheet, Upload, ArrowRight,
2026-03-18 17:10:16 -05:00
} from "lucide-react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
2026-03-18 17:10:16 -05:00
} from "./ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
2026-03-18 17:10:16 -05:00
} from "./ui/select";
// ── constants ──────────────────────────────────────────────────────────────
2026-03-18 17:10:16 -05:00
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",
2026-03-18 17:10:16 -05:00
];
const physicalStates = ["Solid", "Liquid", "Gas"];
const unitOptions = ["mL", "L", "g", "kg", "mg", "oz", "lb", "gal", "mol"];
2026-04-02 00:40:43 +00:00
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 ─────────────────────────────────────────────────────────
2026-03-18 17:10:16 -05:00
export function Inventory() {
const [inventory, setInventory] = useState<ChemicalInventory[]>([]);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
2026-03-18 17:10:16 -05:00
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);
2026-04-02 00:40:43 +00:00
// table view dialog
const [tableViewOpen, setTableViewOpen] = useState(false);
2026-04-01 20:39:34 -05:00
// 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);
2026-03-18 17:10:16 -05:00
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));
}, []);
2026-03-18 17:10:16 -05:00
// ── 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;
2026-03-18 17:10:16 -05:00
}
2026-04-04 23:11:51 -05:00
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);
2026-03-18 17:10:16 -05:00
}
}
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); }
}
2026-04-02 00:40:43 +00:00
// ── 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);
}
2026-04-01 20:12:19 -05:00
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);
2026-04-02 00:40:43 +00:00
}
2026-04-01 20:39:34 -05:00
// ── 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())
2026-03-18 17:10:16 -05:00
);
const lowStockCount = inventory.filter(c => c.percentageFull != null && c.percentageFull < 20).length;
2026-03-18 17:10:16 -05:00
// ── render ───────────────────────────────────────────────────────────────
2026-03-18 17:10:16 -05:00
return (
<div className="p-6 max-w-5xl mx-auto">
2026-03-18 17:10:16 -05:00
{/* 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">
2026-04-01 20:39:34 -05:00
{/* Import Excel */}
<Button variant="outline" className="gap-2" onClick={openImport}>
<Upload className="w-4 h-4" /> Import Table
2026-04-02 00:40:43 +00:00
</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>
2026-03-18 17:10:16 -05:00
<Button variant="outline" className="gap-2">
<Camera className="w-4 h-4" /> Scan Label
2026-03-18 17:10:16 -05:00
</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>
2026-03-18 17:10:16 -05:00
</div>
) : extractedData ? (
2026-03-18 17:10:16 -05:00
<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>
2026-03-18 17:10:16 -05:00
</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 })} />
2026-03-18 17:10:16 -05:00
</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>
2026-03-18 17:10:16 -05:00
2026-04-01 20:39:34 -05:00
{/* 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>
</>
)}
2026-04-01 20:39:34 -05:00
<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>
2026-03-18 17:10:16 -05:00
{/* 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>
2026-03-18 17:10:16 -05:00
{/* Amount */}
<div className="text-sm text-muted-foreground shrink-0 hidden sm:block">
{item.numberOfContainers} × {item.amountPerContainer} {item.unitOfMeasure}
</div>
2026-03-18 17:10:16 -05:00
{/* 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}%` }} />
2026-03-18 17:10:16 -05:00
</div>
<span className="text-sm font-medium">{item.percentageFull}%</span>
2026-03-18 17:10:16 -05:00
</div>
</div>
)}
</div>
2026-03-18 17:10:16 -05:00
{/* 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>
2026-03-18 17:10:16 -05:00
</div>
)}
2026-03-18 17:10:16 -05:00
</div>
</div>
2026-03-18 17:10:16 -05:00
<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>
);
})}
2026-03-18 17:10:16 -05:00
</div>
)}
2026-03-18 17:10:16 -05:00
2026-04-02 00:40:43 +00:00
{/* Table View dialog */}
<Dialog open={tableViewOpen} onOpenChange={setTableViewOpen}>
2026-04-02 00:49:40 +00:00
<DialogContent className="sm:max-w-[95vw] w-[95vw] h-[90vh] flex flex-col p-0">
2026-04-02 00:40:43 +00:00
<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>
2026-04-01 20:12:19 -05:00
<div className="table-scroll flex-1">
2026-04-02 00:40:43 +00:00
<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>
2026-04-04 23:11:51 -05:00
<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>
2026-04-04 23:11:51 -05:00
<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>
2026-03-18 17:10:16 -05:00
</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 && (
2026-03-18 17:10:16 -05:00
<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>
2026-04-04 23:11:51 -05:00
<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>
2026-04-04 23:11:51 -05:00
<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>
2026-03-18 17:10:16 -05:00
</div>
)}
2026-03-18 17:10:16 -05:00
{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>
2026-03-18 17:10:16 -05:00
</div>
</DialogContent>
</Dialog>
2026-03-18 17:10:16 -05:00
</div>
);
}