313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import { parseSpreadsheet, isSpreadsheetFile } from "~/lib/excel.js";
|
|
|
|
type ImportStage = "idle" | "preview" | "importing" | "done";
|
|
|
|
interface ImportResult {
|
|
total: number;
|
|
created: number;
|
|
updated: number;
|
|
errors: { row: number; message: string }[];
|
|
dryRun: boolean;
|
|
message?: string;
|
|
}
|
|
|
|
interface Props {
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ImportModal({ onClose }: Props) {
|
|
const [stage, setStage] = useState<ImportStage>("idle");
|
|
const [rows, setRows] = useState<Record<string, string>[]>([]);
|
|
const [fileName, setFileName] = useState<string>("");
|
|
const [fileError, setFileError] = useState<string>("");
|
|
const [result, setResult] = useState<ImportResult | null>(null);
|
|
const [dryRun, setDryRun] = useState(true);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const importMutation = trpc.importExport.importCSV.useMutation({
|
|
onSuccess: (data) => {
|
|
setResult(data);
|
|
setStage("done");
|
|
},
|
|
onError: (err) => {
|
|
setFileError(err.message);
|
|
setStage("preview");
|
|
},
|
|
});
|
|
|
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setFileError("");
|
|
setRows([]);
|
|
setResult(null);
|
|
|
|
if (!isSpreadsheetFile(file)) {
|
|
setFileError("Unsupported file type. Please upload an Excel (.xlsx, .xls) or CSV file.");
|
|
return;
|
|
}
|
|
|
|
setFileName(file.name);
|
|
|
|
try {
|
|
const parsed = await parseSpreadsheet(file);
|
|
setRows(parsed);
|
|
setStage("preview");
|
|
} catch (err) {
|
|
setFileError(err instanceof Error ? err.message : "Failed to parse file.");
|
|
}
|
|
}
|
|
|
|
function handleImport() {
|
|
if (rows.length === 0) return;
|
|
setStage("importing");
|
|
importMutation.mutate({
|
|
entityType: "resources",
|
|
rows,
|
|
dryRun,
|
|
});
|
|
}
|
|
|
|
function handleReset() {
|
|
setStage("idle");
|
|
setRows([]);
|
|
setFileName("");
|
|
setFileError("");
|
|
setResult(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = "";
|
|
}
|
|
}
|
|
|
|
const previewHeaders = rows.length > 0 ? Object.keys(rows[0]!) : [];
|
|
const previewRows = rows.slice(0, 5);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
|
<h2 className="text-lg font-semibold text-gray-900">Import Resources</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
|
aria-label="Close"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
|
{/* File picker */}
|
|
{stage === "idle" && (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-gray-600">
|
|
Upload an Excel or CSV file to import resources. The first row must contain column headers
|
|
matching the resource fields (e.g.{" "}
|
|
<code className="px-1 py-0.5 bg-gray-100 rounded text-xs font-mono">
|
|
eid, displayName, email, chapter, lcrCents
|
|
</code>
|
|
).
|
|
</p>
|
|
<label className="block">
|
|
<span className="sr-only">Choose spreadsheet file</span>
|
|
<div
|
|
className="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-gray-300 rounded-xl cursor-pointer hover:border-brand-400 hover:bg-brand-50 transition-colors"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<svg className="w-10 h-10 text-gray-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<p className="text-sm text-gray-500">Click to select Excel or CSV</p>
|
|
<p className="text-xs text-gray-400 mt-1">.xlsx, .xls, .csv supported</p>
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".xlsx,.xls,.csv"
|
|
className="hidden"
|
|
onChange={handleFileChange}
|
|
/>
|
|
</label>
|
|
{fileError && (
|
|
<p className="text-sm text-red-600">{fileError}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Preview */}
|
|
{(stage === "preview" || stage === "importing") && rows.length > 0 && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">{fileName}</p>
|
|
<p className="text-xs text-gray-500">{rows.length} row{rows.length !== 1 ? "s" : ""} parsed</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleReset}
|
|
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
|
>
|
|
Choose different file
|
|
</button>
|
|
</div>
|
|
|
|
{previewHeaders.length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
|
|
Preview (first {previewRows.length} of {rows.length} rows)
|
|
</p>
|
|
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
<table className="text-xs w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
{previewHeaders.map((h) => (
|
|
<th
|
|
key={h}
|
|
className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap"
|
|
>
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{previewRows.map((row, i) => (
|
|
<tr key={i} className="hover:bg-gray-50">
|
|
{previewHeaders.map((h) => (
|
|
<td key={h} className="px-3 py-2 text-gray-700 whitespace-nowrap max-w-[200px] truncate">
|
|
{row[h] ?? ""}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{rows.length > 5 && (
|
|
<p className="text-xs text-gray-400 mt-1">…and {rows.length - 5} more rows</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{fileError && (
|
|
<p className="text-sm text-red-600">{fileError}</p>
|
|
)}
|
|
|
|
{/* Import options */}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="dryRun"
|
|
checked={dryRun}
|
|
onChange={(e) => setDryRun(e.target.checked)}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
<label htmlFor="dryRun" className="text-sm text-gray-700">
|
|
Dry run (validate only, do not write to database)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Done */}
|
|
{stage === "done" && result && (
|
|
<div className="space-y-4">
|
|
<div className={`rounded-lg p-4 ${result.errors.length > 0 ? "bg-yellow-50 border border-yellow-200" : "bg-green-50 border border-green-200"}`}>
|
|
<p className="text-sm font-medium text-gray-900 mb-1">
|
|
{result.dryRun ? "Dry run complete" : "Import complete"}
|
|
</p>
|
|
<ul className="text-sm text-gray-700 space-y-0.5">
|
|
<li>Total rows: <strong>{result.total}</strong></li>
|
|
{!result.dryRun && (
|
|
<>
|
|
<li>Created: <strong>{result.created}</strong></li>
|
|
<li>Updated: <strong>{result.updated}</strong></li>
|
|
</>
|
|
)}
|
|
{result.message && <li>{result.message}</li>}
|
|
{result.errors.length > 0 && (
|
|
<li className="text-red-600">Errors: <strong>{result.errors.length}</strong></li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
|
|
{result.errors.length > 0 && (
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Errors</p>
|
|
<ul className="text-xs text-red-600 space-y-0.5 max-h-32 overflow-y-auto">
|
|
{result.errors.map((e, i) => (
|
|
<li key={i}>Row {e.row}: {e.message}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{result.dryRun && result.errors.length === 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="dryRunConfirm"
|
|
checked={false}
|
|
onChange={() => {
|
|
setDryRun(false);
|
|
setStage("preview");
|
|
setResult(null);
|
|
}}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
<label htmlFor="dryRunConfirm" className="text-sm text-gray-700 cursor-pointer">
|
|
Validation passed — click here to run the actual import
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
{(stage === "preview") && (
|
|
<button
|
|
type="button"
|
|
onClick={handleImport}
|
|
disabled={rows.length === 0 || importMutation.isPending}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{dryRun ? "Validate" : "Import"} {rows.length} row{rows.length !== 1 ? "s" : ""}
|
|
</button>
|
|
)}
|
|
{stage === "importing" && (
|
|
<span className="px-4 py-2 text-sm text-gray-500 animate-pulse">Importing…</span>
|
|
)}
|
|
{stage === "done" && (
|
|
<button
|
|
type="button"
|
|
onClick={handleReset}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Import another file
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|