This commit is contained in:
@@ -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<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
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<ChemicalInventory | null>(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<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 [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<string, ImportColumnMapping> = {};
|
||||
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<string, keyof ChemicalInventory | "__skip__"> = {};
|
||||
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<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) {
|
||||
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 (
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
@@ -638,66 +733,144 @@ export function Inventory() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importStep === "map" && (
|
||||
<div className="space-y-4 mt-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importHeaders.map((header, idx) => (
|
||||
<tr key={header} className="border-t border-border">
|
||||
<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] ?? ""}
|
||||
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as ImportColumnMapping }))}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-48">
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs truncate max-w-[120px]">
|
||||
{importRows[0]?.[idx] ?? ""}
|
||||
</td>
|
||||
{importStep === "map" && (() => {
|
||||
// Compute which target keys are claimed by more than one source header.
|
||||
const targetCounts = new Map<string, string[]>();
|
||||
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<string, string[]>(
|
||||
Array.from(targetCounts.entries()).filter(([, hs]) => hs.length > 1),
|
||||
);
|
||||
const conflictHeaders = new Set<string>();
|
||||
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 (
|
||||
<div className="py-12 flex flex-col items-center justify-center space-y-4">
|
||||
<Loader2 className="w-10 h-10 text-primary animate-spin" />
|
||||
<p className="text-foreground font-medium">Matching columns with AI...</p>
|
||||
<p className="text-muted-foreground text-sm">Please wait while Gemini analyzes your data.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mt-2">
|
||||
{fallbackReason && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 space-y-1">
|
||||
<p className="text-sm font-medium text-amber-800 flex items-center gap-1">
|
||||
<AlertTriangle className="w-4 h-4" /> Gemini reasoning failed
|
||||
</p>
|
||||
<p className="text-xs text-amber-700">
|
||||
Error: {fallbackReason}. Used fuzzy matching instead. Please verify the mappings.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 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 && (
|
||||
<div className="space-y-4 mt-2">
|
||||
@@ -741,33 +914,76 @@ export function Inventory() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Search + stats */}
|
||||
<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)}
|
||||
/>
|
||||
{/* Search + stats / Selection Action Bar */}
|
||||
{isSelectionMode ? (
|
||||
<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">
|
||||
<div className="flex items-center gap-4 pl-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
checked={filtered.length > 0 && selectedIds.size === filtered.length}
|
||||
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 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 */}
|
||||
{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 (
|
||||
<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 */}
|
||||
<button
|
||||
className="w-full text-left px-5 py-4 flex items-center gap-4"
|
||||
onClick={() => setExpandedId(isExpanded ? null : item.id)}
|
||||
>
|
||||
{/* Name + state */}
|
||||
<div className="flex items-stretch w-full">
|
||||
{isSelectionMode && (
|
||||
<div
|
||||
className="flex items-center justify-center px-4 cursor-pointer hover:bg-muted/50 border-r border-border/50"
|
||||
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 items-center gap-2 flex-wrap">
|
||||
<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>}
|
||||
{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>}
|
||||
{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>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{item.lab} · {item.storageLocation || "—"}</p>
|
||||
</div>
|
||||
@@ -827,7 +1068,8 @@ export function Inventory() {
|
||||
{isExpanded
|
||||
? <ChevronUp 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 */}
|
||||
{isExpanded && (
|
||||
|
||||
12
lib/api.ts
12
lib/api.ts
@@ -35,11 +35,23 @@ export const chemicalsApi = {
|
||||
remove: (id: string): Promise<void> =>
|
||||
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[] }> =>
|
||||
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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <postmaster@${DOMAIN}>`;
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
Reference in New Issue
Block a user