376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { useState } from "react";
|
|
import type { Route } from "./+types/admin.data-sync";
|
|
import { findAllSports } from "~/models/sport";
|
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
|
import { findAllSeasonTemplates } from "~/models/season-template";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Label } from "~/components/ui/label";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card";
|
|
import { Download, Upload, AlertTriangle, Database } from "lucide-react";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "~/components/ui/alert-dialog";
|
|
|
|
export async function loader() {
|
|
const [sports, sportsSeasons, templates] = await Promise.all([
|
|
findAllSports(),
|
|
findAllSportsSeasons(),
|
|
findAllSeasonTemplates(),
|
|
]);
|
|
|
|
return {
|
|
stats: {
|
|
sportsCount: sports.length,
|
|
sportsSeasonsCount: sportsSeasons.length,
|
|
templatesCount: templates.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function action({ request }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "export") {
|
|
// Export will be handled client-side via download
|
|
return { success: true };
|
|
}
|
|
|
|
if (intent === "import") {
|
|
const mode = formData.get("mode") as "merge" | "replace";
|
|
const fileData = formData.get("fileData") as string;
|
|
|
|
if (!fileData) {
|
|
return { error: "No file data provided" };
|
|
}
|
|
|
|
try {
|
|
// Import the data (we'll create a server-side function for this)
|
|
const { importSportsDataFromJSON } = await import("~/utils/sports-data-sync.server");
|
|
const result = await importSportsDataFromJSON(fileData, mode);
|
|
|
|
return {
|
|
success: true,
|
|
message: `Import complete! Created: ${result.created}, Updated: ${result.updated}, Skipped: ${result.skipped}`
|
|
};
|
|
} catch (error) {
|
|
console.error("Import error:", error);
|
|
return { error: error instanceof Error ? error.message : "Import failed" };
|
|
}
|
|
}
|
|
|
|
return { error: "Invalid intent" };
|
|
}
|
|
|
|
export default function DataSync({ loaderData, actionData }: Route.ComponentProps) {
|
|
const { stats } = loaderData;
|
|
const [importMode, setImportMode] = useState<"merge" | "replace">("merge");
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
|
const handleExport = async () => {
|
|
setIsExporting(true);
|
|
try {
|
|
const response = await fetch("/api/admin/export-sports-data");
|
|
const data = await response.json();
|
|
|
|
// Create download
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `sports-data-export-${new Date().toISOString().split("T")[0]}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error("Export failed:", error);
|
|
alert("Export failed. Please try again.");
|
|
} finally {
|
|
setIsExporting(false);
|
|
}
|
|
};
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
setSelectedFile(file);
|
|
}
|
|
};
|
|
|
|
const handleImport = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!selectedFile) {
|
|
alert("Please select a file first");
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
const fileData = event.target?.result as string;
|
|
|
|
const formData = new FormData();
|
|
formData.append("intent", "import");
|
|
formData.append("mode", importMode);
|
|
formData.append("fileData", fileData);
|
|
|
|
// Submit via form
|
|
const form = document.createElement("form");
|
|
form.method = "POST";
|
|
form.style.display = "none";
|
|
|
|
const intentInput = document.createElement("input");
|
|
intentInput.name = "intent";
|
|
intentInput.value = "import";
|
|
form.appendChild(intentInput);
|
|
|
|
const modeInput = document.createElement("input");
|
|
modeInput.name = "mode";
|
|
modeInput.value = importMode;
|
|
form.appendChild(modeInput);
|
|
|
|
const dataInput = document.createElement("input");
|
|
dataInput.name = "fileData";
|
|
dataInput.value = fileData;
|
|
form.appendChild(dataInput);
|
|
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
};
|
|
|
|
reader.readAsText(selectedFile);
|
|
};
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="max-w-4xl">
|
|
<div className="mb-6">
|
|
<h1 className="text-3xl font-bold">Data Sync</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Import and export sports data between environments
|
|
</p>
|
|
</div>
|
|
|
|
{actionData?.success && (
|
|
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm mb-6">
|
|
{actionData.message || "Operation completed successfully!"}
|
|
</div>
|
|
)}
|
|
|
|
{actionData?.error && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-6 md:grid-cols-3 mb-6">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Sports</CardTitle>
|
|
<Database className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats.sportsCount}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Sports Seasons</CardTitle>
|
|
<Database className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats.sportsSeasonsCount}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Templates</CardTitle>
|
|
<Database className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{stats.templatesCount}</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Export Data</CardTitle>
|
|
<CardDescription>
|
|
Download all sports data as a JSON file
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
This will export all sports, sports seasons, participants, and season templates.
|
|
</p>
|
|
|
|
<Button
|
|
onClick={handleExport}
|
|
disabled={isExporting}
|
|
className="w-full"
|
|
>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
{isExporting ? "Exporting..." : "Export Data"}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Import Data</CardTitle>
|
|
<CardDescription>
|
|
Upload a JSON file to import sports data
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="file">Select File</Label>
|
|
<input
|
|
id="file"
|
|
type="file"
|
|
accept=".json"
|
|
onChange={handleFileChange}
|
|
className="block w-full text-sm text-muted-foreground
|
|
file:mr-4 file:py-2 file:px-4
|
|
file:rounded-md file:border-0
|
|
file:text-sm file:font-semibold
|
|
file:bg-primary file:text-primary-foreground
|
|
hover:file:bg-primary/90"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Import Mode</Label>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant={importMode === "merge" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setImportMode("merge")}
|
|
>
|
|
Merge
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={importMode === "replace" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setImportMode("replace")}
|
|
>
|
|
Replace
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{importMode === "merge"
|
|
? "Updates existing records and adds new ones (safe)"
|
|
: "Deletes all existing data and recreates from file (destructive)"}
|
|
</p>
|
|
</div>
|
|
|
|
{importMode === "replace" ? (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
disabled={!selectedFile}
|
|
variant="destructive"
|
|
className="w-full"
|
|
>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
Import Data (Replace)
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
|
Are you absolutely sure?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will <strong>delete all existing sports data</strong> and replace it
|
|
with the data from the file. This action cannot be undone.
|
|
<br /><br />
|
|
This includes:
|
|
<ul className="list-disc list-inside mt-2">
|
|
<li>All sports</li>
|
|
<li>All sports seasons</li>
|
|
<li>All participants</li>
|
|
<li>All season templates</li>
|
|
</ul>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleImport}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Yes, Replace All Data
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
) : (
|
|
<Button
|
|
onClick={handleImport}
|
|
disabled={!selectedFile}
|
|
className="w-full"
|
|
>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
Import Data (Merge)
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className="mt-6 border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800">
|
|
<CardHeader>
|
|
<CardTitle className="text-blue-900 dark:text-blue-100">How It Works</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-sm text-blue-800 dark:text-blue-200 space-y-3">
|
|
<div>
|
|
<strong>Export:</strong> Downloads all sports data as a JSON file that you can
|
|
save, version control, or transfer to another environment.
|
|
</div>
|
|
<div>
|
|
<strong>Import (Merge):</strong> Updates existing records and adds new ones.
|
|
Safe to use - won't delete anything.
|
|
</div>
|
|
<div>
|
|
<strong>Import (Replace):</strong> Deletes all existing sports data and recreates
|
|
it from the file. Use this for a clean slate.
|
|
</div>
|
|
<div className="pt-2 border-t border-blue-200 dark:border-blue-800">
|
|
<strong>Typical workflow:</strong>
|
|
<ol className="list-decimal list-inside mt-1 space-y-1">
|
|
<li>Export data from development</li>
|
|
<li>Download the JSON file</li>
|
|
<li>Upload it to production</li>
|
|
<li>Import using merge mode</li>
|
|
</ol>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|