Deleting an event from a season only affects that season's scoring event; the shared canonical tournament and the other linked seasons survive. The old flow never communicated this and offered no way to manage the relationship, so it felt like deleting an event might wipe the whole shared tournament. - deleteScoringEvent is now shared-tournament-aware: it auto-heals the primary window (promotes the earliest remaining window when the primary is removed) and optionally deletes the canonical tournament when the last linked window is removed. Returns a result describing what happened. - Add deleteTournament model helper. - Events page: delete confirmation now explains exactly what a delete does (removed from this season only vs. last window), with an opt-in checkbox to also remove the orphaned shared tournament. - Tournament page: add a per-window "Remove" control on the Linked Sports Seasons card to unlink a season, reusing the auto-heal path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBzjCLVkVaQMj1MF3K54t2
426 lines
19 KiB
TypeScript
426 lines
19 KiB
TypeScript
import { Form, Link } from "react-router";
|
|
import { useState } from "react";
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.events";
|
|
|
|
import { loader, action } from "./admin.sports-seasons.$id.events.server";
|
|
import { localDateTimeToUtcIso } from "~/lib/date-utils";
|
|
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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "~/components/ui/select";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Textarea } from "~/components/ui/textarea";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "~/components/ui/alert-dialog";
|
|
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
|
|
import { format, parseISO } from "date-fns";
|
|
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
|
|
import { getEventTypeLabel } from "~/models/scoring-event-types";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
|
}
|
|
|
|
export { loader, action };
|
|
|
|
function getStatusBadge(isComplete: boolean, eventType: string, eventDate?: string | null, eventStartsAt?: Date | string | null) {
|
|
if (isComplete) {
|
|
return (
|
|
<Badge variant="default" className="bg-emerald-500">
|
|
Completed
|
|
</Badge>
|
|
);
|
|
}
|
|
if (eventType === "schedule_event") {
|
|
const isPast = eventStartsAt
|
|
? new Date(eventStartsAt) < new Date()
|
|
: eventDate !== null && eventDate !== undefined && eventDate < new Date().toISOString().split("T")[0];
|
|
return isPast ? (
|
|
<Badge variant="outline" className="text-muted-foreground">Results Pending</Badge>
|
|
) : (
|
|
<Badge variant="outline">Upcoming</Badge>
|
|
);
|
|
}
|
|
return <Badge variant="secondary">In Progress</Badge>;
|
|
}
|
|
|
|
export default function SportsSeasonEvents({
|
|
loaderData,
|
|
actionData,
|
|
}: Route.ComponentProps) {
|
|
const { sportsSeason, events, qpStandings, scoringRules, availableTournaments } = loaderData;
|
|
const [selectedTournamentId, setSelectedTournamentId] = useState("");
|
|
|
|
const [createEventStartsAt, setCreateEventStartsAt] = useState("");
|
|
|
|
const defaultEventType =
|
|
sportsSeason.scoringPattern === "qualifying_points"
|
|
? "major_tournament"
|
|
: sportsSeason.scoringPattern === "season_standings"
|
|
? "schedule_event"
|
|
: "playoff_game";
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="max-w-4xl">
|
|
<div className="mb-6">
|
|
<Button variant="ghost" size="sm" asChild className="mb-2">
|
|
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Sports Season
|
|
</Link>
|
|
</Button>
|
|
<h1 className="text-3xl font-bold">Scoring Events</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
{sportsSeason.sport.name} - {sportsSeason.name}
|
|
</p>
|
|
{sportsSeason.scoringPattern === "qualifying_points" && (
|
|
<div className="mt-2">
|
|
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 dark:bg-amber-950 dark:text-amber-300">
|
|
Qualifying Points Mode
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{/* Success/Error Messages */}
|
|
{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.success}
|
|
</div>
|
|
)}
|
|
|
|
{/* QP Standings for qualifying points sports */}
|
|
{sportsSeason.scoringPattern === "qualifying_points" && qpStandings && (
|
|
<QualifyingPointsStandings
|
|
standings={qpStandings}
|
|
scoringRules={scoringRules}
|
|
isFinalized={sportsSeason.qualifyingPointsFinalized || false}
|
|
totalMajors={sportsSeason.totalMajors}
|
|
majorsCompleted={sportsSeason.majorsCompleted}
|
|
canFinalize={
|
|
sportsSeason.totalMajors !== null &&
|
|
sportsSeason.majorsCompleted >= sportsSeason.totalMajors &&
|
|
!sportsSeason.qualifyingPointsFinalized
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Add Existing Tournament (qualifying_points only) */}
|
|
{sportsSeason.scoringPattern === "qualifying_points" && availableTournaments.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Add Existing Tournament</CardTitle>
|
|
<CardDescription>
|
|
Link a canonical tournament as a qualifying scoring event.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post" className="flex gap-2">
|
|
<input type="hidden" name="intent" value="add-from-tournament" />
|
|
<Select
|
|
name="tournamentId"
|
|
value={selectedTournamentId}
|
|
onValueChange={setSelectedTournamentId}
|
|
required
|
|
>
|
|
<SelectTrigger className="flex-1">
|
|
<SelectValue placeholder="Select a tournament" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableTournaments.map((t) => (
|
|
<SelectItem key={t.id} value={t.id}>
|
|
{t.name} ({t.year})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button type="submit">
|
|
<Trophy className="h-4 w-4" />
|
|
</Button>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Create New Event Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Create New Event</CardTitle>
|
|
<CardDescription>
|
|
Add a new scoring event (bracket, tournament, standings, etc.)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post" className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Event Name</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
placeholder="e.g., Super Bowl, Round 1, Game 3"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="eventType">Event Type</Label>
|
|
<Select
|
|
name="eventType"
|
|
defaultValue={defaultEventType}
|
|
required
|
|
>
|
|
<SelectTrigger id="eventType">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="schedule_event">Non-Scoring</SelectItem>
|
|
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
|
<SelectItem value="playoff_game">Bracket</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{sportsSeason.scoringPattern === "qualifying_points" && (
|
|
<p className="text-sm text-muted-foreground">
|
|
Major Tournament events will automatically award qualifying points when completed.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="eventStartsAtLocal">Event Date & Time (Optional)</Label>
|
|
<Input
|
|
id="eventStartsAtLocal"
|
|
type="datetime-local"
|
|
onChange={(e) =>
|
|
setCreateEventStartsAt(localDateTimeToUtcIso(e.target.value) ?? "")
|
|
}
|
|
/>
|
|
<input type="hidden" name="eventStartsAt" value={createEventStartsAt} />
|
|
</div>
|
|
|
|
{actionData?.error && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
|
|
<Button type="submit" className="w-full">
|
|
<Trophy className="mr-2 h-4 w-4" />
|
|
Create Event
|
|
</Button>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Bulk Create Card */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<ListPlus className="h-5 w-5" />
|
|
Bulk Import Events
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Paste a schedule — one event per line. Format:{" "}
|
|
<code className="text-xs bg-muted px-1 rounded">
|
|
Event Name, YYYY-MM-DD[, HH:MM]
|
|
</code>{" "}
|
|
(date and time are optional; time is UTC).
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post" className="space-y-4">
|
|
<input type="hidden" name="intent" value="bulk-create" />
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bulkEventType">Event Type</Label>
|
|
<Select name="bulkEventType" defaultValue={defaultEventType}>
|
|
<SelectTrigger id="bulkEventType">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="schedule_event">Non-Scoring</SelectItem>
|
|
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
|
<SelectItem value="playoff_game">Bracket</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bulkEvents">Events</Label>
|
|
<Textarea
|
|
id="bulkEvents"
|
|
name="bulkEvents"
|
|
rows={6}
|
|
placeholder={"Bahrain Grand Prix, 2025-03-02, 15:00\nSaudi Arabian Grand Prix, 2025-03-09, 18:00\nAustralian Grand Prix, 2025-03-23"}
|
|
/>
|
|
</div>
|
|
<Button type="submit" variant="outline" className="w-full">
|
|
<ListPlus className="mr-2 h-4 w-4" />
|
|
Import Events
|
|
</Button>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Events List */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Events</CardTitle>
|
|
<CardDescription>
|
|
{events.length} {events.length === 1 ? "event" : "events"}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{events.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No events created yet. Create your first event above.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean; tournamentId: string | null; isPrimary: boolean; tournamentName: string | null; otherWindowCount: number }) => {
|
|
const isLinked = !!event.tournamentId;
|
|
const hasOtherWindows = event.otherWindowCount > 0;
|
|
const isLastWindow = isLinked && !hasOtherWindows;
|
|
return (
|
|
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<Link
|
|
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}
|
|
className="flex-1 cursor-pointer"
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="font-semibold">{event.name}</h3>
|
|
{getStatusBadge(event.isComplete, event.eventType, event.eventDate, event.eventStartsAt)}
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
<span>
|
|
{getEventTypeLabel(event.eventType)}
|
|
</span>
|
|
{(event.eventDate || event.eventStartsAt) && (
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="h-3 w-3" />
|
|
<span suppressHydrationWarning>
|
|
{format(
|
|
event.eventStartsAt
|
|
? new Date(event.eventStartsAt)
|
|
: parseISO(event.eventDate ?? ""),
|
|
"MMM d, yyyy"
|
|
)}
|
|
</span>
|
|
{event.eventStartsAt && (
|
|
<span className="text-muted-foreground/70" suppressHydrationWarning>
|
|
· {format(new Date(event.eventStartsAt), "h:mm a")}
|
|
</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-destructive hover:text-destructive shrink-0"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="delete-event" />
|
|
<input type="hidden" name="eventId" value={event.id} />
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
|
<AlertDialogDescription asChild>
|
|
{!isLinked ? (
|
|
<span>
|
|
This will also delete all results for this event. This action cannot be undone.
|
|
</span>
|
|
) : hasOtherWindows ? (
|
|
<span>
|
|
This event is part of the shared tournament{" "}
|
|
<span className="font-medium">"{event.tournamentName}"</span>, also used by{" "}
|
|
<span className="font-medium">
|
|
{event.otherWindowCount} other season{event.otherWindowCount !== 1 ? "s" : ""}
|
|
</span>
|
|
. Deleting removes it from <span className="font-medium">this season only</span> — the
|
|
tournament and the other seasons keep their data.
|
|
{event.isPrimary && (
|
|
<> This is the primary scoring window, so another season will be promoted to primary automatically.</>
|
|
)}
|
|
</span>
|
|
) : (
|
|
<span>
|
|
This is the only season linked to the shared tournament{" "}
|
|
<span className="font-medium">"{event.tournamentName}"</span>. Deleting removes this
|
|
event and its results. This action cannot be undone.
|
|
</span>
|
|
)}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
{isLastWindow && (
|
|
<label className="flex items-start gap-2 text-sm my-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
name="deleteTournament"
|
|
value="1"
|
|
className="mt-0.5 h-4 w-4 rounded border-input"
|
|
/>
|
|
<span>
|
|
Also delete the shared tournament and its recorded results
|
|
</span>
|
|
</label>
|
|
)}
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</Form>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|