Files
acmcc/src/components/ImportSpreadsheetDialog.tsx
T
2026-06-01 20:19:26 -04:00

357 lines
14 KiB
TypeScript

import { useState, useRef } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2, ArrowRight } from "lucide-react";
import * as XLSX from "xlsx";
interface ExpectedColumn {
key: string;
label: string;
required?: boolean;
aliases?: string[];
}
interface ImportSpreadsheetDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
expectedColumns: ExpectedColumn[];
onImport: (rows: Record<string, string>[]) => Promise<void>;
templateFileName?: string;
}
const normalizeHeader = (value: unknown) =>
String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
export default function ImportSpreadsheetDialog({
open, onOpenChange, title, description, expectedColumns, onImport, templateFileName,
}: ImportSpreadsheetDialogProps) {
const [rawRows, setRawRows] = useState<unknown[][]>([]);
const [rawHeaders, setRawHeaders] = useState<string[]>([]);
const [columnMapping, setColumnMapping] = useState<Record<string, string>>({});
const [step, setStep] = useState<"upload" | "map" | "preview">("upload");
const [parsedRows, setParsedRows] = useState<Record<string, string>[]>([]);
const [importing, setImporting] = useState(false);
const [error, setError] = useState("");
const [fileName, setFileName] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const reset = () => {
setRawRows([]);
setRawHeaders([]);
setColumnMapping({});
setStep("upload");
setParsedRows([]);
setError("");
setFileName("");
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setError("");
setFileName(file.name);
try {
const data = await file.arrayBuffer();
const workbook = XLSX.read(data, { type: "array", cellDates: true });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const json: unknown[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: "", rawNumbers: false });
if (json.length < 2) {
setError("File must have a header row and at least one data row.");
return;
}
const headers = json[0].map((h) => String(h ?? "").trim()).filter(Boolean);
setRawHeaders(headers);
setRawRows(json.slice(1).filter((row) => (row as unknown[]).some((cell: unknown) => String(cell ?? "").trim())));
// Auto-suggest mapping with aliases and partial matching
const autoMap: Record<string, string> = {};
for (const col of expectedColumns) {
const candidates = [col.key, col.label, ...(col.aliases || [])];
const normCandidates = candidates.map(normalizeHeader);
let matched = false;
for (const h of headers) {
const normH = normalizeHeader(h);
if (normCandidates.some(nc => nc === normH)) {
autoMap[col.key] = h;
matched = true;
break;
}
}
// Partial match fallback: check if header contains key or key contains header
if (!matched) {
for (const h of headers) {
const normH = normalizeHeader(h);
if (normCandidates.some(nc => (normH.length > 2 && nc.includes(normH)) || (nc.length > 2 && normH.includes(nc)))) {
autoMap[col.key] = h;
break;
}
}
}
}
setColumnMapping(autoMap);
setStep("map");
} catch {
setError("Could not read file. Please use a valid CSV or Excel (.xlsx/.xls) file.");
}
};
const proceedToPreview = () => {
const missingRequired = expectedColumns
.filter((c) => c.required)
.filter((c) => !columnMapping[c.key]);
if (missingRequired.length > 0) {
setError(`Please map required fields: ${missingRequired.map((c) => c.label).join(", ")}`);
return;
}
setError("");
const mappedHeaders = Object.keys(columnMapping).filter((k) => columnMapping[k]);
const rows = rawRows.map((row) => {
const obj: Record<string, string> = {};
for (const [dbKey, csvHeader] of Object.entries(columnMapping)) {
if (!csvHeader) continue;
const colIndex = rawHeaders.indexOf(csvHeader);
if (colIndex === -1) continue;
const cell = (row as unknown[])[colIndex];
if (cell instanceof Date && !isNaN(cell.getTime())) {
const yyyy = cell.getFullYear();
const mm = String(cell.getMonth() + 1).padStart(2, "0");
const dd = String(cell.getDate()).padStart(2, "0");
obj[dbKey] = `${yyyy}-${mm}-${dd}`;
} else {
obj[dbKey] = String(cell ?? "").trim();
}
}
return obj;
}).filter((row) => Object.values(row).some((v) => v));
if (rows.length === 0) {
setError("No data rows found after mapping.");
return;
}
setParsedRows(rows);
setStep("preview");
};
const handleImport = async () => {
setImporting(true);
try {
await onImport(parsedRows);
reset();
onOpenChange(false);
} catch (err: any) {
setError(err.message || "Import failed");
} finally {
setImporting(false);
}
};
const downloadTemplate = () => {
const ws = XLSX.utils.aoa_to_sheet([expectedColumns.map((column) => column.label)]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Template");
XLSX.writeFile(wb, templateFileName || "import_template.xlsx");
};
const updateMapping = (dbKey: string, csvHeader: string) => {
setColumnMapping((prev) => ({
...prev,
[dbKey]: csvHeader === "__none__" ? "" : csvHeader,
}));
};
const mappedDbKeys = Object.keys(columnMapping).filter((k) => columnMapping[k]);
const previewHeaders = step === "preview" ? mappedDbKeys : [];
return (
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!nextOpen) reset();
onOpenChange(nextOpen);
}}>
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" /> {title}
</DialogTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</DialogHeader>
{/* Step 1: Upload */}
{step === "upload" && (
<div className="space-y-4 py-4">
<div className="flex flex-col items-center gap-4 py-8 border-2 border-dashed rounded-lg">
<Upload className="h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Drop a CSV or Excel file, or click to browse</p>
<input ref={fileInputRef} type="file" accept=".csv,.xlsx,.xls" className="hidden" onChange={handleFile} />
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>Select File</Button>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Need a template?</span>
<Button variant="link" size="sm" onClick={downloadTemplate}>Download Template (.xlsx)</Button>
</div>
{error && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" /> {error}
</div>
)}
</div>
)}
{/* Step 2: Map Fields */}
{step === "map" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="font-medium">{fileName}</span> {rawRows.length} rows, {rawHeaders.length} columns
</div>
<Button variant="ghost" size="sm" onClick={reset}>Choose Different File</Button>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm font-medium mb-4">Map your file columns to fields</p>
<div className="space-y-3">
{expectedColumns.map((col) => (
<div key={col.key} className="flex items-center gap-4">
<div className="w-[45%] text-sm flex items-center gap-1.5">
<span className={col.required ? "font-medium" : "text-muted-foreground"}>
{col.label}
</span>
{col.required && <span className="text-destructive text-xs">*</span>}
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<Select
value={columnMapping[col.key] || "__none__"}
onValueChange={(v) => updateMapping(col.key, v)}
>
<SelectTrigger className="h-9 text-sm flex-1">
<SelectValue placeholder="— skip —" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> skip </SelectItem>
{rawHeaders.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
{/* Sample data preview */}
{rawRows.length > 0 && (
<div className="rounded-lg border overflow-x-auto max-h-36">
<Table>
<TableHeader>
<TableRow>
{rawHeaders.map((h) => <TableHead key={h} className="text-xs font-mono whitespace-nowrap">{h}</TableHead>)}
</TableRow>
</TableHeader>
<TableBody>
{rawRows.slice(0, 3).map((row, i) => (
<TableRow key={i}>
{rawHeaders.map((h, ci) => (
<TableCell key={ci} className="text-xs whitespace-nowrap">
{String((row as unknown[])[ci] ?? "").slice(0, 50) || "—"}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" /> {error}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>Cancel</Button>
<Button onClick={proceedToPreview}>
Continue <ArrowRight className="h-3.5 w-3.5 ml-1" />
</Button>
</div>
</div>
)}
{/* Step 3: Preview & Import */}
{step === "preview" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="font-medium">{parsedRows.length} rows</span> ready to import
</div>
<Button variant="ghost" size="sm" onClick={() => setStep("map")}>Back to Mapping</Button>
</div>
<div className="rounded-lg border overflow-x-auto max-h-64">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 text-xs">#</TableHead>
{previewHeaders.map((key) => {
const col = expectedColumns.find((c) => c.key === key);
return <TableHead key={key} className="text-xs font-mono">{col?.label || key}</TableHead>;
})}
</TableRow>
</TableHeader>
<TableBody>
{parsedRows.slice(0, 10).map((row, index) => (
<TableRow key={index}>
<TableCell className="text-xs text-muted-foreground">{index + 1}</TableCell>
{previewHeaders.map((key) => (
<TableCell key={key} className="text-xs">{row[key] || "—"}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
{parsedRows.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-2">
...and {parsedRows.length - 10} more rows
</p>
)}
</div>
{error && (
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="h-4 w-4" /> {error}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => { reset(); onOpenChange(false); }}>Cancel</Button>
<Button onClick={handleImport} disabled={importing}>
{importing ? "Importing..." : `Import ${parsedRows.length} rows`}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}