Files
LabWise/components/Inventory.tsx
pulipakaa24 24d126eeb9
Some checks failed
Deploy to Server / deploy (push) Failing after 1m7s
table import works now
2026-04-24 18:19:54 -05:00

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