feat: add admin routes and checkbox component for sports management
This commit is contained in:
parent
dc727b6f3f
commit
37de6787bf
22 changed files with 3030 additions and 0 deletions
46
app/components/ui/badge.tsx
Normal file
46
app/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "app/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
30
app/components/ui/checkbox.tsx
Normal file
30
app/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "app/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
114
app/components/ui/table.tsx
Normal file
114
app/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "app/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
18
app/components/ui/textarea.tsx
Normal file
18
app/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "app/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
|
|
@ -8,4 +8,21 @@ export default [
|
||||||
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"),
|
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"),
|
||||||
route("user-profile", "routes/user-profile.tsx"),
|
route("user-profile", "routes/user-profile.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
route("admin", "routes/admin.tsx", [
|
||||||
|
index("routes/admin._index.tsx"),
|
||||||
|
route("sports", "routes/admin.sports.tsx"),
|
||||||
|
route("sports/new", "routes/admin.sports.new.tsx"),
|
||||||
|
route("sports-seasons", "routes/admin.sports-seasons.tsx"),
|
||||||
|
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
|
||||||
|
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),
|
||||||
|
route("sports-seasons/:id/participants", "routes/admin.sports-seasons.$id.participants.tsx"),
|
||||||
|
route("participants", "routes/admin.participants.tsx"),
|
||||||
|
route("templates", "routes/admin.templates.tsx"),
|
||||||
|
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||||
|
route("templates/:id", "routes/admin.templates.$id.tsx"),
|
||||||
|
route("data-sync", "routes/admin.data-sync.tsx"),
|
||||||
|
]),
|
||||||
|
route("api/admin/export-sports-data", "routes/api.admin.export-sports-data.ts"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|
|
||||||
170
app/routes/admin._index.tsx
Normal file
170
app/routes/admin._index.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin._index";
|
||||||
|
import { findAllSports } from "~/models/sport";
|
||||||
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
||||||
|
import { findAllSeasonTemplates } from "~/models/season-template";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Trophy, Calendar, FolderKanban, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
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 default function AdminDashboard({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { stats } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Manage sports, seasons, participants, and templates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-3 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Sports</CardTitle>
|
||||||
|
<Trophy className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.sportsCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Active sports in the system
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.sportsSeasonsCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Across all sports
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
<FolderKanban className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.templatesCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Season templates available
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quick Actions</CardTitle>
|
||||||
|
<CardDescription>Common administrative tasks</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<Button variant="outline" className="w-full justify-between" asChild>
|
||||||
|
<Link to="/admin/sports/new">
|
||||||
|
Create New Sport
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-between" asChild>
|
||||||
|
<Link to="/admin/sports-seasons/new">
|
||||||
|
Create Sports Season
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full justify-between" asChild>
|
||||||
|
<Link to="/admin/templates/new">
|
||||||
|
Create Season Template
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Getting Started</CardTitle>
|
||||||
|
<CardDescription>Set up your sports system</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">Create Sports</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add sports like NFL, NBA, Golf, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">Create Sports Seasons</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add specific seasons for each sport
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">Add Participants</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add teams or players to each sports season
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-medium">
|
||||||
|
4
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">Create Templates</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Bundle sports seasons into templates for leagues
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
376
app/routes/admin.data-sync.tsx
Normal file
376
app/routes/admin.data-sync.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
app/routes/admin.participants.tsx
Normal file
37
app/routes/admin.participants.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AdminParticipants() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Participants</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage teams and players across all sports seasons
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Participants Management</CardTitle>
|
||||||
|
<CardDescription>Coming soon</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Users className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">Under Construction</h3>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Participant management interface will be available soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
app/routes/admin.sports-seasons.$id.participants.tsx
Normal file
207
app/routes/admin.sports-seasons.$id.participants.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { Form, Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.$id.participants";
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
|
import {
|
||||||
|
findParticipantsBySportsSeasonId,
|
||||||
|
createParticipant,
|
||||||
|
deleteParticipant
|
||||||
|
} from "~/models/participant";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table";
|
||||||
|
import { Plus, Trash2, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
const sportsSeason = await findSportsSeasonById(params.id);
|
||||||
|
|
||||||
|
if (!sportsSeason) {
|
||||||
|
throw new Response("Sports season not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
|
||||||
|
// Type assertion since we know the sport relation is included
|
||||||
|
return {
|
||||||
|
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
|
||||||
|
participants
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "delete") {
|
||||||
|
const participantId = formData.get("participantId");
|
||||||
|
if (typeof participantId === "string") {
|
||||||
|
await deleteParticipant(participantId);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add participant
|
||||||
|
const name = formData.get("name");
|
||||||
|
const shortName = formData.get("shortName");
|
||||||
|
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "Participant name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createParticipant({
|
||||||
|
sportsSeasonId: params.id,
|
||||||
|
name: name.trim(),
|
||||||
|
shortName: typeof shortName === "string" && shortName.trim() ? shortName.trim() : null,
|
||||||
|
externalId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating participant:", error);
|
||||||
|
return { error: "Failed to add participant. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ManageParticipants({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { sportsSeason, participants } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||||
|
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Sports Season
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-3xl font-bold">Manage Participants</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{sportsSeason.sport.name} - {sportsSeason.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Participant</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add a {sportsSeason.sport.type === "team" ? "team" : "player"} to this season
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
{sportsSeason.sport.type === "team" ? "Team" : "Player"} Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder={sportsSeason.sport.type === "team" ? "e.g., Kansas City Chiefs" : "e.g., Tiger Woods"}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="shortName">Short Name (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="shortName"
|
||||||
|
name="shortName"
|
||||||
|
type="text"
|
||||||
|
placeholder={sportsSeason.sport.type === "team" ? "e.g., KC" : "e.g., T. Woods"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionData?.success && (
|
||||||
|
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
Participant added successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Participant
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Participants</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{participants.length} {participants.length === 1 ? "participant" : "participants"} total
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{participants.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No participants yet. Add your first {sportsSeason.sport.type === "team" ? "team" : "player"}.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[500px] overflow-y-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Short Name</TableHead>
|
||||||
|
<TableHead className="w-[50px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{participants.map((participant) => (
|
||||||
|
<TableRow key={participant.id}>
|
||||||
|
<TableCell className="font-medium">{participant.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{participant.shortName || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<input type="hidden" name="participantId" value={participant.id} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
app/routes/admin.sports-seasons.$id.tsx
Normal file
309
app/routes/admin.sports-seasons.$id.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { Form, Link, redirect, useNavigate } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.$id";
|
||||||
|
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
|
||||||
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { Trash2, Users } from "lucide-react";
|
||||||
|
|
||||||
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
const sportsSeason = await findSportsSeasonById(params.id);
|
||||||
|
|
||||||
|
if (!sportsSeason) {
|
||||||
|
throw new Response("Sports season not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
|
||||||
|
// Type assertion since we know the sport relation is included
|
||||||
|
return {
|
||||||
|
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
|
||||||
|
participants
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "delete") {
|
||||||
|
await deleteSportsSeason(params.id);
|
||||||
|
return redirect("/admin/sports-seasons");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update
|
||||||
|
const name = formData.get("name");
|
||||||
|
const year = formData.get("year");
|
||||||
|
const startDate = formData.get("startDate");
|
||||||
|
const endDate = formData.get("endDate");
|
||||||
|
const status = formData.get("status");
|
||||||
|
const scoringType = formData.get("scoringType");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "Season name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof year !== "string") {
|
||||||
|
return { error: "Year is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearNum = parseInt(year, 10);
|
||||||
|
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
|
||||||
|
return { error: "Year must be between 2000 and 2100" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "upcoming" && status !== "active" && status !== "completed") {
|
||||||
|
return { error: "Invalid status" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scoringType !== "playoffs" && scoringType !== "regular_season" && scoringType !== "majors") {
|
||||||
|
return { error: "Invalid scoring type" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSportsSeason(params.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
year: yearNum,
|
||||||
|
startDate: typeof startDate === "string" && startDate ? startDate : null,
|
||||||
|
endDate: typeof endDate === "string" && endDate ? endDate : null,
|
||||||
|
status,
|
||||||
|
scoringType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating sports season:", error);
|
||||||
|
return { error: "Failed to update sports season. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { sportsSeason, participants } = loaderData;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Edit Sports Season</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{sportsSeason.sport.name} - {sportsSeason.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sports Season Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update the information for this sports season
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Season Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
defaultValue={sportsSeason.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="year">Year</Label>
|
||||||
|
<Input
|
||||||
|
id="year"
|
||||||
|
name="year"
|
||||||
|
type="number"
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
defaultValue={sportsSeason.year}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startDate">Start Date (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="startDate"
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={sportsSeason.startDate || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endDate">End Date (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="endDate"
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
defaultValue={sportsSeason.endDate || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select name="status" defaultValue={sportsSeason.status} required>
|
||||||
|
<SelectTrigger id="status">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="scoringType">Scoring Type</Label>
|
||||||
|
<Select name="scoringType" defaultValue={sportsSeason.scoringType} required>
|
||||||
|
<SelectTrigger id="scoringType">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="playoffs">Playoffs</SelectItem>
|
||||||
|
<SelectItem value="regular_season">Regular Season</SelectItem>
|
||||||
|
<SelectItem value="majors">Majors</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionData?.success && (
|
||||||
|
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
Sports season updated successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<Link to="/admin/sports-seasons">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Participants</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{participants.length} {participants.length === 1 ? "participant" : "participants"}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/participants`)}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Manage Participants
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{participants.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No participants added yet. Add teams or players to this season.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{participants.slice(0, 5).map((participant) => (
|
||||||
|
<div key={participant.id} className="text-sm">
|
||||||
|
{participant.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{participants.length > 5 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
And {participants.length - 5} more...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Permanently delete this sports season
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Sports Season
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the sports season "{sportsSeason.name}" and all
|
||||||
|
associated participants and results. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
app/routes/admin.sports-seasons.new.tsx
Normal file
221
app/routes/admin.sports-seasons.new.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
import { Form, Link, redirect } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.new";
|
||||||
|
import { createSportsSeason } from "~/models/sports-season";
|
||||||
|
import { findAllSports } from "~/models/sport";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const sports = await findAllSports();
|
||||||
|
return { sports };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const sportId = formData.get("sportId");
|
||||||
|
const name = formData.get("name");
|
||||||
|
const year = formData.get("year");
|
||||||
|
const startDate = formData.get("startDate");
|
||||||
|
const endDate = formData.get("endDate");
|
||||||
|
const status = formData.get("status");
|
||||||
|
const scoringType = formData.get("scoringType");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (typeof sportId !== "string" || !sportId) {
|
||||||
|
return { error: "Sport is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "Season name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof year !== "string") {
|
||||||
|
return { error: "Year is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearNum = parseInt(year, 10);
|
||||||
|
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
|
||||||
|
return { error: "Year must be between 2000 and 2100" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "upcoming" && status !== "active" && status !== "completed") {
|
||||||
|
return { error: "Invalid status" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scoringType !== "playoffs" && scoringType !== "regular_season" && scoringType !== "majors") {
|
||||||
|
return { error: "Invalid scoring type" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createSportsSeason({
|
||||||
|
sportId,
|
||||||
|
name: name.trim(),
|
||||||
|
year: yearNum,
|
||||||
|
startDate: typeof startDate === "string" && startDate ? startDate : null,
|
||||||
|
endDate: typeof endDate === "string" && endDate ? endDate : null,
|
||||||
|
status,
|
||||||
|
scoringType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect("/admin/sports-seasons");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating sports season:", error);
|
||||||
|
return { error: "Failed to create sports season. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { sports } = loaderData;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Create New Sports Season</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Add a new season for a sport
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sports Season Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter the information for the new sports season
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sportId">Sport</Label>
|
||||||
|
<Select name="sportId" required>
|
||||||
|
<SelectTrigger id="sportId">
|
||||||
|
<SelectValue placeholder="Select a sport" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sports.map((sport) => (
|
||||||
|
<SelectItem key={sport.id} value={sport.id}>
|
||||||
|
{sport.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{sports.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No sports available. <Link to="/admin/sports/new" className="underline">Create a sport first</Link>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Season Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., 2025 NFL Playoffs, 2025 PGA Tour"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="year">Year</Label>
|
||||||
|
<Input
|
||||||
|
id="year"
|
||||||
|
name="year"
|
||||||
|
type="number"
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
defaultValue={currentYear}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startDate">Start Date (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="startDate"
|
||||||
|
name="startDate"
|
||||||
|
type="date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endDate">End Date (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="endDate"
|
||||||
|
name="endDate"
|
||||||
|
type="date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="status">Status</Label>
|
||||||
|
<Select name="status" defaultValue="upcoming" required>
|
||||||
|
<SelectTrigger id="status">
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="upcoming">Upcoming</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="scoringType">Scoring Type</Label>
|
||||||
|
<Select name="scoringType" required>
|
||||||
|
<SelectTrigger id="scoringType">
|
||||||
|
<SelectValue placeholder="Select scoring type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="playoffs">Playoffs</SelectItem>
|
||||||
|
<SelectItem value="regular_season">Regular Season</SelectItem>
|
||||||
|
<SelectItem value="majors">Majors</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
Create Sports Season
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<Link to="/admin/sports-seasons">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
app/routes/admin.sports-seasons.tsx
Normal file
126
app/routes/admin.sports-seasons.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons";
|
||||||
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Plus, Calendar } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const sportsSeasons = await findAllSportsSeasons();
|
||||||
|
// Type assertion since we know the sport relation is included from the model
|
||||||
|
return { sportsSeasons: sportsSeasons as Array<typeof sportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }> };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { sportsSeasons } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Sports Seasons</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage sports seasons across all sports
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/admin/sports-seasons/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Sports Season
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Sports Seasons</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{sportsSeasons.length} {sportsSeasons.length === 1 ? "season" : "seasons"} total
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sportsSeasons.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Calendar className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">No sports seasons yet</h3>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Get started by creating your first sports season
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link to="/admin/sports-seasons/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Sports Season
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Sport</TableHead>
|
||||||
|
<TableHead>Year</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Scoring Type</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sportsSeasons.map((season) => (
|
||||||
|
<TableRow key={season.id}>
|
||||||
|
<TableCell className="font-medium">{season.name}</TableCell>
|
||||||
|
<TableCell>{season.sport.name}</TableCell>
|
||||||
|
<TableCell>{season.year}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
season.status === "active"
|
||||||
|
? "default"
|
||||||
|
: season.status === "completed"
|
||||||
|
? "secondary"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{season.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground capitalize">
|
||||||
|
{season.scoringType.replace("_", " ")}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={`/admin/sports-seasons/${season.id}/participants`}>
|
||||||
|
Participants
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={`/admin/sports-seasons/${season.id}`}>Edit</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
app/routes/admin.sports.new.tsx
Normal file
155
app/routes/admin.sports.new.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { Form, Link, redirect } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports.new";
|
||||||
|
import { createSport } from "~/models/sport";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
|
||||||
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get("name");
|
||||||
|
const type = formData.get("type");
|
||||||
|
const slug = formData.get("slug");
|
||||||
|
const description = formData.get("description");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "Sport name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== "team" && type !== "individual") {
|
||||||
|
return { error: "Sport type must be either 'team' or 'individual'" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof slug !== "string" || !slug.trim()) {
|
||||||
|
return { error: "Slug is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate slug format (lowercase, hyphens only)
|
||||||
|
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||||
|
return { error: "Slug must contain only lowercase letters, numbers, and hyphens" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createSport({
|
||||||
|
name: name.trim(),
|
||||||
|
type,
|
||||||
|
slug: slug.trim(),
|
||||||
|
description: typeof description === "string" ? description.trim() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect("/admin/sports");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating sport:", error);
|
||||||
|
return { error: "Failed to create sport. The slug might already exist." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewSport({ actionData }: Route.ComponentProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Create New Sport</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Add a new sport to the system
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sport Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter the information for the new sport
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Sport Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., NFL, NBA, Golf - Men's"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="type">Type</Label>
|
||||||
|
<Select name="type" required>
|
||||||
|
<SelectTrigger id="type">
|
||||||
|
<SelectValue placeholder="Select sport type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="team">Team Sport</SelectItem>
|
||||||
|
<SelectItem value="individual">Individual Sport</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Team sports have teams, individual sports have players
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="slug">Slug</Label>
|
||||||
|
<Input
|
||||||
|
id="slug"
|
||||||
|
name="slug"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., nfl, nba, golf-mens"
|
||||||
|
pattern="[a-z0-9-]+"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
URL-friendly identifier (lowercase, hyphens only)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Brief description of the sport"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
Create Sport
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<Link to="/admin/sports">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
app/routes/admin.sports.tsx
Normal file
106
app/routes/admin.sports.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports";
|
||||||
|
import { findAllSports } from "~/models/sport";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Plus, Trophy } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const sports = await findAllSports();
|
||||||
|
return { sports };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSports({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { sports } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Sports</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage all sports in the system
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/admin/sports/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Sport
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Sports</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{sports.length} {sports.length === 1 ? "sport" : "sports"} total
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sports.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Trophy className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">No sports yet</h3>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Get started by creating your first sport
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link to="/admin/sports/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Sport
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Slug</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sports.map((sport) => (
|
||||||
|
<TableRow key={sport.id}>
|
||||||
|
<TableCell className="font-medium">{sport.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={sport.type === "team" ? "default" : "secondary"}>
|
||||||
|
{sport.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{sport.slug}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={`/admin/sports/${sport.id}`}>Edit</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
app/routes/admin.templates.$id.tsx
Normal file
381
app/routes/admin.templates.$id.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
import { Form, Link, redirect } from "react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { Route } from "./+types/admin.templates.$id";
|
||||||
|
import {
|
||||||
|
findSeasonTemplateWithSportsSeasons,
|
||||||
|
updateSeasonTemplate,
|
||||||
|
deleteSeasonTemplate,
|
||||||
|
setSeasonTemplateActive
|
||||||
|
} from "~/models/season-template";
|
||||||
|
import {
|
||||||
|
addSportToTemplate,
|
||||||
|
removeSportFromTemplate,
|
||||||
|
updateSeasonTemplateSport
|
||||||
|
} from "~/models/season-template-sport";
|
||||||
|
import { findAllSportsSeasons } from "~/models/sports-season";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "~/components/ui/alert-dialog";
|
||||||
|
import { Trash2, Plus, X } from "lucide-react";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
const template = await findSeasonTemplateWithSportsSeasons(params.id);
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new Response("Template not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSportsSeasons = await findAllSportsSeasons();
|
||||||
|
|
||||||
|
// Type assertion since we know the sport relation is included
|
||||||
|
return {
|
||||||
|
template,
|
||||||
|
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "delete") {
|
||||||
|
await deleteSeasonTemplate(params.id);
|
||||||
|
return redirect("/admin/templates");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "toggle-active") {
|
||||||
|
const isActive = formData.get("isActive") === "true";
|
||||||
|
await setSeasonTemplateActive(params.id, isActive);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "add-sport") {
|
||||||
|
const sportsSeasonId = formData.get("sportsSeasonId");
|
||||||
|
const isRequired = formData.get("isRequired") === "on";
|
||||||
|
|
||||||
|
if (typeof sportsSeasonId === "string") {
|
||||||
|
await addSportToTemplate(params.id, sportsSeasonId, isRequired);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "remove-sport") {
|
||||||
|
const sportsSeasonId = formData.get("sportsSeasonId");
|
||||||
|
|
||||||
|
if (typeof sportsSeasonId === "string") {
|
||||||
|
await removeSportFromTemplate(params.id, sportsSeasonId);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "toggle-required") {
|
||||||
|
const sportsSeasonId = formData.get("sportsSeasonId");
|
||||||
|
const isRequired = formData.get("isRequired") === "true";
|
||||||
|
|
||||||
|
if (typeof sportsSeasonId === "string") {
|
||||||
|
await updateSeasonTemplateSport(params.id, sportsSeasonId, isRequired);
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update template
|
||||||
|
const name = formData.get("name");
|
||||||
|
const year = formData.get("year");
|
||||||
|
const description = formData.get("description");
|
||||||
|
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "Template name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof year !== "string") {
|
||||||
|
return { error: "Year is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearNum = parseInt(year, 10);
|
||||||
|
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
|
||||||
|
return { error: "Year must be between 2000 and 2100" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSeasonTemplate(params.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
year: yearNum,
|
||||||
|
description: typeof description === "string" && description.trim() ? description.trim() : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating template:", error);
|
||||||
|
return { error: "Failed to update template. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditTemplate({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { template, allSportsSeasons } = loaderData;
|
||||||
|
const [selectedSportId, setSelectedSportId] = useState("");
|
||||||
|
const [isRequired, setIsRequired] = useState(true);
|
||||||
|
|
||||||
|
const includedSeasonIds = new Set(
|
||||||
|
template.seasonTemplateSports.map((s) => s.sportsSeasonId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableSportsSeasons = allSportsSeasons.filter(
|
||||||
|
(s) => !includedSeasonIds.has(s.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Edit Template</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">{template.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Template Details</CardTitle>
|
||||||
|
<CardDescription>Update the template information</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Template Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
defaultValue={template.name}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="year">Year</Label>
|
||||||
|
<Input
|
||||||
|
id="year"
|
||||||
|
name="year"
|
||||||
|
type="number"
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
defaultValue={template.year}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
defaultValue={template.description || ""}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="toggle-active" />
|
||||||
|
<input type="hidden" name="isActive" value={(!template.isActive).toString()} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant={template.isActive ? "outline" : "default"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{template.isActive ? "Set Inactive" : "Set Active"}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Badge variant={template.isActive ? "default" : "secondary"}>
|
||||||
|
{template.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionData?.success && (
|
||||||
|
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
Template updated successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<Link to="/admin/templates">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sports Seasons</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add sports seasons to this template
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{template.seasonTemplateSports.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{template.seasonTemplateSports.map((templateSport) => (
|
||||||
|
<div
|
||||||
|
key={templateSport.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{templateSport.sportsSeason.sport.name} - {templateSport.sportsSeason.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{templateSport.sportsSeason.year} • {templateSport.sportsSeason.scoringType.replace("_", " ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={templateSport.isRequired ? "default" : "outline"}>
|
||||||
|
{templateSport.isRequired ? "Required" : "Optional"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="toggle-required" />
|
||||||
|
<input type="hidden" name="sportsSeasonId" value={templateSport.sportsSeasonId} />
|
||||||
|
<input type="hidden" name="isRequired" value={(!templateSport.isRequired).toString()} />
|
||||||
|
<Button type="submit" variant="ghost" size="sm">
|
||||||
|
Toggle
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="remove-sport" />
|
||||||
|
<input type="hidden" name="sportsSeasonId" value={templateSport.sportsSeasonId} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{availableSportsSeasons.length > 0 ? (
|
||||||
|
<Form method="post" className="flex gap-2">
|
||||||
|
<input type="hidden" name="intent" value="add-sport" />
|
||||||
|
<Select
|
||||||
|
name="sportsSeasonId"
|
||||||
|
value={selectedSportId}
|
||||||
|
onValueChange={setSelectedSportId}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select a sports season" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableSportsSeasons.map((season) => (
|
||||||
|
<SelectItem key={season.id} value={season.id}>
|
||||||
|
{season.sport.name} - {season.name} ({season.year})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="isRequired"
|
||||||
|
name="isRequired"
|
||||||
|
checked={isRequired}
|
||||||
|
onCheckedChange={(checked) => setIsRequired(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isRequired" className="text-sm">Required</Label>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
All available sports seasons have been added
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-destructive">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Permanently delete this template
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete Template
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete the template "{template.name}".
|
||||||
|
This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="delete" />
|
||||||
|
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
app/routes/admin.templates.new.tsx
Normal file
144
app/routes/admin.templates.new.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { Form, Link, redirect } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.templates.new";
|
||||||
|
import { createSeasonTemplate } from "~/models/season-template";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import { Textarea } from "~/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
|
||||||
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get("name");
|
||||||
|
const year = formData.get("year");
|
||||||
|
const description = formData.get("description");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "Template name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof year !== "string") {
|
||||||
|
return { error: "Year is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearNum = parseInt(year, 10);
|
||||||
|
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
|
||||||
|
return { error: "Year must be between 2000 and 2100" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = await createSeasonTemplate({
|
||||||
|
name: name.trim(),
|
||||||
|
year: yearNum,
|
||||||
|
description: typeof description === "string" && description.trim() ? description.trim() : null,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(`/admin/templates/${template.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating template:", error);
|
||||||
|
return { error: "Failed to create template. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewTemplate({ actionData }: Route.ComponentProps) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Create Season Template</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Bundle sports seasons together for league commissioners
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Template Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a template that commissioners can use when setting up their leagues
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Template Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., 2025 Full Season, 2025 Playoffs Only"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
A descriptive name that commissioners will see
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="year">Year</Label>
|
||||||
|
<Input
|
||||||
|
id="year"
|
||||||
|
name="year"
|
||||||
|
type="number"
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
defaultValue={currentYear}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Describe what sports seasons are included in this template"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" className="flex-1">
|
||||||
|
Create Template
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<Link to="/admin/templates">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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">Next Steps</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<p>After creating the template, you'll be able to:</p>
|
||||||
|
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||||
|
<li>Add sports seasons to the template</li>
|
||||||
|
<li>Mark which sports are required vs optional</li>
|
||||||
|
<li>Set the number of flex spots</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
app/routes/admin.templates.tsx
Normal file
108
app/routes/admin.templates.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.templates";
|
||||||
|
import { findAllSeasonTemplates } from "~/models/season-template";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Plus, FolderKanban } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const templates = await findAllSeasonTemplates();
|
||||||
|
return { templates };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminTemplates({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { templates } = loaderData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Season Templates</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Manage season templates for league commissioners
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/admin/templates/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Template
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Templates</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{templates.length} {templates.length === 1 ? "template" : "templates"} total
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FolderKanban className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold">No templates yet</h3>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Create your first season template to bundle sports seasons
|
||||||
|
</p>
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link to="/admin/templates/new">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Template
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Year</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<TableRow key={template.id}>
|
||||||
|
<TableCell className="font-medium">{template.name}</TableCell>
|
||||||
|
<TableCell>{template.year}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={template.isActive ? "default" : "secondary"}>
|
||||||
|
{template.isActive ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground max-w-md truncate">
|
||||||
|
{template.description || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to={`/admin/templates/${template.id}`}>Edit</Link>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
app/routes/admin.tsx
Normal file
78
app/routes/admin.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Link, Outlet, redirect } from "react-router";
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import type { Route } from "./+types/admin";
|
||||||
|
import { isUserAdminByClerkId } from "~/models/user";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Trophy,
|
||||||
|
Calendar,
|
||||||
|
FolderKanban,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdminByClerkId(userId);
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isAdmin };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 border-r bg-muted/40">
|
||||||
|
<div className="flex h-16 items-center border-b px-6">
|
||||||
|
<h2 className="text-lg font-semibold">Admin Panel</h2>
|
||||||
|
</div>
|
||||||
|
<nav className="space-y-1 p-4">
|
||||||
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
|
<Link to="/admin">
|
||||||
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
|
<Link to="/admin/sports">
|
||||||
|
<Trophy className="mr-2 h-4 w-4" />
|
||||||
|
Sports
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
|
<Link to="/admin/sports-seasons">
|
||||||
|
<Calendar className="mr-2 h-4 w-4" />
|
||||||
|
Sports Seasons
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
|
<Link to="/admin/templates">
|
||||||
|
<FolderKanban className="mr-2 h-4 w-4" />
|
||||||
|
Season Templates
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="border-t my-2" />
|
||||||
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
|
<Link to="/admin/data-sync">
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Data Sync
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
app/routes/api.admin.export-sports-data.ts
Normal file
21
app/routes/api.admin.export-sports-data.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import type { Route } from "./+types/api.admin.export-sports-data";
|
||||||
|
import { isUserAdminByClerkId } from "~/models/user";
|
||||||
|
import { exportSportsDataToJSON } from "~/utils/sports-data-sync.server";
|
||||||
|
|
||||||
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdminByClerkId(userId);
|
||||||
|
if (!isAdmin) {
|
||||||
|
throw new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await exportSportsDataToJSON();
|
||||||
|
|
||||||
|
return Response.json(data);
|
||||||
|
}
|
||||||
334
app/utils/sports-data-sync.server.ts
Normal file
334
app/utils/sports-data-sync.server.ts
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import * as schema from "~/database/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
interface ExportData {
|
||||||
|
version: string;
|
||||||
|
exportedAt: string;
|
||||||
|
sports: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
}>;
|
||||||
|
sportsSeasons: Array<{
|
||||||
|
sportSlug: string;
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
status: string;
|
||||||
|
scoringType: string;
|
||||||
|
}>;
|
||||||
|
participants: Array<{
|
||||||
|
sportSlug: string;
|
||||||
|
seasonName: string;
|
||||||
|
seasonYear: number;
|
||||||
|
name: string;
|
||||||
|
shortName: string | null;
|
||||||
|
externalId: string | null;
|
||||||
|
}>;
|
||||||
|
seasonTemplates: Array<{
|
||||||
|
name: string;
|
||||||
|
year: number;
|
||||||
|
description: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
sports: Array<{
|
||||||
|
sportSlug: string;
|
||||||
|
seasonName: string;
|
||||||
|
seasonYear: number;
|
||||||
|
isRequired: boolean;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSportsDataToJSON(): Promise<ExportData> {
|
||||||
|
const db = database();
|
||||||
|
|
||||||
|
// Export sports
|
||||||
|
const sports = await db.query.sports.findMany({
|
||||||
|
orderBy: (sports, { asc }) => [asc(sports.name)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export sports seasons with sport relation
|
||||||
|
const sportsSeasons = await db.query.sportsSeasons.findMany({
|
||||||
|
with: {
|
||||||
|
sport: true,
|
||||||
|
},
|
||||||
|
orderBy: (sportsSeasons, { desc, asc }) => [
|
||||||
|
desc(sportsSeasons.year),
|
||||||
|
asc(sportsSeasons.name),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export participants with relations
|
||||||
|
const participants = await db.query.participants.findMany({
|
||||||
|
with: {
|
||||||
|
sportsSeason: {
|
||||||
|
with: {
|
||||||
|
sport: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: (participants, { asc }) => [asc(participants.name)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export season templates with sports
|
||||||
|
const seasonTemplates = await db.query.seasonTemplates.findMany({
|
||||||
|
with: {
|
||||||
|
seasonTemplateSports: {
|
||||||
|
with: {
|
||||||
|
sportsSeason: {
|
||||||
|
with: {
|
||||||
|
sport: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: (templates, { desc }) => [desc(templates.year)],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build export data structure
|
||||||
|
return {
|
||||||
|
version: "1.0",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
sports: sports.map((sport) => ({
|
||||||
|
name: sport.name,
|
||||||
|
type: sport.type,
|
||||||
|
slug: sport.slug,
|
||||||
|
description: sport.description,
|
||||||
|
})),
|
||||||
|
sportsSeasons: sportsSeasons.map((season) => ({
|
||||||
|
sportSlug: season.sport.slug,
|
||||||
|
name: season.name,
|
||||||
|
year: season.year,
|
||||||
|
startDate: season.startDate,
|
||||||
|
endDate: season.endDate,
|
||||||
|
status: season.status,
|
||||||
|
scoringType: season.scoringType,
|
||||||
|
})),
|
||||||
|
participants: participants.map((participant) => ({
|
||||||
|
sportSlug: participant.sportsSeason.sport.slug,
|
||||||
|
seasonName: participant.sportsSeason.name,
|
||||||
|
seasonYear: participant.sportsSeason.year,
|
||||||
|
name: participant.name,
|
||||||
|
shortName: participant.shortName,
|
||||||
|
externalId: participant.externalId,
|
||||||
|
})),
|
||||||
|
seasonTemplates: seasonTemplates.map((template) => ({
|
||||||
|
name: template.name,
|
||||||
|
year: template.year,
|
||||||
|
description: template.description,
|
||||||
|
isActive: template.isActive,
|
||||||
|
sports: template.seasonTemplateSports.map((ts) => ({
|
||||||
|
sportSlug: ts.sportsSeason.sport.slug,
|
||||||
|
seasonName: ts.sportsSeason.name,
|
||||||
|
seasonYear: ts.sportsSeason.year,
|
||||||
|
isRequired: ts.isRequired,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importSportsDataFromJSON(
|
||||||
|
jsonData: string,
|
||||||
|
mode: "merge" | "replace" = "merge"
|
||||||
|
): Promise<{ created: number; updated: number; skipped: number }> {
|
||||||
|
const db = database();
|
||||||
|
const importData: ExportData = JSON.parse(jsonData);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
if (mode === "replace") {
|
||||||
|
// Delete in reverse order of dependencies
|
||||||
|
await db.delete(schema.seasonTemplateSports);
|
||||||
|
await db.delete(schema.seasonTemplates);
|
||||||
|
await db.delete(schema.participantResults);
|
||||||
|
await db.delete(schema.participants);
|
||||||
|
await db.delete(schema.seasonSports);
|
||||||
|
await db.delete(schema.sportsSeasons);
|
||||||
|
await db.delete(schema.sports);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track created IDs for relationships
|
||||||
|
const sportIdMap = new Map<string, string>();
|
||||||
|
const sportsSeasonIdMap = new Map<string, string>();
|
||||||
|
|
||||||
|
// Import sports
|
||||||
|
for (const sport of importData.sports) {
|
||||||
|
const existing = await db.query.sports.findFirst({
|
||||||
|
where: eq(schema.sports.slug, sport.slug),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (mode === "merge") {
|
||||||
|
await db
|
||||||
|
.update(schema.sports)
|
||||||
|
.set({
|
||||||
|
name: sport.name,
|
||||||
|
type: sport.type as "team" | "individual",
|
||||||
|
description: sport.description,
|
||||||
|
})
|
||||||
|
.where(eq(schema.sports.slug, sport.slug));
|
||||||
|
sportIdMap.set(sport.slug, existing.id);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const [createdSport] = await db
|
||||||
|
.insert(schema.sports)
|
||||||
|
.values({
|
||||||
|
name: sport.name,
|
||||||
|
type: sport.type as "team" | "individual",
|
||||||
|
slug: sport.slug,
|
||||||
|
description: sport.description,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
sportIdMap.set(sport.slug, createdSport.id);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import sports seasons
|
||||||
|
for (const season of importData.sportsSeasons) {
|
||||||
|
const sportId = sportIdMap.get(season.sportSlug);
|
||||||
|
if (!sportId) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.sportsSeasons.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.sportsSeasons.sportId, sportId),
|
||||||
|
eq(schema.sportsSeasons.name, season.name),
|
||||||
|
eq(schema.sportsSeasons.year, season.year)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const seasonKey = `${season.sportSlug}:${season.name}:${season.year}`;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (mode === "merge") {
|
||||||
|
await db
|
||||||
|
.update(schema.sportsSeasons)
|
||||||
|
.set({
|
||||||
|
startDate: season.startDate,
|
||||||
|
endDate: season.endDate,
|
||||||
|
status: season.status as "upcoming" | "active" | "completed",
|
||||||
|
scoringType: season.scoringType as "playoffs" | "regular_season" | "majors",
|
||||||
|
})
|
||||||
|
.where(eq(schema.sportsSeasons.id, existing.id));
|
||||||
|
sportsSeasonIdMap.set(seasonKey, existing.id);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const [createdSeason] = await db
|
||||||
|
.insert(schema.sportsSeasons)
|
||||||
|
.values({
|
||||||
|
sportId,
|
||||||
|
name: season.name,
|
||||||
|
year: season.year,
|
||||||
|
startDate: season.startDate,
|
||||||
|
endDate: season.endDate,
|
||||||
|
status: season.status as "upcoming" | "active" | "completed",
|
||||||
|
scoringType: season.scoringType as "playoffs" | "regular_season" | "majors",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
sportsSeasonIdMap.set(seasonKey, createdSeason.id);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import participants
|
||||||
|
for (const participant of importData.participants) {
|
||||||
|
const seasonKey = `${participant.sportSlug}:${participant.seasonName}:${participant.seasonYear}`;
|
||||||
|
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
||||||
|
|
||||||
|
if (!sportsSeasonId) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.participants.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.participants.sportsSeasonId, sportsSeasonId),
|
||||||
|
eq(schema.participants.name, participant.name)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await db.insert(schema.participants).values({
|
||||||
|
sportsSeasonId,
|
||||||
|
name: participant.name,
|
||||||
|
shortName: participant.shortName,
|
||||||
|
externalId: participant.externalId,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import season templates
|
||||||
|
for (const template of importData.seasonTemplates) {
|
||||||
|
const existing = await db.query.seasonTemplates.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(schema.seasonTemplates.name, template.name),
|
||||||
|
eq(schema.seasonTemplates.year, template.year)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
let templateId: string;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (mode === "merge") {
|
||||||
|
await db
|
||||||
|
.update(schema.seasonTemplates)
|
||||||
|
.set({
|
||||||
|
description: template.description,
|
||||||
|
isActive: template.isActive,
|
||||||
|
})
|
||||||
|
.where(eq(schema.seasonTemplates.id, existing.id));
|
||||||
|
templateId = existing.id;
|
||||||
|
updated++;
|
||||||
|
|
||||||
|
// Remove existing template sports to re-add them
|
||||||
|
await db
|
||||||
|
.delete(schema.seasonTemplateSports)
|
||||||
|
.where(eq(schema.seasonTemplateSports.templateId, templateId));
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const [createdTemplate] = await db
|
||||||
|
.insert(schema.seasonTemplates)
|
||||||
|
.values({
|
||||||
|
name: template.name,
|
||||||
|
year: template.year,
|
||||||
|
description: template.description,
|
||||||
|
isActive: template.isActive,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
templateId = createdTemplate.id;
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add template sports
|
||||||
|
for (const templateSport of template.sports) {
|
||||||
|
const seasonKey = `${templateSport.sportSlug}:${templateSport.seasonName}:${templateSport.seasonYear}`;
|
||||||
|
const sportsSeasonId = sportsSeasonIdMap.get(seasonKey);
|
||||||
|
|
||||||
|
if (sportsSeasonId) {
|
||||||
|
await db.insert(schema.seasonTemplateSports).values({
|
||||||
|
templateId,
|
||||||
|
sportsSeasonId,
|
||||||
|
isRequired: templateSport.isRequired,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, skipped };
|
||||||
|
}
|
||||||
31
package-lock.json
generated
31
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/react-router": "^2.1.0",
|
"@clerk/react-router": "^2.1.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
|
@ -1769,6 +1770,36 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-checkbox": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/react-router": "^2.1.0",
|
"@clerk/react-router": "^2.1.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue