This commit is contained in:
@@ -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 && (
|
||||||
|
|||||||
12
lib/api.ts
12
lib/api.ts
@@ -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 = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user