289 lines
9.9 KiB
TypeScript
289 lines
9.9 KiB
TypeScript
|
|
import { useState } from "react";
|
|||
|
|
import { Badge } from "~/components/ui/badge";
|
|||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
|||
|
|
import {
|
|||
|
|
Table,
|
|||
|
|
TableBody,
|
|||
|
|
TableCell,
|
|||
|
|
TableHead,
|
|||
|
|
TableHeader,
|
|||
|
|
TableRow,
|
|||
|
|
} from "~/components/ui/table";
|
|||
|
|
import { cn } from "~/lib/utils";
|
|||
|
|
import { PlayoffBracket, type Match } from "~/components/scoring/PlayoffBracket";
|
|||
|
|
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
|||
|
|
import type { Cs2StageResultWithName } from "~/models/cs2-major-stage";
|
|||
|
|
import type { SeasonMatch } from "~/models/season-match";
|
|||
|
|
|
|||
|
|
type StageTab = 1 | 2 | 3 | "champions";
|
|||
|
|
|
|||
|
|
interface Ownership {
|
|||
|
|
participantId: string;
|
|||
|
|
teamName: string;
|
|||
|
|
teamId: string;
|
|||
|
|
ownerName?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Cs2MatchRow extends Omit<SeasonMatch, "scheduledAt" | "startedAt" | "completedAt"> {
|
|||
|
|
scheduledAt: string | null;
|
|||
|
|
startedAt: string | null;
|
|||
|
|
completedAt: string | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface PlayoffMatchWithRelations extends Match {
|
|||
|
|
participant1: { id: string; name: string } | null;
|
|||
|
|
participant2: { id: string; name: string } | null;
|
|||
|
|
winner: { id: string; name: string } | null;
|
|||
|
|
loser: { id: string; name: string } | null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
cs2StageResults: Cs2StageResultWithName[];
|
|||
|
|
seasonMatches: Cs2MatchRow[];
|
|||
|
|
playoffMatches: PlayoffMatchWithRelations[];
|
|||
|
|
playoffRounds: string[];
|
|||
|
|
bracketTemplateId: string | null;
|
|||
|
|
teamOwnerships: Ownership[];
|
|||
|
|
userParticipantIds: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const STAGE_LABELS: Record<StageTab, string> = {
|
|||
|
|
1: "Stage 1",
|
|||
|
|
2: "Stage 2",
|
|||
|
|
3: "Stage 3",
|
|||
|
|
champions: "Champions",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function StageStatusBadge({ result }: { result: Cs2StageResultWithName }) {
|
|||
|
|
if (result.stageEliminated !== null) {
|
|||
|
|
return (
|
|||
|
|
<span className="flex items-center gap-1 text-xs text-red-400">
|
|||
|
|
<XCircle className="h-3 w-3" />
|
|||
|
|
Eliminated
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
const winsNeeded = 3;
|
|||
|
|
const wins = result.stageEliminatedWins ?? 0;
|
|||
|
|
if (result.stageEliminated === null && result.stageEntry !== null) {
|
|||
|
|
// Null stageEliminated = either in progress or advanced
|
|||
|
|
// If their stageEntry is less than the current stage context, they advanced
|
|||
|
|
return (
|
|||
|
|
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
|||
|
|
<CheckCircle2 className="h-3 w-3" />
|
|||
|
|
Advanced
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
return (
|
|||
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|||
|
|
<Clock className="h-3 w-3" />
|
|||
|
|
In Progress
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SwissStageTab({
|
|||
|
|
stage,
|
|||
|
|
stageResults,
|
|||
|
|
matches,
|
|||
|
|
teamOwnerships,
|
|||
|
|
userParticipantIds,
|
|||
|
|
}: {
|
|||
|
|
stage: 1 | 2 | 3;
|
|||
|
|
stageResults: Cs2StageResultWithName[];
|
|||
|
|
matches: Cs2MatchRow[];
|
|||
|
|
teamOwnerships: Ownership[];
|
|||
|
|
userParticipantIds: string[];
|
|||
|
|
}) {
|
|||
|
|
const ownershipMap = new Map(teamOwnerships.map((o) => [o.participantId, o]));
|
|||
|
|
const userIds = new Set(userParticipantIds);
|
|||
|
|
|
|||
|
|
const teamsInStage = stageResults.filter((r) => r.stageEntry <= stage && (r.stageEliminated === null || r.stageEliminated >= stage));
|
|||
|
|
const matchesInStage = matches.filter((m) => m.matchStage === stage);
|
|||
|
|
|
|||
|
|
// Group teams by W-L record (approximated from stageEliminatedWins if eliminated, else unknown)
|
|||
|
|
const eliminated = stageResults.filter((r) => r.stageEliminated === stage);
|
|||
|
|
const advanced = stageResults.filter((r) => r.stageEliminated === null && r.stageEntry <= stage);
|
|||
|
|
|
|||
|
|
if (stageResults.length === 0) {
|
|||
|
|
return (
|
|||
|
|
<p className="text-sm text-muted-foreground py-4">No teams assigned to this stage yet.</p>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<Table>
|
|||
|
|
<TableHeader>
|
|||
|
|
<TableRow>
|
|||
|
|
<TableHead>Team</TableHead>
|
|||
|
|
<TableHead>Manager</TableHead>
|
|||
|
|
<TableHead className="text-right">W</TableHead>
|
|||
|
|
<TableHead className="text-right">L</TableHead>
|
|||
|
|
<TableHead>Status</TableHead>
|
|||
|
|
</TableRow>
|
|||
|
|
</TableHeader>
|
|||
|
|
<TableBody>
|
|||
|
|
{stageResults.map((r) => {
|
|||
|
|
const ownership = ownershipMap.get(r.participantId);
|
|||
|
|
const isUser = userIds.has(r.participantId);
|
|||
|
|
const wins = r.stageEliminated === stage ? (r.stageEliminatedWins ?? "—") : "—";
|
|||
|
|
const losses = r.stageEliminated === stage ? (3 - (r.stageEliminatedWins ?? 0)) : "—";
|
|||
|
|
const isElim = r.stageEliminated === stage;
|
|||
|
|
const isAdvanced = r.stageEliminated === null || (r.stageEliminated !== null && r.stageEliminated > stage);
|
|||
|
|
return (
|
|||
|
|
<TableRow
|
|||
|
|
key={r.participantId}
|
|||
|
|
className={cn(
|
|||
|
|
isUser && "bg-electric/5",
|
|||
|
|
isElim && "opacity-50"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<TableCell className="font-medium">
|
|||
|
|
{r.participantName}
|
|||
|
|
{isUser && <span className="ml-1.5 text-xs text-electric">★</span>}
|
|||
|
|
</TableCell>
|
|||
|
|
<TableCell className="text-muted-foreground text-sm">
|
|||
|
|
{ownership ? ownership.teamName : "—"}
|
|||
|
|
</TableCell>
|
|||
|
|
<TableCell className="text-right">{wins}</TableCell>
|
|||
|
|
<TableCell className="text-right">{losses}</TableCell>
|
|||
|
|
<TableCell>
|
|||
|
|
{isElim ? (
|
|||
|
|
<span className="flex items-center gap-1 text-xs text-red-400">
|
|||
|
|
<XCircle className="h-3 w-3" /> Eliminated
|
|||
|
|
</span>
|
|||
|
|
) : isAdvanced && r.stageEliminated === null ? (
|
|||
|
|
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
|||
|
|
<CheckCircle2 className="h-3 w-3" /> Advanced
|
|||
|
|
</span>
|
|||
|
|
) : (
|
|||
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|||
|
|
<Clock className="h-3 w-3" /> In Progress
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</TableCell>
|
|||
|
|
</TableRow>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</TableBody>
|
|||
|
|
</Table>
|
|||
|
|
|
|||
|
|
{matchesInStage.length > 0 && (
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">Matches</h3>
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
{matchesInStage.map((m) => {
|
|||
|
|
const p1Name = m.participant1Id
|
|||
|
|
? stageResults.find((r) => r.participantId === m.participant1Id)?.participantName ?? m.participant1Id.slice(0, 8)
|
|||
|
|
: "TBD";
|
|||
|
|
const p2Name = m.participant2Id
|
|||
|
|
? stageResults.find((r) => r.participantId === m.participant2Id)?.participantName ?? m.participant2Id.slice(0, 8)
|
|||
|
|
: "TBD";
|
|||
|
|
const dateStr = m.scheduledAt
|
|||
|
|
? new Date(m.scheduledAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
|||
|
|
: null;
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
key={m.id}
|
|||
|
|
className={cn(
|
|||
|
|
"flex items-center justify-between text-sm px-3 py-1.5 rounded-md",
|
|||
|
|
m.status === "complete" ? "bg-muted/30" : "bg-muted/10"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<span className={cn(m.winnerId === m.participant1Id && "font-semibold text-emerald-400")}>{p1Name}</span>
|
|||
|
|
<span className="text-xs text-muted-foreground px-2">
|
|||
|
|
{m.status === "complete"
|
|||
|
|
? `${m.participant1Score ?? 0}–${m.participant2Score ?? 0}`
|
|||
|
|
: dateStr ?? "vs"}
|
|||
|
|
</span>
|
|||
|
|
<span className={cn("text-right", m.winnerId === m.participant2Id && "font-semibold text-emerald-400")}>{p2Name}</span>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function Cs2MajorEventView({
|
|||
|
|
cs2StageResults,
|
|||
|
|
seasonMatches,
|
|||
|
|
playoffMatches,
|
|||
|
|
playoffRounds,
|
|||
|
|
bracketTemplateId,
|
|||
|
|
teamOwnerships,
|
|||
|
|
userParticipantIds,
|
|||
|
|
}: Props) {
|
|||
|
|
const hasChampions = playoffMatches.length > 0;
|
|||
|
|
const [activeTab, setActiveTab] = useState<StageTab>(hasChampions ? "champions" : 3);
|
|||
|
|
|
|||
|
|
const tabs: StageTab[] = [1, 2, 3, "champions"];
|
|||
|
|
const ownershipMap = new Map(teamOwnerships.map((o) => [o.participantId, o]));
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{/* Tab bar */}
|
|||
|
|
<div className="flex gap-1 rounded-lg bg-muted p-1 w-fit">
|
|||
|
|
{tabs.map((tab) => (
|
|||
|
|
<button
|
|||
|
|
key={tab}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setActiveTab(tab)}
|
|||
|
|
className={cn(
|
|||
|
|
"px-3 py-1.5 text-sm font-medium rounded-md transition-colors",
|
|||
|
|
activeTab === tab
|
|||
|
|
? "bg-background text-foreground shadow-sm"
|
|||
|
|
: "text-muted-foreground hover:text-foreground"
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{STAGE_LABELS[tab]}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Swiss stage tabs */}
|
|||
|
|
{activeTab !== "champions" && (
|
|||
|
|
<SwissStageTab
|
|||
|
|
stage={activeTab as 1 | 2 | 3}
|
|||
|
|
stageResults={cs2StageResults.filter(
|
|||
|
|
(r) =>
|
|||
|
|
r.stageEntry <= (activeTab as number) &&
|
|||
|
|
(r.stageEliminated === null || r.stageEliminated >= (activeTab as number))
|
|||
|
|
)}
|
|||
|
|
matches={seasonMatches}
|
|||
|
|
teamOwnerships={teamOwnerships}
|
|||
|
|
userParticipantIds={userParticipantIds}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Champions Stage bracket */}
|
|||
|
|
{activeTab === "champions" && (
|
|||
|
|
hasChampions ? (
|
|||
|
|
<PlayoffBracket
|
|||
|
|
matches={playoffMatches}
|
|||
|
|
rounds={playoffRounds}
|
|||
|
|
bracketTemplateId={bracketTemplateId}
|
|||
|
|
teamOwnerships={teamOwnerships}
|
|||
|
|
userParticipantIds={userParticipantIds}
|
|||
|
|
showOwnership={true}
|
|||
|
|
title="Champions Stage"
|
|||
|
|
mode="bracket"
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<Card>
|
|||
|
|
<CardContent className="py-8 text-center">
|
|||
|
|
<p className="text-muted-foreground text-sm">
|
|||
|
|
Champions Stage bracket will be available once Stage 3 is complete.
|
|||
|
|
</p>
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
)
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|