Futures odds entry was split across two unrelated places: a standalone /futures-odds page (which auto-ran the simulation on save and surfaced a "Simulator is not ready" run failure as if the save itself had failed) and the Bulk Simulator Inputs CSV importer on the simulator setup page. Consolidate everything onto the Bulk Simulator Inputs card: - batchUpsertParticipantSimulatorInputs now COALESCE-wraps every conflict update column, so a partial paste (e.g. odds-only) updates just the columns it provides instead of nulling out previously stored Elo/rating/etc. - The bulk importer accepts sportsbook-style paste (one team per line ending in American odds) when no CSV header is present, reusing the existing fuzzy matcher, and auto-runs the simulation on save. A not-ready run is reported as a successful save plus the readiness gap, never as a failed save. - Retire the standalone /futures-odds page (now redirects to the simulator setup page) and drop the redundant Futures links. - Remove the now-dead futures-only model paths (batchSaveSourceOdds, clearSourceOddsForParticipants, batchSaveFuturesOddsForSimulator, batchSaveParticipantSimulatorSourceOdds); the EV-table bridge is preserved by batchUpsertParticipantSimulatorInputs. - Repoint the test to the consolidated path and assert the non-destructive COALESCE behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BHnoQ7myY3PzNdTnd7iGNE
1246 lines
53 KiB
TypeScript
1246 lines
53 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}/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>
|
||
);
|
||
}
|