table import works now
Some checks failed
Deploy to Server / deploy (push) Failing after 1m7s

This commit is contained in:
2026-04-24 18:19:54 -05:00
parent 4098453c97
commit 24d126eeb9
6 changed files with 524 additions and 118 deletions

View File

@@ -12,6 +12,7 @@ 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, Upload, ArrowRight, Table2, FileDown, FileSpreadsheet, Upload, ArrowRight,
CheckSquare,
} from "lucide-react"; } from "lucide-react";
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
@@ -19,6 +20,7 @@ import {
import { import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "./ui/select"; } from "./ui/select";
import { Checkbox } from "./ui/checkbox";
// ── constants ────────────────────────────────────────────────────────────── // ── constants ──────────────────────────────────────────────────────────────
@@ -130,6 +132,11 @@ export function Inventory() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
// selection mode
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isDeleting, setIsDeleting] = useState(false);
// add/edit dialog // add/edit dialog
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ChemicalInventory | null>(null); const [editingItem, setEditingItem] = useState<ChemicalInventory | null>(null);
@@ -144,9 +151,12 @@ export function Inventory() {
// import dialog // import dialog
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload"); const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload");
const [fallbackReason, setFallbackReason] = useState("");
const [importHeaders, setImportHeaders] = useState<string[]>([]); const [importHeaders, setImportHeaders] = useState<string[]>([]);
const [importRows, setImportRows] = useState<string[][]>([]); const [importRows, setImportRows] = useState<string[][]>([]);
const [columnMapping, setColumnMapping] = useState<Record<string, ImportColumnMapping>>({}); const [columnMapping, setColumnMapping] = useState<Record<string, keyof ChemicalInventory | "__skip__">>({});
const [columnMatchInfo, setColumnMatchInfo] = useState<Record<string, { confidence: number; reason: string }>>({});
const [isMatching, setIsMatching] = useState(false);
const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null); const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState(""); const [importError, setImportError] = useState("");
@@ -192,25 +202,15 @@ export function Inventory() {
} }
async function handleSave() { async function handleSave() {
const required: (keyof ChemicalInventory)[] = [ if (form.casNumber && !validateCAS(String(form.casNumber))) {
"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 || ""))) {
setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1)."); setFormError("CAS # must be in the format ##-##-# (e.g. 67-56-1).");
return; 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."); setFormError("# of containers must be a whole number of 1 or more.");
return; return;
} }
if (!validateNumber(form.amountPerContainer, { min: 0 })) { if (form.amountPerContainer && !validateNumber(form.amountPerContainer, { min: 0 })) {
setFormError("Amount per container must be a number."); setFormError("Amount per container must be a number.");
return; return;
} }
@@ -249,6 +249,31 @@ export function Inventory() {
await chemicalsApi.remove(id); await chemicalsApi.remove(id);
setInventory(inv => inv.filter(c => c.id !== id)); setInventory(inv => inv.filter(c => c.id !== id));
if (expandedId === id) setExpandedId(null); 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 ──────────────────────────────────────────────────────── // ── scan helpers ────────────────────────────────────────────────────────
@@ -305,7 +330,10 @@ export function Inventory() {
function handleDownloadCSV() { function handleDownloadCSV() {
const headers = TABLE_COLUMNS.map(c => c.label); 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] ?? "")) TABLE_COLUMNS.map(c => String(item[c.key] ?? ""))
); );
const csv = [headers, ...rows] const csv = [headers, ...rows]
@@ -324,7 +352,10 @@ export function Inventory() {
const wb = new ExcelJS.Workbook(); const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet("Chemical Inventory"); const ws = wb.addWorksheet("Chemical Inventory");
ws.addRow(TABLE_COLUMNS.map(c => c.label)); 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 buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -371,8 +402,10 @@ export function Inventory() {
setImportHeaders([]); setImportHeaders([]);
setImportRows([]); setImportRows([]);
setColumnMapping({}); setColumnMapping({});
setColumnMatchInfo({});
setImportResult(null); setImportResult(null);
setImportError(""); setImportError("");
setFallbackReason("");
setImportOpen(true); setImportOpen(true);
} }
@@ -418,7 +451,15 @@ export function Inventory() {
if (!ws) throw new Error("No worksheets found in this file."); if (!ws) throw new Error("No worksheets found in this file.");
allRows = []; allRows = [];
ws.eachRow(row => { 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); console.log("[import] parsed rows:", allRows.length);
} }
@@ -432,11 +473,44 @@ export function Inventory() {
console.log("[import] headers:", headers, "data rows:", dataRows.length); console.log("[import] headers:", headers, "data rows:", dataRows.length);
setImportHeaders(headers); setImportHeaders(headers);
setImportRows(dataRows); setImportRows(dataRows);
const mapping: Record<string, ImportColumnMapping> = {}; // Seed every header to skip while we wait for the semantic matcher.
for (const h of headers) mapping[h] = fuzzyMatchColumn(h); const seeded: Record<string, keyof ChemicalInventory | "__skip__"> = {};
setColumnMapping(mapping); for (const h of headers) seeded[h] = "__skip__";
console.log("[import] advancing to map step"); setColumnMapping(seeded);
setColumnMatchInfo({});
setImportStep("map"); 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<string, keyof ChemicalInventory | "__skip__"> = { ...seeded };
const info: Record<string, { confidence: number; reason: string }> = {};
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<string, keyof ChemicalInventory | "__skip__"> = {};
const info: Record<string, { confidence: number; reason: string }> = {};
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) { } catch (err) {
console.error("[import] error:", err); console.error("[import] error:", err);
setImportError(`Could not read file: ${(err as Error).message}`); 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 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 ─────────────────────────────────────────────────────────────── // ── render ───────────────────────────────────────────────────────────────
return ( return (
<div className="p-6 max-w-5xl mx-auto"> <div className="p-6 max-w-5xl mx-auto">
{missingInfoCount > 0 && (
<Card className="mb-4 p-4 bg-amber-50 border-amber-200">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800">Missing Key Information</p>
<p className="text-sm text-amber-700">
You have {missingInfoCount} {missingInfoCount === 1 ? "item" : "items"} missing important fields. Please review the highlighted items below and update them.
</p>
</div>
</div>
</Card>
)}
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
@@ -638,66 +733,144 @@ export function Inventory() {
</div> </div>
)} )}
{importStep === "map" && ( {importStep === "map" && (() => {
<div className="space-y-4 mt-2"> // Compute which target keys are claimed by more than one source header.
<p className="text-sm text-muted-foreground"> const targetCounts = new Map<string, string[]>();
Review the column mapping below. Adjust any mismatches before importing{" "} for (const h of importHeaders) {
<span className="font-medium text-foreground">{importRows.length} row{importRows.length !== 1 ? "s" : ""}</span>. const k = columnMapping[h];
</p> if (!k || k === "__skip__") continue;
<div className="border border-border rounded-lg overflow-hidden"> const list = targetCounts.get(k) ?? [];
<table className="w-full text-sm"> list.push(h);
<thead className="bg-muted/50"> targetCounts.set(k, list);
<tr> }
<th className="text-left px-3 py-2 text-muted-foreground font-medium">File Column</th> const duplicateTargets = new Map<string, string[]>(
<th className="text-left px-3 py-2 text-muted-foreground font-medium w-8"></th> Array.from(targetCounts.entries()).filter(([, hs]) => hs.length > 1),
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Maps To</th> );
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Sample</th> const conflictHeaders = new Set<string>();
</tr> for (const hs of duplicateTargets.values()) for (const h of hs) conflictHeaders.add(h);
</thead> const labelByKey = new Map(TABLE_COLUMNS.map(c => [c.key as string, c.label]));
<tbody> const blockImport = duplicateTargets.size > 0 || isMatching;
{importHeaders.map((header, idx) => (
<tr key={header} className="border-t border-border"> if (isMatching) {
<td className="px-3 py-2 text-foreground font-mono text-xs">{header}</td> return (
<td className="px-1 py-2 text-muted-foreground"> <div className="py-12 flex flex-col items-center justify-center space-y-4">
<ArrowRight className="w-3 h-3" /> <Loader2 className="w-10 h-10 text-primary animate-spin" />
</td> <p className="text-foreground font-medium">Matching columns with AI...</p>
<td className="px-3 py-2"> <p className="text-muted-foreground text-sm">Please wait while Gemini analyzes your data.</p>
<Select </div>
value={columnMapping[header] ?? ""} );
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as ImportColumnMapping }))} }
>
<SelectTrigger className="h-7 text-xs w-48"> return (
<SelectValue placeholder="— skip —" /> <div className="space-y-4 mt-2">
</SelectTrigger> {fallbackReason && (
<SelectContent> <div className="rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-1">
<SelectItem value="__skip__"> skip </SelectItem> <p className="text-sm font-medium text-amber-800 flex items-center gap-1">
{TABLE_COLUMNS.map(col => ( <AlertTriangle className="w-4 h-4" /> Gemini reasoning failed
<SelectItem key={col.key} value={col.key}>{col.label}</SelectItem> </p>
))} <p className="text-xs text-amber-700">
</SelectContent> Error: {fallbackReason}. Used fuzzy matching instead. Please verify the mappings.
</Select> </p>
</td> </div>
<td className="px-3 py-2 text-muted-foreground text-xs truncate max-w-[120px]"> )}
{importRows[0]?.[idx] ?? ""} <p className="text-sm text-muted-foreground">
</td> Review the column mapping below. Adjust any mismatches before importing{" "}
<span className="font-medium text-foreground">{importRows.length} row{importRows.length !== 1 ? "s" : ""}</span>.
</p>
{duplicateTargets.size > 0 && (
<div className="rounded-lg border border-red-300 bg-red-50 p-3 space-y-1">
<p className="text-sm font-medium text-red-700 flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> Each inventory field can only be filled by one file column.
</p>
<ul className="text-xs text-red-700 list-disc pl-5 space-y-0.5">
{Array.from(duplicateTargets.entries()).map(([key, hs]) => (
<li key={key}>
<span className="font-medium">{labelByKey.get(key) ?? key}</span> is mapped from:{" "}
{hs.map(h => `${h}`).join(", ")} pick one and set the others to skip .
</li>
))}
</ul>
</div>
)}
<div className="border border-border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">File Column</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium w-8"></th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Maps To</th>
<th className="text-left px-3 py-2 text-muted-foreground font-medium">Sample</th>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {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 (
<tr
key={header}
className={`border-t border-border ${isConflict ? "bg-red-50" : ""}`}
>
<td className="px-3 py-2 text-foreground font-mono text-xs">{header}</td>
<td className="px-1 py-2 text-muted-foreground">
<ArrowRight className="w-3 h-3" />
</td>
<td className="px-3 py-2">
<Select
value={columnMapping[header] ?? "__skip__"}
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as keyof ChemicalInventory | "__skip__" }))}
>
<SelectTrigger
className={`h-7 text-xs w-48 ${isConflict ? "border-red-400 ring-1 ring-red-300" : ""}`}
title={info?.reason || undefined}
>
<SelectValue placeholder="— skip —" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__skip__"> skip </SelectItem>
{TABLE_COLUMNS.map(col => (
<SelectItem key={col.key} value={col.key}>{col.label}</SelectItem>
))}
</SelectContent>
</Select>
{lowConfidence && !isConflict && (
<p className="text-[10px] text-amber-600 mt-0.5">
Low confidence please verify.
</p>
)}
</td>
<td className="px-3 py-2 text-muted-foreground text-xs truncate max-w-[120px]">
{importRows[0]?.[idx] ?? ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex gap-2 pt-1">
<Button
className="flex-1 bg-primary hover:bg-primary/90 gap-2"
onClick={handleImportConfirm}
disabled={isImporting || blockImport}
>
{isImporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{isImporting
? "Importing…"
: duplicateTargets.size > 0
? "Resolve duplicate mappings to import"
: `Import ${importRows.length} row${importRows.length !== 1 ? "s" : ""}`}
</Button>
<Button variant="outline" onClick={() => setImportStep("upload")}>Back</Button>
</div>
</div> </div>
<div className="flex gap-2 pt-1"> );
<Button })()}
className="flex-1 bg-primary hover:bg-primary/90 gap-2"
onClick={handleImportConfirm}
disabled={isImporting}
>
{isImporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
{isImporting ? "Importing…" : `Import ${importRows.length} row${importRows.length !== 1 ? "s" : ""}`}
</Button>
<Button variant="outline" onClick={() => setImportStep("upload")}>Back</Button>
</div>
</div>
)}
{importStep === "result" && importResult && ( {importStep === "result" && importResult && (
<div className="space-y-4 mt-2"> <div className="space-y-4 mt-2">
@@ -741,33 +914,76 @@ export function Inventory() {
</div> </div>
</Card> </Card>
{/* Search + stats */} {/* Search + stats / Selection Action Bar */}
<div className="flex items-center gap-3 mb-5"> {isSelectionMode ? (
<div className="relative flex-1"> <Card className="mb-5 p-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-primary/20 bg-primary/5">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" /> <div className="flex items-center gap-4 pl-1">
<Input <div className="flex items-center gap-2">
placeholder="Search by name, CAS, lab, or location…" <Checkbox
className="pl-9" id="select-all"
value={searchQuery} checked={filtered.length > 0 && selectedIds.size === filtered.length}
onChange={e => setSearchQuery(e.target.value)} onCheckedChange={(checked) => {
/> if (checked) {
setSelectedIds(new Set(filtered.map(c => c.id)));
} else {
setSelectedIds(new Set());
}
}}
/>
<Label htmlFor="select-all" className="cursor-pointer">Select All</Label>
</div>
<span className="text-sm font-medium text-primary">
{selectedIds.size} selected
</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button size="sm" variant="ghost" onClick={() => { setIsSelectionMode(false); setSelectedIds(new Set()); }}>
Cancel
</Button>
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadCSV} disabled={selectedIds.size === 0}>
<FileDown className="w-4 h-4" /> CSV
</Button>
<Button size="sm" variant="outline" className="gap-1.5" onClick={handleDownloadExcel} disabled={selectedIds.size === 0}>
<FileSpreadsheet className="w-4 h-4" /> Excel
</Button>
<Button size="sm" variant="destructive" className="gap-1.5" onClick={handleDeleteSelected} disabled={selectedIds.size === 0 || isDeleting}>
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
{isDeleting ? "Deleting..." : "Delete Selected"}
</Button>
</div>
</Card>
) : (
<div className="flex items-center gap-3 mb-5">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
placeholder="Search by name, CAS, lab, or location…"
className="pl-9"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex items-center gap-2 shrink-0 text-sm text-muted-foreground">
<FlaskConical className="w-4 h-4 text-[#5a9584]" />
<span className="hidden sm:inline">{inventory.length} chemicals</span>
{lowStockCount > 0 && (
<>
<span className="text-border hidden sm:inline">·</span>
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-amber-600 hidden sm:inline">{lowStockCount} low stock</span>
</>
)}
<span className="text-border">·</span>
<Button variant="ghost" size="sm" className="gap-1.5 h-7 px-2 text-muted-foreground bg-background hover:bg-background/80 hover:text-foreground rounded-md" onClick={() => setIsSelectionMode(true)}>
<CheckSquare className="w-3.5 h-3.5" /> Select
</Button>
<span className="text-border">·</span>
<Button variant="ghost" size="sm" className="gap-1.5 h-7 px-2 text-muted-foreground bg-background hover:bg-background/80 hover:text-foreground rounded-md" onClick={() => setTableViewOpen(true)}>
<Table2 className="w-3.5 h-3.5" /> Table
</Button>
</div>
</div> </div>
<div className="flex items-center gap-2 shrink-0 text-sm text-muted-foreground"> )}
<FlaskConical className="w-4 h-4 text-[#5a9584]" />
<span>{inventory.length} chemicals</span>
{lowStockCount > 0 && (
<>
<span className="text-border">·</span>
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-amber-600">{lowStockCount} low stock</span>
</>
)}
<span className="text-border">·</span>
<Button variant="ghost" size="sm" className="gap-1.5 h-7 px-2 text-muted-foreground bg-background hover:bg-background/80 hover:text-foreground rounded-md" onClick={() => setTableViewOpen(true)}>
<Table2 className="w-3.5 h-3.5" /> View / Export Table
</Button>
</div>
</div>
{/* List */} {/* List */}
{isLoading ? ( {isLoading ? (
@@ -788,15 +1004,39 @@ export function Inventory() {
const expired = days !== null && days < 0; const expired = days !== null && days < 0;
const expiringSoon = days !== null && days >= 0 && days <= 30; const expiringSoon = days !== null && days >= 0 && days <= 30;
const lowStock = item.percentageFull != null && item.percentageFull < 20; const lowStock = item.percentageFull != null && item.percentageFull < 20;
const missingInfo = isMissingKeyInfo(item);
const isSelected = selectedIds.has(item.id);
return ( return (
<Card key={item.id} className={`overflow-hidden transition-shadow ${isExpanded ? "shadow-md" : "hover:shadow-sm"}`}> <Card key={item.id} className={`overflow-hidden transition-shadow ${isExpanded ? "shadow-md" : "hover:shadow-sm"} ${missingInfo && !isExpanded ? "border-amber-300 bg-amber-50/30" : ""} ${isSelected ? "border-primary/50 bg-primary/5" : ""}`}>
{/* Summary row */} {/* Summary row */}
<button <div className="flex items-stretch w-full">
className="w-full text-left px-5 py-4 flex items-center gap-4" {isSelectionMode && (
onClick={() => setExpandedId(isExpanded ? null : item.id)} <div
> className="flex items-center justify-center px-4 cursor-pointer hover:bg-muted/50 border-r border-border/50"
{/* Name + state */} onClick={(e) => {
e.stopPropagation();
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}}
>
<Checkbox
checked={isSelected}
className="w-5 h-5 pointer-events-none"
onCheckedChange={() => {}} // handled by parent div
/>
</div>
)}
<button
className="flex-1 text-left px-5 py-4 flex items-center gap-4"
onClick={() => setExpandedId(isExpanded ? null : item.id)}
>
{/* Name + state */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-foreground">{item.chemicalName}</span> <span className="font-medium text-foreground">{item.chemicalName}</span>
@@ -804,6 +1044,7 @@ export function Inventory() {
{expired && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Expired</Badge>} {expired && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Expired</Badge>}
{expiringSoon && !expired && <Badge className="bg-amber-100 text-amber-700 border-amber-200 border text-xs py-0">Exp. {days}d</Badge>} {expiringSoon && !expired && <Badge className="bg-amber-100 text-amber-700 border-amber-200 border text-xs py-0">Exp. {days}d</Badge>}
{lowStock && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Low stock</Badge>} {lowStock && <Badge className="bg-red-100 text-red-700 border-red-200 border text-xs py-0">Low stock</Badge>}
{missingInfo && <Badge className="bg-amber-100 text-amber-800 border-amber-300 border text-xs py-0 gap-1"><AlertTriangle className="w-3 h-3" /> Missing info</Badge>}
</div> </div>
<p className="text-sm text-muted-foreground mt-0.5">{item.lab} · {item.storageLocation || "—"}</p> <p className="text-sm text-muted-foreground mt-0.5">{item.lab} · {item.storageLocation || "—"}</p>
</div> </div>
@@ -827,7 +1068,8 @@ export function Inventory() {
{isExpanded {isExpanded
? <ChevronUp className="w-4 h-4 text-muted-foreground shrink-0" /> ? <ChevronUp className="w-4 h-4 text-muted-foreground shrink-0" />
: <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />} : <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />}
</button> </button>
</div>
{/* Expanded detail */} {/* Expanded detail */}
{isExpanded && ( {isExpanded && (

View File

@@ -35,11 +35,23 @@ export const chemicalsApi = {
remove: (id: string): Promise<void> => remove: (id: string): Promise<void> =>
apiFetch(`/api/chemicals/${id}`, { method: 'DELETE' }).then(() => undefined), apiFetch(`/api/chemicals/${id}`, { method: 'DELETE' }).then(() => undefined),
bulkRemove: (ids: string[]): Promise<void> =>
apiFetch('/api/chemicals/bulk-delete', {
method: 'POST',
body: JSON.stringify({ ids })
}).then(() => undefined),
import: (rows: Partial<ChemicalInventory>[]): Promise<{ imported: number; errors: string[] }> => import: (rows: Partial<ChemicalInventory>[]): Promise<{ imported: number; errors: string[] }> =>
apiFetch('/api/chemicals/import', { apiFetch('/api/chemicals/import', {
method: 'POST', method: 'POST',
body: JSON.stringify({ rows }), body: JSON.stringify({ rows }),
}).then(r => r.json()), }).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 = { export const protocolsApi = {

View File

@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@better-auth/kysely-adapter": "^1.5.6", "@better-auth/kysely-adapter": "^1.5.6",
"@google/generative-ai": "^0.24.1",
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@@ -2,10 +2,10 @@ import FormData from 'form-data';
import Mailgun from 'mailgun.js'; import Mailgun from 'mailgun.js';
const mailgun = new (Mailgun as any)(FormData); const mailgun = new (Mailgun as any)(FormData);
const mg = mailgun.client({ const mg = process.env.MAILGUN_API_KEY ? mailgun.client({
username: 'api', 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 DOMAIN = process.env.MAILGUN_DOMAIN || 'sandbox06aa4efa8cc342878b7470a7c9113df3.mailgun.org';
const FROM = process.env.FROM_EMAIL || `LabWise <postmaster@${DOMAIN}>`; const FROM = process.env.FROM_EMAIL || `LabWise <postmaster@${DOMAIN}>`;
@@ -19,6 +19,10 @@ export async function sendEmail({
subject: string; subject: string;
html: string; html: string;
}) { }) {
if (!mg) {
console.warn('[auth] MAILGUN_API_KEY is not set. Email not sent:', subject);
return;
}
await mg.messages.create(DOMAIN, { await mg.messages.create(DOMAIN, {
from: FROM, from: FROM,
to: [to], to: [to],

View File

@@ -128,3 +128,16 @@ ALTER TABLE user_profile ALTER COLUMN lab DROP NOT NULL;
-- Allow name to be cleared (set to null). -- Allow name to be cleared (set to null).
ALTER TABLE "user" ALTER COLUMN "name" DROP NOT 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;

View File

@@ -1,6 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { pool } from '../db/pool.js'; import { pool } from '../db/pool.js';
import { requireAuth } from '../auth/middleware.js'; import { requireAuth } from '../auth/middleware.js';
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai';
const router = Router(); const router = Router();
router.use(requireAuth); 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 $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`, ) 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, req.user!.id, b.piFirstName || null, b.physicalState || null, b.chemicalName || null, b.bldgCode || null, b.lab || null,
b.storageLocation, b.storageDevice, b.numberOfContainers, b.amountPerContainer, b.storageLocation || null, b.storageDevice || null, b.numberOfContainers || null, b.amountPerContainer || null,
b.unitOfMeasure, b.casNumber, b.unitOfMeasure || null, b.casNumber || null,
b.chemicalFormula || null, b.molecularWeight || null, b.vendor || null, b.chemicalFormula || null, b.molecularWeight || null, b.vendor || null,
b.catalogNumber || null, b.lotNumber || null, b.catalogNumber || null, b.lotNumber || null,
b.expirationDate || null, b.concentration || null, b.expirationDate || null, b.concentration || null,
@@ -122,6 +123,25 @@ router.post('/import', async (req, res) => {
res.json({ imported, errors }); 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 // DELETE /api/chemicals/:id
router.delete('/:id', async (req, res) => { router.delete('/:id', async (req, res) => {
try { try {
@@ -143,4 +163,118 @@ function snakeToCamel(row: Record<string, unknown>): Record<string, unknown> {
); );
} }
// 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; export default router;