## Summary - 0-3 teams in Stage 3 were being assigned slots 9–10 (high QP) when marked eliminated before other Stage 3 teams, because `computeStage3ExitQP` filled from slot 9 upward using only the teams passed in - 1-3 teams had the same problem when marked before 2-3 teams - Fix: gate all Stage 3 QP computation on `stage3Exits.length === STAGE3_TOTAL_EXITS` (8) — partial saves write 0 QP as a placeholder, and correct QP/placements are assigned once all 8 exits are known - As belt-and-suspenders, `computeStage3ExitQP` now places 0-wins teams from the bottom of the slot range so the function itself is correct even if called with a partial set ## Test plan - [ ] Run `npm run test:run -- app/models/__tests__/cs2-major-stage.test.ts` — all 17 tests pass - [ ] Mark 2 Stage 3 teams as 0-3 eliminated and save — confirm they show 0 QP (not 2 QP) - [ ] Mark all 8 Stage 3 exits and save — confirm 0-3 teams get slots 15–16 QP, 1-3 teams get slots 12–14 QP split correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #89
534 lines
23 KiB
TypeScript
534 lines
23 KiB
TypeScript
import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } from 'react-router';
|
||
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
|
||
|
||
import { findSportsSeasonById } from '~/models/sports-season';
|
||
import { getScoringEventById } from '~/models/scoring-event';
|
||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||
import {
|
||
getCs2StageResultsForEvent,
|
||
upsertCs2StageAssignments,
|
||
markCs2StageEliminations,
|
||
clearCs2StageAssignments,
|
||
clearCs2EliminationsAtStage,
|
||
assignCs2EliminationQP,
|
||
} from '~/models/cs2-major-stage';
|
||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||
import { syncMatches } from '~/services/match-sync';
|
||
import { Button } from '~/components/ui/button';
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardDescription,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from '~/components/ui/card';
|
||
import { Badge } from '~/components/ui/badge';
|
||
import { ArrowLeft, Save, Trash2, CheckCircle2, RefreshCw } from 'lucide-react';
|
||
import { useState } from 'react';
|
||
|
||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||
return [{ title: `CS2 Stage Setup — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
||
}
|
||
|
||
export async function loader({ params }: Route.LoaderArgs) {
|
||
const sportsSeasonId = params.id;
|
||
const eventId = params.eventId;
|
||
|
||
const [sportsSeason, event] = await Promise.all([
|
||
findSportsSeasonById(sportsSeasonId),
|
||
getScoringEventById(eventId),
|
||
]);
|
||
|
||
if (!sportsSeason) throw new Response('Sports season not found', { status: 404 });
|
||
if (!event) throw new Response('Event not found', { status: 404 });
|
||
|
||
const [participants, stageResults, seasonMatches] = await Promise.all([
|
||
findParticipantsBySportsSeasonId(sportsSeasonId),
|
||
getCs2StageResultsForEvent(eventId),
|
||
findSeasonMatchesByScoringEventId(eventId),
|
||
]);
|
||
|
||
return { sportsSeason, event, participants, stageResults, seasonMatches };
|
||
}
|
||
|
||
interface ActionData {
|
||
success?: boolean;
|
||
message?: string;
|
||
}
|
||
|
||
export async function action({ request, params }: Route.ActionArgs) {
|
||
const eventId = params.eventId;
|
||
const formData = await request.formData();
|
||
const intent = formData.get('intent') as string;
|
||
|
||
if (intent === 'reset') {
|
||
await clearCs2StageAssignments(eventId);
|
||
return { success: true, message: 'Stage assignments cleared.' };
|
||
}
|
||
|
||
if (intent === 'assign') {
|
||
const assignments: Array<{ scoringEventId: string; participantId: string; stageEntry: number }> = [];
|
||
for (const [key, value] of formData.entries()) {
|
||
if (key.startsWith('stage_')) {
|
||
const participantId = key.slice('stage_'.length);
|
||
const stageEntry = parseInt(value as string, 10);
|
||
if (!isNaN(stageEntry) && stageEntry >= 1 && stageEntry <= 3) {
|
||
assignments.push({ scoringEventId: eventId, participantId, stageEntry });
|
||
}
|
||
}
|
||
}
|
||
|
||
if (assignments.length === 0) {
|
||
return { success: false, message: 'No stage assignments submitted.' };
|
||
}
|
||
|
||
await upsertCs2StageAssignments(assignments);
|
||
return { success: true, message: `Saved ${assignments.length} stage assignments.` };
|
||
}
|
||
|
||
if (intent === 'mark-eliminations') {
|
||
// Parse elimination data: elim_{stageNum}_{participantId} = winsAtElimination (0, 1, or 2)
|
||
// stageNum is the stage the team was eliminated AT (may differ from their stageEntry
|
||
// when a Stage 1 team advances to Stage 2 before being eliminated).
|
||
//
|
||
// stage_displayed_{N} hidden fields indicate which stage sections were rendered.
|
||
// Before applying submitted eliminations, we clear those stages so that unchecking
|
||
// a team actually removes them from the eliminated list (replacement semantics).
|
||
try {
|
||
const displayedStages: number[] = [];
|
||
for (const [key] of formData.entries()) {
|
||
if (key.startsWith('stage_displayed_')) {
|
||
const stageNum = parseInt(key.slice('stage_displayed_'.length), 10);
|
||
if (!isNaN(stageNum) && stageNum >= 1 && stageNum <= 3) {
|
||
displayedStages.push(stageNum);
|
||
}
|
||
}
|
||
}
|
||
|
||
const eliminations: Array<{ participantId: string; stageEliminated: number; winsAtElimination: number }> = [];
|
||
for (const [key, value] of formData.entries()) {
|
||
if (key.startsWith('elim_')) {
|
||
const rest = key.slice('elim_'.length);
|
||
const underscoreIdx = rest.indexOf('_');
|
||
if (underscoreIdx < 1) continue;
|
||
const stageEliminated = parseInt(rest.slice(0, underscoreIdx), 10);
|
||
const participantId = rest.slice(underscoreIdx + 1);
|
||
const wins = parseInt(value as string, 10);
|
||
if (!isNaN(stageEliminated) && stageEliminated >= 1 && stageEliminated <= 3
|
||
&& participantId.length > 0 && !isNaN(wins) && wins >= 0 && wins <= 2) {
|
||
eliminations.push({ participantId, stageEliminated, winsAtElimination: wins });
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const stageNum of displayedStages) {
|
||
await clearCs2EliminationsAtStage(eventId, stageNum);
|
||
}
|
||
await markCs2StageEliminations(eventId, eliminations);
|
||
await assignCs2EliminationQP(eventId, params.id);
|
||
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||
} catch (err) {
|
||
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
|
||
}
|
||
}
|
||
|
||
if (intent === 'sync-matches') {
|
||
try {
|
||
const result = await syncMatches(params.id);
|
||
const total = result.swissCreated + result.swissUpdated;
|
||
const summary = `Synced: ${total} Swiss match(es), ${result.playoffUpdated} playoff match(es).`;
|
||
return { success: true, message: summary };
|
||
} catch (err) {
|
||
return { success: false, message: err instanceof Error ? err.message : 'Sync failed.' };
|
||
}
|
||
}
|
||
|
||
return { success: false, message: 'Unknown action.' };
|
||
}
|
||
|
||
const STAGE_LABELS: Record<number, { label: string; description: string; maxTeams: number; badgeVariant: 'default' | 'secondary' | 'outline' }> = {
|
||
1: { label: 'Stage 1', description: 'Opening Stage (16 teams, Bo1 Swiss)', maxTeams: 16, badgeVariant: 'outline' },
|
||
2: { label: 'Stage 2', description: 'Challengers Stage (8 direct + 8 from Stage 1, Bo1/Bo3 Swiss)', maxTeams: 16, badgeVariant: 'secondary' },
|
||
3: { label: 'Stage 3', description: 'Legends Stage (8 direct + 8 from Stage 2, all Bo3 Swiss)', maxTeams: 16, badgeVariant: 'default' },
|
||
};
|
||
|
||
const RECORD_OPTIONS = [
|
||
{ value: '2', label: '2-3 (advanced furthest)' },
|
||
{ value: '1', label: '1-3' },
|
||
{ value: '0', label: '0-3 (earliest exit)' },
|
||
];
|
||
|
||
const MATCH_STATUS_STYLES: Record<string, string> = {
|
||
scheduled: 'text-muted-foreground',
|
||
in_progress: 'text-amber-400 font-medium',
|
||
complete: 'text-emerald-400',
|
||
canceled: 'text-destructive line-through',
|
||
postponed: 'text-muted-foreground italic',
|
||
};
|
||
|
||
const STAGE_NAMES: Record<number, string> = {
|
||
1: 'Opening Stage',
|
||
2: 'Challengers Stage',
|
||
3: 'Legends Stage',
|
||
};
|
||
|
||
export default function AdminCs2Setup() {
|
||
const { sportsSeason, event, participants, stageResults, seasonMatches } = useLoaderData<typeof loader>();
|
||
const actionData = useActionData<ActionData>();
|
||
const navigation = useNavigation();
|
||
const isSubmitting = navigation.state === 'submitting';
|
||
const syncFetcher = useFetcher();
|
||
const isSyncing = syncFetcher.state !== 'idle';
|
||
|
||
// Stage assignment state (local, submitted via form)
|
||
const [stageSelections, setStageSelections] = useState<Record<string, string>>(() => {
|
||
const initial: Record<string, string> = {};
|
||
stageResults.forEach(r => { initial[r.participantId] = r.stageEntry.toString(); });
|
||
return initial;
|
||
});
|
||
|
||
// Tracks which stage each team is currently eliminated at (null = still active)
|
||
const [eliminatedAtStage, setEliminatedAtStage] = useState<Record<string, number | null>>(() => {
|
||
const initial: Record<string, number | null> = {};
|
||
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated; });
|
||
return initial;
|
||
});
|
||
|
||
// Group participants by starting stage for the Stage Assignments card
|
||
const byStage: Record<number, typeof stageResults> = { 1: [], 2: [], 3: [] };
|
||
for (const r of stageResults) {
|
||
byStage[r.stageEntry]?.push(r);
|
||
}
|
||
|
||
const stageCounts = Object.fromEntries(
|
||
Object.entries(byStage).map(([k, v]) => [k, v.length])
|
||
);
|
||
|
||
// Determine stage completion from saved data (used to move advancers into the next stage section)
|
||
const stage1EliminatedCount = stageResults.filter(r => r.stageEliminated === 1).length;
|
||
const stage1Complete = stage1EliminatedCount >= 8;
|
||
const stage2EliminatedCount = stageResults.filter(r => r.stageEliminated === 2).length;
|
||
const stage2Complete = stage2EliminatedCount >= 8;
|
||
|
||
// Full field per stage for the Elimination Tracking section.
|
||
// Each team appears in exactly one section — the stage they are currently playing in
|
||
// (or were last eliminated from). Once stage N is complete, its advancers move to stage N+1.
|
||
type StageResultWithAdvance = typeof stageResults[0] & { advancedFrom?: number };
|
||
const byStageFullField: Record<number, StageResultWithAdvance[]> = { 1: [], 2: [], 3: [] };
|
||
for (const r of stageResults) {
|
||
if (r.stageEliminated !== null) {
|
||
// Already eliminated — show in the section for their elimination stage
|
||
byStageFullField[r.stageEliminated]?.push(r);
|
||
} else if (r.stageEntry === 1) {
|
||
if (stage1Complete) {
|
||
// Survived Stage 1 — now in Stage 2 (or beyond)
|
||
if (stage2Complete) {
|
||
byStageFullField[3].push({ ...r, advancedFrom: 1 });
|
||
} else {
|
||
byStageFullField[2].push({ ...r, advancedFrom: 1 });
|
||
}
|
||
} else {
|
||
byStageFullField[1].push(r);
|
||
}
|
||
} else if (r.stageEntry === 2) {
|
||
if (stage2Complete) {
|
||
byStageFullField[3].push({ ...r, advancedFrom: 2 });
|
||
} else {
|
||
byStageFullField[2].push(r);
|
||
}
|
||
} else {
|
||
// stageEntry === 3, always shown in Stage 3
|
||
byStageFullField[3].push(r);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="container mx-auto py-8">
|
||
<div className="mb-6">
|
||
<Link
|
||
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}
|
||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4"
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
Back to event
|
||
</Link>
|
||
<h1 className="text-3xl font-bold mb-1">CS2 Stage Setup</h1>
|
||
<p className="text-muted-foreground">{event.name} — {sportsSeason.name}</p>
|
||
</div>
|
||
|
||
{actionData?.message && (
|
||
<div className={`mb-4 p-3 rounded-md text-sm ${actionData.success ? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30' : 'bg-destructive/10 text-destructive border border-destructive/30'}`}>
|
||
{actionData.message}
|
||
</div>
|
||
)}
|
||
|
||
{/* Stage Assignment */}
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center justify-between">
|
||
Stage Assignments
|
||
{stageResults.length > 0 && (
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="reset" />
|
||
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
||
<Trash2 className="h-4 w-4 mr-1" />
|
||
Reset all
|
||
</Button>
|
||
</Form>
|
||
)}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
Assign each team to their starting stage. Stage 1 = 16 Opening Stage teams,
|
||
Stage 2 = 8 Challengers (skip Stage 1), Stage 3 = 8 Legends (skip Stages 1 and 2).
|
||
Total: 32 teams across all three stages.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-6">
|
||
<input type="hidden" name="intent" value="assign" />
|
||
|
||
{/* Stage summary badges */}
|
||
<div className="flex gap-3 flex-wrap">
|
||
{[1, 2, 3].map(stage => (
|
||
<div key={stage} className="flex items-center gap-2 text-sm">
|
||
<Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge>
|
||
<span className="text-muted-foreground">
|
||
{stageCounts[stage] ?? 0}/{STAGE_LABELS[stage].maxTeams} teams
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Teams listed with stage selectors */}
|
||
<div className="space-y-1">
|
||
<div className="grid grid-cols-[1fr_160px] gap-x-3 text-xs font-medium text-muted-foreground px-1 mb-2">
|
||
<span>Team</span>
|
||
<span>Starting Stage</span>
|
||
</div>
|
||
{participants.map(participant => (
|
||
<div key={participant.id} className="grid grid-cols-[1fr_160px] gap-x-3 items-center py-1 border-b border-border/40 last:border-0">
|
||
<span className="text-sm">{participant.name}</span>
|
||
<select
|
||
name={`stage_${participant.id}`}
|
||
value={stageSelections[participant.id] ?? ''}
|
||
onChange={e => setStageSelections(prev => ({ ...prev, [participant.id]: e.target.value }))}
|
||
className="h-8 text-sm rounded-md border border-input bg-background px-2"
|
||
>
|
||
<option value="">— not assigned —</option>
|
||
<option value="1">Stage 1 (Opening)</option>
|
||
<option value="2">Stage 2 (Elimination)</option>
|
||
<option value="3">Stage 3 (Decider)</option>
|
||
</select>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
<Save className="h-4 w-4 mr-2" />
|
||
{isSubmitting ? 'Saving...' : 'Save Stage Assignments'}
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Advancement Tracking (only shown when stage assignments exist) */}
|
||
{stageResults.length > 0 && (
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle>Stage Advancement Tracking</CardTitle>
|
||
<CardDescription>
|
||
Record which teams were eliminated and their W-L record at elimination.
|
||
Each stage shows its full 16-team field — direct entries plus teams that advanced
|
||
from the previous stage. Teams not checked as eliminated are assumed to have advanced
|
||
(or are still playing). Stage 3 W-L records determine QP sub-placements (9–16), and
|
||
QP is written to the event as you save each stage.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-6">
|
||
<input type="hidden" name="intent" value="mark-eliminations" />
|
||
|
||
{[1, 2, 3].map(stage => {
|
||
const teamsInStage = byStageFullField[stage];
|
||
if (teamsInStage.length === 0) return null;
|
||
|
||
return (
|
||
<div key={stage}>
|
||
<input type="hidden" name={`stage_displayed_${stage}`} value="1" />
|
||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||
<Badge variant={STAGE_LABELS[stage].badgeVariant}>{STAGE_LABELS[stage].label}</Badge>
|
||
{STAGE_LABELS[stage].description}
|
||
<span className="text-xs font-normal text-muted-foreground">
|
||
({teamsInStage.length} teams)
|
||
</span>
|
||
</h3>
|
||
<div className="space-y-1">
|
||
{teamsInStage.map(r => {
|
||
const currentElimStage = eliminatedAtStage[r.participantId] ?? null;
|
||
const isEliminated = currentElimStage === stage;
|
||
return (
|
||
<div key={r.participantId} className="grid grid-cols-[1fr_auto_200px] gap-x-3 items-center py-1 border-b border-border/40 last:border-0">
|
||
<span className="text-sm flex items-center gap-2">
|
||
{r.participantName}
|
||
{'advancedFrom' in r && r.advancedFrom !== undefined && (
|
||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||
adv. from Stage {r.advancedFrom}
|
||
</Badge>
|
||
)}
|
||
{r.stageEliminated !== null && r.stageEliminated === stage && (
|
||
<Badge variant="outline" className="text-xs">
|
||
{r.stageEliminatedWins}-3
|
||
</Badge>
|
||
)}
|
||
</span>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={isEliminated}
|
||
onChange={e =>
|
||
setEliminatedAtStage(prev => ({
|
||
...prev,
|
||
[r.participantId]: e.target.checked ? stage : null,
|
||
}))
|
||
}
|
||
className="rounded"
|
||
/>
|
||
Eliminated
|
||
</label>
|
||
{isEliminated && (
|
||
<select
|
||
name={`elim_${stage}_${r.participantId}`}
|
||
defaultValue={r.stageEliminated === stage ? (r.stageEliminatedWins?.toString() ?? '0') : '0'}
|
||
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
||
>
|
||
{RECORD_OPTIONS.map(opt => (
|
||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
{!isEliminated && <div />}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<Button type="submit" disabled={isSubmitting}>
|
||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||
{isSubmitting ? 'Saving...' : 'Save Eliminations'}
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Swiss Rounds */}
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center justify-between">
|
||
Swiss Rounds
|
||
<syncFetcher.Form method="post">
|
||
<input type="hidden" name="intent" value="sync-matches" />
|
||
<Button
|
||
type="submit"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={isSyncing || !sportsSeason.externalSeasonId}
|
||
title={!sportsSeason.externalSeasonId ? "Set External Season ID on the sports season to enable sync" : undefined}
|
||
>
|
||
<RefreshCw className={`h-4 w-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} />
|
||
{isSyncing ? 'Syncing...' : 'Sync from API'}
|
||
</Button>
|
||
</syncFetcher.Form>
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{sportsSeason.externalSeasonId
|
||
? `Syncing from external ID: ${sportsSeason.externalSeasonId}`
|
||
: 'Set an External Season ID on the sports season to enable API sync.'}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{seasonMatches.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">No match data yet. Sync from the API or use the <Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/swiss-matches`} className="underline">manual match entry</Link> page.</p>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{[1, 2, 3].map(stage => {
|
||
const stageMatches = seasonMatches.filter(m => m.matchStage === stage);
|
||
if (stageMatches.length === 0) return null;
|
||
|
||
// Group by round
|
||
const rounds = new Map<number, typeof stageMatches>();
|
||
for (const m of stageMatches) {
|
||
const r = m.matchRound ?? 0;
|
||
let roundGroup = rounds.get(r);
|
||
if (!roundGroup) {
|
||
roundGroup = [];
|
||
rounds.set(r, roundGroup);
|
||
}
|
||
roundGroup.push(m);
|
||
}
|
||
|
||
return (
|
||
<div key={stage}>
|
||
<h3 className="text-sm font-semibold mb-3">
|
||
Stage {stage} — {STAGE_NAMES[stage] ?? ''}
|
||
</h3>
|
||
<div className="space-y-4">
|
||
{[...rounds.entries()].toSorted(([a], [b]) => a - b).map(([round, matches]) => (
|
||
<div key={round}>
|
||
<p className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||
Round {round}
|
||
</p>
|
||
<div className="space-y-1">
|
||
{matches.map(m => (
|
||
<div key={m.id} className={`flex items-center gap-3 text-sm py-1 ${MATCH_STATUS_STYLES[m.status] ?? ''}`}>
|
||
<span className="flex-1">{m.participant1?.name ?? '?'}</span>
|
||
<span className="tabular-nums font-medium">
|
||
{m.status === 'complete' || m.status === 'in_progress'
|
||
? `${m.participant1Score ?? '–'} – ${m.participant2Score ?? '–'}`
|
||
: 'vs'}
|
||
</span>
|
||
<span className="flex-1 text-right">{m.participant2?.name ?? '?'}</span>
|
||
{m.subGames.length > 0 && (
|
||
<span className="text-xs text-muted-foreground ml-2">
|
||
[{m.subGames.map(g => `${g.participant1Score}-${g.participant2Score}`).join(', ')}]
|
||
</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Champions Stage link */}
|
||
{stageResults.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Champions Stage (Playoffs)</CardTitle>
|
||
<CardDescription>
|
||
The 8-team single-elimination bracket. Use the bracket admin to track
|
||
Quarterfinals, Semifinals, and Grand Final results.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/bracket`}>
|
||
<Button variant="outline">
|
||
Open Bracket Admin →
|
||
</Button>
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|