Table Importing Added, UI updates
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user