338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
|
|
import { Form, useLoaderData, useActionData, useNavigation, 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/participant';
|
|||
|
|
import {
|
|||
|
|
getCs2StageResultsForEvent,
|
|||
|
|
upsertCs2StageAssignments,
|
|||
|
|
markCs2StageEliminations,
|
|||
|
|
clearCs2StageAssignments,
|
|||
|
|
} from '~/models/cs2-major-stage';
|
|||
|
|
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 } 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] = await Promise.all([
|
|||
|
|
findParticipantsBySportsSeasonId(sportsSeasonId),
|
|||
|
|
getCs2StageResultsForEvent(eventId),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
return { sportsSeason, event, participants, stageResults };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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_{participantId} = winsAtElimination (0, 1, or 2)
|
|||
|
|
const eliminations: Array<{ participantId: string; winsAtElimination: number }> = [];
|
|||
|
|
for (const [key, value] of formData.entries()) {
|
|||
|
|
if (key.startsWith('elim_')) {
|
|||
|
|
const participantId = key.slice('elim_'.length);
|
|||
|
|
const wins = parseInt(value as string, 10);
|
|||
|
|
if (!isNaN(wins) && wins >= 0 && wins <= 2) {
|
|||
|
|
eliminations.push({ participantId, winsAtElimination: wins });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
await markCs2StageEliminations(eventId, eliminations);
|
|||
|
|
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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: 'Elimination Stage (8 Challengers, Bo1/Bo3 Swiss)', maxTeams: 8, badgeVariant: 'secondary' },
|
|||
|
|
3: { label: 'Stage 3', description: 'Decider Stage (8 Legends, all Bo3 Swiss)', maxTeams: 8, badgeVariant: 'default' },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const RECORD_OPTIONS = [
|
|||
|
|
{ value: '2', label: '2-3 (advanced furthest)' },
|
|||
|
|
{ value: '1', label: '1-3' },
|
|||
|
|
{ value: '0', label: '0-3 (earliest exit)' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
export default function AdminCs2Setup() {
|
|||
|
|
const { sportsSeason, event, participants, stageResults } = useLoaderData<typeof loader>();
|
|||
|
|
const actionData = useActionData<ActionData>();
|
|||
|
|
const navigation = useNavigation();
|
|||
|
|
const isSubmitting = navigation.state === 'submitting';
|
|||
|
|
|
|||
|
|
// 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;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Elimination checkbox state — tracks which teams are checked as eliminated
|
|||
|
|
const [eliminatedChecked, setEliminatedChecked] = useState<Record<string, boolean>>(() => {
|
|||
|
|
const initial: Record<string, boolean> = {};
|
|||
|
|
stageResults.forEach(r => { initial[r.participantId] = r.stageEliminated !== null; });
|
|||
|
|
return initial;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Group participants by stage for display
|
|||
|
|
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])
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
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.
|
|||
|
|
Teams not listed as eliminated are assumed to have advanced (or are still playing).
|
|||
|
|
Stage 3 W-L records determine QP sub-placements (9–16).
|
|||
|
|
</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 = byStage[stage];
|
|||
|
|
if (teamsInStage.length === 0) return null;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div key={stage}>
|
|||
|
|
<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}
|
|||
|
|
</h3>
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
{teamsInStage.map(r => {
|
|||
|
|
const isEliminated = eliminatedChecked[r.participantId] ?? false;
|
|||
|
|
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}
|
|||
|
|
{r.stageEliminated !== null && (
|
|||
|
|
<Badge variant="outline" className="text-xs">
|
|||
|
|
eliminated {r.stageEliminatedWins}-3
|
|||
|
|
</Badge>
|
|||
|
|
)}
|
|||
|
|
</span>
|
|||
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={isEliminated}
|
|||
|
|
onChange={e =>
|
|||
|
|
setEliminatedChecked(prev => ({
|
|||
|
|
...prev,
|
|||
|
|
[r.participantId]: e.target.checked,
|
|||
|
|
}))
|
|||
|
|
}
|
|||
|
|
className="rounded"
|
|||
|
|
/>
|
|||
|
|
Eliminated
|
|||
|
|
</label>
|
|||
|
|
{isEliminated && (
|
|||
|
|
<select
|
|||
|
|
name={`elim_${r.participantId}`}
|
|||
|
|
defaultValue={r.stageEliminatedWins?.toString() ?? '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>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 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>
|
|||
|
|
);
|
|||
|
|
}
|