Table Importing Added, UI updates

This commit is contained in:
2026-04-01 20:39:34 -05:00
parent f47385abdc
commit d3021014ed
3 changed files with 324 additions and 4 deletions

View File

@@ -10,7 +10,7 @@ import { Badge } from "./ui/badge";
import {
Camera, Sparkles, Mail, Search, Plus, AlertTriangle,
ChevronDown, ChevronUp, Loader2, Trash2, FlaskConical,
Table2, FileDown, FileSpreadsheet,
Table2, FileDown, FileSpreadsheet, Upload, ArrowRight,
} from "lucide-react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
@@ -138,6 +138,17 @@ export function Inventory() {
// table view dialog
const [tableViewOpen, setTableViewOpen] = useState(false);
// import dialog
const [importOpen, setImportOpen] = useState(false);
const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload");
const [importHeaders, setImportHeaders] = useState<string[]>([]);
const [importRows, setImportRows] = useState<string[][]>([]);
const [columnMapping, setColumnMapping] = useState<Record<string, keyof ChemicalInventory | "__skip__">>({});
const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState("");
const importFileRef = useRef<HTMLInputElement>(null);
// scan dialog
const [scanOpen, setScanOpen] = useState(false);
const [capturedImage, setCapturedImage] = useState<string | null>(null);
@@ -297,6 +308,140 @@ export function Inventory() {
URL.revokeObjectURL(url);
}
// ── import helpers ──────────────────────────────────────────────────────
function normalizeHeader(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function fuzzyMatchColumn(header: string): keyof ChemicalInventory | "__skip__" {
const norm = normalizeHeader(header);
if (!norm) return "__skip__";
for (const col of TABLE_COLUMNS) {
if (normalizeHeader(col.label) === norm) return col.key;
}
for (const col of TABLE_COLUMNS) {
const colNorm = normalizeHeader(col.label);
if (colNorm.startsWith(norm) || norm.startsWith(colNorm)) return col.key;
}
let bestKey: keyof ChemicalInventory | "__skip__" = "__skip__";
let bestScore = 0.55;
for (const col of TABLE_COLUMNS) {
const colNorm = normalizeHeader(col.label);
let matches = 0, ci = 0;
for (const ch of norm) {
while (ci < colNorm.length && colNorm[ci] !== ch) ci++;
if (ci < colNorm.length) { matches++; ci++; }
}
const score = matches / Math.max(norm.length, colNorm.length);
if (score > bestScore) { bestScore = score; bestKey = col.key; }
}
return bestKey;
}
function openImport() {
setImportStep("upload");
setImportHeaders([]);
setImportRows([]);
setColumnMapping({});
setImportResult(null);
setImportError("");
setImportOpen(true);
}
async function handleImportFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
console.log("[import] file selected:", file?.name, file?.type, file?.size);
if (!file) return;
e.target.value = "";
setImportError("");
try {
let allRows: string[][];
if (file.name.toLowerCase().endsWith(".csv")) {
console.log("[import] parsing as CSV");
const text = await file.text();
console.log("[import] CSV text length:", text.length);
allRows = text.split(/\r?\n/).map(line => {
const row: string[] = [];
let cur = "", inQuote = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuote && line[i + 1] === '"') { cur += '"'; i++; }
else inQuote = !inQuote;
} else if (ch === "," && !inQuote) {
row.push(cur); cur = "";
} else {
cur += ch;
}
}
row.push(cur);
return row;
});
} else {
console.log("[import] parsing as XLSX");
const buffer = await file.arrayBuffer();
console.log("[import] arrayBuffer size:", buffer.byteLength);
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buffer);
console.log("[import] workbook loaded, worksheets:", wb.worksheets.length);
const ws = wb.worksheets[0];
if (!ws) throw new Error("No worksheets found in this file.");
allRows = [];
ws.eachRow(row => {
allRows.push((row.values as ExcelJS.CellValue[]).slice(1).map(v => String(v ?? "")));
});
console.log("[import] parsed rows:", allRows.length);
}
if (allRows.length < 2) {
setImportError("The file appears to be empty or has no data rows.");
return;
}
const headers = allRows[0];
const dataRows = allRows.slice(1).filter(r => r.some(c => c.trim() !== ""));
console.log("[import] headers:", headers, "data rows:", dataRows.length);
setImportHeaders(headers);
setImportRows(dataRows);
const mapping: Record<string, keyof ChemicalInventory | ""> = {};
for (const h of headers) mapping[h] = fuzzyMatchColumn(h);
setColumnMapping(mapping);
console.log("[import] advancing to map step");
setImportStep("map");
} catch (err) {
console.error("[import] error:", err);
setImportError(`Could not read file: ${(err as Error).message}`);
}
}
async function handleImportConfirm() {
setIsImporting(true);
try {
const rows = importRows.map(row => {
const chemical: Partial<ChemicalInventory> = {};
importHeaders.forEach((header, i) => {
const key = columnMapping[header];
if (key && key !== "__skip__") (chemical as Record<string, string>)[key] = row[i] ?? "";
});
return chemical;
});
const result = await chemicalsApi.import(rows);
setImportResult(result);
setImportStep("result");
if (result.imported > 0) {
const updated = await chemicalsApi.list();
setInventory(updated);
}
} catch (err) {
setImportResult({ imported: 0, errors: [(err as Error).message] });
setImportStep("result");
} finally {
setIsImporting(false);
}
}
// ── derived ─────────────────────────────────────────────────────────────
const filtered = inventory.filter(c =>
@@ -320,9 +465,9 @@ export function Inventory() {
<p className="text-muted-foreground text-sm">Track, manage, and monitor your lab chemicals</p>
</div>
<div className="flex gap-2">
{/* Table view */}
<Button variant="outline" className="gap-2" onClick={() => setTableViewOpen(true)}>
<Table2 className="w-4 h-4" /> Table View
{/* Import Excel */}
<Button variant="outline" className="gap-2" onClick={openImport}>
<Upload className="w-4 h-4" /> Import Table
</Button>
{/* Add manually */}
@@ -430,6 +575,133 @@ export function Inventory() {
</div>
</div>
{/* Import Excel dialog */}
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" /> Import File
</DialogTitle>
</DialogHeader>
{importStep === "upload" && (
<div className="space-y-3 mt-2">
<div
className="border-2 border-dashed border-border rounded-lg p-12 text-center hover:border-primary transition-colors cursor-pointer bg-muted/30"
onClick={() => importFileRef.current?.click()}
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add("border-primary"); }}
onDragLeave={e => e.currentTarget.classList.remove("border-primary")}
onDrop={e => {
e.preventDefault();
e.currentTarget.classList.remove("border-primary");
const file = e.dataTransfer.files[0];
if (file) handleImportFileSelect({ target: { files: e.dataTransfer.files, value: "" } } as unknown as React.ChangeEvent<HTMLInputElement>);
}}
>
<FileSpreadsheet className="w-14 h-14 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground mb-1">Click or drag a file here</p>
<p className="text-muted-foreground text-sm">Supports .xlsx and .csv columns will be automatically matched to your inventory fields</p>
<input ref={importFileRef} type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={handleImportFileSelect} />
</div>
{importError && (
<p className="text-sm text-red-600 flex items-center gap-1">
<AlertTriangle className="w-4 h-4 shrink-0" /> {importError}
</p>
)}
</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 keyof ChemicalInventory | "__skip__" }))}
>
<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>
</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}
>
{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">
<div className={`rounded-lg p-4 border ${importResult.imported > 0 ? "bg-accent border-border" : "bg-red-50 border-red-200"}`}>
<p className="font-medium text-foreground">
{importResult.imported > 0
? `Successfully imported ${importResult.imported} chemical${importResult.imported !== 1 ? "s" : ""}.`
: "No rows were imported."}
</p>
</div>
{importResult.errors.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium text-red-600 flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> {importResult.errors.length} row{importResult.errors.length !== 1 ? "s" : ""} failed
</p>
<div className="border border-red-200 rounded-lg bg-red-50 p-3 max-h-40 overflow-y-auto space-y-1">
{importResult.errors.map((e, i) => (
<p key={i} className="text-xs text-red-700 font-mono">{e}</p>
))}
</div>
</div>
)}
<div className="flex gap-2">
<Button className="flex-1 bg-primary hover:bg-primary/90" onClick={() => setImportOpen(false)}>Done</Button>
<Button variant="outline" onClick={openImport}>Import Another File</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Email receipt banner */}
<Card className="p-4 mb-5 bg-gradient-to-r from-accent to-secondary border-border">
<div className="flex items-start gap-3">
@@ -463,6 +735,10 @@ export function Inventory() {
<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>