Review of the llws_20 bracket turned up two display bugs in the shared ranking code, both triggered by the template setting loserFeedsInto on every winners-bracket round. findConsolationRound took the FIRST round with loserFeedsInto and treated its target as a third-place game. That is sound for fifa_48, whose only such round is the Semifinals, but llws_20 uses loserFeedsInto to route losers into the elimination bracket — so "Elimination Round 1" was being read as the consolation game. Its four matches were then placed as exact positions inside the Opening Round tier, corrupting the final rankings list. A consolation round now has to be terminal: its winner plays no further game, which is precisely what lets its result split two exact positions. The rank walk also advanced by a round's match count on the assumption that every match places its loser. That holds in single elimination and must be kept for undecided rounds — four semifinalists occupy positions 1-4 whether or not the games have been played — but a winners-bracket loss places nobody, since the loser drops into the elimination bracket and is ranked by whatever knocks them out later. Those rounds consumed positions they never filled, so a 20-team bracket ranked its last teams T23. Rounds whose losers are placed later now consume nothing, leaving LLWS at 1, T2, 3, 4, T5, T7, T9, T13, T17 for the full 20-team field. Single-elimination templates are unaffected; afl_10's sub-8th ranks were inflated the same way and are now correct too. Also from review: the llws_20 participant labels were 21-24 characters in an 80px fixed-width admin column and would have wrapped to three lines across all 20 rows. Shortened them and widened the column. Tests play a full 20-team tournament through the real advancement map and assert the resulting rank labels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAxqBVzfmKJe6WQ6VF9guj
1544 lines
75 KiB
TypeScript
1544 lines
75 KiB
TypeScript
import { Form, Link, useFetcher } from "react-router";
|
||
import { Fragment, useState, useEffect, useMemo, useRef } from "react";
|
||
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } 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, getOrderedRoundsFromMatches, buildNCAA68SlotMap, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||
import { computeGroupStandings } from "~/models/group-stage-match";
|
||
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` }];
|
||
}
|
||
|
||
/**
|
||
* Schedule editor for a single group-stage match.
|
||
*
|
||
* The admin enters a local wall-clock time; on submit it is converted to a UTC
|
||
* ISO string (in the hidden `scheduledAt` field) so the DB stores a true UTC
|
||
* instant. The stored UTC value is rendered back into the input in the
|
||
* browser's local timezone via a client-only effect (avoids SSR hydration
|
||
* mismatch, since the server runs in UTC).
|
||
*/
|
||
function GroupMatchScheduleForm({
|
||
match,
|
||
localTzAbbr,
|
||
}: {
|
||
match: { id: string; scheduledAt: string | Date | null | undefined };
|
||
localTzAbbr: string;
|
||
}) {
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
useEffect(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.value = utcIsoToLocalDateTime(match.scheduledAt);
|
||
}
|
||
}, [match.scheduledAt]);
|
||
return (
|
||
<Form
|
||
method="post"
|
||
className="flex items-center gap-1"
|
||
onSubmit={(e) => {
|
||
const form = e.currentTarget;
|
||
const local = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
|
||
const hidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
|
||
if (hidden) hidden.value = localDateTimeToUtcIso(local?.value) ?? "";
|
||
}}
|
||
>
|
||
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
||
<input type="hidden" name="matchId" value={match.id} />
|
||
{/* Hidden field holds the UTC ISO string written by onSubmit */}
|
||
<input type="hidden" name="scheduledAt" defaultValue="" />
|
||
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
||
<Input
|
||
ref={inputRef}
|
||
type="datetime-local"
|
||
name="scheduledAtLocal"
|
||
title={`Time zone: ${localTzAbbr}`}
|
||
className="h-6 text-xs px-1 py-0"
|
||
/>
|
||
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
||
Save
|
||
</Button>
|
||
</Form>
|
||
);
|
||
}
|
||
|
||
export { loader, action };
|
||
|
||
/**
|
||
* One "possible duplicate" row in the draw preview. Uses a fetcher so resolving
|
||
* it (rename existing / create as new) submits in the background — the preview
|
||
* stays on screen instead of reloading the page and forcing a fresh dry run.
|
||
*/
|
||
function DuplicateRow({
|
||
p,
|
||
}: {
|
||
p: { name: string; externalId: string | null; suggestion: string; suggestionId: string };
|
||
}) {
|
||
const fetcher = useFetcher<{ success?: string; error?: string }>();
|
||
const busy = fetcher.state !== "idle";
|
||
|
||
return (
|
||
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
<span>
|
||
<span className="font-medium">{p.name}</span>
|
||
<span className="text-muted-foreground"> → looks like </span>
|
||
<span className="font-medium">{p.suggestion}</span>
|
||
</span>
|
||
{fetcher.data?.success ? (
|
||
<span className="text-xs font-medium text-emerald-500">✓ {fetcher.data.success}</span>
|
||
) : (
|
||
<fetcher.Form method="post" className="flex items-center gap-2">
|
||
<input type="hidden" name="name" value={p.name} />
|
||
<input type="hidden" name="externalId" value={p.externalId ?? ""} />
|
||
<input type="hidden" name="participantId" value={p.suggestionId} />
|
||
<Button
|
||
type="submit"
|
||
name="intent"
|
||
value="relink-draw-participant"
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={busy}
|
||
>
|
||
Rename “{p.suggestion}” → “{p.name}”
|
||
</Button>
|
||
<Button
|
||
type="submit"
|
||
name="intent"
|
||
value="create-draw-participant"
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={busy}
|
||
>
|
||
Create as new
|
||
</Button>
|
||
</fetcher.Form>
|
||
)}
|
||
{fetcher.data?.error && (
|
||
<span className="text-xs text-destructive">{fetcher.data.error}</span>
|
||
)}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
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 && template?.groupStage !== undefined;
|
||
|
||
// Determine if this template uses named regions (e.g. NCAA 68)
|
||
const hasRegions = (template?.regions?.length ?? 0) > 0;
|
||
|
||
// Per-region configuration state: name + which seed slots are play-ins
|
||
// Initialized from: stored event config → template defaults → empty
|
||
const buildDefaultRegionConfig = (tmpl: typeof template) => {
|
||
const source =
|
||
(event.bracketRegionConfig as unknown as BracketRegion[] | null) ?? tmpl?.regions ?? [];
|
||
return source.map((r) => ({
|
||
name: r.name,
|
||
playInSeeds: r.playIns.map((pi) => pi.seedSlot),
|
||
}));
|
||
};
|
||
const [regionConfig, setRegionConfig] = useState(() => buildDefaultRegionConfig(template));
|
||
|
||
// Derive effective BracketRegion[] from regionConfig state
|
||
const effectiveRegions: BracketRegion[] = useMemo(() => {
|
||
return regionConfig.map((cfg) => {
|
||
const playInSet = new Set(cfg.playInSeeds);
|
||
return {
|
||
name: cfg.name,
|
||
directSeeds: ALL_16_SEEDS.filter((s) => !playInSet.has(s)),
|
||
playIns: cfg.playInSeeds.map((s) => ({ seedSlot: s, teams: 2 as const })),
|
||
};
|
||
});
|
||
}, [regionConfig]);
|
||
|
||
const regionSlotMap = useMemo(
|
||
() => (effectiveRegions.length > 0 ? buildNCAA68SlotMap(effectiveRegions) : null),
|
||
[effectiveRegions]
|
||
);
|
||
|
||
// Region config handlers
|
||
const updateRegionName = (regionIdx: number, name: string) => {
|
||
setRegionConfig((prev) =>
|
||
prev.map((r, i) => (i === regionIdx ? { ...r, name } : r))
|
||
);
|
||
};
|
||
const addPlayIn = (regionIdx: number) => {
|
||
setRegionConfig((prev) =>
|
||
prev.map((r, i) => {
|
||
if (i !== regionIdx || r.playInSeeds.length >= 2) return r;
|
||
// Default to seed 16 if no play-ins yet, otherwise seed 11
|
||
const defaultSeed = r.playInSeeds.length === 0 ? 16 : 11;
|
||
const seed = r.playInSeeds.includes(defaultSeed)
|
||
? ([11, 16].find((s) => !r.playInSeeds.includes(s)) ?? 11)
|
||
: defaultSeed;
|
||
return { ...r, playInSeeds: [...r.playInSeeds, seed] };
|
||
})
|
||
);
|
||
// Reset participant selections since slot indices change
|
||
setSelectedParticipants({});
|
||
};
|
||
const removePlayIn = (regionIdx: number, piIdx: number) => {
|
||
setRegionConfig((prev) =>
|
||
prev.map((r, i) =>
|
||
i === regionIdx
|
||
? { ...r, playInSeeds: r.playInSeeds.filter((_, j) => j !== piIdx) }
|
||
: r
|
||
)
|
||
);
|
||
setSelectedParticipants({});
|
||
};
|
||
const updatePlayInSeed = (regionIdx: number, piIdx: number, seed: number) => {
|
||
setRegionConfig((prev) =>
|
||
prev.map((r, i) =>
|
||
i === regionIdx
|
||
? {
|
||
...r,
|
||
playInSeeds: r.playInSeeds.map((s, j) => (j === piIdx ? seed : s)),
|
||
}
|
||
: r
|
||
)
|
||
);
|
||
setSelectedParticipants({});
|
||
};
|
||
|
||
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) => m.round === "Round of 32");
|
||
return r32Matches.some((m) => 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.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.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 and region config when template changes
|
||
const handleTemplateChange = (newTemplateId: string) => {
|
||
setSelectedTemplate(newTemplateId);
|
||
setSelectedParticipants({});
|
||
const newTemplate = templates.find((t) => t.id === newTemplateId);
|
||
setRegionConfig(buildDefaultRegionConfig(newTemplate));
|
||
};
|
||
|
||
// 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) => 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 bracketTemplate = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : undefined;
|
||
return getOrderedRoundsFromMatches(matches, bracketTemplate);
|
||
}, [matches, event.bracketTemplateId]);
|
||
|
||
// Check if all matches are complete
|
||
const allMatchesComplete = useMemo(() => {
|
||
return matches.length > 0 && matches.every((m) => 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>
|
||
)}
|
||
|
||
{/* Possible-duplicate review after a draw sync */}
|
||
{actionData?.drawSyncResult && actionData.drawSyncResult.unmatched.length > 0 && (
|
||
<Card className="border-amber-500/40">
|
||
<CardHeader>
|
||
<CardTitle className="text-amber-500">
|
||
Review {actionData.drawSyncResult.unmatched.length} possible duplicate
|
||
{actionData.drawSyncResult.unmatched.length === 1 ? "" : "s"}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
These players were auto-created but closely resemble an existing participant —
|
||
if a duplicate, results won't reach the drafted copy. To fix: on the{" "}
|
||
<Link
|
||
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
|
||
className="underline font-medium"
|
||
>
|
||
participants page
|
||
</Link>
|
||
, set the correct participant's external ID to the Wikipedia name, delete the
|
||
duplicate, then click <span className="font-medium">Sync Draw</span> again.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ul className="space-y-1 text-sm">
|
||
{actionData.drawSyncResult.unmatched.map((p) => (
|
||
<li key={p.externalId ?? p.name} className="flex items-center gap-2">
|
||
<span className="font-medium">{p.name}</span>
|
||
{p.externalId && p.externalId !== p.name && (
|
||
<span className="text-muted-foreground text-xs">({p.externalId})</span>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Dry-run preview of a draw sync */}
|
||
{actionData?.drawPreview && (
|
||
<Card className="border-sky-500/40">
|
||
<CardHeader>
|
||
<CardTitle className="text-sky-500">Draw preview (no changes made)</CardTitle>
|
||
<CardDescription>{actionData.drawPreview.article}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-3 text-sm">
|
||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-3">
|
||
<div>
|
||
Players: <span className="font-medium">{actionData.drawPreview.totalPlayers}</span>
|
||
</div>
|
||
<div>
|
||
Matched:{" "}
|
||
<span className="font-medium text-emerald-500">
|
||
{actionData.drawPreview.matched}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
Will create:{" "}
|
||
<span className="font-medium">{actionData.drawPreview.willCreate.length}</span>
|
||
</div>
|
||
<div>
|
||
Matches:{" "}
|
||
<span className="font-medium">
|
||
{actionData.drawPreview.completedMatches}/{actionData.drawPreview.totalMatches}{" "}
|
||
done
|
||
</span>
|
||
</div>
|
||
<div>
|
||
Unfilled R1 slots:{" "}
|
||
<span className="font-medium">{actionData.drawPreview.tbdFirstRound}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{actionData.drawPreview.possibleDuplicates.length > 0 && (
|
||
<div>
|
||
<p className="font-medium text-amber-500">
|
||
Possible duplicates ({actionData.drawPreview.possibleDuplicates.length}) — would
|
||
be created but resemble an existing participant:
|
||
</p>
|
||
<ul className="mt-2 space-y-2">
|
||
{actionData.drawPreview.possibleDuplicates.map((p) => (
|
||
<DuplicateRow key={p.externalId ?? p.name} p={p} />
|
||
))}
|
||
</ul>
|
||
<p className="mt-2 text-xs text-muted-foreground">
|
||
<span className="font-medium">Rename</span> if it's the same player (links
|
||
the existing participant so it matches);{" "}
|
||
<span className="font-medium">Create as new</span> if they're different
|
||
people. Then re-run Preview or Sync Draw.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{actionData.drawPreview.willCreate.length > 0 && (
|
||
<details>
|
||
<summary className="cursor-pointer text-muted-foreground">
|
||
Show all {actionData.drawPreview.willCreate.length} players that would be created
|
||
</summary>
|
||
<ul className="mt-1 columns-2 sm:columns-3">
|
||
{actionData.drawPreview.willCreate.map((p) => (
|
||
<li key={p.externalId ?? p.name}>{p.name}</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
)}
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
Fix any duplicates on the{" "}
|
||
<Link
|
||
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
|
||
className="underline font-medium"
|
||
>
|
||
participants page
|
||
</Link>{" "}
|
||
first, then click <span className="font-medium">Sync Draw</span>.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Sync Draw from Wikipedia — tennis Grand Slam auto-populate + auto-score */}
|
||
{event.isQualifyingEvent &&
|
||
sportsSeason.sport.simulatorType === "tennis_qualifying_points" && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Sync Draw from Wikipedia</CardTitle>
|
||
<CardDescription>
|
||
Auto-populate and score this Grand Slam bracket from its Wikipedia draw
|
||
article. Re-run during the tournament to pull in completed matches.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-3">
|
||
<div className="space-y-1.5">
|
||
<Label htmlFor="externalSourceKey">Wikipedia draw page (URL or title)</Label>
|
||
<Input
|
||
id="externalSourceKey"
|
||
name="externalSourceKey"
|
||
placeholder="https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_–_Men's_singles"
|
||
defaultValue={event.externalSourceKey ?? ""}
|
||
/>
|
||
<p className="text-xs text-muted-foreground">
|
||
Paste the article URL or type the title — both work. Use{" "}
|
||
<span className="font-medium">Preview</span> first to see matches and any
|
||
new/duplicate players before writing anything.
|
||
</p>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<Button type="submit" name="intent" value="preview-draw" variant="outline">
|
||
Preview (dry run)
|
||
</Button>
|
||
<Button type="submit" name="intent" value="sync-draw">
|
||
Sync Draw
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Reprocess Bracket - Full rebuild of participant results.
|
||
Hidden once every match is complete — at that point all placements are final
|
||
and "finalize bracket" should be used instead. */}
|
||
{matches.length > 0 && !matches.every((m) => m.isComplete) && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Reprocess Bracket</CardTitle>
|
||
<CardDescription>
|
||
Replay all completed matches from scratch and re-mark non-bracket
|
||
participants as eliminated. Use this to fix scoring data after rule
|
||
changes or when results look incorrect.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="reprocess-bracket" />
|
||
<Button type="submit" variant="outline">
|
||
Reprocess Bracket
|
||
</Button>
|
||
</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 ?? 0)].map((_, teamIndex) => {
|
||
const seedIndex = groupIndex * (template.groupStage?.teamsPerGroup ?? 0) + 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: region-grouped or flat seeded list */}
|
||
{!hasGroupStage && hasRegions && regionSlotMap && (
|
||
<div className="space-y-6">
|
||
|
||
{/* ── Region configuration ─────────────────────────────── */}
|
||
<div className="border rounded-lg p-4 space-y-3">
|
||
<div>
|
||
<h4 className="font-semibold text-sm">Configure Regions</h4>
|
||
<p className="text-xs text-muted-foreground mt-0.5">
|
||
Set each region's name and which seed slots are play-in games.
|
||
</p>
|
||
</div>
|
||
{regionConfig.map((region, r) => (
|
||
// eslint-disable-next-line react/no-array-index-key
|
||
<div key={r} className="flex flex-wrap items-center gap-2">
|
||
<Input
|
||
className="w-36 h-8 text-sm"
|
||
value={region.name}
|
||
onChange={(e) => updateRegionName(r, e.target.value)}
|
||
placeholder={`Region ${r + 1}`}
|
||
/>
|
||
{region.playInSeeds.map((seed, pi) => (
|
||
// eslint-disable-next-line react/no-array-index-key
|
||
<div key={pi} className="flex items-center gap-1">
|
||
<span className="text-xs text-muted-foreground">play-in seed:</span>
|
||
<Select
|
||
value={String(seed)}
|
||
onValueChange={(val) => updatePlayInSeed(r, pi, parseInt(val, 10))}
|
||
>
|
||
<SelectTrigger className="h-8 w-16 text-sm">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{ALL_16_SEEDS.map((s) => (
|
||
<SelectItem key={s} value={String(s)}>{s}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 w-7 p-0 text-muted-foreground"
|
||
onClick={() => removePlayIn(r, pi)}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
{region.playInSeeds.length < 2 && (
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
onClick={() => addPlayIn(r)}
|
||
>
|
||
<Plus className="h-3 w-3 mr-1" />
|
||
Add play-in
|
||
</Button>
|
||
)}
|
||
</div>
|
||
))}
|
||
{/* Hidden fields so server receives the region config */}
|
||
{regionConfig.map((region, r) => (
|
||
// eslint-disable-next-line react/no-array-index-key
|
||
<Fragment key={r}>
|
||
<input type="hidden" name={`regionName${r}`} value={region.name} />
|
||
<input type="hidden" name={`regionPlayInSeeds${r}`} value={region.playInSeeds.join(",")} />
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
|
||
{/* ── Seed entry per region ─────────────────────────────── */}
|
||
<p className="text-sm text-muted-foreground">
|
||
Select participants by region. Use search to find teams quickly.
|
||
</p>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{effectiveRegions.map((region, r) => {
|
||
const directOffset = regionSlotMap.directOffsets[r];
|
||
return (
|
||
// eslint-disable-next-line react/no-array-index-key
|
||
<div key={r} className="border rounded-lg p-4 space-y-2">
|
||
<h4 className="font-semibold text-sm">{region.name || `Region ${r + 1}`}</h4>
|
||
{region.directSeeds.map((seedNum, pos) => {
|
||
const globalIdx = directOffset + pos;
|
||
const opponentSeed = 17 - seedNum;
|
||
const opponentIsPlayIn = region.playIns.some((pi) => pi.seedSlot === opponentSeed);
|
||
return (
|
||
<div key={seedNum} className="flex items-center gap-2">
|
||
<Label className="w-16 text-xs text-muted-foreground shrink-0">
|
||
Seed {seedNum}
|
||
</Label>
|
||
<div className="flex-1 min-w-0">
|
||
<input
|
||
type="hidden"
|
||
name={`participant${globalIdx}`}
|
||
value={selectedParticipants[globalIdx] || ""}
|
||
required
|
||
/>
|
||
<ParticipantSelector
|
||
participants={getAvailableParticipants(globalIdx)}
|
||
value={selectedParticipants[globalIdx] || ""}
|
||
onValueChange={(value) => handleParticipantChange(globalIdx, value)}
|
||
placeholder={`Seed ${seedNum}…`}
|
||
/>
|
||
</div>
|
||
{opponentIsPlayIn && (
|
||
<span className="text-xs text-muted-foreground shrink-0">← faces play-in</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{region.playIns.length > 0 && (
|
||
<p className="text-xs text-muted-foreground pt-1 border-t">
|
||
{region.playIns.map((pi) => `Seed ${pi.seedSlot}`).join(" & ")} via play-in ↓
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* ── Play-in team entry ─────────────────────────────────── */}
|
||
{regionSlotMap.playInOffsets.length > 0 && (
|
||
<div className="border rounded-lg p-4 space-y-4">
|
||
<h4 className="font-semibold text-sm">Play-In Teams</h4>
|
||
<p className="text-xs text-muted-foreground">
|
||
Enter both teams for each play-in game. The winner advances to the main bracket.
|
||
</p>
|
||
{regionSlotMap.playInOffsets.map((pi, ffIdx) => {
|
||
const regionName = effectiveRegions[pi.regionIndex]?.name || `Region ${pi.regionIndex + 1}`;
|
||
const idx1 = pi.startIndex;
|
||
const idx2 = pi.startIndex + 1;
|
||
return (
|
||
<div key={`${pi.regionIndex}-${pi.seedSlot}`} className="space-y-2">
|
||
<p className="text-xs font-medium text-muted-foreground">
|
||
Play-In {ffIdx + 1} — {regionName} {pi.seedSlot}-seed
|
||
</p>
|
||
{[idx1, idx2].map((globalIdx, slot) => (
|
||
<div key={globalIdx} className="flex items-center gap-2">
|
||
<Label className="w-16 text-xs text-muted-foreground shrink-0">
|
||
Team {slot + 1}
|
||
</Label>
|
||
<div className="flex-1 min-w-0">
|
||
<input
|
||
type="hidden"
|
||
name={`participant${globalIdx}`}
|
||
value={selectedParticipants[globalIdx] || ""}
|
||
required
|
||
/>
|
||
<ParticipantSelector
|
||
participants={getAvailableParticipants(globalIdx)}
|
||
value={selectedParticipants[globalIdx] || ""}
|
||
onValueChange={(value) => handleParticipantChange(globalIdx, value)}
|
||
placeholder={`${regionName} ${pi.seedSlot}-seed play-in team ${slot + 1}…`}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Non-group-stage, non-region template: flat seeded list */}
|
||
{!hasGroupStage && !hasRegions && (
|
||
<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);
|
||
const slotLabel = template?.participantLabels?.[i] ?? `Seed ${i + 1}`;
|
||
|
||
return (
|
||
// eslint-disable-next-line react/no-array-index-key
|
||
<div key={i} className="flex items-center gap-2">
|
||
<Label className="w-28 text-sm text-muted-foreground shrink-0">
|
||
{slotLabel}
|
||
</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 ${slotLabel.toLowerCase()}...`}
|
||
/>
|
||
</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) => {
|
||
const members = group.members || [];
|
||
const groupMatches = group.groupMatches || [];
|
||
const standings = computeGroupStandings(
|
||
members
|
||
.filter((m) => m.participant !== null && m.participant !== undefined)
|
||
.map((m) => ({
|
||
participantId: m.participant?.id ?? "",
|
||
participantName: m.participant?.name ?? "",
|
||
})),
|
||
groupMatches.map((m) => ({
|
||
participant1Id: m.participant1Id,
|
||
participant2Id: m.participant2Id,
|
||
participant1Score: m.participant1Score,
|
||
participant2Score: m.participant2Score,
|
||
isComplete: m.isComplete,
|
||
}))
|
||
);
|
||
|
||
return (
|
||
<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-3">
|
||
{/* Standings table */}
|
||
{standings.length > 0 && (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b text-muted-foreground">
|
||
<th className="text-left pb-1 font-medium">Team</th>
|
||
<th className="text-center pb-1 font-medium w-6">P</th>
|
||
<th className="text-center pb-1 font-medium w-6">W</th>
|
||
<th className="text-center pb-1 font-medium w-6">D</th>
|
||
<th className="text-center pb-1 font-medium w-6">L</th>
|
||
<th className="text-center pb-1 font-medium w-8">GD</th>
|
||
<th className="text-center pb-1 font-medium w-7">Pts</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{standings.map((row, idx) => {
|
||
const member = members.find((m) => m.participant?.id === row.participantId);
|
||
const isTop2 = idx < 2;
|
||
return (
|
||
<tr
|
||
key={row.participantId}
|
||
className={`border-b last:border-0 ${
|
||
member?.eliminated
|
||
? "text-muted-foreground line-through"
|
||
: isTop2
|
||
? "text-green-600 dark:text-green-400 font-medium"
|
||
: ""
|
||
}`}
|
||
>
|
||
<td className="py-0.5 truncate max-w-[80px]">{row.participantName}</td>
|
||
<td className="text-center">{row.played}</td>
|
||
<td className="text-center">{row.wins}</td>
|
||
<td className="text-center">{row.draws}</td>
|
||
<td className="text-center">{row.losses}</td>
|
||
<td className="text-center">{row.gd > 0 ? `+${row.gd}` : row.gd}</td>
|
||
<td className="text-center font-semibold">{row.points}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Elimination toggles */}
|
||
<div className="space-y-1.5">
|
||
{members.map((member) => (
|
||
<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>
|
||
))}
|
||
</div>
|
||
|
||
{/* Match score entry */}
|
||
{groupMatches.length > 0 && (
|
||
<div className="space-y-1.5 pt-1 border-t">
|
||
<p className="text-xs font-medium text-muted-foreground">Matches</p>
|
||
{groupMatches.map((match) => (
|
||
<div key={match.id} className="text-xs space-y-1">
|
||
{/* Schedule row */}
|
||
<GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
|
||
{/* Score row */}
|
||
<Form method="post" className="flex items-center gap-1">
|
||
<input type="hidden" name="intent" value="update-group-match" />
|
||
<input type="hidden" name="matchId" value={match.id} />
|
||
<span className="truncate max-w-[70px]">
|
||
{match.participant1?.name ?? "?"}
|
||
</span>
|
||
<Input
|
||
type="number"
|
||
name="participant1Score"
|
||
min={0}
|
||
defaultValue={match.participant1Score ?? ""}
|
||
className={`h-6 w-10 text-center text-xs px-1 py-0 ${match.isComplete ? "bg-muted" : ""}`}
|
||
disabled={match.isComplete}
|
||
/>
|
||
<span className="text-muted-foreground">–</span>
|
||
<Input
|
||
type="number"
|
||
name="participant2Score"
|
||
min={0}
|
||
defaultValue={match.participant2Score ?? ""}
|
||
className={`h-6 w-10 text-center text-xs px-1 py-0 ${match.isComplete ? "bg-muted" : ""}`}
|
||
disabled={match.isComplete}
|
||
/>
|
||
<span className="truncate max-w-[70px] text-right">
|
||
{match.participant2?.name ?? "?"}
|
||
</span>
|
||
{!match.isComplete && (
|
||
<Button type="submit" size="sm" variant="default" className="h-6 px-1.5 text-xs shrink-0">
|
||
Save
|
||
</Button>
|
||
)}
|
||
{match.isComplete && (
|
||
<Badge variant="secondary" className="h-5 text-xs px-1 shrink-0">FT</Badge>
|
||
)}
|
||
</Form>
|
||
</div>
|
||
))}
|
||
</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 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.games?.length > 0 ? (
|
||
<div className="space-y-2 mb-3">
|
||
{match.games.map((game) => (
|
||
<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.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.odds?.length > 0 ? (
|
||
<div className="space-y-2 mb-3">
|
||
{match.odds.map((odd) => (
|
||
<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>
|
||
))}
|
||
|
||
|
||
{/* 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>
|
||
);
|
||
}
|