From fc143bea5dfdb149d421e2b08767f2e2cdafe28e Mon Sep 17 00:00:00 2001 From: pulipakaa24 Date: Fri, 20 Mar 2026 00:28:45 -0500 Subject: [PATCH] Dashboard allows for manual inventory updates and shows relevant info. --- components/Dashboard.tsx | 334 +++++---- components/Inventory.tsx | 1249 +++++++++++++++++--------------- server/src/routes/chemicals.ts | 13 +- 3 files changed, 863 insertions(+), 733 deletions(-) diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index 21f9bb8..ffd92ca 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -1,19 +1,21 @@ +import { useEffect, useState } from "react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; import { Badge } from "./ui/badge"; -import { - AlertTriangle, - CheckCircle2, - Clock, - TrendingUp, +import { + AlertTriangle, + Clock, Package, FileCheck, MessageSquare, ArrowRight, Bell, Camera, - Activity + Activity, + Loader2, } from "lucide-react"; +import { chemicalsApi, protocolsApi } from "../lib/api"; +import type { ChemicalInventory, Protocol } from "../shared/types"; type Tab = "dashboard" | "inventory" | "protocol"; @@ -21,56 +23,124 @@ interface DashboardProps { setActiveTab: (tab: Tab) => void; } +function daysUntil(dateStr: string): number { + const expDate = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + return Math.ceil((expDate.getTime() - today.getTime()) / 86400000); +} + +function timeAgo(dateStr: string): string { + const diffMs = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diffMs / 60000); + const hours = Math.floor(mins / 60); + const days = Math.floor(hours / 24); + if (mins < 60) return `${mins}m ago`; + if (hours < 24) return `${hours}h ago`; + return `${days}d ago`; +} + export function Dashboard({ setActiveTab }: DashboardProps) { + const [chemicals, setChemicals] = useState([]); + const [protocols, setProtocols] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([chemicalsApi.list(), protocolsApi.list()]) + .then(([chems, protos]) => { + setChemicals(chems); + setProtocols(protos); + }) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + // ── Derived data ────────────────────────────────────────────────────────── + + const lowStockChems = chemicals + .filter(c => c.percentageFull != null && c.percentageFull < 20) + .sort((a, b) => (a.percentageFull ?? 0) - (b.percentageFull ?? 0)); + + const expirationChems = chemicals + .filter(c => c.expirationDate && daysUntil(c.expirationDate) <= 30) + .sort((a, b) => daysUntil(a.expirationDate!) - daysUntil(b.expirationDate!)); + + const analyzedProtocols = protocols.filter(p => p.analysis_results?.length); + const stats = [ - { label: "Chemicals Tracked", value: "252", icon: Package, color: "text-[#5a9584]" }, - { label: "Low Stock (<20%)", value: "2", icon: AlertTriangle, color: "text-amber-600" }, - { label: "Expiring Soon", value: "3", icon: Clock, color: "text-red-600" }, - { label: "Protocols Reviewed", value: "12", icon: FileCheck, color: "text-[#2d5a4a]" }, + { label: "Chemicals Tracked", value: chemicals.length, icon: Package, color: "text-[#5a9584]" }, + { label: "Low Stock (<20%)", value: lowStockChems.length, icon: AlertTriangle, color: "text-amber-600" }, + { label: "Expiring ≤30 Days", value: expirationChems.length, icon: Clock, color: "text-red-600" }, + { label: "Protocols Reviewed", value: analyzedProtocols.length, icon: FileCheck, color: "text-[#2d5a4a]" }, ]; - const lowStockAlerts = [ - { chemical: "Sodium Hydroxide", location: "Cabinet B-1", percentFull: 15, lab: "Lab 305" }, - { chemical: "Ethanol", location: "Cabinet A-5", percentFull: 18, lab: "Lab 201" }, - ]; + // Recent activity: merge newest chemicals + protocols, take top 4 + type ActivityItem = { label: string; time: string; ts: number; type: string }; + const activity: ActivityItem[] = [ + ...chemicals + .filter(c => c.created_at) + .map(c => ({ + label: `Added ${c.chemicalName} to inventory`, + time: timeAgo(c.created_at!), + ts: new Date(c.created_at!).getTime(), + type: "inventory", + })), + ...protocols.map(p => ({ + label: `${p.title} protocol ${p.analysis_results?.length ? "reviewed" : "uploaded"}`, + time: timeAgo(p.created_at), + ts: new Date(p.created_at).getTime(), + type: "protocol", + })), + ] + .sort((a, b) => b.ts - a.ts) + .slice(0, 4); - const expirationAlerts = [ - { chemical: "Hydrochloric Acid", location: "Acid Cabinet", daysLeft: -2, status: "expired", lab: "Lab 201" }, - { chemical: "Benzene", location: "Flammables Cabinet", daysLeft: 15, status: "expiring-soon", lab: "Lab 305" }, - { chemical: "Acetone", location: "Cabinet A-3", daysLeft: 28, status: "expiring-soon", lab: "Lab 201" }, - ]; + // Safety alerts derived from real data + const expiredChems = chemicals.filter(c => c.expirationDate && daysUntil(c.expirationDate) < 0); + const soonChems = chemicals.filter( + c => c.expirationDate && daysUntil(c.expirationDate) >= 0 && daysUntil(c.expirationDate) <= 30 + ); - const recentActivity = [ - { action: "Scanned and added Methanol via photo", time: "30 mins ago", type: "inventory" }, - { action: "Protocol safety review completed", time: "2 hours ago", type: "protocol" }, - { action: "Low stock alert: Sodium Hydroxide", time: "4 hours ago", type: "alert" }, - { action: "Expiration reminder sent for 3 chemicals", time: "1 day ago", type: "reminder" }, - ]; - - const alerts = [ - { message: "Hydrochloric Acid expired 2 days ago - requires disposal", severity: "critical" }, - { message: "Sodium Hydroxide below 20% full - reorder needed", severity: "warning" }, - { message: "3 chemicals expiring in next 30 days", severity: "warning" }, + type AlertItem = { message: string; severity: "critical" | "warning" | "info" }; + const safetyAlerts: AlertItem[] = [ + ...expiredChems.map(c => ({ + message: `${c.chemicalName} expired ${Math.abs(daysUntil(c.expirationDate!))}d ago — verify disposal`, + severity: "critical" as const, + })), + ...lowStockChems.slice(0, 2).map(c => ({ + message: `${c.chemicalName} is ${c.percentageFull}% full — consider reordering`, + severity: "warning" as const, + })), + ...(soonChems.length > 0 + ? [{ message: `${soonChems.length} chemical${soonChems.length > 1 ? "s" : ""} expiring in the next 30 days`, severity: "warning" as const }] + : []), ]; return (
-

Welcome to Labwise

+

Welcome to LabWise

Your AI-powered lab safety and compliance assistant

{/* Stats Grid */}
- {stats.map((stat) => { + {stats.map(stat => { const Icon = stat.icon; return (

{stat.label}

-

{stat.value}

+

{stat.value}

@@ -91,10 +161,7 @@ export function Dashboard({ setActiveTab }: DashboardProps) {

Ask questions with sourced answers

- @@ -109,11 +176,7 @@ export function Dashboard({ setActiveTab }: DashboardProps) {

Get AI safety feedback

- @@ -128,11 +191,7 @@ export function Dashboard({ setActiveTab }: DashboardProps) {

Photo capture with auto-fill

- @@ -146,31 +205,31 @@ export function Dashboard({ setActiveTab }: DashboardProps) {

Low Stock Alerts

- -
- {lowStockAlerts.map((alert, idx) => ( -
setActiveTab("inventory")} - > -
-

{alert.chemical}

-

{alert.lab} • {alert.location}

+ {lowStockChems.length === 0 ? ( +

No low stock items.

+ ) : ( +
+ {lowStockChems.slice(0, 3).map(c => ( +
setActiveTab("inventory")} + > +
+

{c.chemicalName}

+

{c.lab} · {c.storageLocation}

+
+ + {c.percentageFull}% full +
- - {alert.percentFull}% full - -
- ))} -
+ ))} +
+ )} {/* Expiration Alerts */} @@ -180,45 +239,39 @@ export function Dashboard({ setActiveTab }: DashboardProps) {

Expiration Alerts

- -
- {expirationAlerts.map((alert, idx) => ( -
setActiveTab("inventory")} - > -
-
-
-

{alert.chemical}

-

{alert.lab} • {alert.location}

+ {expirationChems.length === 0 ? ( +

No chemicals expiring within 30 days.

+ ) : ( +
+ {expirationChems.slice(0, 4).map(c => { + const days = daysUntil(c.expirationDate!); + const expired = days < 0; + return ( +
setActiveTab("inventory")} + > +
+
+
+

{c.chemicalName}

+

{c.lab} · {c.storageLocation}

+
+
+ + + {expired ? `Expired ${Math.abs(days)}d ago` : `${days}d left`} +
-
- - - {alert.status === 'expired' - ? `Expired ${Math.abs(alert.daysLeft)}d ago` - : `${alert.daysLeft}d left`} - -
- ))} -
+ ); + })} +
+ )}
@@ -226,50 +279,55 @@ export function Dashboard({ setActiveTab }: DashboardProps) { {/* Recent Activity */}

Recent Activity

-
- {recentActivity.map((activity, idx) => ( -
- -
-

{activity.action}

-

{activity.time}

+ {activity.length === 0 ? ( +

No activity yet. Add chemicals or upload a protocol to get started.

+ ) : ( +
+ {activity.map((item, idx) => ( +
+ +
+

{item.label}

+

{item.time}

+
-
- ))} -
+ ))} +
+ )} {/* Safety Alerts */}

Safety Alerts

-
- {alerts.map((alert, idx) => ( -
- + +

All clear — no active safety alerts.

+
+ ) : ( +
+ {safetyAlerts.map((alert, idx) => ( +
-

{alert.message}

-
- ))} -
+ ? "bg-red-50 border border-red-200" + : "bg-amber-50 border border-amber-200" + }`} + > + +

{alert.message}

+
+ ))} +
+ )}
); -} \ No newline at end of file +} diff --git a/components/Inventory.tsx b/components/Inventory.tsx index ab87847..20bfa56 100644 --- a/components/Inventory.tsx +++ b/components/Inventory.tsx @@ -4,613 +4,684 @@ 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, - Users, - Download, - Upload, - Search, - Filter, - Plus, - AlertTriangle, - Check, - X +import { + Camera, Sparkles, Mail, Search, Plus, AlertTriangle, + ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical, } from "lucide-react"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "./ui/dialog"; -import { Label } from "./ui/label"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + 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" + "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(null); - const [isProcessing, setIsProcessing] = useState(false); - const [extractedData, setExtractedData] = useState | null>(null); - const [shareDialogOpen, setShareDialogOpen] = useState(false); - const fileInputRef = useRef(null); +const physicalStates = ["Solid", "Liquid", "Gas"]; - const [inventory, setInventory] = useState([]); - const [isLoading, setIsLoading] = useState(true); +const unitOptions = ["mL", "L", "g", "kg", "mg", "oz", "lb", "gal", "mol"]; - useEffect(() => { - chemicalsApi.list() - .then(data => setInventory(data)) - .catch(console.error) - .finally(() => setIsLoading(false)); - }, []); +// ── helpers ──────────────────────────────────────────────────────────────── - const handlePhotoCapture = (event: React.ChangeEvent) => { - 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); - } +function daysUntil(dateStr: string) { + const today = new Date(); today.setHours(0, 0, 0, 0); + return Math.ceil((new Date(dateStr).getTime() - today.getTime()) / 86400000); +} + +function fillBarColor(pct: number) { + if (pct < 20) return "bg-red-500"; + if (pct < 40) return "bg-amber-500"; + return "bg-[#5a9584]"; +} + +type UserProfile = { pi_first_name: string; bldg_code: string; lab: string; contact?: string }; + +function emptyForm(profile?: UserProfile | null): Partial { + return { + piFirstName: profile?.pi_first_name ?? "", + bldgCode: profile?.bldg_code ?? "", + lab: profile?.lab ?? "", + contact: profile?.contact ?? "", + physicalState: "", + chemicalName: "", + storageLocation: "", + storageDevice: "Glass Bottle", + numberOfContainers: "1", + amountPerContainer: "", + unitOfMeasure: "", + casNumber: "", + chemicalFormula: "", + molecularWeight: "", + vendor: "", + catalogNumber: "", + lotNumber: "", + expirationDate: "", + concentration: "", + percentageFull: undefined, + comments: "", }; +} - const processImage = (imageData: string) => { - setIsProcessing(true); - - // Simulate AI processing with OCR - setTimeout(() => { - const mockExtractedData: Partial = { - 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 () => { - 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); - } - } - }; - - 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" - ]; +// ── field helper ─────────────────────────────────────────────────────────── +function Field({ label, value, required }: { label: string; value?: string | number; required?: boolean }) { + if (!value && value !== 0) return null; return ( -
-
- {/* Header */} -
-
-
-

Chemical Inventory

-

Collaborative inventory tracking with automated scanning

-
-
- - - - - - - Share Inventory Access - - Give students and lab members access to this inventory - - -
-
- - -
-
- - -
-
-

Current Access

-
-
- Dr. Smith (You) - Owner -
-
- Lab Assistant - Editor -
-
-
- -
-
-
- - - - - - - - - - - - Scan Chemical Label - - - Automatically extract information from chemical labels - - -
- {!capturedImage ? ( -
fileInputRef.current?.click()} - > - -

Click to capture or upload label photo

-

AI will extract all available information

- -
- ) : ( -
-
- Scanned label -
- - {isProcessing ? ( -
- -

Scanning label...

-

Reading all visible information with AI

-
- ) : extractedData ? ( -
-
-
- -
-

Information Extracted!

-

- Yellow fields need manual entry. Review and complete before adding. -

-
-
-
- -
- {/* Required Fields */} -
- - setExtractedData({ ...extractedData, piFirstName: e.target.value })} - placeholder="Required" - /> -
- -
- - -
- -
- - setExtractedData({ ...extractedData, chemicalName: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, bldgCode: e.target.value })} placeholder="Required" /> -
- -
- - setExtractedData({ ...extractedData, lab: e.target.value })} placeholder="Required" /> -
- -
- - setExtractedData({ ...extractedData, storageLocation: e.target.value })} placeholder="Required" /> -
- -
- - -
- -
- - setExtractedData({ ...extractedData, numberOfContainers: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, amountPerContainer: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, unitOfMeasure: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, casNumber: e.target.value })} /> -
- - {/* Optional Fields */} -
- - setExtractedData({ ...extractedData, chemicalFormula: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, molecularWeight: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, vendor: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, catalogNumber: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, lotNumber: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, expirationDate: e.target.value })} /> -
- -
- - setExtractedData({ ...extractedData, percentageFull: parseFloat(e.target.value) })} - placeholder="Auto-detected for transparent bottles" - /> -
- -
- - setExtractedData({ ...extractedData, concentration: e.target.value })} /> -
-
- -
- - -
-
- ) : null} -
- )} -
-
-
-
-
- - {/* Email Receipt Info */} - -
- -
-

- Auto-import chemical receipts: Email receipts to{" "} - inventory@labwise-auto.com -

-

- We'll automatically extract and add chemicals to your inventory -

-
-
-
-
- - {/* Search and Stats */} -
- -
- - setSearchQuery(e.target.value)} - /> -
-
- - -
-
-

Total Chemicals

-

{inventory.length}

-
- -
-
- - -
-
-

Low Stock

-

{lowStockChemicals.length}

-
- -
-
-
- - {/* Spreadsheet Table */} - {isLoading && ( -

Loading inventory...

- )} - -
- - - - {/* Required columns - red headers */} - - - - - - - - - - - - {/* Blue column */} - - {/* Optional columns */} - - - - - - - - - - - - - {filteredInventory.map((item) => ( - - {/* Required fields with yellow highlight if needed */} - - - - - - - - - - - - {/* Blue percentage full column */} - - {/* Optional fields */} - - - - - - - - - - - ))} - -
PI First NamePhysical StateChemical NameBldg CodeLABStorage LocationStorage Device# ContainersAmount/ContainerUnitCAS #% FullFormulaMol. WeightVendorCatalog #ExpirationLot #ConcentrationDate EnteredActions
- {item.piFirstName || Missing} - {item.physicalState}{item.chemicalName} - {item.bldgCode || Missing} - - {item.lab || Missing} - - {item.storageLocation || Missing} - {item.storageDevice}{item.numberOfContainers}{item.amountPerContainer}{item.unitOfMeasure}{item.casNumber} - {item.percentageFull !== undefined ? ( -
- - {item.percentageFull}% - - {item.percentageFull < 20 && } -
- ) : ( - - - )} -
{item.chemicalFormula || "-"}{item.molecularWeight || "-"}{item.vendor || "-"}{item.catalogNumber || "-"} - {item.expirationDate ? ( - - {item.expirationDate} - - ) : "-"} - {item.lotNumber || "-"}{item.concentration || "-"}{item.dateEntered || "-"} - -
-
-
-
+
+

{label}

+

{value}

+
+ ); +} + +// ── main component ───────────────────────────────────────────────────────── + +export function Inventory() { + const [inventory, setInventory] = useState([]); + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + const [expandedId, setExpandedId] = useState(null); + + // add/edit dialog + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [form, setForm] = useState>(emptyForm()); + const [isSaving, setIsSaving] = useState(false); + const [formError, setFormError] = useState(""); + const [showOptional, setShowOptional] = useState(false); + + // scan dialog + const [scanOpen, setScanOpen] = useState(false); + const [capturedImage, setCapturedImage] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [extractedData, setExtractedData] = useState | null>(null); + const fileInputRef = useRef(null); + + useEffect(() => { + Promise.all([ + chemicalsApi.list(), + fetch("/api/profile", { credentials: "include" }).then(r => r.ok ? r.json() : null), + ]).then(([chems, prof]) => { + setInventory(chems); + setProfile(prof); + }).finally(() => setIsLoading(false)); + }, []); + + // ── form helpers ──────────────────────────────────────────────────────── + + function openAdd() { + setEditingItem(null); + setForm(emptyForm(profile)); + setFormError(""); + setShowOptional(false); + setDialogOpen(true); + } + + function openEdit(item: ChemicalInventory) { + setEditingItem(item); + setForm({ ...item }); + setFormError(""); + setShowOptional(true); + setDialogOpen(true); + } + + function setField(key: keyof ChemicalInventory, value: string | number | undefined) { + setForm(f => ({ ...f, [key]: value })); + } + + async function handleSave() { + const required: (keyof ChemicalInventory)[] = [ + "piFirstName","physicalState","chemicalName","bldgCode","lab", + "storageLocation","storageDevice","numberOfContainers","amountPerContainer", + "unitOfMeasure","casNumber", + ]; + const missing = required.filter(k => !form[k]); + if (missing.length) { + setFormError("Please fill in all required fields."); + return; + } + setFormError(""); + setIsSaving(true); + try { + if (editingItem) { + const updated = await chemicalsApi.update(editingItem.id, form); + setInventory(inv => inv.map(c => c.id === editingItem.id ? updated : c)); + } else { + const saved = await chemicalsApi.create(form as ChemicalInventory); + setInventory(inv => [saved, ...inv]); + } + setDialogOpen(false); + } catch { + setFormError("Failed to save. Please try again."); + } finally { + setIsSaving(false); + } + } + + async function handleDelete(id: string) { + if (!confirm("Remove this chemical from inventory?")) return; + await chemicalsApi.remove(id); + setInventory(inv => inv.filter(c => c.id !== id)); + if (expandedId === id) setExpandedId(null); + } + + // ── scan helpers ──────────────────────────────────────────────────────── + + function handlePhotoCapture(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onloadend = () => { + setCapturedImage(reader.result as string); + setIsProcessing(true); + setTimeout(() => { + setExtractedData({ + chemicalName: "Methanol", casNumber: "67-56-1", physicalState: "Liquid", + vendor: "Sigma-Aldrich", lotNumber: "SLCD7890", catalogNumber: "34860", + amountPerContainer: "1", unitOfMeasure: "L", numberOfContainers: "1", + storageDevice: "Glass Bottle", chemicalFormula: "CH3OH", molecularWeight: "32.04", + expirationDate: "2026-03-15", concentration: "99.8%", percentageFull: 95, + needsManualEntry: ["piFirstName", "bldgCode", "lab", "storageLocation"], + scannedImage: reader.result as string, + piFirstName: profile?.pi_first_name ?? "", + bldgCode: profile?.bldg_code ?? "", + lab: profile?.lab ?? "", + }); + setIsProcessing(false); + }, 2000); + }; + reader.readAsDataURL(file); + } + + async function handleAddFromScan() { + if (!extractedData) return; + try { + const saved = await chemicalsApi.create({ + ...extractedData, + piFirstName: extractedData.piFirstName || "", + physicalState: extractedData.physicalState || "", + chemicalName: extractedData.chemicalName || "", + bldgCode: extractedData.bldgCode || "", + lab: extractedData.lab || "", + storageLocation: extractedData.storageLocation || "", + storageDevice: extractedData.storageDevice || "Glass Bottle", + numberOfContainers: extractedData.numberOfContainers || "1", + amountPerContainer: extractedData.amountPerContainer || "", + unitOfMeasure: extractedData.unitOfMeasure || "", + casNumber: extractedData.casNumber || "", + }); + setInventory(inv => [saved, ...inv]); + setCapturedImage(null); setExtractedData(null); setScanOpen(false); + } catch (err) { console.error(err); } + } + + // ── derived ───────────────────────────────────────────────────────────── + + const filtered = inventory.filter(c => + c.chemicalName.toLowerCase().includes(searchQuery.toLowerCase()) || + c.casNumber.includes(searchQuery) || + c.lab.toLowerCase().includes(searchQuery.toLowerCase()) || + (c.storageLocation || "").toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const lowStockCount = inventory.filter(c => c.percentageFull != null && c.percentageFull < 20).length; + + // ── render ─────────────────────────────────────────────────────────────── + + return ( +
+ + {/* Header */} +
+
+

Chemical Inventory

+

Track, manage, and monitor your lab chemicals

+
+
+ {/* Add manually */} + + + {/* Scan */} + + + + + + + + Scan Chemical Label + + +
+ {!capturedImage ? ( +
fileInputRef.current?.click()} + > + +

Click to capture or upload a label photo

+

AI will extract all available information

+ +
+ ) : ( +
+ Scanned label + {isProcessing ? ( +
+ +

Scanning label…

+

Reading all visible information with AI

+
+ ) : extractedData ? ( +
+
+ +

Information extracted. Yellow fields need manual entry.

+
+
+ {(["piFirstName","bldgCode","lab","storageLocation"] as const).map(k => ( +
+ + )[k] || ""} onChange={e => setExtractedData({ ...extractedData, [k]: e.target.value })} /> +
+ ))} +
+ + setExtractedData({...extractedData, chemicalName: e.target.value})} /> +
+
+ + setExtractedData({...extractedData, casNumber: e.target.value})} /> +
+
+ + +
+
+ + +
+
+ + setExtractedData({...extractedData, amountPerContainer: e.target.value})} /> +
+
+ + setExtractedData({...extractedData, unitOfMeasure: e.target.value})} /> +
+
+ + setExtractedData({...extractedData, expirationDate: e.target.value})} /> +
+
+ + setExtractedData({...extractedData, percentageFull: parseFloat(e.target.value)})} /> +
+
+
+ + +
+
+ ) : null} +
+ )} +
+
+
+
+
+ + {/* Email receipt banner */} + +
+ +

+ Auto-import receipts: email them to{" "} + inventory@labwise-auto.com + {" "}and we'll extract and add chemicals automatically. +

+
+
+ + {/* Search + stats */} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {inventory.length} chemicals + {lowStockCount > 0 && ( + <> + · + + {lowStockCount} low stock + + )} +
+
+ + {/* List */} + {isLoading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ {inventory.length === 0 + ? "No chemicals yet. Add one to get started." + : "No chemicals match your search."} +
+ ) : ( +
+ {filtered.map(item => { + const isExpanded = expandedId === item.id; + const days = item.expirationDate ? daysUntil(item.expirationDate) : null; + const expired = days !== null && days < 0; + const expiringSoon = days !== null && days >= 0 && days <= 30; + const lowStock = item.percentageFull != null && item.percentageFull < 20; + + return ( + + {/* Summary row */} + + + {/* Expanded detail */} + {isExpanded && ( +
+
+ + {/* Storage */} +
+

Storage

+ + + + + + + {item.percentageFull != null && ( +
+

Fill level

+
+
+
+
+ {item.percentageFull}% +
+
+ )} +
+ + {/* Chemical info */} +
+

Chemical

+ + + + + + + +
+ + {/* Procurement */} +
+

Procurement

+ + + + {item.expirationDate && ( +
+

Expiration

+

+ {item.expirationDate} + {expired && ` (expired ${Math.abs(days!)}d ago)`} + {expiringSoon && ` (${days}d left)`} +

+
+ )} + + {item.comments && ( +
+

Comments

+

{item.comments}

+
+ )} +
+
+ +
+ + +
+
+ )} + + ); + })} +
+ )} + + {/* Add / Edit dialog */} + + + + {editingItem ? "Edit Chemical" : "Add Chemical"} + + +
+ {/* Required fields */} +
+

Required fields

+
+ +
+ + setField("chemicalName", e.target.value)} /> +
+ +
+ + setField("casNumber", e.target.value)} placeholder="e.g. 67-56-1" /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setField("piFirstName", e.target.value)} /> +
+ +
+ + setField("bldgCode", e.target.value)} /> +
+ +
+ + setField("lab", e.target.value)} /> +
+ +
+ + setField("storageLocation", e.target.value)} placeholder="e.g. Cabinet A-3" /> +
+ +
+ + setField("numberOfContainers", e.target.value)} /> +
+ +
+ + setField("amountPerContainer", e.target.value)} placeholder="e.g. 500" /> +
+ +
+ + +
+
+
+ + {/* Optional fields toggle */} + + + {showOptional && ( +
+

Optional fields

+
+
+ + setField("chemicalFormula", e.target.value)} placeholder="e.g. CH₃OH" /> +
+
+ + setField("molecularWeight", e.target.value)} placeholder="g/mol" /> +
+
+ + setField("concentration", e.target.value)} placeholder="e.g. 99.8%" /> +
+
+ + setField("percentageFull", e.target.value ? parseFloat(e.target.value) : undefined)} /> +
+
+ + setField("vendor", e.target.value)} /> +
+
+ + setField("catalogNumber", e.target.value)} /> +
+
+ + setField("lotNumber", e.target.value)} /> +
+
+ + setField("expirationDate", e.target.value)} /> +
+
+ + setField("barcode", e.target.value)} /> +
+
+ + setField("contact", e.target.value)} /> +
+
+ + setField("comments", e.target.value)} /> +
+
+
+ )} + + {formError &&

{formError}

} + +
+ + +
+
+
+
); } diff --git a/server/src/routes/chemicals.ts b/server/src/routes/chemicals.ts index fa08db2..4b9cde5 100644 --- a/server/src/routes/chemicals.ts +++ b/server/src/routes/chemicals.ts @@ -45,11 +45,11 @@ router.post('/', async (req, res) => { req.user!.id, b.piFirstName, b.physicalState, b.chemicalName, b.bldgCode, b.lab, b.storageLocation, b.storageDevice, b.numberOfContainers, b.amountPerContainer, b.unitOfMeasure, b.casNumber, - b.chemicalFormula ?? null, b.molecularWeight ?? null, b.vendor ?? null, - b.catalogNumber ?? null, b.lotNumber ?? null, - b.expirationDate ?? null, b.concentration ?? null, + b.chemicalFormula || null, b.molecularWeight || null, b.vendor || null, + b.catalogNumber || null, b.lotNumber || null, + b.expirationDate || null, b.concentration || null, b.percentageFull ?? null, b.needsManualEntry ?? null, - b.scannedImage ?? null, b.comments ?? null, b.barcode ?? null, b.contact ?? null, + b.scannedImage || null, b.comments || null, b.barcode || null, b.contact || null, ] ); res.status(201).json(snakeToCamel(result.rows[0])); @@ -62,12 +62,13 @@ router.post('/', async (req, res) => { // PATCH /api/chemicals/:id router.patch('/:id', async (req, res) => { try { - const fields = Object.keys(req.body); + const skip = new Set(['id', 'user_id', 'created_at', 'updated_at']); + const fields = Object.keys(req.body).filter(k => !skip.has(k) && !skip.has(camelToSnake(k))); if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' }); const snakeFields = fields.map(camelToSnake); const setClauses = snakeFields.map((f, i) => `${f} = $${i + 3}`).join(', '); - const values = fields.map(f => req.body[f]); + const values = fields.map(f => req.body[f] || null); const result = await pool.query( `UPDATE chemicals SET ${setClauses}, updated_at = NOW()