* fix: use hidden input for UTC date value in add-game form The onSubmit handler was setting the datetime-local input's value to an ISO UTC string (e.g. "2026-03-11T17:00:00.000Z"). datetime-local inputs only accept the format "YYYY-MM-DDTHH:MM" — values with a 'Z' timezone suffix are invalid and the browser silently clears the field to "". This caused the form to submit an empty scheduledAt, which the server treated as null, so games were always saved without a date. Fix by keeping the datetime-local input for user interaction (renamed scheduledAtLocal, not submitted) and writing the converted UTC ISO string into a separate hidden input named scheduledAt in the onSubmit handler. Also adds app/lib/date-utils.ts with a localDateTimeToUtcIso helper and matching tests to document and verify the datetime-local ↔ ISO conversion behaviour. https://claude.ai/code/session_0148fgZiXpyvGX8ZX3BFRGCs * refactor: use localDateTimeToUtcIso in component and clean up tests - Import and call localDateTimeToUtcIso in the add-game onSubmit handler instead of duplicating the conversion inline; also applies the null/ invalid-date safety from the utility to the component - Remove the redundant "incompatible with datetime-local" test case (its intent is better expressed as a code comment than a test assertion) - Move the datetime-local incompatibility explanation into a NOTE comment on the utility function's JSDoc https://claude.ai/code/session_0148fgZiXpyvGX8ZX3BFRGCs --------- Co-authored-by: Claude <noreply@anthropic.com>
958 lines
46 KiB
TypeScript
958 lines
46 KiB
TypeScript
import { Form, Link } from "react-router";
|
||
import { Fragment, useState, useEffect, useMemo } from "react";
|
||
import { localDateTimeToUtcIso } from "~/lib/date-utils";
|
||
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, X, Check, ChevronDown, ChevronRight, Calendar, TrendingUp, Trash2 } from "lucide-react";
|
||
import { getAllBracketTemplates, getBracketTemplate } from "~/lib/bracket-templates";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "~/components/ui/table";
|
||
import { Badge } from "~/components/ui/badge";
|
||
import { ParticipantSelector } from "~/components/ParticipantSelector";
|
||
|
||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||
return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
||
}
|
||
|
||
export { loader, action };
|
||
|
||
export default function EventBracket({
|
||
loaderData,
|
||
actionData,
|
||
}: Route.ComponentProps) {
|
||
const { sportsSeason, event, participants, matches, tournamentGroups } = loaderData;
|
||
const [selectedTemplate, setSelectedTemplate] = useState("simple_8");
|
||
const [selectedParticipants, setSelectedParticipants] = useState<Record<number, string>>({});
|
||
const [selectedWinners, setSelectedWinners] = useState<Record<string, string>>({});
|
||
const [knockoutAssignments, setKnockoutAssignments] = useState<Record<string, string>>({});
|
||
const [expandedMatches, setExpandedMatches] = useState<Set<string>>(new Set());
|
||
const localTzAbbr = new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
|
||
.formatToParts(new Date())
|
||
.find(p => p.type === "timeZoneName")?.value ?? "local";
|
||
|
||
const toggleMatchExpanded = (matchId: string) => {
|
||
setExpandedMatches(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(matchId)) next.delete(matchId);
|
||
else next.add(matchId);
|
||
return next;
|
||
});
|
||
};
|
||
const templates = getAllBracketTemplates();
|
||
const template = templates.find((t) => t.id === selectedTemplate);
|
||
|
||
// Determine if this is a group-stage event
|
||
const hasGroupStage = template?.groupStage != null;
|
||
const isGroupStageEvent = tournamentGroups.length > 0;
|
||
|
||
// Determine the phase for group-stage events
|
||
const knockoutPopulated = useMemo(() => {
|
||
if (!isGroupStageEvent) return false;
|
||
// Check if any Round of 32 match has participants assigned
|
||
const r32Matches = matches.filter((m: any) => m.round === "Round of 32");
|
||
return r32Matches.some((m: any) => m.participant1Id || m.participant2Id);
|
||
}, [isGroupStageEvent, matches]);
|
||
|
||
// Group stage stats
|
||
const groupStageStats = useMemo(() => {
|
||
if (!isGroupStageEvent) return null;
|
||
let eliminated = 0;
|
||
let total = 0;
|
||
for (const group of tournamentGroups) {
|
||
for (const member of (group as any).members || []) {
|
||
total++;
|
||
if (member.eliminated) eliminated++;
|
||
}
|
||
}
|
||
return { eliminated, advancing: total - eliminated, total };
|
||
}, [isGroupStageEvent, tournamentGroups]);
|
||
|
||
// Advancing participants (non-eliminated) for knockout assignment
|
||
const advancingParticipants = useMemo(() => {
|
||
if (!isGroupStageEvent) return [];
|
||
const advancing: Array<{ id: string; name: string }> = [];
|
||
for (const group of tournamentGroups) {
|
||
for (const member of (group as any).members || []) {
|
||
if (!member.eliminated && member.participant) {
|
||
advancing.push({
|
||
id: member.participant.id,
|
||
name: member.participant.name,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return advancing;
|
||
}, [isGroupStageEvent, tournamentGroups]);
|
||
|
||
// Available advancing participants for knockout assignment (exclude already assigned)
|
||
const availableAdvancingMap = useMemo(() => {
|
||
const assignedIds = new Set(Object.values(knockoutAssignments).filter(Boolean));
|
||
const map: Record<string, typeof advancingParticipants> = {};
|
||
|
||
// Create 32 slots (16 matches × 2 slots)
|
||
for (let m = 1; m <= 16; m++) {
|
||
for (const slot of ["participant1Id", "participant2Id"]) {
|
||
const key = `match-${m}-${slot}`;
|
||
const currentSelection = knockoutAssignments[key];
|
||
map[key] = advancingParticipants.filter(
|
||
(p) => !assignedIds.has(p.id) || p.id === currentSelection
|
||
);
|
||
}
|
||
}
|
||
return map;
|
||
}, [knockoutAssignments, advancingParticipants]);
|
||
|
||
// Clear selected winners after successful batch submission
|
||
useEffect(() => {
|
||
if (actionData?.success) {
|
||
setSelectedWinners({});
|
||
}
|
||
}, [actionData?.success]);
|
||
|
||
// Reset selected participants when template changes
|
||
const handleTemplateChange = (newTemplateId: string) => {
|
||
setSelectedTemplate(newTemplateId);
|
||
setSelectedParticipants({});
|
||
};
|
||
|
||
// Update selected participant for a seed
|
||
const handleParticipantChange = (seedIndex: number, participantId: string) => {
|
||
setSelectedParticipants(prev => ({
|
||
...prev,
|
||
[seedIndex]: participantId
|
||
}));
|
||
};
|
||
|
||
// Memoized available participants calculation
|
||
const availableParticipantsMap = useMemo(() => {
|
||
const selectedIdsSet = new Set(Object.values(selectedParticipants));
|
||
const map: Record<number, typeof participants> = {};
|
||
const totalSeeds = template?.totalTeams || 8;
|
||
|
||
for (let i = 0; i < totalSeeds; i++) {
|
||
const currentSelection = selectedParticipants[i];
|
||
map[i] = participants.filter((p: { id: string; name: string }) => {
|
||
return !selectedIdsSet.has(p.id) || p.id === currentSelection;
|
||
});
|
||
}
|
||
return map;
|
||
}, [selectedParticipants, participants, template?.totalTeams]);
|
||
|
||
const getAvailableParticipants = (seedIndex: number) => {
|
||
return availableParticipantsMap[seedIndex] || participants;
|
||
};
|
||
|
||
// Update selected winner for a match
|
||
const handleWinnerChange = (matchId: string, winnerId: string) => {
|
||
setSelectedWinners(prev => ({
|
||
...prev,
|
||
[matchId]: winnerId
|
||
}));
|
||
};
|
||
|
||
// Get matches with pending winner selections in a round
|
||
const getPendingWinnersInRound = (round: string) => {
|
||
return Object.entries(selectedWinners)
|
||
.filter(([matchId]) => {
|
||
const match = matches.find((m: any) => m.id === matchId);
|
||
return match && match.round === round && !match.isComplete;
|
||
});
|
||
};
|
||
|
||
// Memoized: Group matches by round
|
||
const matchesByRound = useMemo(() => {
|
||
const grouped: Record<string, typeof matches> = {};
|
||
for (const match of matches) {
|
||
if (!grouped[match.round]) {
|
||
grouped[match.round] = [];
|
||
}
|
||
grouped[match.round].push(match);
|
||
}
|
||
return grouped;
|
||
}, [matches]);
|
||
|
||
// Memoized: Get available rounds in chronological order
|
||
const availableRounds = useMemo(() => {
|
||
const roundsInMatches = Array.from(new Set(matches.map(m => m.round)));
|
||
|
||
if (event.bracketTemplateId) {
|
||
const eventTemplate = getBracketTemplate(event.bracketTemplateId);
|
||
if (eventTemplate) {
|
||
return eventTemplate.rounds
|
||
.map((r) => r.name)
|
||
.filter((roundName: string) => roundsInMatches.includes(roundName));
|
||
}
|
||
}
|
||
|
||
return roundsInMatches;
|
||
}, [matches, event.bracketTemplateId]);
|
||
|
||
// Check if all matches are complete
|
||
const allMatchesComplete = useMemo(() => {
|
||
return matches.length > 0 && matches.every((m: any) => m.isComplete);
|
||
}, [matches]);
|
||
|
||
// No bracket and no groups yet = setup phase
|
||
const showSetup = matches.length === 0 && !isGroupStageEvent;
|
||
|
||
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-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||
{actionData.success}
|
||
</div>
|
||
)}
|
||
|
||
{/* Reprocess Eliminations - For existing brackets (non-group-stage) */}
|
||
{matches.length > 0 && !isGroupStageEvent && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Reprocess Eliminations</CardTitle>
|
||
<CardDescription>
|
||
Mark participants not in this bracket as eliminated (for brackets created before automatic elimination tracking)
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="reprocess-eliminations" />
|
||
<div className="space-y-4">
|
||
<div className="text-sm text-muted-foreground">
|
||
This will set finalPosition = 0 for all participants who are not in any playoff match,
|
||
allowing probability recalculation to set their odds to 0%.
|
||
</div>
|
||
<Button type="submit" variant="outline">
|
||
Reprocess Eliminations
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ====== SETUP PHASE ====== */}
|
||
{showSetup && (
|
||
<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">
|
||
{/* Intent depends on whether this is a group-stage template */}
|
||
<input
|
||
type="hidden"
|
||
name="intent"
|
||
value={hasGroupStage ? "generate-groups" : "generate-bracket"}
|
||
/>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="templateId">Bracket Template</Label>
|
||
<Select
|
||
name="templateId"
|
||
value={selectedTemplate}
|
||
onValueChange={handleTemplateChange}
|
||
required
|
||
>
|
||
<SelectTrigger id="templateId">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{templates.map((t) => (
|
||
<SelectItem key={t.id} value={t.id}>
|
||
{t.name} - {t.totalTeams} teams
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
{template && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{template.groupStage ? (
|
||
<>
|
||
Groups: {template.groupStage.groupCount} groups of {template.groupStage.teamsPerGroup}
|
||
{" | "}Knockout: {template.rounds.map(r => r.name).join(" \u2192 ")}
|
||
</>
|
||
) : (
|
||
<>Rounds: {template.rounds.map(r => r.name).join(" \u2192 ")}</>
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Group-stage template: show group assignments */}
|
||
{hasGroupStage && template?.groupStage && (
|
||
<div className="space-y-4">
|
||
<Label>Assign Participants to Groups</Label>
|
||
<p className="text-sm text-muted-foreground">
|
||
Assign {template.totalTeams} participants across {template.groupStage.groupCount} groups
|
||
({template.groupStage.teamsPerGroup} per group).
|
||
</p>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{template.groupStage.groupLabels.map((label, groupIndex) => (
|
||
<Card key={label} className="border-dashed">
|
||
<CardHeader className="py-3 px-4">
|
||
<CardTitle className="text-base">Group {label}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 space-y-2">
|
||
{[...Array(template.groupStage!.teamsPerGroup)].map((_, teamIndex) => {
|
||
const seedIndex = groupIndex * template.groupStage!.teamsPerGroup + teamIndex;
|
||
const availableParticipants = getAvailableParticipants(seedIndex);
|
||
|
||
return (
|
||
<div key={seedIndex}>
|
||
<input
|
||
type="hidden"
|
||
name={`participant${seedIndex}`}
|
||
value={selectedParticipants[seedIndex] || ""}
|
||
required
|
||
/>
|
||
<ParticipantSelector
|
||
participants={availableParticipants}
|
||
value={selectedParticipants[seedIndex] || ""}
|
||
onValueChange={(value) => handleParticipantChange(seedIndex, value)}
|
||
placeholder={`Team ${teamIndex + 1}...`}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Non-group-stage template: show seeded list */}
|
||
{!hasGroupStage && (
|
||
<div className="space-y-2">
|
||
<Label>Select Participants (in order)</Label>
|
||
<p className="text-sm text-muted-foreground">
|
||
Select {template?.totalTeams || 8} participants for the bracket. Use search to quickly find participants.
|
||
</p>
|
||
{[...Array(template?.totalTeams || 8)].map((_, i) => {
|
||
const availableParticipants = getAvailableParticipants(i);
|
||
|
||
return (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
||
Seed {i + 1}
|
||
</Label>
|
||
<div className="flex-1 min-w-0">
|
||
<input
|
||
type="hidden"
|
||
name={`participant${i}`}
|
||
value={selectedParticipants[i] || ""}
|
||
required
|
||
/>
|
||
<ParticipantSelector
|
||
participants={availableParticipants}
|
||
value={selectedParticipants[i] || ""}
|
||
onValueChange={(value) => handleParticipantChange(i, value)}
|
||
placeholder={`Select seed ${i + 1}...`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<Button type="submit" className="w-full">
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
{hasGroupStage ? "Generate Groups & Bracket" : "Generate Bracket"}
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ====== GROUP STAGE MANAGEMENT (Phase 2) ====== */}
|
||
{isGroupStageEvent && !knockoutPopulated && (
|
||
<>
|
||
{/* Group Stage Summary */}
|
||
{groupStageStats && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Group Stage</CardTitle>
|
||
<CardDescription>
|
||
Manage group stage eliminations. Mark teams as eliminated, then assign advancing teams to the knockout bracket.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex gap-4 text-sm">
|
||
<Badge variant="secondary">
|
||
{groupStageStats.total} total teams
|
||
</Badge>
|
||
<Badge variant="destructive">
|
||
{groupStageStats.eliminated} eliminated
|
||
</Badge>
|
||
<Badge variant="default">
|
||
{groupStageStats.advancing} advancing
|
||
</Badge>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Group Cards */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||
{tournamentGroups.map((group: any) => (
|
||
<Card key={group.id}>
|
||
<CardHeader className="py-3 px-4">
|
||
<CardTitle className="text-base">Group {group.groupName}</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="px-4 pb-4 space-y-2">
|
||
{(group.members || []).map((member: any) => (
|
||
<div
|
||
key={member.id}
|
||
className={`flex items-center justify-between gap-2 p-2 rounded-md border ${
|
||
member.eliminated
|
||
? "bg-destructive/10 border-destructive/20"
|
||
: "bg-background border-border"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`text-sm truncate ${
|
||
member.eliminated ? "line-through text-muted-foreground" : ""
|
||
}`}
|
||
>
|
||
{member.participant?.name || "Unknown"}
|
||
</span>
|
||
<Form method="post" className="shrink-0">
|
||
<input type="hidden" name="intent" value="toggle-elimination" />
|
||
<input type="hidden" name="memberId" value={member.id} />
|
||
<Button
|
||
type="submit"
|
||
size="sm"
|
||
variant={member.eliminated ? "outline" : "destructive"}
|
||
className="h-7 text-xs"
|
||
>
|
||
{member.eliminated ? (
|
||
<>
|
||
<Check className="h-3 w-3 mr-1" />
|
||
Reinstate
|
||
</>
|
||
) : (
|
||
<>
|
||
<X className="h-3 w-3 mr-1" />
|
||
Eliminate
|
||
</>
|
||
)}
|
||
</Button>
|
||
</Form>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Knockout Assignment Section */}
|
||
{groupStageStats && groupStageStats.advancing === 32 && (
|
||
<Card className="border-blue-500">
|
||
<CardHeader>
|
||
<CardTitle>Assign Knockout Bracket</CardTitle>
|
||
<CardDescription>
|
||
32 teams are advancing. Assign them to the Round of 32 knockout bracket slots.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-4">
|
||
<input type="hidden" name="intent" value="populate-knockout" />
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{[...Array(16)].map((_, matchIndex) => {
|
||
const matchNum = matchIndex + 1;
|
||
const key1 = `match-${matchNum}-participant1Id`;
|
||
const key2 = `match-${matchNum}-participant2Id`;
|
||
|
||
return (
|
||
<Card key={matchNum} className="border-dashed">
|
||
<CardContent className="p-4 space-y-2">
|
||
<div className="text-sm font-semibold text-muted-foreground">
|
||
Match {matchNum}
|
||
</div>
|
||
<input
|
||
type="hidden"
|
||
name={key1}
|
||
value={knockoutAssignments[key1] || ""}
|
||
required
|
||
/>
|
||
<ParticipantSelector
|
||
participants={availableAdvancingMap[key1] || []}
|
||
value={knockoutAssignments[key1] || ""}
|
||
onValueChange={(value) =>
|
||
setKnockoutAssignments((prev) => ({ ...prev, [key1]: value }))
|
||
}
|
||
placeholder="Team 1..."
|
||
/>
|
||
<div className="text-center text-xs text-muted-foreground">vs</div>
|
||
<input
|
||
type="hidden"
|
||
name={key2}
|
||
value={knockoutAssignments[key2] || ""}
|
||
required
|
||
/>
|
||
<ParticipantSelector
|
||
participants={availableAdvancingMap[key2] || []}
|
||
value={knockoutAssignments[key2] || ""}
|
||
onValueChange={(value) =>
|
||
setKnockoutAssignments((prev) => ({ ...prev, [key2]: value }))
|
||
}
|
||
placeholder="Team 2..."
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<Button type="submit" className="w-full">
|
||
Populate Knockout Bracket
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{groupStageStats && groupStageStats.advancing !== 32 && groupStageStats.eliminated > 0 && (
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<p className="text-sm text-muted-foreground">
|
||
{groupStageStats.advancing} teams advancing (need exactly 32 to populate knockout bracket).
|
||
{groupStageStats.advancing > 32 && ` Eliminate ${groupStageStats.advancing - 32} more.`}
|
||
{groupStageStats.advancing < 32 && ` Reinstate ${32 - groupStageStats.advancing} teams or adjust eliminations.`}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* ====== KNOCKOUT BRACKET DISPLAY (Phase 3) ====== */}
|
||
{/* Show bracket rounds when: non-group event with matches, OR group event with knockout populated */}
|
||
{((matches.length > 0 && !isGroupStageEvent) || knockoutPopulated) && (
|
||
<>
|
||
{availableRounds.map((round: string) => (
|
||
<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-8"></TableHead>
|
||
<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) => (
|
||
<Fragment key={match.id}>
|
||
<TableRow>
|
||
<TableCell className="w-8 p-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleMatchExpanded(match.id)}
|
||
className="text-muted-foreground hover:text-foreground"
|
||
aria-label="Toggle games and odds"
|
||
>
|
||
{expandedMatches.has(match.id)
|
||
? <ChevronDown className="h-4 w-4" />
|
||
: <ChevronRight className="h-4 w-4" />}
|
||
</button>
|
||
</TableCell>
|
||
<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 ? (
|
||
<Select
|
||
value={selectedWinners[match.id] || ""}
|
||
onValueChange={(value) => handleWinnerChange(match.id, value)}
|
||
>
|
||
<SelectTrigger className="h-8 w-full">
|
||
<SelectValue placeholder="Select winner" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={match.participant1Id}>
|
||
{match.participant1?.name}
|
||
</SelectItem>
|
||
<SelectItem value={match.participant2Id}>
|
||
{match.participant2?.name}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
) : match.isComplete ? (
|
||
<span className="text-sm text-muted-foreground">Complete</span>
|
||
) : (
|
||
<span className="text-sm text-muted-foreground">Waiting</span>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
|
||
{/* Expanded: Games & Odds panel */}
|
||
{expandedMatches.has(match.id) && (
|
||
<TableRow className="bg-muted/30">
|
||
<TableCell colSpan={9} className="p-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
|
||
{/* Games section */}
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm font-semibold">Game Schedule</span>
|
||
</div>
|
||
|
||
{/* Existing games */}
|
||
{(match as any).games?.length > 0 ? (
|
||
<div className="space-y-2 mb-3">
|
||
{(match as any).games.map((game: any) => (
|
||
<div key={game.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border">
|
||
<span className="font-medium w-12 shrink-0">Game {game.gameNumber}</span>
|
||
<span className="text-muted-foreground flex-1">
|
||
{game.scheduledAt
|
||
? new Date(game.scheduledAt).toLocaleString()
|
||
: "No date set"}
|
||
</span>
|
||
<Badge variant={
|
||
game.status === "complete" ? "default"
|
||
: game.status === "postponed" ? "destructive"
|
||
: "secondary"
|
||
} className="text-xs">
|
||
{game.status}
|
||
</Badge>
|
||
{game.status === "complete" && game.winner && (
|
||
<span className="text-xs text-muted-foreground">
|
||
W: {game.winner.name}
|
||
</span>
|
||
)}
|
||
<Form method="post" className="shrink-0">
|
||
<input type="hidden" name="intent" value="delete-game" />
|
||
<input type="hidden" name="gameId" value={game.id} />
|
||
<button type="submit" className="text-muted-foreground hover:text-destructive">
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</Form>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground mb-3">No games scheduled yet.</p>
|
||
)}
|
||
|
||
{/* Add game form */}
|
||
<Form method="post" className="flex gap-2 items-end" onSubmit={(e) => {
|
||
const form = e.currentTarget;
|
||
// Read from the datetime-local display input (not submitted directly)
|
||
const scheduledAtLocal = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
|
||
// Write the UTC ISO string into the hidden input that IS submitted
|
||
const scheduledAtHidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
|
||
if (scheduledAtHidden) {
|
||
scheduledAtHidden.value = localDateTimeToUtcIso(scheduledAtLocal?.value) ?? "";
|
||
}
|
||
}}>
|
||
<input type="hidden" name="intent" value="add-game" />
|
||
<input type="hidden" name="matchId" value={match.id} />
|
||
{/* Hidden field holds the UTC ISO string written by onSubmit */}
|
||
<input type="hidden" name="scheduledAt" defaultValue="" />
|
||
<div className="flex-1">
|
||
<Label className="text-xs">Game #</Label>
|
||
<Input
|
||
name="gameNumber"
|
||
type="number"
|
||
min={1}
|
||
max={9}
|
||
defaultValue={((match as any).games?.length ?? 0) + 1}
|
||
className="h-8 w-20"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="flex-1">
|
||
<Label className="text-xs">Date & Time ({localTzAbbr})</Label>
|
||
<Input
|
||
name="scheduledAtLocal"
|
||
type="datetime-local"
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<Button type="submit" size="sm" variant="outline" className="h-8">
|
||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||
Add
|
||
</Button>
|
||
</Form>
|
||
</div>
|
||
|
||
{/* Odds section */}
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm font-semibold">Moneyline Odds</span>
|
||
</div>
|
||
|
||
{(match as any).odds?.length > 0 ? (
|
||
<div className="space-y-2 mb-3">
|
||
{(match as any).odds.map((odd: any) => (
|
||
<div key={odd.id} className="flex items-center gap-2 text-sm bg-background rounded p-2 border">
|
||
<span className="flex-1 font-medium">{odd.participant?.name}</span>
|
||
<span className={`font-mono ${odd.moneylineOdds > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||
{odd.moneylineOdds > 0 ? "+" : ""}{odd.moneylineOdds}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
({(parseFloat(odd.impliedProbability) * 100).toFixed(1)}%)
|
||
</span>
|
||
{odd.oddsSource && (
|
||
<span className="text-xs text-muted-foreground">{odd.oddsSource}</span>
|
||
)}
|
||
<Form method="post" className="shrink-0">
|
||
<input type="hidden" name="intent" value="delete-odds" />
|
||
<input type="hidden" name="matchId" value={match.id} />
|
||
<input type="hidden" name="participantId" value={odd.participantId} />
|
||
<button type="submit" className="text-muted-foreground hover:text-destructive">
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</button>
|
||
</Form>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-muted-foreground mb-3">No odds set.</p>
|
||
)}
|
||
|
||
{/* Set odds forms for each participant */}
|
||
{[
|
||
{ id: match.participant1Id, name: match.participant1?.name },
|
||
{ id: match.participant2Id, name: match.participant2?.name },
|
||
].filter(p => p.id).map(participant => (
|
||
<Form key={participant.id} method="post" className="flex gap-2 items-end mb-2">
|
||
<input type="hidden" name="intent" value="upsert-odds" />
|
||
<input type="hidden" name="matchId" value={match.id} />
|
||
<input type="hidden" name="participantId" value={participant.id!} />
|
||
<div className="flex-1">
|
||
<Label className="text-xs">{participant.name ?? "TBD"}</Label>
|
||
<Input
|
||
name="moneylineOdds"
|
||
type="number"
|
||
placeholder="-110"
|
||
className="h-8"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="flex-1">
|
||
<Label className="text-xs">Source</Label>
|
||
<Input
|
||
name="oddsSource"
|
||
placeholder="e.g. DraftKings"
|
||
className="h-8"
|
||
/>
|
||
</div>
|
||
<Button type="submit" size="sm" variant="outline" className="h-8">
|
||
Set
|
||
</Button>
|
||
</Form>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
{/* Batch Submit Winners */}
|
||
{getPendingWinnersInRound(round).length > 0 && (
|
||
<Form method="post" className="mt-4">
|
||
<input type="hidden" name="intent" value="set-round-winners" />
|
||
<input type="hidden" name="round" value={round} />
|
||
{getPendingWinnersInRound(round).map(([matchId, winnerId]) => (
|
||
<input key={matchId} type="hidden" name={`winner-${matchId}`} value={winnerId} />
|
||
))}
|
||
<Button type="submit" className="w-full">
|
||
Save {getPendingWinnersInRound(round).length} Winner{getPendingWinnersInRound(round).length > 1 ? 's' : ''} for {round}
|
||
</Button>
|
||
</Form>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
|
||
{/* Complete Round Button */}
|
||
{availableRounds.length > 0 && !event.isComplete && (
|
||
<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: string) => (
|
||
<SelectItem key={round} value={round}>
|
||
{round}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<Button type="submit">
|
||
Complete Round & Calculate Placements
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Finalize Bracket Button */}
|
||
{allMatchesComplete && !event.isComplete && (
|
||
<Card className="border-emerald-500/30 bg-emerald-500/10">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Trophy className="h-5 w-5 text-emerald-400" />
|
||
Finalize Bracket
|
||
</CardTitle>
|
||
<CardDescription>
|
||
All matches are complete! Process all rounds and assign final placements.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="finalize-bracket" />
|
||
<div className="space-y-4">
|
||
<div className="text-sm space-y-2">
|
||
<p className="font-medium">This will:</p>
|
||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||
<li>Process all playoff rounds in order</li>
|
||
<li>Assign fantasy placements (1st-8th) based on bracket results</li>
|
||
<li>Assign 0 points to participants not in the bracket</li>
|
||
<li>Mark the event as complete</li>
|
||
<li>Recalculate all team standings</li>
|
||
</ul>
|
||
</div>
|
||
<Button type="submit" className="w-full">
|
||
Finalize Bracket & Update Standings
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Event Complete Badge */}
|
||
{event.isComplete && (
|
||
<Card className="border-electric/30 bg-electric/10">
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center gap-2 text-electric">
|
||
<Trophy className="h-5 w-5" />
|
||
<span className="font-semibold">Event Complete</span>
|
||
<span className="text-sm text-muted-foreground ml-auto">
|
||
{event.completedAt && new Date(event.completedAt).toLocaleDateString()}
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|