diff --git a/components/Inventory.tsx b/components/Inventory.tsx index 034bbf0..b8d396f 100644 --- a/components/Inventory.tsx +++ b/components/Inventory.tsx @@ -12,6 +12,7 @@ import { Camera, Sparkles, Mail, Search, Plus, AlertTriangle, ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical, Table2, FileDown, FileSpreadsheet, Upload, ArrowRight, + CheckSquare, } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, @@ -19,6 +20,7 @@ import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./ui/select"; +import { Checkbox } from "./ui/checkbox"; // ── constants ────────────────────────────────────────────────────────────── @@ -130,6 +132,11 @@ export function Inventory() { const [searchQuery, setSearchQuery] = useState(""); const [expandedId, setExpandedId] = useState(null); + // selection mode + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isDeleting, setIsDeleting] = useState(false); + // add/edit dialog const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); @@ -144,9 +151,12 @@ export function Inventory() { // import dialog const [importOpen, setImportOpen] = useState(false); const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload"); + const [fallbackReason, setFallbackReason] = useState(""); const [importHeaders, setImportHeaders] = useState([]); const [importRows, setImportRows] = useState([]); - const [columnMapping, setColumnMapping] = useState>({}); + const [columnMapping, setColumnMapping] = useState>({}); + const [columnMatchInfo, setColumnMatchInfo] = useState>({}); + const [isMatching, setIsMatching] = useState(false); const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null); const [isImporting, setIsImporting] = useState(false); const [importError, setImportError] = useState(""); @@ -192,25 +202,15 @@ export function Inventory() { } 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; - } - if (!validateCAS(String(form.casNumber || ""))) { + if (form.casNumber && !validateCAS(String(form.casNumber))) { setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1)."); return; } - if (!validateNumber(form.numberOfContainers, { min: 1, integer: true })) { + if (form.numberOfContainers && !validateNumber(form.numberOfContainers, { min: 1, integer: true })) { setFormError("# of containers must be a whole number of 1 or more."); return; } - if (!validateNumber(form.amountPerContainer, { min: 0 })) { + if (form.amountPerContainer && !validateNumber(form.amountPerContainer, { min: 0 })) { setFormError("Amount per container must be a number."); return; } @@ -249,6 +249,31 @@ export function Inventory() { await chemicalsApi.remove(id); setInventory(inv => inv.filter(c => c.id !== id)); if (expandedId === id) setExpandedId(null); + setSelectedIds(prev => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + + async function handleDeleteSelected() { + if (selectedIds.size === 0) return; + if (!confirm(`Remove ${selectedIds.size} selected chemical(s) from inventory?`)) return; + + setIsDeleting(true); + try { + const ids = Array.from(selectedIds); + await chemicalsApi.bulkRemove(ids); + setInventory(inv => inv.filter(c => !selectedIds.has(c.id))); + setExpandedId(null); + setSelectedIds(new Set()); + setIsSelectionMode(false); + } catch (err) { + console.error("Failed to delete selected:", err); + alert("Failed to delete some or all items. Please refresh and try again."); + } finally { + setIsDeleting(false); + } } // ── scan helpers ──────────────────────────────────────────────────────── @@ -305,7 +330,10 @@ export function Inventory() { function handleDownloadCSV() { const headers = TABLE_COLUMNS.map(c => c.label); - const rows = filtered.map(item => + const itemsToExport = isSelectionMode && selectedIds.size > 0 + ? filtered.filter(item => selectedIds.has(item.id)) + : filtered; + const rows = itemsToExport.map(item => TABLE_COLUMNS.map(c => String(item[c.key] ?? "")) ); const csv = [headers, ...rows] @@ -324,7 +352,10 @@ export function Inventory() { const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet("Chemical Inventory"); ws.addRow(TABLE_COLUMNS.map(c => c.label)); - filtered.forEach(item => ws.addRow(TABLE_COLUMNS.map(c => item[c.key] ?? ""))); + const itemsToExport = isSelectionMode && selectedIds.size > 0 + ? filtered.filter(item => selectedIds.has(item.id)) + : filtered; + itemsToExport.forEach(item => ws.addRow(TABLE_COLUMNS.map(c => item[c.key] ?? ""))); const buffer = await wb.xlsx.writeBuffer(); const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); const url = URL.createObjectURL(blob); @@ -371,8 +402,10 @@ export function Inventory() { setImportHeaders([]); setImportRows([]); setColumnMapping({}); + setColumnMatchInfo({}); setImportResult(null); setImportError(""); + setFallbackReason(""); setImportOpen(true); } @@ -418,7 +451,15 @@ export function Inventory() { 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 ?? ""))); + allRows.push((row.values as ExcelJS.CellValue[]).slice(1).map(v => { + if (v instanceof Date) { + return v.toISOString().split('T')[0]; + } + if (v && typeof v === 'object' && 'result' in v && v.result instanceof Date) { + return v.result.toISOString().split('T')[0]; + } + return String(v ?? ""); + })); }); console.log("[import] parsed rows:", allRows.length); } @@ -432,11 +473,44 @@ export function Inventory() { 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"); + // Seed every header to skip while we wait for the semantic matcher. + const seeded: Record = {}; + for (const h of headers) seeded[h] = "__skip__"; + setColumnMapping(seeded); + setColumnMatchInfo({}); setImportStep("map"); + + // Run the server-side semantic matcher: Gemini API + setIsMatching(true); + try { + const sampleRows = dataRows.slice(0, 12); + const result = await chemicalsApi.columnMatch(headers, sampleRows); + const validKeys = new Set(TABLE_COLUMNS.map(c => c.key as string)); + const next: Record = { ...seeded }; + const info: Record = {}; + for (const m of result.mappings) { + const key = m.key === "__skip__" || validKeys.has(m.key) + ? (m.key as keyof ChemicalInventory | "__skip__") + : "__skip__"; + next[m.header] = key; + info[m.header] = { confidence: m.confidence, reason: m.reason }; + } + setColumnMapping(next); + setColumnMatchInfo(info); + } catch (matchErr) { + console.error("[import] semantic match failed:", matchErr); + setFallbackReason((matchErr as Error).message); + const mapping: Record = {}; + const info: Record = {}; + for (const h of headers) { + mapping[h] = fuzzyMatchColumn(h); + info[h] = { confidence: 0.6, reason: "Used fuzzy matching fallback." }; + } + setColumnMapping(mapping); + setColumnMatchInfo(info); + } finally { + setIsMatching(false); + } } catch (err) { console.error("[import] error:", err); setImportError(`Could not read file: ${(err as Error).message}`); @@ -480,11 +554,32 @@ export function Inventory() { const lowStockCount = inventory.filter(c => c.percentageFull != null && c.percentageFull < 20).length; + const isMissingKeyInfo = (c: ChemicalInventory) => + !c.piFirstName || !c.physicalState || !c.chemicalName || !c.bldgCode || !c.lab || + !c.storageLocation || !c.storageDevice || !c.numberOfContainers || !c.amountPerContainer || + !c.unitOfMeasure || !c.casNumber; + + const missingInfoCount = inventory.filter(isMissingKeyInfo).length; + // ── render ─────────────────────────────────────────────────────────────── return (
+ {missingInfoCount > 0 && ( + +
+ +
+

Missing Key Information

+

+ You have {missingInfoCount} {missingInfoCount === 1 ? "item" : "items"} missing important fields. Please review the highlighted items below and update them. +

+
+
+
+ )} + {/* Header */}
@@ -638,66 +733,144 @@ export function Inventory() {
)} - {importStep === "map" && ( -
-

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

-
- - - - - - - - - - - {importHeaders.map((header, idx) => ( - - - - - + {importStep === "map" && (() => { + // Compute which target keys are claimed by more than one source header. + const targetCounts = new Map(); + for (const h of importHeaders) { + const k = columnMapping[h]; + if (!k || k === "__skip__") continue; + const list = targetCounts.get(k) ?? []; + list.push(h); + targetCounts.set(k, list); + } + const duplicateTargets = new Map( + Array.from(targetCounts.entries()).filter(([, hs]) => hs.length > 1), + ); + const conflictHeaders = new Set(); + for (const hs of duplicateTargets.values()) for (const h of hs) conflictHeaders.add(h); + const labelByKey = new Map(TABLE_COLUMNS.map(c => [c.key as string, c.label])); + const blockImport = duplicateTargets.size > 0 || isMatching; + + if (isMatching) { + return ( +
+ +

Matching columns with AI...

+

Please wait while Gemini analyzes your data.

+
+ ); + } + + return ( +
+ {fallbackReason && ( +
+

+ Gemini reasoning failed +

+

+ Error: {fallbackReason}. Used fuzzy matching instead. Please verify the mappings. +

+
+ )} +

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

+ + {duplicateTargets.size > 0 && ( +
+

+ Each inventory field can only be filled by one file column. +

+
    + {Array.from(duplicateTargets.entries()).map(([key, hs]) => ( +
  • + {labelByKey.get(key) ?? key} is mapped from:{" "} + {hs.map(h => `“${h}”`).join(", ")} — pick one and set the others to “— skip —”. +
  • + ))} +
+
+ )} + +
+
File ColumnMaps ToSample
{header} - - - - - {importRows[0]?.[idx] ?? ""} -
+ + + + + + - ))} - -
File ColumnMaps ToSample
+ + + {importHeaders.map((header, idx) => { + const isConflict = conflictHeaders.has(header); + const info = columnMatchInfo[header]; + const lowConfidence = info && info.confidence > 0 && info.confidence < 0.5 + && columnMapping[header] !== "__skip__"; + return ( + + {header} + + + + + + {lowConfidence && !isConflict && ( +

+ Low confidence — please verify. +

+ )} + + + {importRows[0]?.[idx] ?? ""} + + + ); + })} + + +
+ +
+ + +
-
- - -
-
- )} + ); + })()} {importStep === "result" && importResult && (
@@ -741,33 +914,76 @@ export function Inventory() {
- {/* Search + stats */} -
-
- - setSearchQuery(e.target.value)} - /> + {/* Search + stats / Selection Action Bar */} + {isSelectionMode ? ( + +
+
+ 0 && selectedIds.size === filtered.length} + onCheckedChange={(checked) => { + if (checked) { + setSelectedIds(new Set(filtered.map(c => c.id))); + } else { + setSelectedIds(new Set()); + } + }} + /> + +
+ + {selectedIds.size} selected + +
+
+ + + + +
+
+ ) : ( +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {inventory.length} chemicals + {lowStockCount > 0 && ( + <> + · + + {lowStockCount} low stock + + )} + · + + · + +
-
- - {inventory.length} chemicals - {lowStockCount > 0 && ( - <> - · - - {lowStockCount} low stock - - )} - · - -
-
+ )} {/* List */} {isLoading ? ( @@ -788,15 +1004,39 @@ export function Inventory() { const expired = days !== null && days < 0; const expiringSoon = days !== null && days >= 0 && days <= 30; const lowStock = item.percentageFull != null && item.percentageFull < 20; + const missingInfo = isMissingKeyInfo(item); + + const isSelected = selectedIds.has(item.id); return ( - + {/* Summary row */} - + +
{/* Expanded detail */} {isExpanded && ( diff --git a/lib/api.ts b/lib/api.ts index 872cae4..dbd8f47 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -35,11 +35,23 @@ export const chemicalsApi = { remove: (id: string): Promise => apiFetch(`/api/chemicals/${id}`, { method: 'DELETE' }).then(() => undefined), + bulkRemove: (ids: string[]): Promise => + apiFetch('/api/chemicals/bulk-delete', { + method: 'POST', + body: JSON.stringify({ ids }) + }).then(() => undefined), + import: (rows: Partial[]): Promise<{ imported: number; errors: string[] }> => apiFetch('/api/chemicals/import', { method: 'POST', body: JSON.stringify({ rows }), }).then(r => r.json()), + + columnMatch: (headers: string[], sampleRows: string[][]): Promise<{ mappings: { header: string; key: string; confidence: number; reason: string }[] }> => + apiFetch('/api/chemicals/column-match', { + method: 'POST', + body: JSON.stringify({ headers, sampleRows }), + }).then(r => r.json()), }; export const protocolsApi = { diff --git a/server/package.json b/server/package.json index 20bd96a..4206c00 100644 --- a/server/package.json +++ b/server/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@better-auth/kysely-adapter": "^1.5.6", + "@google/generative-ai": "^0.24.1", "@node-rs/argon2": "^2.0.2", "better-auth": "^1.5.5", "cors": "^2.8.5", diff --git a/server/src/auth/email.ts b/server/src/auth/email.ts index 23ede32..7aa5b8d 100644 --- a/server/src/auth/email.ts +++ b/server/src/auth/email.ts @@ -2,10 +2,10 @@ import FormData from 'form-data'; import Mailgun from 'mailgun.js'; const mailgun = new (Mailgun as any)(FormData); -const mg = mailgun.client({ +const mg = process.env.MAILGUN_API_KEY ? mailgun.client({ username: 'api', - key: process.env.MAILGUN_API_KEY || '', -}); + key: process.env.MAILGUN_API_KEY, +}) : null; const DOMAIN = process.env.MAILGUN_DOMAIN || 'sandbox06aa4efa8cc342878b7470a7c9113df3.mailgun.org'; const FROM = process.env.FROM_EMAIL || `LabWise `; @@ -19,6 +19,10 @@ export async function sendEmail({ subject: string; html: string; }) { + if (!mg) { + console.warn('[auth] MAILGUN_API_KEY is not set. Email not sent:', subject); + return; + } await mg.messages.create(DOMAIN, { from: FROM, to: [to], diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index d61283e..232138d 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -128,3 +128,16 @@ ALTER TABLE user_profile ALTER COLUMN lab DROP NOT NULL; -- Allow name to be cleared (set to null). ALTER TABLE "user" ALTER COLUMN "name" DROP NOT NULL; + +-- Allow incomplete chemical imports. +ALTER TABLE chemicals ALTER COLUMN pi_first_name DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN physical_state DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN chemical_name DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN bldg_code DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN lab DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN storage_location DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN storage_device DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN number_of_containers DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN amount_per_container DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN unit_of_measure DROP NOT NULL; +ALTER TABLE chemicals ALTER COLUMN cas_number DROP NOT NULL; diff --git a/server/src/routes/chemicals.ts b/server/src/routes/chemicals.ts index cbab52f..c1f9767 100644 --- a/server/src/routes/chemicals.ts +++ b/server/src/routes/chemicals.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { pool } from '../db/pool.js'; import { requireAuth } from '../auth/middleware.js'; +import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai'; const router = Router(); router.use(requireAuth); @@ -42,9 +43,9 @@ router.post('/', async (req, res) => { $13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25 ) RETURNING *, percentage_full::float AS percentage_full, expiration_date::text AS expiration_date`, [ - 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, + req.user!.id, b.piFirstName || null, b.physicalState || null, b.chemicalName || null, b.bldgCode || null, b.lab || null, + b.storageLocation || null, b.storageDevice || null, b.numberOfContainers || null, b.amountPerContainer || null, + b.unitOfMeasure || null, b.casNumber || null, b.chemicalFormula || null, b.molecularWeight || null, b.vendor || null, b.catalogNumber || null, b.lotNumber || null, b.expirationDate || null, b.concentration || null, @@ -122,6 +123,25 @@ router.post('/import', async (req, res) => { res.json({ imported, errors }); }); +// POST /api/chemicals/bulk-delete +router.post('/bulk-delete', async (req, res) => { + try { + const { ids } = req.body; + if (!Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ error: 'No IDs provided' }); + } + + await pool.query( + 'DELETE FROM chemicals WHERE id = ANY($1) AND user_id = $2', + [ids, req.user!.id] + ); + res.status(204).end(); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // DELETE /api/chemicals/:id router.delete('/:id', async (req, res) => { try { @@ -143,4 +163,118 @@ function snakeToCamel(row: Record): Record { ); } +// POST /api/chemicals/column-match +router.post('/column-match', async (req, res) => { + try { + const { headers, sampleRows } = req.body; + if (!headers || !Array.isArray(headers)) { + return res.status(400).json({ error: 'Headers must be an array' }); + } + + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + // Graceful fallback or error + return res.status(500).json({ error: 'GEMINI_API_KEY is not configured.' }); + } + + const genAI = new GoogleGenerativeAI(apiKey); + const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); + + const canonicalColumns = [ + { 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" }, + ]; + + const prompt = `You are a data migration assistant. Your task is to map a user's uploaded spreadsheet columns to a canonical database schema. + +Here are the user's headers and a few sample rows: +Headers: ${JSON.stringify(headers)} +Samples: ${JSON.stringify(sampleRows || [])} + +Here is our canonical schema: +${JSON.stringify(canonicalColumns, null, 2)} + +For each user header, find the single best matching canonical 'key'. If there is no logical match, map it to "__skip__". +Constraints: +- You may only map a given canonical key ONCE across all headers. Do not assign two user headers to the same canonical key. +- Try to be accurate based on semantic meaning, typical lab inventory vocabulary, and the sample values. + +Respond ONLY with a JSON object in this exact format, with no markdown code block fences and no other text: +{ + "mappings": [ + { + "header": "the exact user header string", + "key": "the canonical key or __skip__", + "confidence": 0.95, + "reason": "short explanation of why this matches or why it was skipped" + } + ] +} +Ensure there is exactly one mapping per user header in the same order as the input headers.`; + + const result = await model.generateContent({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { + responseMimeType: 'application/json', + responseSchema: { + type: SchemaType.OBJECT, + properties: { + mappings: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.OBJECT, + properties: { + header: { type: SchemaType.STRING }, + key: { type: SchemaType.STRING }, + confidence: { type: SchemaType.NUMBER }, + reason: { type: SchemaType.STRING }, + }, + required: ["header", "key", "confidence", "reason"] + } + } + }, + required: ["mappings"] + } + } + }); + + let text = result.response.text(); + text = text.replace(/^```(?:json)?\n?/i, '').replace(/```\n?$/, '').trim(); + const parsed = JSON.parse(text); + res.json(parsed); + } catch (err) { + console.error('Column match error:', err); + res.status(500).json({ error: 'Failed to process column mapping.' }); + } +}); + export default router;