824 lines
41 KiB
TypeScript
824 lines
41 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
||
import * as XLSX from "xlsx";
|
||
import { chemicalsApi } from "../lib/api";
|
||
import type { ChemicalInventory } from "../shared/types";
|
||
import { Card } from "./ui/card";
|
||
import { Button } from "./ui/button";
|
||
import { Input } from "./ui/input";
|
||
import { Label } from "./ui/label";
|
||
import { Badge } from "./ui/badge";
|
||
import {
|
||
Camera, Sparkles, Mail, Search, Plus, AlertTriangle,
|
||
ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical,
|
||
Table2, FileDown, FileSpreadsheet,
|
||
} from "lucide-react";
|
||
import {
|
||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
||
} from "./ui/dialog";
|
||
import {
|
||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||
} from "./ui/select";
|
||
|
||
// ── constants ──────────────────────────────────────────────────────────────
|
||
|
||
const storageDeviceOptions = [
|
||
"Aerosol Can","Ampule","Bulked Item","Fiber Box","Gas Cylinder",
|
||
"Glass Bottle","Metal Can","Metal Drum","Metal Open Drum","Pallet",
|
||
"Plastic Bag","Plastic Bottle","Plastic Drum","Plastic Open Drum",
|
||
];
|
||
|
||
const physicalStates = ["Solid", "Liquid", "Gas"];
|
||
|
||
const unitOptions = ["mL", "L", "g", "kg", "mg", "oz", "lb", "gal", "mol"];
|
||
|
||
const TABLE_COLUMNS: { label: string; key: keyof ChemicalInventory }[] = [
|
||
{ label: "PI First Name", key: "piFirstName" },
|
||
{ label: "Physical State", key: "physicalState" },
|
||
{ label: "Chemical Name", key: "chemicalName" },
|
||
{ label: "Bldg Code", key: "bldgCode" },
|
||
{ label: "LAB", key: "lab" },
|
||
{ label: "Storage Location", key: "storageLocation" },
|
||
{ label: "Storage Device", key: "storageDevice" },
|
||
{ label: "# of Containers", key: "numberOfContainers" },
|
||
{ label: "Amount per Container", key: "amountPerContainer" },
|
||
{ label: "Unit of Measure", key: "unitOfMeasure" },
|
||
{ label: "CAS #", key: "casNumber" },
|
||
{ label: "Chemical Formula", key: "chemicalFormula" },
|
||
{ label: "Molecular Weight", key: "molecularWeight" },
|
||
{ label: "Vendor", key: "vendor" },
|
||
{ label: "Catalog #", key: "catalogNumber" },
|
||
{ label: "Found in Catalog", key: "foundInCatalog" },
|
||
{ label: "PO#", key: "poNumber" },
|
||
{ label: "Receipt Date", key: "receiptDate" },
|
||
{ label: "Open Date", key: "openDate" },
|
||
{ label: "MAX on Hand", key: "maxOnHand" },
|
||
{ label: "Expiration Date", key: "expirationDate" },
|
||
{ label: "Contact", key: "contact" },
|
||
{ label: "Comments", key: "comments" },
|
||
{ label: "Date Entered", key: "dateEntered" },
|
||
{ label: "Permit #", key: "permitNumber" },
|
||
{ label: "Barcode", key: "barcode" },
|
||
{ label: "LAST_CHANGED", key: "lastChanged" },
|
||
{ label: "Concentration", key: "concentration" },
|
||
{ label: "Chemical Number", key: "chemicalNumber" },
|
||
{ label: "Lot Number", key: "lotNumber" },
|
||
{ label: "Multiple CAS (comma delimited)", key: "multipleCAS" },
|
||
];
|
||
|
||
// ── helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function daysUntil(dateStr: string) {
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
return Math.ceil((new Date(dateStr).getTime() - today.getTime()) / 86400000);
|
||
}
|
||
|
||
function fillBarColor(pct: number) {
|
||
if (pct < 20) return "bg-red-500";
|
||
if (pct < 40) return "bg-amber-500";
|
||
return "bg-[#5a9584]";
|
||
}
|
||
|
||
type UserProfile = { pi_first_name: string; bldg_code: string; lab: string; contact?: string };
|
||
|
||
function emptyForm(profile?: UserProfile | null): Partial<ChemicalInventory> {
|
||
return {
|
||
piFirstName: profile?.pi_first_name ?? "",
|
||
bldgCode: profile?.bldg_code ?? "",
|
||
lab: profile?.lab ?? "",
|
||
contact: profile?.contact ?? "",
|
||
physicalState: "",
|
||
chemicalName: "",
|
||
storageLocation: "",
|
||
storageDevice: "Glass Bottle",
|
||
numberOfContainers: "1",
|
||
amountPerContainer: "",
|
||
unitOfMeasure: "",
|
||
casNumber: "",
|
||
chemicalFormula: "",
|
||
molecularWeight: "",
|
||
vendor: "",
|
||
catalogNumber: "",
|
||
lotNumber: "",
|
||
expirationDate: "",
|
||
concentration: "",
|
||
percentageFull: undefined,
|
||
comments: "",
|
||
};
|
||
}
|
||
|
||
// ── field helper ───────────────────────────────────────────────────────────
|
||
|
||
function Field({ label, value, required }: { label: string; value?: string | number; required?: boolean }) {
|
||
if (!value && value !== 0) return null;
|
||
return (
|
||
<div>
|
||
<p className={`text-xs mb-0.5 ${required ? "text-red-500" : "text-muted-foreground"}`}>{label}</p>
|
||
<p className="text-sm text-foreground">{value}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── main component ─────────────────────────────────────────────────────────
|
||
|
||
export function Inventory() {
|
||
const [inventory, setInventory] = useState<ChemicalInventory[]>([]);
|
||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||
|
||
// add/edit dialog
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editingItem, setEditingItem] = useState<ChemicalInventory | null>(null);
|
||
const [form, setForm] = useState<Partial<ChemicalInventory>>(emptyForm());
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [formError, setFormError] = useState("");
|
||
const [showOptional, setShowOptional] = useState(false);
|
||
|
||
// table view dialog
|
||
const [tableViewOpen, setTableViewOpen] = useState(false);
|
||
|
||
// scan dialog
|
||
const [scanOpen, setScanOpen] = useState(false);
|
||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||
const [isProcessing, setIsProcessing] = useState(false);
|
||
const [extractedData, setExtractedData] = useState<Partial<ChemicalInventory> | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
chemicalsApi.list(),
|
||
fetch("/api/profile", { credentials: "include" }).then(r => r.ok ? r.json() : null),
|
||
]).then(([chems, prof]) => {
|
||
setInventory(chems);
|
||
setProfile(prof);
|
||
}).finally(() => setIsLoading(false));
|
||
}, []);
|
||
|
||
// ── form helpers ────────────────────────────────────────────────────────
|
||
|
||
function openAdd() {
|
||
setEditingItem(null);
|
||
setForm(emptyForm(profile));
|
||
setFormError("");
|
||
setShowOptional(false);
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function openEdit(item: ChemicalInventory) {
|
||
setEditingItem(item);
|
||
setForm({ ...item });
|
||
setFormError("");
|
||
setShowOptional(true);
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function setField(key: keyof ChemicalInventory, value: string | number | undefined) {
|
||
setForm(f => ({ ...f, [key]: value }));
|
||
}
|
||
|
||
async function handleSave() {
|
||
const required: (keyof ChemicalInventory)[] = [
|
||
"piFirstName","physicalState","chemicalName","bldgCode","lab",
|
||
"storageLocation","storageDevice","numberOfContainers","amountPerContainer",
|
||
"unitOfMeasure","casNumber",
|
||
];
|
||
const missing = required.filter(k => !form[k]);
|
||
if (missing.length) {
|
||
setFormError("Please fill in all required fields.");
|
||
return;
|
||
}
|
||
setFormError("");
|
||
setIsSaving(true);
|
||
try {
|
||
if (editingItem) {
|
||
const updated = await chemicalsApi.update(editingItem.id, form);
|
||
setInventory(inv => inv.map(c => c.id === editingItem.id ? updated : c));
|
||
} else {
|
||
const saved = await chemicalsApi.create(form as ChemicalInventory);
|
||
setInventory(inv => [saved, ...inv]);
|
||
}
|
||
setDialogOpen(false);
|
||
} catch {
|
||
setFormError("Failed to save. Please try again.");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
if (!confirm("Remove this chemical from inventory?")) return;
|
||
await chemicalsApi.remove(id);
|
||
setInventory(inv => inv.filter(c => c.id !== id));
|
||
if (expandedId === id) setExpandedId(null);
|
||
}
|
||
|
||
// ── scan helpers ────────────────────────────────────────────────────────
|
||
|
||
function handlePhotoCapture(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onloadend = () => {
|
||
setCapturedImage(reader.result as string);
|
||
setIsProcessing(true);
|
||
setTimeout(() => {
|
||
setExtractedData({
|
||
chemicalName: "Methanol", casNumber: "67-56-1", physicalState: "Liquid",
|
||
vendor: "Sigma-Aldrich", lotNumber: "SLCD7890", catalogNumber: "34860",
|
||
amountPerContainer: "1", unitOfMeasure: "L", numberOfContainers: "1",
|
||
storageDevice: "Glass Bottle", chemicalFormula: "CH3OH", molecularWeight: "32.04",
|
||
expirationDate: "2026-03-15", concentration: "99.8%", percentageFull: 95,
|
||
needsManualEntry: ["piFirstName", "bldgCode", "lab", "storageLocation"],
|
||
scannedImage: reader.result as string,
|
||
piFirstName: profile?.pi_first_name ?? "",
|
||
bldgCode: profile?.bldg_code ?? "",
|
||
lab: profile?.lab ?? "",
|
||
});
|
||
setIsProcessing(false);
|
||
}, 2000);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
async function handleAddFromScan() {
|
||
if (!extractedData) return;
|
||
try {
|
||
const saved = await chemicalsApi.create({
|
||
...extractedData,
|
||
piFirstName: extractedData.piFirstName || "",
|
||
physicalState: extractedData.physicalState || "",
|
||
chemicalName: extractedData.chemicalName || "",
|
||
bldgCode: extractedData.bldgCode || "",
|
||
lab: extractedData.lab || "",
|
||
storageLocation: extractedData.storageLocation || "",
|
||
storageDevice: extractedData.storageDevice || "Glass Bottle",
|
||
numberOfContainers: extractedData.numberOfContainers || "1",
|
||
amountPerContainer: extractedData.amountPerContainer || "",
|
||
unitOfMeasure: extractedData.unitOfMeasure || "",
|
||
casNumber: extractedData.casNumber || "",
|
||
});
|
||
setInventory(inv => [saved, ...inv]);
|
||
setCapturedImage(null); setExtractedData(null); setScanOpen(false);
|
||
} catch (err) { console.error(err); }
|
||
}
|
||
|
||
// ── export helpers ──────────────────────────────────────────────────────
|
||
|
||
function handleDownloadCSV() {
|
||
const headers = TABLE_COLUMNS.map(c => c.label);
|
||
const rows = filtered.map(item =>
|
||
TABLE_COLUMNS.map(c => String(item[c.key] ?? ""))
|
||
);
|
||
const csv = [headers, ...rows]
|
||
.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(","))
|
||
.join("\n");
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "chemical_inventory.csv";
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function handleDownloadExcel() {
|
||
const wsData = [
|
||
TABLE_COLUMNS.map(c => c.label),
|
||
...filtered.map(item => TABLE_COLUMNS.map(c => item[c.key] ?? "")),
|
||
];
|
||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||
const wb = XLSX.utils.book_new();
|
||
XLSX.utils.book_append_sheet(wb, ws, "Chemical Inventory");
|
||
XLSX.writeFile(wb, "chemical_inventory.xlsx");
|
||
}
|
||
|
||
// ── derived ─────────────────────────────────────────────────────────────
|
||
|
||
const filtered = inventory.filter(c =>
|
||
c.chemicalName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
c.casNumber.includes(searchQuery) ||
|
||
c.lab.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
(c.storageLocation || "").toLowerCase().includes(searchQuery.toLowerCase())
|
||
);
|
||
|
||
const lowStockCount = inventory.filter(c => c.percentageFull != null && c.percentageFull < 20).length;
|
||
|
||
// ── render ───────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<div className="p-6 max-w-5xl mx-auto">
|
||
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h1 className="text-foreground mb-1">Chemical Inventory</h1>
|
||
<p className="text-muted-foreground text-sm">Track, manage, and monitor your lab chemicals</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{/* Table view */}
|
||
<Button variant="outline" className="gap-2" onClick={() => setTableViewOpen(true)}>
|
||
<Table2 className="w-4 h-4" /> Table View
|
||
</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>
|
||
|
||
{/* 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>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* List */}
|
||
{isLoading ? (
|
||
<div className="flex justify-center py-16">
|
||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : filtered.length === 0 ? (
|
||
<div className="text-center py-16 text-muted-foreground">
|
||
{inventory.length === 0
|
||
? "No chemicals yet. Add one to get started."
|
||
: "No chemicals match your search."}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{filtered.map(item => {
|
||
const isExpanded = expandedId === item.id;
|
||
const days = item.expirationDate ? daysUntil(item.expirationDate) : null;
|
||
const expired = days !== null && days < 0;
|
||
const expiringSoon = days !== null && days >= 0 && days <= 30;
|
||
const lowStock = item.percentageFull != null && item.percentageFull < 20;
|
||
|
||
return (
|
||
<Card key={item.id} className={`overflow-hidden transition-shadow ${isExpanded ? "shadow-md" : "hover:shadow-sm"}`}>
|
||
{/* Summary row */}
|
||
<button
|
||
className="w-full text-left px-5 py-4 flex items-center gap-4"
|
||
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
||
>
|
||
{/* Name + state */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="font-medium text-foreground">{item.chemicalName}</span>
|
||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{item.physicalState}</span>
|
||
{expired && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Expired</Badge>}
|
||
{expiringSoon && !expired && <Badge className="bg-amber-100 text-amber-700 border-amber-200 border text-xs py-0">Exp. {days}d</Badge>}
|
||
{lowStock && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Low stock</Badge>}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground mt-0.5">{item.lab} · {item.storageLocation || "—"}</p>
|
||
</div>
|
||
|
||
{/* Amount */}
|
||
<div className="text-sm text-muted-foreground shrink-0 hidden sm:block">
|
||
{item.numberOfContainers} × {item.amountPerContainer} {item.unitOfMeasure}
|
||
</div>
|
||
|
||
{/* Fill bar */}
|
||
{item.percentageFull != null && (
|
||
<div className="flex items-center gap-2 shrink-0 hidden md:flex">
|
||
<div className="w-20 h-1.5 bg-muted rounded-full overflow-hidden">
|
||
<div className={`h-full rounded-full ${fillBarColor(item.percentageFull)}`} style={{ width: `${item.percentageFull}%` }} />
|
||
</div>
|
||
<span className="text-xs text-muted-foreground w-8">{item.percentageFull}%</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Chevron */}
|
||
{isExpanded
|
||
? <ChevronUp className="w-4 h-4 text-muted-foreground shrink-0" />
|
||
: <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />}
|
||
</button>
|
||
|
||
{/* Expanded detail */}
|
||
{isExpanded && (
|
||
<div className="border-t border-border px-5 py-4 bg-secondary/40">
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-5">
|
||
|
||
{/* Storage */}
|
||
<div className="space-y-3">
|
||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Storage</p>
|
||
<Field label="Building" value={item.bldgCode} required />
|
||
<Field label="Lab" value={item.lab} required />
|
||
<Field label="Location" value={item.storageLocation} required />
|
||
<Field label="Device" value={item.storageDevice} required />
|
||
<Field label="Containers" value={item.numberOfContainers} required />
|
||
<Field label="Amount / Container" value={`${item.amountPerContainer} ${item.unitOfMeasure}`} required />
|
||
{item.percentageFull != null && (
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-1">Fill level</p>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||
<div className={`h-full rounded-full ${fillBarColor(item.percentageFull)}`} style={{ width: `${item.percentageFull}%` }} />
|
||
</div>
|
||
<span className="text-sm font-medium">{item.percentageFull}%</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Chemical info */}
|
||
<div className="space-y-3">
|
||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Chemical</p>
|
||
<Field label="CAS #" value={item.casNumber} required />
|
||
<Field label="Physical State" value={item.physicalState} required />
|
||
<Field label="Formula" value={item.chemicalFormula} />
|
||
<Field label="Molecular Weight" value={item.molecularWeight ? `${item.molecularWeight} g/mol` : undefined} />
|
||
<Field label="Concentration" value={item.concentration} />
|
||
<Field label="PI" value={item.piFirstName} />
|
||
<Field label="Contact" value={item.contact} />
|
||
</div>
|
||
|
||
{/* Procurement */}
|
||
<div className="space-y-3">
|
||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Procurement</p>
|
||
<Field label="Vendor" value={item.vendor} />
|
||
<Field label="Catalog #" value={item.catalogNumber} />
|
||
<Field label="Lot #" value={item.lotNumber} />
|
||
{item.expirationDate && (
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-0.5">Expiration</p>
|
||
<p className={`text-sm font-medium ${expired ? "text-red-600" : expiringSoon ? "text-amber-600" : "text-foreground"}`}>
|
||
{item.expirationDate}
|
||
{expired && ` (expired ${Math.abs(days!)}d ago)`}
|
||
{expiringSoon && ` (${days}d left)`}
|
||
</p>
|
||
</div>
|
||
)}
|
||
<Field label="Barcode" value={item.barcode} />
|
||
{item.comments && (
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-0.5">Comments</p>
|
||
<p className="text-sm text-foreground whitespace-pre-wrap">{item.comments}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2 pt-2 border-t border-border">
|
||
<Button size="sm" variant="outline" onClick={() => openEdit(item)}>Edit</Button>
|
||
<Button size="sm" variant="ghost" className="text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => handleDelete(item.id)}>
|
||
<Trash2 className="w-4 h-4 mr-1" /> Delete
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Table View dialog */}
|
||
<Dialog open={tableViewOpen} onOpenChange={setTableViewOpen}>
|
||
<DialogContent className="sm:max-w-[95vw] w-[95vw] h-[90vh] flex flex-col p-0">
|
||
<DialogHeader className="px-5 pt-5 pb-3 shrink-0 border-b border-border">
|
||
<div className="flex items-center justify-between">
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<Table2 className="w-5 h-5" /> Inventory Table
|
||
<span className="text-sm font-normal text-muted-foreground ml-1">
|
||
({filtered.length} {filtered.length === 1 ? "chemical" : "chemicals"})
|
||
</span>
|
||
</DialogTitle>
|
||
<div className="flex gap-2 mr-6">
|
||
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadCSV}>
|
||
<FileDown className="w-4 h-4" /> CSV
|
||
</Button>
|
||
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadExcel}>
|
||
<FileSpreadsheet className="w-4 h-4" /> Excel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogHeader>
|
||
<div className="overflow-auto 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" />
|
||
</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 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 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)} />
|
||
</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>
|
||
);
|
||
}
|