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,
shortName: p.shortName,
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)],
with: {
sport: true,
participants: {
columns: {
id: true,
},
},
},
});
}
@ -128,6 +133,7 @@ export async function copyParticipantsFromPreviousSeason(
name: p.name,
shortName: p.shortName,
externalId: p.externalId,
expectedValue: p.expectedValue,
}))
);
}

View file

@ -9,6 +9,7 @@ import {
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,
@ -54,9 +55,43 @@ export async function action({ request, params }: Route.ActionArgs) {
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 shortName = formData.get("shortName");
if (typeof name !== "string" || !name.trim()) {
return { error: "Participant name is required" };
@ -66,8 +101,9 @@ export async function action({ request, params }: Route.ActionArgs) {
await createParticipant({
sportsSeasonId: params.id,
name: name.trim(),
shortName: typeof shortName === "string" && shortName.trim() ? shortName.trim() : null,
shortName: null,
externalId: null,
expectedValue: 0,
});
return { success: true };
@ -79,6 +115,9 @@ export async function action({ request, params }: Route.ActionArgs) {
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">
@ -96,7 +135,7 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
</p>
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-6 md:grid-cols-2 mb-6">
<Card>
<CardHeader>
<CardTitle>Add Participant</CardTitle>
@ -105,11 +144,9 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<Form method="post" className="space-y-4" key={`single-${formKey}`}>
<div className="space-y-2">
<Label htmlFor="name">
{sportsSeason.sport.type === "team" ? "Team" : "Player"} Name
</Label>
<Label htmlFor="name">Participant Name</Label>
<Input
id="name"
name="name"
@ -119,23 +156,13 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
/>
</div>
<div className="space-y-2">
<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 && (
{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?.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>
@ -150,6 +177,53 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
</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>
@ -167,7 +241,6 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Short Name</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
@ -175,9 +248,6 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
{participants.map((participant) => (
<TableRow key={participant.id}>
<TableCell className="font-medium">{participant.name}</TableCell>
<TableCell className="text-muted-foreground">
{participant.shortName || "-"}
</TableCell>
<TableCell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
@ -200,7 +270,6 @@ export default function ManageParticipants({ loaderData, actionData }: Route.Com
)}
</CardContent>
</Card>
</div>
</div>
</div>
);

View file

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

View file

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

View file

@ -142,6 +142,7 @@ export const participants = pgTable("participants", {
name: varchar("name", { length: 255 }).notNull(),
shortName: varchar("short_name", { length: 100 }),
externalId: varchar("external_id", { length: 255 }),
expectedValue: integer("expected_value").notNull().default(0),
createdAt: timestamp("created_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,
"tag": "0013_pink_the_order",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1760588333462,
"tag": "0014_lowly_gateway",
"breakpoints": true
}
]
}