Files
LabWise/components/Inventory.tsx

617 lines
31 KiB
TypeScript
Raw Normal View History

import { useState, useRef, useEffect } from "react";
import { chemicalsApi } from "../lib/api";
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 { Badge } from "./ui/badge";
import {
Camera,
Sparkles,
Mail,
Users,
Download,
Upload,
Search,
Filter,
Plus,
AlertTriangle,
Check,
X
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
import { Label } from "./ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
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"
];
export function Inventory() {
const [searchQuery, setSearchQuery] = useState("");
const [isPhotoDialogOpen, setIsPhotoDialogOpen] = useState(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [extractedData, setExtractedData] = useState<Partial<ChemicalInventory> | null>(null);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [inventory, setInventory] = useState<ChemicalInventory[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
chemicalsApi.list()
.then(data => setInventory(data))
.catch(console.error)
.finally(() => setIsLoading(false));
}, []);
2026-03-18 17:10:16 -05:00
const handlePhotoCapture = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
setCapturedImage(reader.result as string);
processImage(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const processImage = (imageData: string) => {
setIsProcessing(true);
// Simulate AI processing with OCR
setTimeout(() => {
const mockExtractedData: Partial<ChemicalInventory> = {
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, // Scanner detected transparency
// Fields that need manual entry
needsManualEntry: ["piFirstName", "bldgCode", "lab", "storageLocation"],
scannedImage: imageData
};
setExtractedData(mockExtractedData);
setIsProcessing(false);
}, 2000);
};
const handleAddFromScan = async () => {
2026-03-18 17:10:16 -05:00
if (extractedData) {
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([saved, ...inventory]);
setCapturedImage(null);
setExtractedData(null);
setIsPhotoDialogOpen(false);
} catch (err) {
console.error("Failed to save chemical:", err);
}
2026-03-18 17:10:16 -05:00
}
};
const filteredInventory = inventory.filter(item =>
item.chemicalName.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.casNumber.includes(searchQuery) ||
item.piFirstName.toLowerCase().includes(searchQuery.toLowerCase())
);
const lowStockChemicals = inventory.filter(item =>
item.percentageFull !== undefined && item.percentageFull < 20
);
const expiringChemicals = inventory.filter(item => {
if (!item.expirationDate) return false;
const today = new Date();
const expDate = new Date(item.expirationDate);
const daysUntilExpiry = Math.floor((expDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
return daysUntilExpiry <= 30 || daysUntilExpiry < 0;
});
const requiredFields = [
"piFirstName", "physicalState", "chemicalName", "bldgCode", "lab",
"storageLocation", "storageDevice", "numberOfContainers",
"amountPerContainer", "unitOfMeasure", "casNumber"
];
return (
<div className="p-8">
<div className="max-w-[1800px] mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-foreground mb-2">Chemical Inventory</h1>
<p className="text-muted-foreground">Collaborative inventory tracking with automated scanning</p>
</div>
<div className="flex gap-2">
<Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<Users className="w-4 h-4" />
Share Access
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Share Inventory Access</DialogTitle>
<DialogDescription>
Give students and lab members access to this inventory
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="student@university.edu"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Access Level</Label>
<Select defaultValue="editor">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer (Read-only)</SelectItem>
<SelectItem value="editor">Editor (Can add/scan)</SelectItem>
<SelectItem value="admin">Admin (Full access)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="bg-accent p-4 rounded-lg border border-border">
<h4 className="text-foreground mb-2">Current Access</h4>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-foreground">Dr. Smith (You)</span>
<Badge variant="outline">Owner</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-foreground">Lab Assistant</span>
<Badge variant="outline">Editor</Badge>
</div>
</div>
</div>
<Button className="w-full bg-primary hover:bg-primary/90">
Send Invitation
</Button>
</div>
</DialogContent>
</Dialog>
<Button variant="outline" className="gap-2">
<Download className="w-4 h-4" />
Export CSV
</Button>
<Dialog open={isPhotoDialogOpen} onOpenChange={setIsPhotoDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-primary hover:bg-primary/90 gap-2">
<Camera className="w-4 h-4" />
Scan Chemical
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Camera className="w-5 h-5" />
Scan Chemical Label
</DialogTitle>
<DialogDescription>
Automatically extract information from chemical labels
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{!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-16 h-16 text-muted-foreground mx-auto mb-4" />
<p className="text-foreground mb-2">Click to capture or upload label photo</p>
<p className="text-muted-foreground">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">
<div className="relative rounded-lg overflow-hidden border border-border">
<img src={capturedImage} alt="Scanned label" className="w-full h-auto" />
</div>
{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-3 animate-pulse" />
<p className="text-foreground mb-1">Scanning label...</p>
<p className="text-muted-foreground">Reading all visible information with AI</p>
</div>
) : extractedData ? (
<div className="space-y-4">
<div className="bg-accent border border-border rounded-lg p-4">
<div className="flex items-start gap-3">
<Sparkles className="w-5 h-5 text-primary mt-0.5" />
<div>
<p className="text-foreground mb-1">Information Extracted!</p>
<p className="text-muted-foreground">
Yellow fields need manual entry. Review and complete before adding.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{/* Required Fields */}
<div className={`space-y-2 ${extractedData.needsManualEntry?.includes('piFirstName') ? 'bg-yellow-50 p-2 rounded' : ''}`}>
<Label className="text-red-600">PI First Name *</Label>
<Input
value={extractedData.piFirstName || ""}
onChange={(e) => setExtractedData({ ...extractedData, piFirstName: e.target.value })}
placeholder="Required"
/>
</div>
<div className="space-y-2">
<Label className="text-red-600">Physical State *</Label>
<Select
value={extractedData.physicalState}
onValueChange={(value) => setExtractedData({ ...extractedData, physicalState: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Solid">Solid</SelectItem>
<SelectItem value="Liquid">Liquid</SelectItem>
<SelectItem value="Gas">Gas</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-red-600">Chemical Name *</Label>
<Input value={extractedData.chemicalName || ""} onChange={(e) => setExtractedData({ ...extractedData, chemicalName: e.target.value })} />
</div>
<div className={`space-y-2 ${extractedData.needsManualEntry?.includes('bldgCode') ? 'bg-yellow-50 p-2 rounded' : ''}`}>
<Label className="text-red-600">Bldg Code *</Label>
<Input value={extractedData.bldgCode || ""} onChange={(e) => setExtractedData({ ...extractedData, bldgCode: e.target.value })} placeholder="Required" />
</div>
<div className={`space-y-2 ${extractedData.needsManualEntry?.includes('lab') ? 'bg-yellow-50 p-2 rounded' : ''}`}>
<Label className="text-red-600">LAB *</Label>
<Input value={extractedData.lab || ""} onChange={(e) => setExtractedData({ ...extractedData, lab: e.target.value })} placeholder="Required" />
</div>
<div className={`space-y-2 ${extractedData.needsManualEntry?.includes('storageLocation') ? 'bg-yellow-50 p-2 rounded' : ''}`}>
<Label className="text-red-600">Storage Location *</Label>
<Input value={extractedData.storageLocation || ""} onChange={(e) => setExtractedData({ ...extractedData, storageLocation: e.target.value })} placeholder="Required" />
</div>
<div className="space-y-2">
<Label className="text-red-600">Storage Device *</Label>
<Select
value={extractedData.storageDevice}
onValueChange={(value) => setExtractedData({ ...extractedData, storageDevice: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{storageDeviceOptions.map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-red-600"># of Containers *</Label>
<Input type="number" value={extractedData.numberOfContainers || ""} onChange={(e) => setExtractedData({ ...extractedData, numberOfContainers: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-red-600">Amount Per Container *</Label>
<Input value={extractedData.amountPerContainer || ""} onChange={(e) => setExtractedData({ ...extractedData, amountPerContainer: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-red-600">Unit of Measure *</Label>
<Input value={extractedData.unitOfMeasure || ""} onChange={(e) => setExtractedData({ ...extractedData, unitOfMeasure: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-red-600">CAS # *</Label>
<Input value={extractedData.casNumber || ""} onChange={(e) => setExtractedData({ ...extractedData, casNumber: e.target.value })} />
</div>
{/* Optional Fields */}
<div className="space-y-2">
<Label>Chemical Formula</Label>
<Input value={extractedData.chemicalFormula || ""} onChange={(e) => setExtractedData({ ...extractedData, chemicalFormula: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Molecular Weight</Label>
<Input value={extractedData.molecularWeight || ""} onChange={(e) => setExtractedData({ ...extractedData, molecularWeight: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Vendor</Label>
<Input value={extractedData.vendor || ""} onChange={(e) => setExtractedData({ ...extractedData, vendor: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Catalog #</Label>
<Input value={extractedData.catalogNumber || ""} onChange={(e) => setExtractedData({ ...extractedData, catalogNumber: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Lot Number</Label>
<Input value={extractedData.lotNumber || ""} onChange={(e) => setExtractedData({ ...extractedData, lotNumber: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Expiration Date</Label>
<Input type="date" value={extractedData.expirationDate || ""} onChange={(e) => setExtractedData({ ...extractedData, expirationDate: e.target.value })} />
</div>
<div className="space-y-2 bg-blue-50 p-2 rounded">
<Label className="text-blue-600">Percentage Full</Label>
<Input
type="number"
value={extractedData.percentageFull || ""}
onChange={(e) => setExtractedData({ ...extractedData, percentageFull: parseFloat(e.target.value) })}
placeholder="Auto-detected for transparent bottles"
/>
</div>
<div className="space-y-2">
<Label>Concentration</Label>
<Input value={extractedData.concentration || ""} onChange={(e) => setExtractedData({ ...extractedData, concentration: 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 Info */}
<Card className="p-4 bg-gradient-to-r from-accent to-secondary border-border">
<div className="flex items-start gap-3">
<Mail className="w-5 h-5 text-primary mt-0.5" />
<div className="flex-1">
<p className="text-foreground mb-1">
<span className="">Auto-import chemical receipts:</span> Email receipts to{" "}
<code className="bg-muted px-2 py-0.5 rounded text-primary">inventory@labwise-auto.com</code>
</p>
<p className="text-muted-foreground">
We'll automatically extract and add chemicals to your inventory
</p>
</div>
</div>
</Card>
</div>
{/* Search and Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<Card className="col-span-2 p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-5 h-5" />
<Input
placeholder="Search by chemical name, CAS, or PI..."
className="pl-10"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground">Total Chemicals</p>
<p className="text-foreground">{inventory.length}</p>
</div>
<AlertTriangle className="w-8 h-8 text-primary" />
</div>
</Card>
<Card className="p-4 bg-amber-50 border-amber-200">
<div className="flex items-center justify-between">
<div>
<p className="text-amber-700">Low Stock</p>
<p className="text-foreground">{lowStockChemicals.length}</p>
</div>
<AlertTriangle className="w-8 h-8 text-amber-600" />
</div>
</Card>
</div>
{/* Spreadsheet Table */}
{isLoading && (
<p className="text-center text-muted-foreground py-8">Loading inventory...</p>
)}
2026-03-18 17:10:16 -05:00
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted border-b border-border sticky top-0">
<tr>
{/* Required columns - red headers */}
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">PI First Name</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">Physical State</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">Chemical Name</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">Bldg Code</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">LAB</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">Storage Location</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">Storage Device</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200"># Containers</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">Amount/Container</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">Unit</th>
<th className="text-left p-3 text-red-600 bg-red-50 border-r border-red-200">CAS #</th>
{/* Blue column */}
<th className="text-left p-3 text-blue-600 bg-blue-50 border-r border-blue-200">% Full</th>
{/* Optional columns */}
<th className="text-left p-3 text-muted-foreground border-r border-border">Formula</th>
<th className="text-left p-3 text-muted-foreground border-r border-border">Mol. Weight</th>
<th className="text-left p-3 text-muted-foreground border-r border-border">Vendor</th>
<th className="text-left p-3 text-muted-foreground border-r border-border">Catalog #</th>
<th className="text-left p-3 text-muted-foreground border-r border-border">Expiration</th>
<th className="text-left p-3 text-muted-foreground border-r border-border">Lot #</th>
<th className="text-left p-3 text-muted-foreground border-r border-border">Concentration</th>
<th className="text-left p-3 text-muted-foreground border-r border-border">Date Entered</th>
<th className="text-left p-3 text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{filteredInventory.map((item) => (
<tr key={item.id} className="border-b border-border hover:bg-accent/50">
{/* Required fields with yellow highlight if needed */}
<td className={`p-3 border-r border-border ${item.needsManualEntry?.includes('piFirstName') ? 'bg-yellow-100' : ''}`}>
{item.piFirstName || <span className="text-muted-foreground italic">Missing</span>}
</td>
<td className="p-3 border-r border-border">{item.physicalState}</td>
<td className="p-3 border-r border-border">{item.chemicalName}</td>
<td className={`p-3 border-r border-border ${item.needsManualEntry?.includes('bldgCode') ? 'bg-yellow-100' : ''}`}>
{item.bldgCode || <span className="text-muted-foreground italic">Missing</span>}
</td>
<td className={`p-3 border-r border-border ${item.needsManualEntry?.includes('lab') ? 'bg-yellow-100' : ''}`}>
{item.lab || <span className="text-muted-foreground italic">Missing</span>}
</td>
<td className={`p-3 border-r border-border ${item.needsManualEntry?.includes('storageLocation') ? 'bg-yellow-100' : ''}`}>
{item.storageLocation || <span className="text-muted-foreground italic">Missing</span>}
</td>
<td className="p-3 border-r border-border">{item.storageDevice}</td>
<td className="p-3 border-r border-border">{item.numberOfContainers}</td>
<td className="p-3 border-r border-border">{item.amountPerContainer}</td>
<td className="p-3 border-r border-border">{item.unitOfMeasure}</td>
<td className="p-3 border-r border-border">{item.casNumber}</td>
{/* Blue percentage full column */}
<td className="p-3 border-r border-blue-200 bg-blue-50">
{item.percentageFull !== undefined ? (
<div className="flex items-center gap-2">
<span className={item.percentageFull < 20 ? 'text-red-600' : 'text-blue-600'}>
{item.percentageFull}%
</span>
{item.percentageFull < 20 && <AlertTriangle className="w-4 h-4 text-red-600" />}
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
{/* Optional fields */}
<td className="p-3 border-r border-border">{item.chemicalFormula || "-"}</td>
<td className="p-3 border-r border-border">{item.molecularWeight || "-"}</td>
<td className="p-3 border-r border-border">{item.vendor || "-"}</td>
<td className="p-3 border-r border-border">{item.catalogNumber || "-"}</td>
<td className="p-3 border-r border-border">
{item.expirationDate ? (
<span className={
new Date(item.expirationDate) < new Date() ? 'text-red-600' :
Math.floor((new Date(item.expirationDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)) <= 30 ? 'text-amber-600' :
'text-foreground'
}>
{item.expirationDate}
</span>
) : "-"}
</td>
<td className="p-3 border-r border-border">{item.lotNumber || "-"}</td>
<td className="p-3 border-r border-border">{item.concentration || "-"}</td>
<td className="p-3 border-r border-border">{item.dateEntered || "-"}</td>
<td className="p-3">
<Button variant="ghost" size="sm">Edit</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
</div>
);
}