Table Importing Added, UI updates
This commit is contained in:
@@ -10,7 +10,7 @@ import { Badge } from "./ui/badge";
|
|||||||
import {
|
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,
|
Table2, FileDown, FileSpreadsheet, Upload, ArrowRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
|
||||||
@@ -138,6 +138,17 @@ export function Inventory() {
|
|||||||
// table view dialog
|
// table view dialog
|
||||||
const [tableViewOpen, setTableViewOpen] = useState(false);
|
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
|
// scan dialog
|
||||||
const [scanOpen, setScanOpen] = useState(false);
|
const [scanOpen, setScanOpen] = useState(false);
|
||||||
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
const [capturedImage, setCapturedImage] = useState<string | null>(null);
|
||||||
@@ -297,6 +308,140 @@ export function Inventory() {
|
|||||||
URL.revokeObjectURL(url);
|
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 ─────────────────────────────────────────────────────────────
|
// ── derived ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const filtered = inventory.filter(c =>
|
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>
|
<p className="text-muted-foreground text-sm">Track, manage, and monitor your lab chemicals</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{/* Table view */}
|
{/* Import Excel */}
|
||||||
<Button variant="outline" className="gap-2" onClick={() => setTableViewOpen(true)}>
|
<Button variant="outline" className="gap-2" onClick={openImport}>
|
||||||
<Table2 className="w-4 h-4" /> Table View
|
<Upload className="w-4 h-4" /> Import Table
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Add manually */}
|
{/* Add manually */}
|
||||||
@@ -430,6 +575,133 @@ export function Inventory() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Email receipt banner */}
|
||||||
<Card className="p-4 mb-5 bg-gradient-to-r from-accent to-secondary border-border">
|
<Card className="p-4 mb-5 bg-gradient-to-r from-accent to-secondary border-border">
|
||||||
<div className="flex items-start gap-3">
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ 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),
|
||||||
|
|
||||||
|
import: (rows: Partial<ChemicalInventory>[]): Promise<{ imported: number; errors: string[] }> =>
|
||||||
|
apiFetch('/api/chemicals/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ rows }),
|
||||||
|
}).then(r => r.json()),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const protocolsApi = {
|
export const protocolsApi = {
|
||||||
|
|||||||
@@ -84,6 +84,44 @@ router.patch('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/chemicals/import
|
||||||
|
router.post('/import', async (req, res) => {
|
||||||
|
const rows: Partial<Record<string, unknown>>[] = req.body.rows;
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No rows provided' });
|
||||||
|
}
|
||||||
|
if (rows.length > 1000) {
|
||||||
|
return res.status(400).json({ error: 'Maximum 1000 rows per import' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipFields = new Set(['id', 'userId', 'createdAt', 'updatedAt', 'dateEntered', 'lastChanged']);
|
||||||
|
let imported = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const b = rows[i];
|
||||||
|
const fields = Object.keys(b).filter(k => !skipFields.has(k) && b[k] !== '' && b[k] != null);
|
||||||
|
if (fields.length === 0) continue;
|
||||||
|
|
||||||
|
const snakeCols = fields.map(camelToSnake);
|
||||||
|
const allCols = ['user_id', ...snakeCols];
|
||||||
|
const placeholders = allCols.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||||
|
const values = [req.user!.id, ...fields.map(f => b[f] || null)];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO chemicals (${allCols.join(', ')}) VALUES (${placeholders})`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
imported++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`Row ${i + 1}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ imported, errors });
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /api/chemicals/:id
|
// DELETE /api/chemicals/:id
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user