279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
|
|
import { Form, Link } from "react-router";
|
||
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||
|
|
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||
|
|
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 { ArrowLeft, Plus, Trophy } from "lucide-react";
|
||
|
|
import {
|
||
|
|
Table,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
TableHead,
|
||
|
|
TableHeader,
|
||
|
|
TableRow,
|
||
|
|
} from "~/components/ui/table";
|
||
|
|
|
||
|
|
export { loader, action };
|
||
|
|
|
||
|
|
export default function EventBracket({
|
||
|
|
loaderData,
|
||
|
|
actionData,
|
||
|
|
}: Route.ComponentProps) {
|
||
|
|
const { sportsSeason, event, participants, matches } = loaderData;
|
||
|
|
|
||
|
|
// Group matches by round
|
||
|
|
const matchesByRound: Record<string, typeof matches> = {};
|
||
|
|
for (const match of matches) {
|
||
|
|
if (!matchesByRound[match.round]) {
|
||
|
|
matchesByRound[match.round] = [];
|
||
|
|
}
|
||
|
|
matchesByRound[match.round].push(match);
|
||
|
|
}
|
||
|
|
|
||
|
|
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||
|
|
const availableRounds = rounds.filter(r => matchesByRound[r]);
|
||
|
|
|
||
|
|
// Get participants not yet in any match
|
||
|
|
const participantsInMatches = new Set(
|
||
|
|
matches.flatMap(m => [m.participant1Id, m.participant2Id].filter(Boolean))
|
||
|
|
);
|
||
|
|
const availableParticipants = participants.filter(
|
||
|
|
(p: { id: string }) => !participantsInMatches.has(p.id)
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="p-8">
|
||
|
|
<div className="max-w-6xl mx-auto">
|
||
|
|
<div className="mb-6">
|
||
|
|
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||
|
|
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}>
|
||
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||
|
|
Back to Event
|
||
|
|
</Link>
|
||
|
|
</Button>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-3xl font-bold">Playoff Bracket</h1>
|
||
|
|
<p className="text-muted-foreground mt-1">
|
||
|
|
{event.name} - {sportsSeason.sport.name} {sportsSeason.name}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-6">
|
||
|
|
{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">
|
||
|
|
{actionData.success}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Generate Bracket Form */}
|
||
|
|
{matches.length === 0 && (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Generate Bracket</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Create the bracket structure for this playoff event
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<Form method="post" className="space-y-4">
|
||
|
|
<input type="hidden" name="intent" value="generate-bracket" />
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="bracketSize">Bracket Size</Label>
|
||
|
|
<Select name="bracketSize" defaultValue="8" required>
|
||
|
|
<SelectTrigger id="bracketSize">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="4">4 teams (Semifinals + Finals)</SelectItem>
|
||
|
|
<SelectItem value="8">8 teams (Quarterfinals + Semifinals + Finals)</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Select Participants (in order)</Label>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
Select {8} participants for the bracket
|
||
|
|
</p>
|
||
|
|
{[...Array(8)].map((_, i) => (
|
||
|
|
<Select key={i} name={`participant${i}`} required>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder={`Seed ${i + 1}`} />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{participants.map((participant: { id: string; name: string }) => (
|
||
|
|
<SelectItem key={participant.id} value={participant.id}>
|
||
|
|
{participant.name}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button type="submit" className="w-full">
|
||
|
|
<Plus className="mr-2 h-4 w-4" />
|
||
|
|
Generate Bracket
|
||
|
|
</Button>
|
||
|
|
</Form>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Bracket Display */}
|
||
|
|
{availableRounds.map(round => (
|
||
|
|
<Card key={round}>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>{round}</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
{matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"}
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="w-16">Match</TableHead>
|
||
|
|
<TableHead>Participant 1</TableHead>
|
||
|
|
<TableHead>Score</TableHead>
|
||
|
|
<TableHead className="w-24 text-center">vs</TableHead>
|
||
|
|
<TableHead>Score</TableHead>
|
||
|
|
<TableHead>Participant 2</TableHead>
|
||
|
|
<TableHead>Winner</TableHead>
|
||
|
|
<TableHead className="w-32">Actions</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{matchesByRound[round].map((match) => (
|
||
|
|
<TableRow key={match.id}>
|
||
|
|
<TableCell className="font-semibold">
|
||
|
|
{match.matchNumber}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{match.participant1?.name || "TBD"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{match.participant1Score ? parseFloat(match.participant1Score) : "-"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-center text-muted-foreground">
|
||
|
|
vs
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{match.participant2Score ? parseFloat(match.participant2Score) : "-"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{match.participant2?.name || "TBD"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{match.winner?.name ? (
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<Trophy className="h-4 w-4 text-yellow-500" />
|
||
|
|
{match.winner.name}
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
"-"
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{!match.isComplete && match.participant1Id && match.participant2Id ? (
|
||
|
|
<Form method="post" className="inline">
|
||
|
|
<input type="hidden" name="intent" value="set-winner" />
|
||
|
|
<input type="hidden" name="matchId" value={match.id} />
|
||
|
|
<Select name="winnerId" required>
|
||
|
|
<SelectTrigger className="h-8 w-full">
|
||
|
|
<SelectValue placeholder="Set winner" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value={match.participant1Id}>
|
||
|
|
{match.participant1?.name}
|
||
|
|
</SelectItem>
|
||
|
|
<SelectItem value={match.participant2Id}>
|
||
|
|
{match.participant2?.name}
|
||
|
|
</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<Button type="submit" size="sm" className="ml-2 h-8">
|
||
|
|
Set
|
||
|
|
</Button>
|
||
|
|
</Form>
|
||
|
|
) : match.isComplete ? (
|
||
|
|
<span className="text-sm text-muted-foreground">Complete</span>
|
||
|
|
) : (
|
||
|
|
<span className="text-sm text-muted-foreground">Waiting</span>
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{/* Complete Round Button */}
|
||
|
|
{availableRounds.length > 0 && (
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Complete Round</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
Finalize the current round and calculate placements
|
||
|
|
</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<Form method="post">
|
||
|
|
<input type="hidden" name="intent" value="complete-round" />
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label htmlFor="round">Round to Complete</Label>
|
||
|
|
<Select name="round" required>
|
||
|
|
<SelectTrigger id="round">
|
||
|
|
<SelectValue placeholder="Select round" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{availableRounds.map(round => (
|
||
|
|
<SelectItem key={round} value={round}>
|
||
|
|
{round}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<Button type="submit">
|
||
|
|
Complete Round & Calculate Placements
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</Form>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|