diff --git a/components/Inventory.tsx b/components/Inventory.tsx index 20bfa56..8263add 100644 --- a/components/Inventory.tsx +++ b/components/Inventory.tsx @@ -1,4 +1,5 @@ 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"; @@ -9,6 +10,7 @@ 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, @@ -29,6 +31,40 @@ 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) { @@ -99,6 +135,9 @@ export function Inventory() { 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(null); @@ -224,6 +263,36 @@ export function Inventory() { } 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 => @@ -247,6 +316,11 @@ export function Inventory() {

Track, manage, and monitor your lab chemicals

+ {/* Table view */} + + {/* Add manually */}
)} + {/* Table View dialog */} + + + +
+ + Inventory Table + + ({filtered.length} {filtered.length === 1 ? "chemical" : "chemicals"}) + + +
+ + +
+
+
+
+ + + + {TABLE_COLUMNS.map(col => ( + + ))} + + + + {filtered.map((item, i) => ( + + {TABLE_COLUMNS.map(col => ( + + ))} + + ))} + {filtered.length === 0 && ( + + + + )} + +
+ {col.label} +
+ {String(item[col.key] ?? "")} +
+ No chemicals to display. +
+
+
+
+ {/* Add / Edit dialog */} diff --git a/package.json b/package.json index 7eded93..baaea8f 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "recharts": "^2.15.2", "sonner": "^2.0.3", "tailwind-merge": "^2.6.0", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/server/package.json b/server/package.json index 7170fad..0441176 100644 --- a/server/package.json +++ b/server/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "@aws-sdk/client-ses": "^3.1013.0", + "@better-auth/kysely-adapter": "^1.5.6", "better-auth": "^1.5.5", "cors": "^2.8.5", "dotenv": "^17.3.1", "express": "^4.21.2", "express-rate-limit": "^7.5.0", + "kysely": "^0.28.15", "multer": "^2.0.0", "pg": "^8.13.3" },