added table view
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
import { chemicalsApi } from "../lib/api";
|
import { chemicalsApi } from "../lib/api";
|
||||||
import type { ChemicalInventory } from "../shared/types";
|
import type { ChemicalInventory } from "../shared/types";
|
||||||
import { Card } from "./ui/card";
|
import { Card } from "./ui/card";
|
||||||
@@ -9,6 +10,7 @@ import { Badge } from "./ui/badge";
|
|||||||
import {
|
import {
|
||||||
Camera, Sparkles, Mail, Search, Plus, AlertTriangle,
|
Camera, Sparkles, Mail, Search, Plus, AlertTriangle,
|
||||||
ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical,
|
ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical,
|
||||||
|
Table2, FileDown, FileSpreadsheet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
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 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 ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function daysUntil(dateStr: string) {
|
function daysUntil(dateStr: string) {
|
||||||
@@ -99,6 +135,9 @@ export function Inventory() {
|
|||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
const [showOptional, setShowOptional] = useState(false);
|
const [showOptional, setShowOptional] = useState(false);
|
||||||
|
|
||||||
|
// table view dialog
|
||||||
|
const [tableViewOpen, setTableViewOpen] = useState(false);
|
||||||
|
|
||||||
// scan dialog
|
// scan dialog
|
||||||
const [scanOpen, setScanOpen] = useState(false);
|
const [scanOpen, setScanOpen] = useState(false);
|
||||||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||||
@@ -224,6 +263,36 @@ export function Inventory() {
|
|||||||
} catch (err) { console.error(err); }
|
} 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 ─────────────────────────────────────────────────────────────
|
// ── derived ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filtered = inventory.filter(c =>
|
const filtered = inventory.filter(c =>
|
||||||
@@ -247,6 +316,11 @@ export function Inventory() {
|
|||||||
<p className="text-muted-foreground text-sm">Track, manage, and monitor your lab chemicals</p>
|
<p className="text-muted-foreground text-sm">Track, manage, and monitor your lab chemicals</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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 */}
|
{/* Add manually */}
|
||||||
<Button onClick={openAdd} className="gap-2 bg-primary hover:bg-primary/90">
|
<Button onClick={openAdd} className="gap-2 bg-primary hover:bg-primary/90">
|
||||||
<Plus className="w-4 h-4" /> Add Chemical
|
<Plus className="w-4 h-4" /> Add Chemical
|
||||||
@@ -527,6 +601,68 @@ export function Inventory() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Table View dialog */}
|
||||||
|
<Dialog open={tableViewOpen} onOpenChange={setTableViewOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] w-[95vw] max-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 */}
|
{/* Add / Edit dialog */}
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
|||||||
@@ -53,7 +53,8 @@
|
|||||||
"recharts": "^2.15.2",
|
"recharts": "^2.15.2",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"vaul": "^1.1.2"
|
"vaul": "^1.1.2",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
|||||||
@@ -10,11 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-ses": "^3.1013.0",
|
"@aws-sdk/client-ses": "^3.1013.0",
|
||||||
|
"@better-auth/kysely-adapter": "^1.5.6",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"kysely": "^0.28.15",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
"pg": "^8.13.3"
|
"pg": "^8.13.3"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user