brackt/app/routes/admin.sports-seasons.$id.standings.tsx

188 lines
6.8 KiB
TypeScript
Raw Normal View History

import { Form, Link } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.standings";
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";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Standings — ${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);
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) {
console.error("Error updating standings:", error);
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)
const sorted = [...participants].sort((a, b) => {
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>
{sorted.map((participant, index) => {
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>
);
}