382 lines
17 KiB
TypeScript
382 lines
17 KiB
TypeScript
import { Form, useLoaderData, useActionData, useNavigation, Link } from 'react-router';
|
|
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.swiss-matches';
|
|
|
|
import { findSportsSeasonById } from '~/models/sports-season';
|
|
import { getScoringEventById } from '~/models/scoring-event';
|
|
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
|
import {
|
|
findSeasonMatchesByScoringEventId,
|
|
upsertSeasonMatch,
|
|
updateSeasonMatch,
|
|
deleteSeasonMatch,
|
|
upsertMatchSubGame,
|
|
} from '~/models/season-match';
|
|
import type { MatchStatus } from '~/models/season-match';
|
|
import { Button } from '~/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '~/components/ui/card';
|
|
import { Badge } from '~/components/ui/badge';
|
|
import { ArrowLeft, Plus, Save, Trash2 } from 'lucide-react';
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `Swiss Matches — ${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, matches] = await Promise.all([
|
|
findParticipantsBySportsSeasonId(sportsSeasonId),
|
|
findSeasonMatchesByScoringEventId(eventId),
|
|
]);
|
|
|
|
return { sportsSeason, event, participants, matches };
|
|
}
|
|
|
|
interface ActionData {
|
|
success?: boolean;
|
|
message?: string;
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const eventId = params.eventId;
|
|
const sportsSeasonId = params.id;
|
|
const formData = await request.formData();
|
|
const intent = formData.get('intent') as string;
|
|
|
|
if (intent === 'create-match') {
|
|
const participant1Id = formData.get('participant1Id') as string;
|
|
const participant2Id = formData.get('participant2Id') as string;
|
|
const matchStage = parseInt(formData.get('matchStage') as string, 10);
|
|
const matchRound = parseInt(formData.get('matchRound') as string, 10);
|
|
const isSeries = formData.get('isSeries') === 'true';
|
|
|
|
if (!participant1Id || !participant2Id || isNaN(matchStage) || isNaN(matchRound)) {
|
|
return { success: false, message: 'Missing required fields.' };
|
|
}
|
|
|
|
const externalMatchId = `manual_${eventId}_s${matchStage}_r${matchRound}_${participant1Id}_${participant2Id}`;
|
|
await upsertSeasonMatch({
|
|
sportsSeasonId,
|
|
scoringEventId: eventId,
|
|
participant1Id,
|
|
participant2Id,
|
|
matchStage,
|
|
matchRound,
|
|
isSeries,
|
|
status: 'scheduled',
|
|
externalMatchId,
|
|
});
|
|
return { success: true, message: 'Match created.' };
|
|
}
|
|
|
|
if (intent === 'update-result') {
|
|
const matchId = formData.get('matchId') as string;
|
|
const participant1Score = formData.get('participant1Score');
|
|
const participant2Score = formData.get('participant2Score');
|
|
const winnerId = formData.get('winnerId') as string | null;
|
|
const status = formData.get('status') as MatchStatus;
|
|
|
|
const p1Score = participant1Score ? parseInt(participant1Score as string, 10) : null;
|
|
const p2Score = participant2Score ? parseInt(participant2Score as string, 10) : null;
|
|
|
|
await updateSeasonMatch(matchId, {
|
|
participant1Score: p1Score,
|
|
participant2Score: p2Score,
|
|
winnerId: winnerId || null,
|
|
status,
|
|
completedAt: status === 'complete' ? new Date() : null,
|
|
});
|
|
|
|
// Handle sub-games (maps) if provided
|
|
const mapCount = parseInt(formData.get('mapCount') as string ?? '0', 10);
|
|
for (let i = 1; i <= mapCount; i++) {
|
|
const mapLabel = formData.get(`map${i}_label`) as string | null;
|
|
const map1Score = formData.get(`map${i}_p1`) as string | null;
|
|
const map2Score = formData.get(`map${i}_p2`) as string | null;
|
|
if (map1Score !== null && map2Score !== null) {
|
|
await upsertMatchSubGame({
|
|
seasonMatchId: matchId,
|
|
gameNumber: i,
|
|
gameLabel: mapLabel || null,
|
|
participant1Score: parseInt(map1Score, 10),
|
|
participant2Score: parseInt(map2Score, 10),
|
|
status: 'complete',
|
|
});
|
|
}
|
|
}
|
|
|
|
return { success: true, message: 'Result saved.' };
|
|
}
|
|
|
|
if (intent === 'delete-match') {
|
|
const matchId = formData.get('matchId') as string;
|
|
await deleteSeasonMatch(matchId);
|
|
return { success: true, message: 'Match deleted.' };
|
|
}
|
|
|
|
return { success: false, message: 'Unknown action.' };
|
|
}
|
|
|
|
const STAGE_NAMES: Record<number, string> = {
|
|
1: 'Opening Stage',
|
|
2: 'Challengers Stage',
|
|
3: 'Legends Stage',
|
|
};
|
|
|
|
const STATUS_BADGE: Record<MatchStatus, { label: string; variant: 'default' | 'secondary' | 'outline' | 'destructive' }> = {
|
|
scheduled: { label: 'Scheduled', variant: 'outline' },
|
|
in_progress: { label: 'Live', variant: 'default' },
|
|
complete: { label: 'Complete', variant: 'secondary' },
|
|
canceled: { label: 'Canceled', variant: 'destructive' },
|
|
postponed: { label: 'Postponed', variant: 'outline' },
|
|
};
|
|
|
|
export default function AdminSwissMatches() {
|
|
const { sportsSeason, event, participants, matches } = useLoaderData<typeof loader>();
|
|
const actionData = useActionData<ActionData>();
|
|
const navigation = useNavigation();
|
|
const isSubmitting = navigation.state === 'submitting';
|
|
|
|
// Group matches by stage → round
|
|
const byStage = new Map<number, Map<number, typeof matches>>();
|
|
for (const m of matches) {
|
|
const stage = m.matchStage ?? 0;
|
|
const round = m.matchRound ?? 0;
|
|
let stageMap = byStage.get(stage);
|
|
if (!stageMap) {
|
|
stageMap = new Map();
|
|
byStage.set(stage, stageMap);
|
|
}
|
|
let roundList = stageMap.get(round);
|
|
if (!roundList) {
|
|
roundList = [];
|
|
stageMap.set(round, roundList);
|
|
}
|
|
roundList.push(m);
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto py-8">
|
|
<div className="mb-6">
|
|
<Link
|
|
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/cs2-setup`}
|
|
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-4"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to CS2 Setup
|
|
</Link>
|
|
<h1 className="text-3xl font-bold mb-1">Swiss Match Entry</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>
|
|
)}
|
|
|
|
{/* Create new match */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Add Match</CardTitle>
|
|
<CardDescription>Manually add a Swiss round match.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post" className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
<input type="hidden" name="intent" value="create-match" />
|
|
<div className="space-y-1">
|
|
<label className="text-sm font-medium">Stage</label>
|
|
<select name="matchStage" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
|
<option value="1">Stage 1 — Opening</option>
|
|
<option value="2">Stage 2 — Challengers</option>
|
|
<option value="3">Stage 3 — Legends</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-sm font-medium">Round</label>
|
|
<select name="matchRound" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
|
{[1, 2, 3, 4, 5].map(r => <option key={r} value={r}>Round {r}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-sm font-medium">Format</label>
|
|
<select name="isSeries" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
|
<option value="false">Bo1</option>
|
|
<option value="true">Bo3</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-sm font-medium">Team 1</label>
|
|
<select name="participant1Id" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
|
<option value="">— select —</option>
|
|
{participants.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-sm font-medium">Team 2</label>
|
|
<select name="participant2Id" className="h-9 w-full text-sm rounded-md border border-input bg-background px-2">
|
|
<option value="">— select —</option>
|
|
{participants.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Match
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Existing matches grouped by stage/round */}
|
|
{matches.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No matches yet.</p>
|
|
) : (
|
|
[...byStage.entries()].toSorted(([a], [b]) => a - b).map(([stage, rounds]) => (
|
|
<Card key={stage} className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Stage {stage} — {STAGE_NAMES[stage] ?? ''}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{[...rounds.entries()].toSorted(([a], [b]) => a - b).map(([round, roundMatches]) => (
|
|
<div key={round}>
|
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Round {round}</p>
|
|
<div className="space-y-4">
|
|
{roundMatches.map(match => (
|
|
<div key={match.id} className="border border-border rounded-md p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-sm font-medium">
|
|
{match.participant1?.name ?? '?'} vs {match.participant2?.name ?? '?'}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={STATUS_BADGE[match.status]?.variant ?? 'outline'}>
|
|
{STATUS_BADGE[match.status]?.label ?? match.status}
|
|
</Badge>
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="delete-match" />
|
|
<input type="hidden" name="matchId" value={match.id} />
|
|
<Button type="submit" variant="ghost" size="sm" className="text-destructive h-7 px-2" disabled={isSubmitting}>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</Form>
|
|
</div>
|
|
</div>
|
|
<Form method="post" className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<input type="hidden" name="intent" value="update-result" />
|
|
<input type="hidden" name="matchId" value={match.id} />
|
|
<input type="hidden" name="mapCount" value={match.isSeries ? '3' : '0'} />
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium">{match.participant1?.name ?? 'P1'} score</label>
|
|
<input
|
|
type="number"
|
|
name="participant1Score"
|
|
defaultValue={match.participant1Score ?? ''}
|
|
min="0"
|
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium">{match.participant2?.name ?? 'P2'} score</label>
|
|
<input
|
|
type="number"
|
|
name="participant2Score"
|
|
defaultValue={match.participant2Score ?? ''}
|
|
min="0"
|
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium">Winner</label>
|
|
<select
|
|
name="winnerId"
|
|
defaultValue={match.winnerId ?? ''}
|
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
|
>
|
|
<option value="">— none —</option>
|
|
<option value={match.participant1Id ?? ''}>{match.participant1?.name ?? 'P1'}</option>
|
|
<option value={match.participant2Id ?? ''}>{match.participant2?.name ?? 'P2'}</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium">Status</label>
|
|
<select
|
|
name="status"
|
|
defaultValue={match.status}
|
|
className="h-8 w-full text-sm rounded-md border border-input bg-background px-2"
|
|
>
|
|
<option value="scheduled">Scheduled</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="complete">Complete</option>
|
|
<option value="canceled">Canceled</option>
|
|
<option value="postponed">Postponed</option>
|
|
</select>
|
|
</div>
|
|
{match.isSeries && (
|
|
<>
|
|
{[1, 2, 3].map(mapNum => {
|
|
const subGame = match.subGames.find(g => g.gameNumber === mapNum);
|
|
return (
|
|
<div key={mapNum} className="col-span-2 grid grid-cols-3 gap-2">
|
|
<input
|
|
type="text"
|
|
name={`map${mapNum}_label`}
|
|
placeholder={`Map ${mapNum} name`}
|
|
defaultValue={subGame?.gameLabel ?? ''}
|
|
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
|
/>
|
|
<input
|
|
type="number"
|
|
name={`map${mapNum}_p1`}
|
|
placeholder="P1"
|
|
defaultValue={subGame?.participant1Score ?? ''}
|
|
min="0"
|
|
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
|
/>
|
|
<input
|
|
type="number"
|
|
name={`map${mapNum}_p2`}
|
|
placeholder="P2"
|
|
defaultValue={subGame?.participant2Score ?? ''}
|
|
min="0"
|
|
className="h-8 text-xs rounded-md border border-input bg-background px-2"
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
<div className="col-span-2 md:col-span-4 flex justify-end">
|
|
<Button type="submit" size="sm" disabled={isSubmitting}>
|
|
<Save className="h-3 w-3 mr-1" />
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
);
|
|
}
|