1254 lines
54 KiB
TypeScript
1254 lines
54 KiB
TypeScript
import { Form, Link, redirect, useNavigate, useNavigation } from "react-router";
|
||
import { auth } from "~/lib/auth.server";
|
||
import { isUserAdmin } from "~/models/user";
|
||
import type { Route } from "./+types/admin.sports-seasons.$id";
|
||
|
||
import { logger } from "~/lib/logger";
|
||
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason, type NewSportsSeason } from "~/models/sports-season";
|
||
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
|
||
import { createDailySnapshot } from "~/models/standings";
|
||
import { database } from "~/database/context";
|
||
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
||
import { eq, desc } from "drizzle-orm";
|
||
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||
import { syncStandings } from "~/services/standings-sync/index";
|
||
import {
|
||
getPendingStandingsMappings,
|
||
deletePendingStandingsMapping,
|
||
} from "~/models/pending-standings-mappings";
|
||
import {
|
||
findParticipantById,
|
||
findParticipantsBySportsSeasonId,
|
||
updateParticipant,
|
||
} from "~/models/season-participant";
|
||
import { getLastSyncedAt, upsertRegularSeasonStandings } from "~/models/regular-season-standings";
|
||
import { getLastSeasonResultsSyncedAt, upsertParticipantSeasonResult } from "~/models/participant-season-result";
|
||
import { buildUnmatchedTeamResolutionView, type MatchConfidence } from "~/lib/unmatched-team-reconciliation";
|
||
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 {
|
||
AlertDialog,
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogTitle,
|
||
AlertDialogTrigger,
|
||
} from "~/components/ui/alert-dialog";
|
||
import { Badge } from "~/components/ui/badge";
|
||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
||
import { useState } from "react";
|
||
|
||
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||
|
||
type PendingStandingData = {
|
||
teamName?: string;
|
||
wins?: number;
|
||
losses?: number;
|
||
otLosses?: number | null;
|
||
ties?: number | null;
|
||
gamesPlayed?: number;
|
||
conference?: string | null;
|
||
division?: string | null;
|
||
leagueRank?: number;
|
||
streak?: string | null;
|
||
lastTen?: string | null;
|
||
};
|
||
|
||
function getConfidenceBadgeVariant(confidence: MatchConfidence) {
|
||
switch (confidence) {
|
||
case "exact":
|
||
return "bg-emerald-500/15 text-emerald-400 border-emerald-500/30";
|
||
case "partial":
|
||
return "bg-sky-500/15 text-sky-400 border-sky-500/30";
|
||
case "review":
|
||
return "bg-amber-500/15 text-amber-500 border-amber-500/30";
|
||
default:
|
||
return "bg-muted text-muted-foreground border-border";
|
||
}
|
||
}
|
||
|
||
function getConfidenceLabel(confidence: MatchConfidence) {
|
||
switch (confidence) {
|
||
case "exact":
|
||
return "Exact match";
|
||
case "partial":
|
||
return "Partial match";
|
||
case "review":
|
||
return "Needs review";
|
||
default:
|
||
return "No suggestion";
|
||
}
|
||
}
|
||
|
||
function formatTeamRecord(standingData: PendingStandingData) {
|
||
const wins = standingData.wins ?? 0;
|
||
const losses = standingData.losses ?? 0;
|
||
|
||
if (typeof standingData.otLosses === "number") {
|
||
return `${wins}-${losses}-${standingData.otLosses}`;
|
||
}
|
||
|
||
if (typeof standingData.ties === "number") {
|
||
return `${wins}-${losses}-${standingData.ties}`;
|
||
}
|
||
|
||
return `${wins}-${losses}`;
|
||
}
|
||
|
||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||
return [{ title: `${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||
}
|
||
|
||
export async function loader({ params }: Route.LoaderArgs) {
|
||
const sportsSeason = await findSportsSeasonById(params.id);
|
||
|
||
if (!sportsSeason) {
|
||
throw new Response("Sports season not found", { status: 404 });
|
||
}
|
||
|
||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||
|
||
// Get the most recent snapshot date (if any)
|
||
const db = database();
|
||
const lastSnapshot = await db
|
||
.select({ snapshotDate: participantEvSnapshots.snapshotDate })
|
||
.from(participantEvSnapshots)
|
||
.where(eq(participantEvSnapshots.sportsSeasonId, params.id))
|
||
.orderBy(desc(participantEvSnapshots.snapshotDate))
|
||
.limit(1);
|
||
|
||
const lastSimulatedDate = lastSnapshot[0]?.snapshotDate ?? null;
|
||
|
||
const simulatorInfo = sportsSeason.sport?.simulatorType
|
||
? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType)
|
||
: null;
|
||
|
||
const lastStandingsSyncedAt =
|
||
sportsSeason.sport?.type === "team"
|
||
? await getLastSyncedAt(params.id)
|
||
: sportsSeason.sport?.type === "individual"
|
||
? await getLastSeasonResultsSyncedAt(params.id)
|
||
: null;
|
||
|
||
const pendingMappings = await getPendingStandingsMappings(params.id);
|
||
|
||
return {
|
||
sportsSeason,
|
||
participants,
|
||
lastSimulatedDate,
|
||
simulatorInfo,
|
||
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
||
pendingMappings,
|
||
};
|
||
}
|
||
|
||
export async function action(args: Route.ActionArgs) {
|
||
const { request, params } = args;
|
||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||
const userId = session?.user.id ?? null;
|
||
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
||
if (!isAdmin) {
|
||
throw new Response("Forbidden", { status: 403 });
|
||
}
|
||
|
||
const formData = await request.formData();
|
||
const intent = formData.get("intent");
|
||
|
||
if (intent === "delete") {
|
||
await deleteSportsSeason(params.id);
|
||
return redirect("/admin/sports-seasons");
|
||
}
|
||
|
||
if (intent === "rescore") {
|
||
try {
|
||
const db = database();
|
||
const links = await db.query.seasonSports.findMany({
|
||
where: eq(seasonSports.sportsSeasonId, params.id),
|
||
});
|
||
if (links.length === 0) {
|
||
return { success: true, intent: "rescore", message: "No linked fantasy seasons found — nothing to rescore." };
|
||
}
|
||
await Promise.all(links.map((link) => recalculateStandings(link.seasonId, db)));
|
||
await Promise.all(links.map((link) => createDailySnapshot(link.seasonId, db)));
|
||
return { success: true, intent: "rescore", message: `Rescored ${links.length} linked season(s).` };
|
||
} catch (error) {
|
||
logger.error("Error rescoring:", error);
|
||
return { error: "Failed to rescore. Please try again." };
|
||
}
|
||
}
|
||
|
||
if (intent === "sync-standings") {
|
||
try {
|
||
const result = await syncStandings(params.id);
|
||
return { success: true, intent: "sync-standings", syncResult: result };
|
||
} catch (error) {
|
||
logger.error("Error syncing standings:", error);
|
||
return {
|
||
syncError:
|
||
error instanceof Error ? error.message : "Failed to sync standings. Please try again.",
|
||
};
|
||
}
|
||
}
|
||
|
||
if (intent === "resolve-mapping") {
|
||
const externalTeamId = formData.get("externalTeamId");
|
||
const participantId = formData.get("participantId");
|
||
const standingDataRaw = formData.get("standingData");
|
||
|
||
if (
|
||
typeof externalTeamId !== "string" ||
|
||
!externalTeamId.trim() ||
|
||
typeof participantId !== "string" ||
|
||
!participantId.trim() ||
|
||
typeof standingDataRaw !== "string"
|
||
) {
|
||
return { error: "Invalid resolve-mapping payload." };
|
||
}
|
||
|
||
try {
|
||
const standingData = JSON.parse(standingDataRaw) as Record<string, unknown>;
|
||
const [participant, sportsSeason] = await Promise.all([
|
||
findParticipantById(participantId),
|
||
findSportsSeasonById(params.id),
|
||
]);
|
||
|
||
if (!participant || participant.sportsSeasonId !== params.id) {
|
||
return { error: "Selected participant does not belong to this sports season." };
|
||
}
|
||
|
||
// Write externalId onto the participant for future ID-first matching
|
||
await updateParticipant(participantId, { externalId: externalTeamId });
|
||
|
||
// Route upsert to the correct table based on sport type
|
||
if (sportsSeason?.scoringPattern === "season_standings") {
|
||
await upsertParticipantSeasonResult({
|
||
participantId,
|
||
sportsSeasonId: params.id,
|
||
currentPoints: (standingData.currentPoints as number) ?? 0,
|
||
currentPosition: (standingData.leagueRank as number) ?? null,
|
||
});
|
||
} else {
|
||
await upsertRegularSeasonStandings([
|
||
{
|
||
participantId,
|
||
sportsSeasonId: params.id,
|
||
wins: (standingData.wins as number) ?? 0,
|
||
losses: (standingData.losses as number) ?? 0,
|
||
otLosses: (standingData.otLosses as number | null) ?? null,
|
||
ties: (standingData.ties as number | null) ?? null,
|
||
winPct: (standingData.winPct as number) ?? 0,
|
||
gamesPlayed: (standingData.gamesPlayed as number) ?? 0,
|
||
gamesBack: (standingData.gamesBack as number | null) ?? null,
|
||
conference: (standingData.conference as string | null) ?? null,
|
||
division: (standingData.division as string | null) ?? null,
|
||
conferenceRank: (standingData.conferenceRank as number | null) ?? null,
|
||
divisionRank: (standingData.divisionRank as number | null) ?? null,
|
||
leagueRank: (standingData.leagueRank as number) ?? 0,
|
||
streak: (standingData.streak as string | null) ?? null,
|
||
lastTen: (standingData.lastTen as string | null) ?? null,
|
||
homeRecord: (standingData.homeRecord as string | null) ?? null,
|
||
awayRecord: (standingData.awayRecord as string | null) ?? null,
|
||
externalTeamId,
|
||
syncedAt: new Date(),
|
||
},
|
||
]);
|
||
}
|
||
|
||
// Remove from pending queue
|
||
await deletePendingStandingsMapping(params.id, externalTeamId);
|
||
|
||
return {
|
||
success: true,
|
||
intent: "resolve-mapping",
|
||
resolvedTeam: String(standingData.teamName ?? ""),
|
||
resolvedParticipant: participant?.name ?? "",
|
||
};
|
||
} catch (error) {
|
||
logger.error("Error resolving mapping:", error);
|
||
return { error: "Failed to resolve mapping. Please try again." };
|
||
}
|
||
}
|
||
|
||
if (intent === "finalize-standings") {
|
||
try {
|
||
await processSeasonStandings(params.id);
|
||
await updateSportsSeason(params.id, { status: "completed" });
|
||
return { success: true, intent: "finalize-standings", message: "Standings finalized and fantasy placements assigned!" };
|
||
} catch (error) {
|
||
logger.error("Error finalizing standings:", error);
|
||
return { error: "Failed to finalize standings. Please try again." };
|
||
}
|
||
}
|
||
|
||
if (intent === "simulate") {
|
||
try {
|
||
await runSportsSeasonSimulation(params.id);
|
||
return redirect(`/admin/sports-seasons/${params.id}/expected-values`);
|
||
} catch (error) {
|
||
return {
|
||
simulateError: error instanceof Error ? error.message : "Simulation failed",
|
||
};
|
||
}
|
||
}
|
||
|
||
// Update
|
||
const name = formData.get("name");
|
||
const year = formData.get("year");
|
||
const startDate = formData.get("startDate");
|
||
const endDate = formData.get("endDate");
|
||
const status = formData.get("status");
|
||
const scoringType = formData.get("scoringType");
|
||
const scoringPattern = formData.get("scoringPattern");
|
||
const totalMajors = formData.get("totalMajors");
|
||
const draftOn = formData.get("draftOn");
|
||
const draftOff = formData.get("draftOff");
|
||
const externalSeasonId = formData.get("externalSeasonId");
|
||
|
||
// Validation
|
||
if (typeof name !== "string" || !name.trim()) {
|
||
return { error: "Season name is required" };
|
||
}
|
||
|
||
if (typeof year !== "string") {
|
||
return { error: "Year is required" };
|
||
}
|
||
|
||
const yearNum = parseInt(year, 10);
|
||
if (isNaN(yearNum) || yearNum < 2000 || yearNum > 2100) {
|
||
return { error: "Year must be between 2000 and 2100" };
|
||
}
|
||
|
||
if (status !== "upcoming" && status !== "active" && status !== "completed") {
|
||
return { error: "Invalid status" };
|
||
}
|
||
|
||
if (scoringType !== "playoffs" && scoringType !== "regular_season" && scoringType !== "majors") {
|
||
return { error: "Invalid scoring type" };
|
||
}
|
||
|
||
const validScoringPatterns = ["playoff_bracket", "season_standings", "qualifying_points"];
|
||
if (scoringPattern && typeof scoringPattern === "string" && !validScoringPatterns.includes(scoringPattern)) {
|
||
return { error: "Invalid scoring pattern" };
|
||
}
|
||
|
||
if (typeof draftOn !== "string" || !draftOn) {
|
||
return { error: "Draft open date is required" };
|
||
}
|
||
|
||
if (typeof draftOff !== "string" || !draftOff) {
|
||
return { error: "Draft close date is required" };
|
||
}
|
||
|
||
if (draftOff < draftOn) {
|
||
return { error: "Draft close date must be on or after draft open date" };
|
||
}
|
||
|
||
try {
|
||
const updateData: Partial<NewSportsSeason> = {
|
||
name: name.trim(),
|
||
year: yearNum,
|
||
startDate: typeof startDate === "string" && startDate ? startDate : null,
|
||
endDate: typeof endDate === "string" && endDate ? endDate : null,
|
||
status,
|
||
scoringType,
|
||
draftOn,
|
||
draftOff,
|
||
};
|
||
|
||
if (scoringPattern && typeof scoringPattern === "string") {
|
||
updateData.scoringPattern = scoringPattern as "playoff_bracket" | "season_standings" | "qualifying_points";
|
||
}
|
||
|
||
if (totalMajors && typeof totalMajors === "string") {
|
||
const totalMajorsNum = parseInt(totalMajors, 10);
|
||
if (!isNaN(totalMajorsNum) && totalMajorsNum > 0) {
|
||
updateData.totalMajors = totalMajorsNum;
|
||
}
|
||
}
|
||
|
||
updateData.externalSeasonId = typeof externalSeasonId === "string" && externalSeasonId.trim()
|
||
? externalSeasonId.trim()
|
||
: null;
|
||
|
||
await updateSportsSeason(params.id, updateData);
|
||
|
||
return { success: true, message: "Sports season updated successfully!" };
|
||
} catch (error) {
|
||
logger.error("Error updating sports season:", error);
|
||
return { error: "Failed to update sports season. Please try again." };
|
||
}
|
||
}
|
||
|
||
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
||
const navigate = useNavigate();
|
||
const navigation = useNavigation();
|
||
const isSyncingStandings =
|
||
navigation.state === "submitting" &&
|
||
(navigation.formData?.get("intent") as string) === "sync-standings";
|
||
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
||
const pendingMappingsWithSuggestions = pendingMappings.map((mapping) => ({
|
||
...mapping,
|
||
standingData: (mapping.standingData ?? {}) as PendingStandingData,
|
||
resolutionView: buildUnmatchedTeamResolutionView(mapping.teamName, participants),
|
||
}));
|
||
|
||
return (
|
||
<div className="p-8">
|
||
<div className="max-w-2xl">
|
||
<div className="mb-6 flex items-start justify-between">
|
||
<div>
|
||
<h1 className="text-3xl font-bold">Edit Sports Season</h1>
|
||
<p className="text-muted-foreground mt-1">
|
||
{sportsSeason.sport.name} - {sportsSeason.name}
|
||
</p>
|
||
</div>
|
||
<Button variant="outline" asChild>
|
||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/clone`}>
|
||
<Copy className="mr-2 h-4 w-4" />
|
||
Clone Season
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Sports Season Details</CardTitle>
|
||
<CardDescription>
|
||
Update the information for this sports season
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Form method="post" className="space-y-6">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="name">Season Name</Label>
|
||
<Input
|
||
id="name"
|
||
name="name"
|
||
type="text"
|
||
defaultValue={sportsSeason.name}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="year">Year</Label>
|
||
<Input
|
||
id="year"
|
||
name="year"
|
||
type="number"
|
||
min="2000"
|
||
max="2100"
|
||
defaultValue={sportsSeason.year}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="startDate">Start Date (Optional)</Label>
|
||
<Input
|
||
id="startDate"
|
||
name="startDate"
|
||
type="date"
|
||
defaultValue={sportsSeason.startDate || ""}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="endDate">End Date (Optional)</Label>
|
||
<Input
|
||
id="endDate"
|
||
name="endDate"
|
||
type="date"
|
||
defaultValue={sportsSeason.endDate || ""}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="status">Status</Label>
|
||
<select id="status" name="status" defaultValue={sportsSeason.status} required className={SELECT_CLASS}>
|
||
<option value="upcoming">Upcoming</option>
|
||
<option value="active">Active</option>
|
||
<option value="completed">Completed</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="scoringType">Scoring Type</Label>
|
||
<select
|
||
id="scoringType"
|
||
name="scoringType"
|
||
defaultValue={sportsSeason.scoringType}
|
||
required
|
||
className={SELECT_CLASS}
|
||
>
|
||
<option value="playoffs">Playoffs</option>
|
||
<option value="regular_season">Regular Season</option>
|
||
<option value="majors">Majors</option>
|
||
</select>
|
||
<p className="text-sm text-muted-foreground">
|
||
Playoffs: Team sports playoffs. Regular Season: Full season standings. Majors: Individual sport majors.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="scoringPattern">Scoring Pattern (Optional)</Label>
|
||
<select
|
||
id="scoringPattern"
|
||
name="scoringPattern"
|
||
value={scoringPattern}
|
||
onChange={(event) => setScoringPattern(event.target.value)}
|
||
className={SELECT_CLASS}
|
||
>
|
||
<option value="">Select scoring pattern (optional)</option>
|
||
<option value="playoff_bracket">Playoff Bracket</option>
|
||
<option value="season_standings">Season Standings</option>
|
||
<option value="qualifying_points">Qualifying Points (Golf/Tennis)</option>
|
||
</select>
|
||
<p className="text-sm text-muted-foreground">
|
||
Qualifying Points: For sports like Golf/Tennis where participants earn points across majors, then top 8 get fantasy points.
|
||
</p>
|
||
</div>
|
||
|
||
{scoringPattern === "qualifying_points" && (
|
||
<div className="space-y-2">
|
||
<Label htmlFor="totalMajors">Total Majors</Label>
|
||
<Input
|
||
id="totalMajors"
|
||
name="totalMajors"
|
||
type="number"
|
||
min="1"
|
||
max="10"
|
||
defaultValue={sportsSeason.totalMajors || 4}
|
||
placeholder="e.g., 4 (for Golf)"
|
||
/>
|
||
<p className="text-sm text-muted-foreground">
|
||
How many major tournaments will be tracked? (e.g., Golf has 4 majors, Tennis has 4 Grand Slams)
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="draftOn">Draft Open Date</Label>
|
||
<Input
|
||
id="draftOn"
|
||
name="draftOn"
|
||
type="date"
|
||
defaultValue={sportsSeason.draftOn}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="draftOff">Draft Close Date</Label>
|
||
<Input
|
||
id="draftOff"
|
||
name="draftOff"
|
||
type="date"
|
||
defaultValue={sportsSeason.draftOff}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
This season appears in league creation and pre-draft settings only between these two dates (inclusive).
|
||
</p>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="externalSeasonId">External Season ID (Optional)</Label>
|
||
<Input
|
||
id="externalSeasonId"
|
||
name="externalSeasonId"
|
||
type="text"
|
||
defaultValue={sportsSeason.externalSeasonId || ""}
|
||
placeholder="e.g., PandaScore serie_id or ESPN season year"
|
||
/>
|
||
<p className="text-sm text-muted-foreground">
|
||
Used by the match sync cron job. For CS2: PandaScore serie_id. For MLB/NBA: year (e.g., 2025).
|
||
</p>
|
||
</div>
|
||
|
||
{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.message}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-4">
|
||
<Button type="submit" className="flex-1">
|
||
Save Changes
|
||
</Button>
|
||
<Button type="button" variant="outline" asChild>
|
||
<Link to="/admin/sports-seasons">Cancel</Link>
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Participants</CardTitle>
|
||
<CardDescription>
|
||
{participants.length} {participants.length === 1 ? "participant" : "participants"}
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/participants`)}
|
||
>
|
||
<Users className="mr-2 h-4 w-4" />
|
||
Manage Participants
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{participants.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
No participants added yet. Add teams or players to this season.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{participants.slice(0, 5).map((participant) => (
|
||
<div key={participant.id} className="text-sm">
|
||
{participant.name}
|
||
</div>
|
||
))}
|
||
{participants.length > 5 && (
|
||
<p className="text-sm text-muted-foreground">
|
||
And {participants.length - 5} more...
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Expected Values</CardTitle>
|
||
<CardDescription>
|
||
{simulatorInfo
|
||
? simulatorInfo.name
|
||
: "Manage probability distributions and projected points"}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{sportsSeason.simulationStatus === "failed" && (
|
||
<Badge variant="outline" className="bg-destructive/15 text-destructive border-destructive/30">
|
||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||
Last run failed
|
||
</Badge>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/simulator`)}
|
||
>
|
||
<Calculator className="mr-2 h-4 w-4" />
|
||
Simulator Setup
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
|
||
>
|
||
<Calculator className="mr-2 h-4 w-4" />
|
||
Futures Odds
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`)}
|
||
>
|
||
<Calculator className="mr-2 h-4 w-4" />
|
||
Elo Ratings
|
||
</Button>
|
||
{sportsSeason.sport?.simulatorType === "tennis_qualifying_points" && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/surface-elo`)}
|
||
>
|
||
<Calculator className="mr-2 h-4 w-4" />
|
||
Surface Elo
|
||
</Button>
|
||
)}
|
||
{sportsSeason.sport?.simulatorType === "golf_qualifying_points" && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/golf-skills`)}
|
||
>
|
||
<Calculator className="mr-2 h-4 w-4" />
|
||
Golf Skills
|
||
</Button>
|
||
)}
|
||
{simulatorInfo && (
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="simulate" />
|
||
<Button
|
||
type="submit"
|
||
size="sm"
|
||
disabled={sportsSeason.simulationStatus === "running"}
|
||
>
|
||
{sportsSeason.simulationStatus === "running" ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Running...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Zap className="mr-2 h-4 w-4" />
|
||
Run Simulation
|
||
</>
|
||
)}
|
||
</Button>
|
||
</Form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-muted-foreground">
|
||
{simulatorInfo
|
||
? <>
|
||
{simulatorInfo.description}.{" "}
|
||
{lastSimulatedDate ? `Last simulated: ${lastSimulatedDate}.` : "No simulation has been run yet."}
|
||
{" "}Import futures odds first, then run the simulation to update EVs and save a snapshot.
|
||
</>
|
||
: "Import futures odds to set probability distributions."}
|
||
</p>
|
||
{"simulateError" in (actionData ?? {}) && actionData?.simulateError && (
|
||
<div className="mt-3 bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||
{actionData.simulateError}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Scoring Events</CardTitle>
|
||
<CardDescription>
|
||
Manage games, tournaments, and schedule entries
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/events`)}
|
||
>
|
||
<Trophy className="mr-2 h-4 w-4" />
|
||
Manage Events
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-sm text-muted-foreground">
|
||
Create playoff brackets, major tournaments, or import a race/game schedule.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{sportsSeason.sport?.type === "team" && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Regular Season Standings</CardTitle>
|
||
<CardDescription>
|
||
Sync current W/L standings from the official API, or edit manually.
|
||
{lastStandingsSyncedAt && (
|
||
<span className="ml-1 text-muted-foreground">
|
||
Last synced: {new Date(lastStandingsSyncedAt).toLocaleString()}.
|
||
</span>
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/regular-standings`)}
|
||
>
|
||
Edit Manually
|
||
</Button>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="sync-standings" />
|
||
<Button type="submit" size="sm" disabled={isSyncingStandings}>
|
||
{isSyncingStandings ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Syncing...
|
||
</>
|
||
) : (
|
||
<>
|
||
<RefreshCw className="mr-2 h-4 w-4" />
|
||
Sync Standings
|
||
</>
|
||
)}
|
||
</Button>
|
||
</Form>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
{(actionData?.success && actionData.intent === "sync-standings") || actionData?.syncError ? (
|
||
<CardContent>
|
||
{actionData?.success && actionData.intent === "sync-standings" && actionData.syncResult && (
|
||
<div className="space-y-2">
|
||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||
Synced {actionData.syncResult.synced} team{actionData.syncResult.synced !== 1 ? "s" : ""} successfully.
|
||
</div>
|
||
{actionData.syncResult.unmatched.length > 0 && (
|
||
<div className="bg-amber-500/15 text-amber-600 dark:text-amber-400 px-4 py-3 rounded-md text-sm">
|
||
<p className="font-medium mb-1">
|
||
{actionData.syncResult.unmatched.length} team{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched to participants:
|
||
</p>
|
||
<ul className="list-disc list-inside space-y-0.5">
|
||
{actionData.syncResult.unmatched.map((u: { teamName: string; externalTeamId: string }) => (
|
||
<li key={u.externalTeamId}>{u.teamName}</li>
|
||
))}
|
||
</ul>
|
||
<p className="mt-2 text-xs">
|
||
Use the "Unmatched Teams" card below to assign these to participants. Future syncs will use the saved ID.
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{actionData?.syncError && (
|
||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||
{actionData.syncError}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
) : null}
|
||
</Card>
|
||
)}
|
||
|
||
{sportsSeason.sport?.type === "individual" && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Championship Standings</CardTitle>
|
||
<CardDescription>
|
||
Sync current standings from the official API, or edit manually.
|
||
{lastStandingsSyncedAt && (
|
||
<span className="ml-1 text-muted-foreground">
|
||
Last synced: {new Date(lastStandingsSyncedAt).toLocaleString()}.
|
||
</span>
|
||
)}
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/standings`)}
|
||
>
|
||
Edit Manually
|
||
</Button>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="sync-standings" />
|
||
<Button type="submit" size="sm" disabled={isSyncingStandings}>
|
||
{isSyncingStandings ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
Syncing...
|
||
</>
|
||
) : (
|
||
<>
|
||
<RefreshCw className="mr-2 h-4 w-4" />
|
||
Sync Standings
|
||
</>
|
||
)}
|
||
</Button>
|
||
</Form>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
{(actionData?.success && actionData.intent === "sync-standings") || actionData?.syncError ? (
|
||
<CardContent>
|
||
{actionData?.success && actionData.intent === "sync-standings" && actionData.syncResult && (
|
||
<div className="space-y-2">
|
||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||
Synced {actionData.syncResult.synced} driver{actionData.syncResult.synced !== 1 ? "s" : ""} successfully.
|
||
</div>
|
||
{actionData.syncResult.unmatched.length > 0 && (
|
||
<div className="bg-amber-500/15 text-amber-600 dark:text-amber-400 px-4 py-3 rounded-md text-sm">
|
||
{actionData.syncResult.unmatched.length} driver{actionData.syncResult.unmatched.length !== 1 ? "s" : ""} could not be matched — see the "Unmatched Drivers" card below to resolve.
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{actionData?.syncError && (
|
||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||
{actionData.syncError}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
) : null}
|
||
</Card>
|
||
)}
|
||
|
||
{pendingMappings.length > 0 && (
|
||
<Card className="border-amber-500/30">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||
{sportsSeason.sport?.type === "individual" ? "Unmatched Drivers" : "Unmatched Teams"} ({pendingMappings.length})
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{sportsSeason.sport?.type === "individual"
|
||
? "These drivers came back from the official standings feed but could not be tied to a participant in this season."
|
||
: "These teams came back from the official standings feed but could not be tied to a participant in this season."
|
||
}{" "}
|
||
Use the standings details and suggested participant to
|
||
confirm the right match. Once resolved, future syncs will reuse the saved external ID.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||
<Badge variant="outline" className={getConfidenceBadgeVariant("exact")}>
|
||
Exact match
|
||
</Badge>
|
||
<Badge variant="outline" className={getConfidenceBadgeVariant("partial")}>
|
||
Partial match
|
||
</Badge>
|
||
<Badge variant="outline" className={getConfidenceBadgeVariant("review")}>
|
||
Needs review
|
||
</Badge>
|
||
</div>
|
||
{actionData?.success && actionData.intent === "resolve-mapping" && (
|
||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
||
Resolved "{actionData.resolvedTeam}" to "{actionData.resolvedParticipant}"
|
||
</div>
|
||
)}
|
||
{pendingMappingsWithSuggestions.map((mapping) => (
|
||
(() => {
|
||
const topCandidateIds = new Set(
|
||
mapping.resolutionView.topCandidates.map((candidate) => candidate.participantId)
|
||
);
|
||
const remainingParticipants = mapping.resolutionView.orderedParticipants.filter(
|
||
(participant) => !topCandidateIds.has(participant.id)
|
||
);
|
||
|
||
return (
|
||
<Form
|
||
key={mapping.externalTeamId}
|
||
method="post"
|
||
className="rounded-md border border-border/60 bg-muted/20 p-4 space-y-4"
|
||
>
|
||
<input type="hidden" name="intent" value="resolve-mapping" />
|
||
<input type="hidden" name="externalTeamId" value={mapping.externalTeamId} />
|
||
<input
|
||
type="hidden"
|
||
name="standingData"
|
||
value={JSON.stringify(mapping.standingData)}
|
||
/>
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div className="space-y-3 lg:max-w-sm">
|
||
<div className="space-y-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<p className="text-sm font-semibold">{mapping.teamName}</p>
|
||
<Badge
|
||
variant="outline"
|
||
className={getConfidenceBadgeVariant(mapping.resolutionView.confidence)}
|
||
>
|
||
{getConfidenceLabel(mapping.resolutionView.confidence)}
|
||
</Badge>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground break-all">
|
||
Feed ID: {mapping.externalTeamId}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
{typeof mapping.standingData.leagueRank === "number" && (
|
||
<Badge variant="secondary">League rank #{mapping.standingData.leagueRank}</Badge>
|
||
)}
|
||
{(mapping.standingData.conference || mapping.standingData.division) && (
|
||
<Badge variant="secondary">
|
||
{[mapping.standingData.conference, mapping.standingData.division].filter(Boolean).join(" - ")}
|
||
</Badge>
|
||
)}
|
||
{typeof mapping.standingData.wins === "number" && typeof mapping.standingData.losses === "number" && (
|
||
<Badge variant="secondary">Record {formatTeamRecord(mapping.standingData)}</Badge>
|
||
)}
|
||
{typeof mapping.standingData.gamesPlayed === "number" && (
|
||
<Badge variant="secondary">{mapping.standingData.gamesPlayed} GP</Badge>
|
||
)}
|
||
{mapping.standingData.streak && (
|
||
<Badge variant="secondary">Streak {mapping.standingData.streak}</Badge>
|
||
)}
|
||
{mapping.standingData.lastTen && (
|
||
<Badge variant="secondary">Last 10 {mapping.standingData.lastTen}</Badge>
|
||
)}
|
||
</div>
|
||
|
||
<p className="text-xs text-muted-foreground">
|
||
No participant name matched this API team.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-3 lg:w-80">
|
||
<div className="rounded-md border border-border/60 bg-background/80 p-3 space-y-2">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||
Suggested participant
|
||
</p>
|
||
<Badge
|
||
variant="outline"
|
||
className={getConfidenceBadgeVariant(mapping.resolutionView.confidence)}
|
||
>
|
||
{getConfidenceLabel(mapping.resolutionView.confidence)}
|
||
</Badge>
|
||
</div>
|
||
<p className="text-sm font-medium">
|
||
{mapping.resolutionView.suggestedParticipantName ?? "No strong suggestion"}
|
||
</p>
|
||
{mapping.resolutionView.topCandidates.length > 0 && (
|
||
<div className="text-xs text-muted-foreground">
|
||
Top matches:{" "}
|
||
{mapping.resolutionView.topCandidates
|
||
.map((candidate) => candidate.participantName)
|
||
.join(", ")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor={`participant-${mapping.externalTeamId}`}>
|
||
Match to season participant
|
||
</Label>
|
||
<Select
|
||
name="participantId"
|
||
required
|
||
defaultValue={mapping.resolutionView.suggestedParticipantId ?? undefined}
|
||
>
|
||
<SelectTrigger
|
||
id={`participant-${mapping.externalTeamId}`}
|
||
className="w-full"
|
||
>
|
||
<SelectValue placeholder="Choose the correct participant" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{mapping.resolutionView.topCandidates.length > 0 && (
|
||
<SelectGroup>
|
||
<SelectLabel>Suggested matches</SelectLabel>
|
||
{mapping.resolutionView.topCandidates.map((candidate) => (
|
||
<SelectItem
|
||
key={`${mapping.externalTeamId}-${candidate.participantId}`}
|
||
value={candidate.participantId}
|
||
>
|
||
{candidate.participantName}
|
||
</SelectItem>
|
||
))}
|
||
</SelectGroup>
|
||
)}
|
||
<SelectGroup>
|
||
<SelectLabel>All participants</SelectLabel>
|
||
{remainingParticipants.map((participant) => (
|
||
<SelectItem
|
||
key={`${mapping.externalTeamId}-all-${participant.id}`}
|
||
value={participant.id}
|
||
>
|
||
{participant.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<Button type="submit" size="sm" className="w-full sm:w-auto">
|
||
Confirm Match
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Form>
|
||
);
|
||
})()
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{sportsSeason.scoringPattern === "season_standings" && (
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle>Championship Standings</CardTitle>
|
||
<CardDescription>
|
||
Update participant positions and points as the season progresses
|
||
</CardDescription>
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/standings`)}
|
||
>
|
||
Update Standings
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
</Card>
|
||
)}
|
||
|
||
{sportsSeason.scoringPattern === "season_standings" && (
|
||
<Card className={sportsSeason.status === "completed" ? "border-emerald-500/30" : "border-amber-500/30"}>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<CardTitle className="flex items-center gap-2">
|
||
Finalize Standings
|
||
{sportsSeason.status === "completed" && (
|
||
<Badge variant="outline" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30">
|
||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||
Finalized
|
||
</Badge>
|
||
)}
|
||
</CardTitle>
|
||
<CardDescription>
|
||
{sportsSeason.status === "completed"
|
||
? "Season standings have been finalized and fantasy placements assigned."
|
||
: "When the championship is over, finalize standings to assign fantasy placements (1st–8th) to participants based on their final positions."}
|
||
</CardDescription>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
{sportsSeason.status !== "completed" && (
|
||
<CardContent>
|
||
{actionData?.error && (
|
||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-4">
|
||
{actionData.error}
|
||
</div>
|
||
)}
|
||
{actionData?.success && actionData.intent === "finalize-standings" && (
|
||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
||
{actionData.message}
|
||
</div>
|
||
)}
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<Button variant="outline" className="border-amber-500/50 text-amber-600 hover:bg-amber-500/10 dark:text-amber-400">
|
||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||
Finalize Standings
|
||
</Button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Finalize championship standings?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This will read the current participant standings and assign fantasy placements (1st through 8th place). The season will be marked as completed. This action can be re-run to correct results if needed.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="finalize-standings" />
|
||
<AlertDialogAction type="submit">
|
||
Finalize Standings
|
||
</AlertDialogAction>
|
||
</Form>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</CardContent>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Fantasy Standings</CardTitle>
|
||
<CardDescription>
|
||
Force a recalculation of all fantasy standings linked to this sports season. Use this after fixing scoring bugs or data corrections.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{actionData?.success && actionData.intent === "rescore" && (
|
||
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm mb-4">
|
||
{actionData.message}
|
||
</div>
|
||
)}
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="rescore" />
|
||
<Button type="submit" variant="outline" className="border-blue-500/50 text-blue-600 hover:bg-blue-500/10 dark:text-blue-400">
|
||
Force Re-score
|
||
</Button>
|
||
</Form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-destructive">
|
||
<CardHeader>
|
||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||
<CardDescription>
|
||
Permanently delete this sports season
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<Button variant="destructive">
|
||
<Trash2 className="mr-2 h-4 w-4" />
|
||
Delete Sports Season
|
||
</Button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This will permanently delete the sports season "{sportsSeason.name}" and all
|
||
associated participants and results. This action cannot be undone.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="delete" />
|
||
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||
Delete
|
||
</AlertDialogAction>
|
||
</Form>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|