From d3021014ed56dca8efdaf9ca8ddf411bc5f09f48 Mon Sep 17 00:00:00 2001 From: pulipakaa24 Date: Wed, 1 Apr 2026 20:39:34 -0500 Subject: [PATCH] Table Importing Added, UI updates --- components/Inventory.tsx | 284 ++++++++++++++++++++++++++++++++- lib/api.ts | 6 + server/src/routes/chemicals.ts | 38 +++++ 3 files changed, 324 insertions(+), 4 deletions(-) diff --git a/components/Inventory.tsx b/components/Inventory.tsx index dd0278c..85fe4d5 100644 --- a/components/Inventory.tsx +++ b/components/Inventory.tsx @@ -10,7 +10,7 @@ import { Badge } from "./ui/badge"; import { Camera, Sparkles, Mail, Search, Plus, AlertTriangle, ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical, - Table2, FileDown, FileSpreadsheet, + Table2, FileDown, FileSpreadsheet, Upload, ArrowRight, } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, @@ -138,6 +138,17 @@ export function Inventory() { // table view dialog const [tableViewOpen, setTableViewOpen] = useState(false); + // import dialog + const [importOpen, setImportOpen] = useState(false); + const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload"); + const [importHeaders, setImportHeaders] = useState([]); + const [importRows, setImportRows] = useState([]); + const [columnMapping, setColumnMapping] = useState>({}); + const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(""); + const importFileRef = useRef(null); + // scan dialog const [scanOpen, setScanOpen] = useState(false); const [capturedImage, setCapturedImage] = useState(null); @@ -297,6 +308,140 @@ export function Inventory() { URL.revokeObjectURL(url); } + // ── import helpers ────────────────────────────────────────────────────── + + function normalizeHeader(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]/g, ""); + } + + function fuzzyMatchColumn(header: string): keyof ChemicalInventory | "__skip__" { + const norm = normalizeHeader(header); + if (!norm) return "__skip__"; + for (const col of TABLE_COLUMNS) { + if (normalizeHeader(col.label) === norm) return col.key; + } + for (const col of TABLE_COLUMNS) { + const colNorm = normalizeHeader(col.label); + if (colNorm.startsWith(norm) || norm.startsWith(colNorm)) return col.key; + } + let bestKey: keyof ChemicalInventory | "__skip__" = "__skip__"; + let bestScore = 0.55; + for (const col of TABLE_COLUMNS) { + const colNorm = normalizeHeader(col.label); + let matches = 0, ci = 0; + for (const ch of norm) { + while (ci < colNorm.length && colNorm[ci] !== ch) ci++; + if (ci < colNorm.length) { matches++; ci++; } + } + const score = matches / Math.max(norm.length, colNorm.length); + if (score > bestScore) { bestScore = score; bestKey = col.key; } + } + return bestKey; + } + + function openImport() { + setImportStep("upload"); + setImportHeaders([]); + setImportRows([]); + setColumnMapping({}); + setImportResult(null); + setImportError(""); + setImportOpen(true); + } + + async function handleImportFileSelect(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + console.log("[import] file selected:", file?.name, file?.type, file?.size); + if (!file) return; + e.target.value = ""; + setImportError(""); + + try { + let allRows: string[][]; + + if (file.name.toLowerCase().endsWith(".csv")) { + console.log("[import] parsing as CSV"); + const text = await file.text(); + console.log("[import] CSV text length:", text.length); + allRows = text.split(/\r?\n/).map(line => { + const row: string[] = []; + let cur = "", inQuote = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + if (inQuote && line[i + 1] === '"') { cur += '"'; i++; } + else inQuote = !inQuote; + } else if (ch === "," && !inQuote) { + row.push(cur); cur = ""; + } else { + cur += ch; + } + } + row.push(cur); + return row; + }); + } else { + console.log("[import] parsing as XLSX"); + const buffer = await file.arrayBuffer(); + console.log("[import] arrayBuffer size:", buffer.byteLength); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(buffer); + console.log("[import] workbook loaded, worksheets:", wb.worksheets.length); + const ws = wb.worksheets[0]; + if (!ws) throw new Error("No worksheets found in this file."); + allRows = []; + ws.eachRow(row => { + allRows.push((row.values as ExcelJS.CellValue[]).slice(1).map(v => String(v ?? ""))); + }); + console.log("[import] parsed rows:", allRows.length); + } + + if (allRows.length < 2) { + setImportError("The file appears to be empty or has no data rows."); + return; + } + const headers = allRows[0]; + const dataRows = allRows.slice(1).filter(r => r.some(c => c.trim() !== "")); + console.log("[import] headers:", headers, "data rows:", dataRows.length); + setImportHeaders(headers); + setImportRows(dataRows); + const mapping: Record = {}; + for (const h of headers) mapping[h] = fuzzyMatchColumn(h); + setColumnMapping(mapping); + console.log("[import] advancing to map step"); + setImportStep("map"); + } catch (err) { + console.error("[import] error:", err); + setImportError(`Could not read file: ${(err as Error).message}`); + } + } + + async function handleImportConfirm() { + setIsImporting(true); + try { + const rows = importRows.map(row => { + const chemical: Partial = {}; + importHeaders.forEach((header, i) => { + const key = columnMapping[header]; + if (key && key !== "__skip__") (chemical as Record)[key] = row[i] ?? ""; + }); + return chemical; + }); + const result = await chemicalsApi.import(rows); + setImportResult(result); + setImportStep("result"); + if (result.imported > 0) { + const updated = await chemicalsApi.list(); + setInventory(updated); + } + } catch (err) { + setImportResult({ imported: 0, errors: [(err as Error).message] }); + setImportStep("result"); + } finally { + setIsImporting(false); + } + } + // ── derived ───────────────────────────────────────────────────────────── const filtered = inventory.filter(c => @@ -320,9 +465,9 @@ export function Inventory() {

Track, manage, and monitor your lab chemicals

- {/* Table view */} - {/* Add manually */} @@ -430,6 +575,133 @@ export function Inventory() {
+ {/* Import Excel dialog */} + + + + + Import File + + + + {importStep === "upload" && ( +
+
importFileRef.current?.click()} + onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add("border-primary"); }} + onDragLeave={e => e.currentTarget.classList.remove("border-primary")} + onDrop={e => { + e.preventDefault(); + e.currentTarget.classList.remove("border-primary"); + const file = e.dataTransfer.files[0]; + if (file) handleImportFileSelect({ target: { files: e.dataTransfer.files, value: "" } } as unknown as React.ChangeEvent); + }} + > + +

Click or drag a file here

+

Supports .xlsx and .csv — columns will be automatically matched to your inventory fields

+ +
+ {importError && ( +

+ {importError} +

+ )} +
+ )} + + {importStep === "map" && ( +
+

+ Review the column mapping below. Adjust any mismatches before importing{" "} + {importRows.length} row{importRows.length !== 1 ? "s" : ""}. +

+
+ + + + + + + + + + + {importHeaders.map((header, idx) => ( + + + + + + + ))} + +
File ColumnMaps ToSample
{header} + + + + + {importRows[0]?.[idx] ?? ""} +
+
+
+ + +
+
+ )} + + {importStep === "result" && importResult && ( +
+
0 ? "bg-accent border-border" : "bg-red-50 border-red-200"}`}> +

+ {importResult.imported > 0 + ? `Successfully imported ${importResult.imported} chemical${importResult.imported !== 1 ? "s" : ""}.` + : "No rows were imported."} +

+
+ {importResult.errors.length > 0 && ( +
+

+ {importResult.errors.length} row{importResult.errors.length !== 1 ? "s" : ""} failed +

+
+ {importResult.errors.map((e, i) => ( +

{e}

+ ))} +
+
+ )} +
+ + +
+
+ )} +
+
+ {/* Email receipt banner */}
@@ -463,6 +735,10 @@ export function Inventory() { {lowStockCount} low stock )} + · +
diff --git a/lib/api.ts b/lib/api.ts index 058faca..050b6f9 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -34,6 +34,12 @@ export const chemicalsApi = { remove: (id: string): Promise => apiFetch(`/api/chemicals/${id}`, { method: 'DELETE' }).then(() => undefined), + + import: (rows: Partial[]): Promise<{ imported: number; errors: string[] }> => + apiFetch('/api/chemicals/import', { + method: 'POST', + body: JSON.stringify({ rows }), + }).then(r => r.json()), }; export const protocolsApi = { diff --git a/server/src/routes/chemicals.ts b/server/src/routes/chemicals.ts index 8f45a34..9dbc58a 100644 --- a/server/src/routes/chemicals.ts +++ b/server/src/routes/chemicals.ts @@ -84,6 +84,44 @@ router.patch('/:id', async (req, res) => { } }); +// POST /api/chemicals/import +router.post('/import', async (req, res) => { + const rows: Partial>[] = req.body.rows; + if (!Array.isArray(rows) || rows.length === 0) { + return res.status(400).json({ error: 'No rows provided' }); + } + if (rows.length > 1000) { + return res.status(400).json({ error: 'Maximum 1000 rows per import' }); + } + + const skipFields = new Set(['id', 'userId', 'createdAt', 'updatedAt', 'dateEntered', 'lastChanged']); + let imported = 0; + const errors: string[] = []; + + for (let i = 0; i < rows.length; i++) { + const b = rows[i]; + const fields = Object.keys(b).filter(k => !skipFields.has(k) && b[k] !== '' && b[k] != null); + if (fields.length === 0) continue; + + const snakeCols = fields.map(camelToSnake); + const allCols = ['user_id', ...snakeCols]; + const placeholders = allCols.map((_, idx) => `$${idx + 1}`).join(', '); + const values = [req.user!.id, ...fields.map(f => b[f] || null)]; + + try { + await pool.query( + `INSERT INTO chemicals (${allCols.join(', ')}) VALUES (${placeholders})`, + values + ); + imported++; + } catch (err) { + errors.push(`Row ${i + 1}: ${(err as Error).message}`); + } + } + + res.json({ imported, errors }); +}); + // DELETE /api/chemicals/:id router.delete('/:id', async (req, res) => { try {