feat: add bulk participant creation and expected value field

This commit is contained in:
Chris Parsons 2025-10-15 21:21:33 -07:00
parent fdfce98d87
commit 0e68bd2037
9 changed files with 1171 additions and 27 deletions

View file

@ -96,6 +96,7 @@ export async function copyParticipantsFromSeason(
name: p.name, name: p.name,
shortName: p.shortName, shortName: p.shortName,
externalId: p.externalId, externalId: p.externalId,
expectedValue: p.expectedValue,
})) }))
); );
} }

View file

@ -80,6 +80,11 @@ export async function findAllSportsSeasons(): Promise<SportsSeason[]> {
orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)], orderBy: (sportsSeasons, { desc, asc }) => [desc(sportsSeasons.year), asc(sportsSeasons.name)],
with: { with: {
sport: true, sport: true,
participants: {
columns: {
id: true,
},
},
}, },
}); });
} }
@ -128,6 +133,7 @@ export async function copyParticipantsFromPreviousSeason(
name: p.name, name: p.name,
shortName: p.shortName, shortName: p.shortName,
externalId: p.externalId, externalId: p.externalId,
expectedValue: p.expectedValue,
})) }))
); );
} }

View file

@ -9,6 +9,7 @@ import {
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { import {
Card, Card,
CardContent, CardContent,
@ -54,9 +55,43 @@ export async function action({ request, params }: Route.ActionArgs) {
return { success: true }; return { success: true };
} }
// Add participant // 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"); const name = formData.get("name");
const shortName = formData.get("shortName");
if (typeof name !== "string" || !name.trim()) { if (typeof name !== "string" || !name.trim()) {
return { error: "Participant name is required" }; return { error: "Participant name is required" };
@ -66,8 +101,9 @@ export async function action({ request, params }: Route.ActionArgs) {
await createParticipant({ await createParticipant({
sportsSeasonId: params.id, sportsSeasonId: params.id,
name: name.trim(), name: name.trim(),
shortName: typeof shortName === "string" && shortName.trim() ? shortName.trim() : null, shortName: null,
externalId: null, externalId: null,
expectedValue: 0,
}); });
return { success: true }; return { success: true };
@ -79,6 +115,9 @@ export async function action({ request, params }: Route.ActionArgs) {
export default function ManageParticipants({ loaderData, actionData }: Route.ComponentProps) { export default function ManageParticipants({ loaderData, actionData }: Route.ComponentProps) {
const { sportsSeason, participants } = loaderData; const { sportsSeason, participants } = loaderData;
// Use success state to reset forms by changing the key
const formKey = actionData?.success ? Date.now() : 'static';
return ( return (
<div className="p-8"> <div className="p-8">
@ -96,7 +135,7 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
</p> </p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2 mb-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Add Participant</CardTitle> <CardTitle>Add Participant</CardTitle>
@ -105,11 +144,9 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form method="post" className="space-y-4"> <Form method="post" className="space-y-4" key={`single-${formKey}`}>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name"> <Label htmlFor="name">Participant Name</Label>
{sportsSeason.sport.type === "team" ? "Team" : "Player"} Name
</Label>
<Input <Input
id="name" id="name"
name="name" name="name"
@ -119,23 +156,13 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
/> />
</div> </div>
<div className="space-y-2"> {actionData?.error && !actionData?.count && (
<Label htmlFor="shortName">Short Name (Optional)</Label>
<Input
id="shortName"
name="shortName"
type="text"
placeholder={sportsSeason.sport.type === "team" ? "e.g., KC" : "e.g., T. Woods"}
/>
</div>
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm"> <div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error} {actionData.error}
</div> </div>
)} )}
{actionData?.success && ( {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"> <div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
Participant added successfully! Participant added successfully!
</div> </div>
@ -150,6 +177,53 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
</Card> </Card>
<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> <CardHeader>
<CardTitle>All Participants</CardTitle> <CardTitle>All Participants</CardTitle>
<CardDescription> <CardDescription>
@ -167,7 +241,6 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Short Name</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -175,9 +248,6 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
{participants.map((participant) => ( {participants.map((participant) => (
<TableRow key={participant.id}> <TableRow key={participant.id}>
<TableCell className="font-medium">{participant.name}</TableCell> <TableCell className="font-medium">{participant.name}</TableCell>
<TableCell className="text-muted-foreground">
{participant.shortName || "-"}
</TableCell>
<TableCell> <TableCell>
<Form method="post"> <Form method="post">
<input type="hidden" name="intent" value="delete" /> <input type="hidden" name="intent" value="delete" />
@ -200,7 +270,6 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
)} )}
</CardContent> </CardContent>
</Card> </Card>
</div>
</div> </div>
</div> </div>
); );

View file

@ -22,8 +22,15 @@ import { Badge } from "~/components/ui/badge";
export async function loader() { export async function loader() {
const sportsSeasons = await findAllSportsSeasons(); const sportsSeasons = await findAllSportsSeasons();
// Type assertion since we know the sport relation is included from the model // Type assertion since we know the sport and participants relations are included from the model
return { sportsSeasons: sportsSeasons as Array<typeof sportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }> }; return {
sportsSeasons: sportsSeasons as Array<
typeof sportsSeasons[0] & {
sport: { id: string; name: string; type: string; slug: string };
participants: Array<{ id: string }>;
}
>
};
} }
export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps) { export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps) {
@ -77,6 +84,7 @@ export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps)
<TableHead>Year</TableHead> <TableHead>Year</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Scoring Type</TableHead> <TableHead>Scoring Type</TableHead>
<TableHead>Participants</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -102,6 +110,9 @@ export default function AdminSportsSeasons({ loaderData }: Route.ComponentProps)
<TableCell className="text-muted-foreground capitalize"> <TableCell className="text-muted-foreground capitalize">
{season.scoringType.replace("_", " ")} {season.scoringType.replace("_", " ")}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">
{season.participants.length}
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild> <Button variant="ghost" size="sm" asChild>

View file

@ -27,6 +27,7 @@ interface ExportData {
name: string; name: string;
shortName: string | null; shortName: string | null;
externalId: string | null; externalId: string | null;
expectedValue: number;
}>; }>;
seasonTemplates: Array<{ seasonTemplates: Array<{
name: string; name: string;
@ -114,6 +115,7 @@ export async function exportSportsDataToJSON(): Promise<ExportData> {
name: participant.name, name: participant.name,
shortName: participant.shortName, shortName: participant.shortName,
externalId: participant.externalId, externalId: participant.externalId,
expectedValue: participant.expectedValue,
})), })),
seasonTemplates: seasonTemplates.map((template) => ({ seasonTemplates: seasonTemplates.map((template) => ({
name: template.name, name: template.name,
@ -262,6 +264,7 @@ export async function importSportsDataFromJSON(
name: participant.name, name: participant.name,
shortName: participant.shortName, shortName: participant.shortName,
externalId: participant.externalId, externalId: participant.externalId,
expectedValue: participant.expectedValue ?? 0,
}); });
created++; created++;
} else { } else {

View file

@ -142,6 +142,7 @@ export const participants = pgTable("participants", {
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
shortName: varchar("short_name", { length: 100 }), shortName: varchar("short_name", { length: 100 }),
externalId: varchar("external_id", { length: 255 }), externalId: varchar("external_id", { length: 255 }),
expectedValue: integer("expected_value").notNull().default(0),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });

View file

@ -0,0 +1 @@
ALTER TABLE "participants" ADD COLUMN "expected_value" integer DEFAULT 0 NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -99,6 +99,13 @@
"when": 1760543654915, "when": 1760543654915,
"tag": "0013_pink_the_order", "tag": "0013_pink_the_order",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1760588333462,
"tag": "0014_lowly_gateway",
"breakpoints": true
} }
] ]
} }