The junction table was a redundant second source of truth: syncTournamentResults already fans out by querying scoring_events.tournamentId directly, so the table only served the admin UI and could drift out of sync (causing orphaned events). - Drop sports_season_tournaments table and all link/unlink admin actions - Add getTournamentsBySportsSeason / getSportsSeasonsByTournament helpers that derive the same information from scoring_events.tournamentId - Add "Add Existing Tournament" dropdown to the events admin page (qualifying_points seasons only) — selecting a tournament creates the scoring event in one step - Fix clone: scoring events now carry tournamentId, fixing a latent check-constraint violation when cloning qualifying_points seasons - Tournament admin page "Linked Sports Seasons" is now a read-only derived view Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
import { Link, useFetcher } from "react-router";
|
|
import { auth } from "~/lib/auth.server";
|
|
import type { Route } from "./+types/admin.tournaments.$id";
|
|
|
|
import { logger } from "~/lib/logger";
|
|
import {
|
|
getTournamentById,
|
|
updateTournamentStatus,
|
|
} from "~/models/tournament";
|
|
import {
|
|
getTournamentResults,
|
|
upsertTournamentResult,
|
|
} from "~/models/tournament-result";
|
|
import { findCanonicalParticipantsBySport } from "~/models/participant";
|
|
import { isUserAdmin } from "~/models/user";
|
|
import {
|
|
syncTournamentResults,
|
|
type SyncReport,
|
|
} from "~/services/sync-tournament-results";
|
|
import { getSportsSeasonsByTournament } from "~/models/scoring-event";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "~/components/ui/table";
|
|
import { ArrowLeft, CheckCircle2, AlertTriangle } from "lucide-react";
|
|
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [
|
|
{
|
|
title: `${data?.tournament?.name ?? "Tournament"} - Brackt Admin`,
|
|
},
|
|
];
|
|
}
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const tournament = await getTournamentById(params.id);
|
|
if (!tournament) {
|
|
throw new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const [results, canonicalParticipants, linkedSportsSeasons] = await Promise.all([
|
|
getTournamentResults(tournament.id),
|
|
findCanonicalParticipantsBySport(tournament.sportId),
|
|
getSportsSeasonsByTournament(tournament.id),
|
|
]);
|
|
|
|
return { tournament, results, canonicalParticipants, linkedSportsSeasons };
|
|
}
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
|
const { request, params } = args;
|
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
const userId = session?.user.id ?? null;
|
|
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
|
if (!isAdmin) {
|
|
throw new Response("Forbidden", { status: 403 });
|
|
}
|
|
|
|
const tournament = await getTournamentById(params.id);
|
|
if (!tournament) {
|
|
throw new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "batch-upsert-results") {
|
|
const resultsRaw = formData.get("results");
|
|
if (typeof resultsRaw !== "string" || !resultsRaw) {
|
|
return {
|
|
success: false as const,
|
|
error: "Missing results payload",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
|
|
let parsed: Array<{ participantId: string; placement: number }>;
|
|
try {
|
|
parsed = JSON.parse(resultsRaw);
|
|
} catch {
|
|
return {
|
|
success: false as const,
|
|
error: "Invalid JSON in results payload",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
return {
|
|
success: false as const,
|
|
error: "Results payload must be an array",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
for (const row of parsed) {
|
|
if (
|
|
!row ||
|
|
typeof row.participantId !== "string" ||
|
|
typeof row.placement !== "number"
|
|
) {
|
|
return {
|
|
success: false as const,
|
|
error: "Each result must have participantId and placement",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
await upsertTournamentResult({
|
|
tournamentId: tournament.id,
|
|
participantId: row.participantId,
|
|
placement: row.placement,
|
|
});
|
|
}
|
|
|
|
if (tournament.status !== "completed") {
|
|
await updateTournamentStatus(tournament.id, "completed");
|
|
}
|
|
|
|
const syncReport = await syncTournamentResults(tournament.id);
|
|
return {
|
|
success: true as const,
|
|
error: null,
|
|
syncReport,
|
|
};
|
|
} catch (error) {
|
|
logger.error("batch-upsert-results failed:", error);
|
|
return {
|
|
success: false as const,
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to save results",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "retry-window-sync") {
|
|
try {
|
|
const syncReport = await syncTournamentResults(tournament.id);
|
|
return { success: true as const, error: null, syncReport };
|
|
} catch (error) {
|
|
logger.error("retry-window-sync failed:", error);
|
|
return {
|
|
success: false as const,
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to retry sync",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: false as const,
|
|
error: "Invalid intent",
|
|
syncReport: null,
|
|
};
|
|
}
|
|
|
|
export default function AdminTournamentDetail({
|
|
loaderData,
|
|
actionData,
|
|
}: Route.ComponentProps) {
|
|
const { tournament, results, canonicalParticipants, linkedSportsSeasons } = loaderData;
|
|
const retryFetcher = useFetcher<typeof action>();
|
|
|
|
// Prefer the latest action/retry response for the sync report
|
|
const liveReport: SyncReport | null =
|
|
(retryFetcher.data?.syncReport ?? actionData?.syncReport) ?? null;
|
|
|
|
const participantById = new Map(
|
|
canonicalParticipants.map((p) => [p.id, p])
|
|
);
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="max-w-5xl">
|
|
<div className="mb-6">
|
|
<Button variant="ghost" size="sm" asChild className="mb-2">
|
|
<Link to="/admin/tournaments">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to tournaments
|
|
</Link>
|
|
</Button>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold">{tournament.name}</h1>
|
|
<Badge
|
|
variant={
|
|
tournament.status === "completed"
|
|
? "default"
|
|
: tournament.status === "in_progress"
|
|
? "secondary"
|
|
: "outline"
|
|
}
|
|
>
|
|
{tournament.status}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-muted-foreground mt-1">
|
|
{tournament.year}
|
|
{tournament.location ? ` — ${tournament.location}` : ""}
|
|
{tournament.surface ? ` — ${tournament.surface}` : ""}
|
|
</p>
|
|
</div>
|
|
|
|
{liveReport && (
|
|
<Card className="mb-6 border-emerald-500/30">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-emerald-500">
|
|
<CheckCircle2 className="h-5 w-5" />
|
|
Synced to {liveReport.windowsSynced}{" "}
|
|
{liveReport.windowsSynced === 1 ? "window" : "windows"}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Canonical results were fanned out to every linked scoring
|
|
window.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
{liveReport.failures.length > 0 && (
|
|
<CardContent>
|
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 space-y-3">
|
|
<div className="flex items-center gap-2 text-destructive font-medium">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
{liveReport.failures.length}{" "}
|
|
{liveReport.failures.length === 1 ? "window" : "windows"}{" "}
|
|
failed to sync
|
|
</div>
|
|
<div className="space-y-2">
|
|
{liveReport.failures.map((f) => (
|
|
<div
|
|
key={f.scoringEventId}
|
|
className="flex items-start justify-between gap-4 text-sm"
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="font-mono text-xs text-muted-foreground">
|
|
event: {f.scoringEventId}
|
|
</div>
|
|
<div className="font-mono text-xs text-muted-foreground">
|
|
season: {f.sportsSeasonId}
|
|
</div>
|
|
<div className="text-destructive">{f.error}</div>
|
|
</div>
|
|
<retryFetcher.Form method="post">
|
|
<input
|
|
type="hidden"
|
|
name="intent"
|
|
value="retry-window-sync"
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="scoringEventId"
|
|
value={f.scoringEventId}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={retryFetcher.state !== "idle"}
|
|
>
|
|
{retryFetcher.state !== "idle"
|
|
? "Retrying…"
|
|
: "Retry"}
|
|
</Button>
|
|
</retryFetcher.Form>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Linked Sports Seasons</CardTitle>
|
|
<CardDescription>
|
|
{linkedSportsSeasons.length}{" "}
|
|
{linkedSportsSeasons.length === 1
|
|
? "sports season"
|
|
: "sports seasons"}{" "}
|
|
use this tournament as a scoring event
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{linkedSportsSeasons.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{linkedSportsSeasons.map((link) => (
|
|
<div
|
|
key={link.id}
|
|
className="flex items-center justify-between p-3 border rounded-lg"
|
|
>
|
|
<div>
|
|
<p className="font-medium">
|
|
{link.sportsSeason.sport.name} -{" "}
|
|
{link.sportsSeason.name}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{link.sportsSeason.year} •{" "}
|
|
{link.sportsSeason.scoringType.replace("_", " ")}
|
|
</p>
|
|
</div>
|
|
<Badge
|
|
variant={
|
|
link.sportsSeason.status === "completed"
|
|
? "default"
|
|
: link.sportsSeason.status === "active"
|
|
? "secondary"
|
|
: "outline"
|
|
}
|
|
>
|
|
{link.sportsSeason.status}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground text-center py-4">
|
|
No sports seasons are using this tournament yet.
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Current Results</CardTitle>
|
|
<CardDescription>
|
|
{results.length}{" "}
|
|
{results.length === 1 ? "result" : "results"} recorded
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{results.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-6 text-center">
|
|
No results recorded yet. Paste a ranked list to the right to
|
|
import.
|
|
</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-24">Placement</TableHead>
|
|
<TableHead>Participant</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{results.map((r) => {
|
|
const p = participantById.get(r.participantId);
|
|
return (
|
|
<TableRow key={r.id}>
|
|
<TableCell className="font-semibold">
|
|
{r.placement ?? "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
{p?.name ?? (
|
|
<span className="text-muted-foreground font-mono text-xs">
|
|
{r.participantId}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<BatchResultEntry
|
|
participants={canonicalParticipants.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
}))}
|
|
sportsSeasonId=""
|
|
existingResultParticipantIds={
|
|
new Set(results.map((r) => r.participantId))
|
|
}
|
|
intent="batch-upsert-results"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|