feat: add bulk participant creation and expected value field
This commit is contained in:
parent
fdfce98d87
commit
0e68bd2037
9 changed files with 1171 additions and 27 deletions
|
|
@ -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,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1
drizzle/0014_lowly_gateway.sql
Normal file
1
drizzle/0014_lowly_gateway.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "participants" ADD COLUMN "expected_value" integer DEFAULT 0 NOT NULL;
|
||||||
1045
drizzle/meta/0014_snapshot.json
Normal file
1045
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue