* feat: sync odds-based EV to participants table for draft room ranking When admin enters futures odds or manual probabilities, the calculated EV is now synced from participantExpectedValues to participants.expectedValue. This closes the gap where EVs were calculated but never reflected in the draft room ranking. Also changes expectedValue from integer to decimal(10,2) for better precision, and displays "—" for participants with no odds data. https://claude.ai/code/session_01JWG2zg2EMzCn6RgEufPC1T * fix: correct expectedValue type to string in participants route Missed instance of `expectedValue: 0` (number) that failed typecheck after schema change from integer to decimal. https://claude.ai/code/session_01JWG2zg2EMzCn6RgEufPC1T --------- Co-authored-by: Claude <noreply@anthropic.com>
276 lines
9.5 KiB
TypeScript
276 lines
9.5 KiB
TypeScript
import { Form, Link } from "react-router";
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.participants";
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import {
|
|
findParticipantsBySportsSeasonId,
|
|
createParticipant,
|
|
deleteParticipant
|
|
} 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 } from "lucide-react";
|
|
|
|
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 };
|
|
}
|
|
|
|
// 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" };
|
|
}
|
|
|
|
const names = bulkNames
|
|
.split("\n")
|
|
.map(name => name.trim())
|
|
.filter(name => name.length > 0);
|
|
|
|
if (names.length === 0) {
|
|
return { error: "Please enter at least one participant name" };
|
|
}
|
|
|
|
try {
|
|
for (const name of names) {
|
|
await createParticipant({
|
|
sportsSeasonId: params.id,
|
|
name,
|
|
shortName: null,
|
|
externalId: null,
|
|
expectedValue: "0",
|
|
});
|
|
}
|
|
|
|
return { success: true, count: names.length };
|
|
} catch (error) {
|
|
console.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" };
|
|
}
|
|
|
|
try {
|
|
await createParticipant({
|
|
sportsSeasonId: params.id,
|
|
name: name.trim(),
|
|
shortName: null,
|
|
externalId: null,
|
|
expectedValue: "0",
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.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;
|
|
|
|
// Use success state to reset forms by changing the key
|
|
const formKey = actionData?.success ? Date.now() : 'static';
|
|
|
|
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 && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
|
|
{actionData?.success && !actionData?.count && (
|
|
<div className="bg-green-500/15 text-green-700 dark:text-green-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 && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
|
|
{actionData?.success && actionData?.count && (
|
|
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
|
|
{actionData.count} participant{actionData.count !== 1 ? 's' : ''} added successfully!
|
|
</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-[50px]"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{participants.map((participant) => (
|
|
<TableRow key={participant.id}>
|
|
<TableCell className="font-medium">{participant.name}</TableCell>
|
|
<TableCell>
|
|
<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>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|