2026-03-07 21:59:29 -08:00
|
|
|
import { Form, Link } from "react-router";
|
|
|
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.standings";
|
2026-03-10 12:10:52 -07:00
|
|
|
|
2026-03-21 13:41:39 -07:00
|
|
|
import { logger } from "~/lib/logger";
|
2026-03-07 21:59:29 -08:00
|
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
|
|
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
|
|
|
|
import { getSeasonResults, upsertSeasonResultsBulk } from "~/models/participant-season-result";
|
|
|
|
|
import { Button } from "~/components/ui/button";
|
|
|
|
|
import { Input } from "~/components/ui/input";
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "~/components/ui/card";
|
|
|
|
|
import { ArrowLeft, Save, ListOrdered } from "lucide-react";
|
|
|
|
|
|
2026-03-10 12:10:52 -07:00
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
|
|
|
return [{ title: `Standings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-07 21:59:29 -08:00
|
|
|
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);
|
|
|
|
|
const seasonResults = await getSeasonResults(params.id);
|
|
|
|
|
|
|
|
|
|
// Build a map of existing results keyed by participantId
|
|
|
|
|
const resultsMap = new Map(
|
|
|
|
|
seasonResults.map((r) => [r.participantId, r])
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string } },
|
|
|
|
|
participants,
|
|
|
|
|
resultsMap: Object.fromEntries(resultsMap),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
|
|
|
const formData = await request.formData();
|
|
|
|
|
|
|
|
|
|
// Parse updates directly from form field names: position_<id> and points_<id>
|
|
|
|
|
const byId = new Map<string, { position?: number; points?: number }>();
|
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
|
|
|
const posMatch = key.match(/^position_(.+)$/);
|
|
|
|
|
const ptsMatch = key.match(/^points_(.+)$/);
|
|
|
|
|
if (posMatch) {
|
|
|
|
|
const id = posMatch[1];
|
|
|
|
|
const position = parseInt(value as string, 10);
|
|
|
|
|
if (!isNaN(position)) {
|
|
|
|
|
byId.set(id, { ...byId.get(id), position });
|
|
|
|
|
}
|
|
|
|
|
} else if (ptsMatch) {
|
|
|
|
|
const id = ptsMatch[1];
|
|
|
|
|
const points = parseFloat(value as string);
|
|
|
|
|
if (!isNaN(points)) {
|
|
|
|
|
byId.set(id, { ...byId.get(id), points });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updates = Array.from(byId.entries()).map(([participantId, { position, points }]) => ({
|
|
|
|
|
participantId,
|
|
|
|
|
sportsSeasonId: params.id,
|
|
|
|
|
currentPosition: position,
|
|
|
|
|
currentPoints: points,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await upsertSeasonResultsBulk(updates);
|
|
|
|
|
return { success: true };
|
|
|
|
|
} catch (error) {
|
2026-03-21 13:41:39 -07:00
|
|
|
logger.error("Error updating standings:", error);
|
2026-03-07 21:59:29 -08:00
|
|
|
return { error: "Failed to save standings. Please try again." };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ManageStandings({ loaderData, actionData }: Route.ComponentProps) {
|
|
|
|
|
const { sportsSeason, participants, resultsMap } = loaderData;
|
|
|
|
|
|
|
|
|
|
// Sort participants by current position (unranked at bottom)
|
2026-03-21 09:44:05 -07:00
|
|
|
const sorted = [...participants].toSorted((a, b) => {
|
2026-03-07 21:59:29 -08:00
|
|
|
const posA = resultsMap[a.id]?.currentPosition ?? Infinity;
|
|
|
|
|
const posB = resultsMap[b.id]?.currentPosition ?? Infinity;
|
|
|
|
|
return posA - posB;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-8">
|
|
|
|
|
<div className="max-w-3xl">
|
|
|
|
|
<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">Update Standings</h1>
|
|
|
|
|
<p className="text-muted-foreground mt-1">
|
|
|
|
|
{sportsSeason.sport.name} — {sportsSeason.name}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{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">
|
|
|
|
|
Standings saved.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Standings table */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<ListOrdered className="h-5 w-5" />
|
|
|
|
|
Championship Standings
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
Enter each participant's current position and championship points. Leave blank to skip.
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<Form method="post">
|
|
|
|
|
<div className="space-y-1 mb-4">
|
|
|
|
|
<div className="grid grid-cols-[2rem_1fr_6rem_7rem] gap-3 px-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wide border-b">
|
|
|
|
|
<span>#</span>
|
|
|
|
|
<span>Participant</span>
|
|
|
|
|
<span>Position</span>
|
|
|
|
|
<span>Points</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-21 09:44:05 -07:00
|
|
|
{sorted.map((participant) => {
|
2026-03-07 21:59:29 -08:00
|
|
|
const result = resultsMap[participant.id];
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={participant.id}
|
|
|
|
|
className="grid grid-cols-[2rem_1fr_6rem_7rem] gap-3 items-center px-2 py-1.5 rounded hover:bg-muted/40"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-sm text-muted-foreground tabular-nums">
|
|
|
|
|
{result?.currentPosition ?? "—"}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-sm font-medium truncate">
|
|
|
|
|
{participant.name}
|
|
|
|
|
</span>
|
|
|
|
|
<Input
|
|
|
|
|
name={`position_${participant.id}`}
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
|
|
|
|
defaultValue={result?.currentPosition ?? ""}
|
|
|
|
|
placeholder="—"
|
|
|
|
|
className="h-8 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
name={`points_${participant.id}`}
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
step="0.5"
|
|
|
|
|
defaultValue={result?.currentPoints ?? ""}
|
|
|
|
|
placeholder="—"
|
|
|
|
|
className="h-8 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button type="submit" className="w-full">
|
|
|
|
|
<Save className="mr-2 h-4 w-4" />
|
|
|
|
|
Save Standings
|
|
|
|
|
</Button>
|
|
|
|
|
</Form>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|