mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
357 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|