Participants are created with externalId=null by default, causing the LLWS simulator to crash at runtime. Fix in two parts: 1. Name-prefix inference: if externalId is null, participants whose name starts with "US " or equals "US" are assigned to the US side; all others are assigned to Intl. Pools are always randomized when inferred (no pool suffix). 2. Admin UI: add an inline-editable "Group" column to the Manage Participants page so admins can explicitly set externalId for any simulator that uses it (LLWS, and future cases like NHL conferences). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
451 lines
18 KiB
TypeScript
451 lines
18 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Form, Link } from "react-router";
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.participants";
|
|
|
|
import { logger } from "~/lib/logger";
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import {
|
|
findParticipantsBySportsSeasonId,
|
|
findParticipantByName,
|
|
createParticipant,
|
|
deleteParticipant,
|
|
updateParticipant,
|
|
} from "~/models/participant";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { Textarea } from "~/components/ui/textarea";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "~/components/ui/table";
|
|
import { Plus, Trash2, ArrowLeft, Pencil, Check, X } from "lucide-react";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `Participants — ${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);
|
|
|
|
// Type assertion since we know the sport relation is included
|
|
return {
|
|
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
|
|
participants
|
|
};
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "delete") {
|
|
const participantId = formData.get("participantId");
|
|
if (typeof participantId === "string") {
|
|
await deleteParticipant(participantId);
|
|
}
|
|
return { success: true };
|
|
}
|
|
|
|
if (intent === "update-participant") {
|
|
const participantId = formData.get("participantId");
|
|
const newName = formData.get("newName");
|
|
const newExternalId = formData.get("newExternalId");
|
|
|
|
if (typeof participantId !== "string") {
|
|
return { error: "Invalid participant", intent: "update-participant" };
|
|
}
|
|
|
|
if (typeof newName !== "string" || !newName.trim()) {
|
|
return { error: "Name is required", intent: "update-participant" };
|
|
}
|
|
|
|
const trimmedName = newName.trim();
|
|
const existing = await findParticipantByName(params.id, trimmedName);
|
|
if (existing && existing.id !== participantId) {
|
|
return { error: `"${trimmedName}" already exists in this season.`, intent: "update-participant" };
|
|
}
|
|
|
|
const externalIdValue =
|
|
typeof newExternalId === "string" && newExternalId.trim()
|
|
? newExternalId.trim()
|
|
: null;
|
|
|
|
try {
|
|
await updateParticipant(participantId, { name: trimmedName, externalId: externalIdValue });
|
|
return { success: true, intent: "update-participant" };
|
|
} catch (error) {
|
|
logger.error("Error updating participant:", error);
|
|
return { error: "Failed to update participant. Please try again.", intent: "update-participant" };
|
|
}
|
|
}
|
|
|
|
if (intent === "update-name") {
|
|
const participantId = formData.get("participantId");
|
|
const newName = formData.get("newName");
|
|
|
|
if (typeof participantId !== "string") {
|
|
return { error: "Invalid participant", intent: "update-name" };
|
|
}
|
|
|
|
if (typeof newName !== "string" || !newName.trim()) {
|
|
return { error: "Name is required", intent: "update-name" };
|
|
}
|
|
|
|
const trimmedName = newName.trim();
|
|
const existing = await findParticipantByName(params.id, trimmedName);
|
|
if (existing && existing.id !== participantId) {
|
|
return { error: `"${trimmedName}" already exists in this season.`, intent: "update-name" };
|
|
}
|
|
|
|
try {
|
|
await updateParticipant(participantId, { name: trimmedName });
|
|
return { success: true, intent: "update-name" };
|
|
} catch (error) {
|
|
logger.error("Error updating participant:", error);
|
|
return { error: "Failed to update participant. Please try again.", intent: "update-name" };
|
|
}
|
|
}
|
|
|
|
// Bulk add participants
|
|
if (intent === "bulk") {
|
|
const bulkNames = formData.get("bulkNames");
|
|
|
|
if (typeof bulkNames !== "string" || !bulkNames.trim()) {
|
|
return { error: "Please enter at least one participant name" };
|
|
}
|
|
|
|
// Dedup case-insensitively, preserving first occurrence's casing
|
|
const seenKeys = new Map<string, string>();
|
|
for (const raw of bulkNames.split("\n").map(n => n.trim()).filter(n => n.length > 0)) {
|
|
const key = raw.toLowerCase();
|
|
if (!seenKeys.has(key)) seenKeys.set(key, raw);
|
|
}
|
|
const names = [...seenKeys.values()];
|
|
|
|
if (names.length === 0) {
|
|
return { error: "Please enter at least one participant name" };
|
|
}
|
|
|
|
try {
|
|
const existingParticipants = await findParticipantsBySportsSeasonId(params.id);
|
|
const existingNames = new Set(existingParticipants.map(p => p.name.toLowerCase()));
|
|
|
|
const skipped: string[] = [];
|
|
let added = 0;
|
|
|
|
for (const name of names) {
|
|
if (existingNames.has(name.toLowerCase())) {
|
|
skipped.push(name);
|
|
continue;
|
|
}
|
|
await createParticipant({
|
|
sportsSeasonId: params.id,
|
|
name,
|
|
shortName: null,
|
|
externalId: null,
|
|
expectedValue: "0",
|
|
});
|
|
added++;
|
|
}
|
|
|
|
return { success: true, count: added, skipped };
|
|
} catch (error) {
|
|
logger.error("Error creating participants:", error);
|
|
return { error: "Failed to add participants. Please try again." };
|
|
}
|
|
}
|
|
|
|
// Add single participant
|
|
const name = formData.get("name");
|
|
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
return { error: "Participant name is required" };
|
|
}
|
|
|
|
const trimmedName = name.trim();
|
|
const existing = await findParticipantByName(params.id, trimmedName);
|
|
if (existing) {
|
|
return { error: `"${trimmedName}" already exists in this season.` };
|
|
}
|
|
|
|
try {
|
|
await createParticipant({
|
|
sportsSeasonId: params.id,
|
|
name: trimmedName,
|
|
shortName: null,
|
|
externalId: null,
|
|
expectedValue: "0",
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error("Error creating participant:", error);
|
|
return { error: "Failed to add participant. Please try again." };
|
|
}
|
|
}
|
|
|
|
export default function ManageParticipants({ loaderData, actionData }: Route.ComponentProps) {
|
|
const { sportsSeason, participants } = loaderData;
|
|
const [editing, setEditing] = useState<{ id: string; name: string; externalId: string } | null>(null);
|
|
|
|
const cancelEdit = () => setEditing(null);
|
|
|
|
// Use success state to reset forms by changing the key
|
|
const formKey = actionData?.success ? Date.now() : 'static';
|
|
|
|
useEffect(() => {
|
|
if (actionData?.success && (actionData?.intent === "update-name" || actionData?.intent === "update-participant")) {
|
|
cancelEdit();
|
|
}
|
|
}, [actionData]);
|
|
|
|
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">Manage Participants</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
{sportsSeason.sport.name} - {sportsSeason.name}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2 mb-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Add Participant</CardTitle>
|
|
<CardDescription>
|
|
Add a {sportsSeason.sport.type === "team" ? "team" : "player"} to this season
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post" className="space-y-4" key={`single-${formKey}`}>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Participant Name</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
placeholder={sportsSeason.sport.type === "team" ? "e.g., Kansas City Chiefs" : "e.g., Tiger Woods"}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{actionData?.error && !actionData?.count && actionData?.intent !== "update-name" && actionData?.intent !== "update-participant" && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
|
|
{actionData?.success && !actionData?.count && actionData?.intent !== "update-participant" && (
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
|
Participant added successfully!
|
|
</div>
|
|
)}
|
|
|
|
<Button type="submit" className="w-full">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Participant
|
|
</Button>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Bulk Add Participants</CardTitle>
|
|
<CardDescription>
|
|
Add multiple participants at once (one per line)
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post" className="space-y-4" key={`bulk-${formKey}`}>
|
|
<input type="hidden" name="intent" value="bulk" />
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bulkNames">Participant Names</Label>
|
|
<Textarea
|
|
id="bulkNames"
|
|
name="bulkNames"
|
|
placeholder={sportsSeason.sport.type === "team"
|
|
? "Kansas City Chiefs\nBuffalo Bills\nSan Francisco 49ers"
|
|
: "Tiger Woods\nRory McIlroy\nJon Rahm"}
|
|
rows={8}
|
|
className="font-mono text-sm"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Enter one participant name per line
|
|
</p>
|
|
</div>
|
|
|
|
{actionData?.error && actionData?.count === undefined && actionData?.intent !== "update-name" && actionData?.intent !== "update-participant" && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
|
|
{actionData?.success && actionData?.count !== undefined && (
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
|
{actionData.count} participant{actionData.count !== 1 ? 's' : ''} added.
|
|
{actionData.skipped && actionData.skipped.length > 0 && (
|
|
<span className="block mt-1 text-yellow-400">
|
|
{actionData.skipped.length} skipped (already exist): {actionData.skipped.join(", ")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Button type="submit" className="w-full">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add All Participants
|
|
</Button>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>All Participants</CardTitle>
|
|
<CardDescription>
|
|
{participants.length} {participants.length === 1 ? "participant" : "participants"} total
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{participants.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
No participants yet. Add your first {sportsSeason.sport.type === "team" ? "team" : "player"}.
|
|
</p>
|
|
) : (
|
|
<div className="max-h-[500px] overflow-y-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead className="w-[120px]">Group</TableHead>
|
|
<TableHead className="w-[100px]" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{participants.map((participant) => (
|
|
<TableRow key={participant.id}>
|
|
<TableCell className="font-medium">
|
|
{editing?.id === participant.id ? (
|
|
<div className="space-y-1">
|
|
<Input
|
|
value={editing.name}
|
|
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
|
className="h-8"
|
|
autoFocus
|
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
|
/>
|
|
{actionData?.error && (actionData?.intent === "update-name" || actionData?.intent === "update-participant") && (
|
|
<p className="text-xs text-destructive">{actionData.error}</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
participant.name
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{editing?.id === participant.id ? (
|
|
<Input
|
|
value={editing.externalId}
|
|
onChange={(e) => setEditing({ ...editing, externalId: e.target.value })}
|
|
className="h-8 font-mono text-xs"
|
|
placeholder="US, Intl, US:A…"
|
|
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
|
|
/>
|
|
) : (
|
|
<span className="text-xs font-mono text-muted-foreground">
|
|
{participant.externalId ?? "—"}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{editing?.id === participant.id ? (
|
|
<div className="flex gap-1">
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="update-participant" />
|
|
<input type="hidden" name="participantId" value={participant.id} />
|
|
<input type="hidden" name="newName" value={editing.name} />
|
|
<input type="hidden" name="newExternalId" value={editing.externalId} />
|
|
<Button
|
|
type="submit"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 text-emerald-500 hover:text-emerald-400"
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</Button>
|
|
</Form>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={cancelEdit}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0"
|
|
onClick={() => setEditing({ id: participant.id, name: participant.name, externalId: participant.externalId ?? "" })}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="delete" />
|
|
<input type="hidden" name="participantId" value={participant.id} />
|
|
<Button
|
|
type="submit"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</Form>
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|