chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user