Files
CapaKraken/apps/web/src/components/resources/ImportModal.tsx
T

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>
);
}