feat: Implement event management for sports seasons with CRUD operations and UI
This commit is contained in:
parent
7dddf2c9a7
commit
60ba3d4a6e
7 changed files with 742 additions and 5 deletions
11
.mcp.json
Normal file
11
.mcp.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,14 @@ export default [
|
||||||
"sports-seasons/:id/participants",
|
"sports-seasons/:id/participants",
|
||||||
"routes/admin.sports-seasons.$id.participants.tsx"
|
"routes/admin.sports-seasons.$id.participants.tsx"
|
||||||
),
|
),
|
||||||
|
route(
|
||||||
|
"sports-seasons/:id/events",
|
||||||
|
"routes/admin.sports-seasons.$id.events.tsx"
|
||||||
|
),
|
||||||
|
route(
|
||||||
|
"sports-seasons/:id/events/:eventId",
|
||||||
|
"routes/admin.sports-seasons.$id.events.$eventId.tsx"
|
||||||
|
),
|
||||||
route("participants", "routes/admin.participants.tsx"),
|
route("participants", "routes/admin.participants.tsx"),
|
||||||
route("templates", "routes/admin.templates.tsx"),
|
route("templates", "routes/admin.templates.tsx"),
|
||||||
route("templates/new", "routes/admin.templates.new.tsx"),
|
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||||
|
|
|
||||||
136
app/routes/admin.sports-seasons.$id.events.$eventId.server.ts
Normal file
136
app/routes/admin.sports-seasons.$id.events.$eventId.server.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId";
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
|
import {
|
||||||
|
getScoringEventById,
|
||||||
|
completeScoringEvent,
|
||||||
|
} from "~/models/scoring-event";
|
||||||
|
import {
|
||||||
|
getEventResults,
|
||||||
|
createEventResult,
|
||||||
|
updateEventResult,
|
||||||
|
deleteEventResult,
|
||||||
|
type CreateEventResultData,
|
||||||
|
type UpdateEventResultData,
|
||||||
|
} from "~/models/event-result";
|
||||||
|
|
||||||
|
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 event = await getScoringEventById(params.eventId);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
throw new Response("Event not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
const results = await getEventResults(params.eventId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||||
|
sport: { id: string; name: string; type: string; slug: string };
|
||||||
|
},
|
||||||
|
event,
|
||||||
|
participants,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "complete") {
|
||||||
|
try {
|
||||||
|
await completeScoringEvent(params.eventId);
|
||||||
|
return { success: "Event marked as completed" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error completing event:", error);
|
||||||
|
return { error: "Failed to complete event" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "add-result") {
|
||||||
|
const participantId = formData.get("participantId");
|
||||||
|
const placement = formData.get("placement");
|
||||||
|
|
||||||
|
if (typeof participantId !== "string" || !participantId) {
|
||||||
|
return { error: "Participant is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof placement !== "string" || !placement) {
|
||||||
|
return { error: "Placement is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const placementNum = parseInt(placement, 10);
|
||||||
|
if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) {
|
||||||
|
return { error: "Placement must be between 1 and 100" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultData: CreateEventResultData = {
|
||||||
|
scoringEventId: params.eventId,
|
||||||
|
participantId,
|
||||||
|
placement: placementNum,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createEventResult(resultData);
|
||||||
|
return { success: "Result added successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding result:", error);
|
||||||
|
return { error: "Failed to add result" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "update-result") {
|
||||||
|
const resultId = formData.get("resultId");
|
||||||
|
const placement = formData.get("placement");
|
||||||
|
|
||||||
|
if (typeof resultId !== "string" || !resultId) {
|
||||||
|
return { error: "Result ID is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof placement !== "string" || !placement) {
|
||||||
|
return { error: "Placement is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const placementNum = parseInt(placement, 10);
|
||||||
|
if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) {
|
||||||
|
return { error: "Placement must be between 1 and 100" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: UpdateEventResultData = {
|
||||||
|
placement: placementNum,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateEventResult(resultId, updateData);
|
||||||
|
return { success: "Result updated successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating result:", error);
|
||||||
|
return { error: "Failed to update result" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent === "delete-result") {
|
||||||
|
const resultId = formData.get("resultId");
|
||||||
|
|
||||||
|
if (typeof resultId !== "string" || !resultId) {
|
||||||
|
return { error: "Result ID is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteEventResult(resultId);
|
||||||
|
return { success: "Result deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting result:", error);
|
||||||
|
return { error: "Failed to delete result" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: "Invalid action" };
|
||||||
|
}
|
||||||
287
app/routes/admin.sports-seasons.$id.events.$eventId.tsx
Normal file
287
app/routes/admin.sports-seasons.$id.events.$eventId.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
import { Form, Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId";
|
||||||
|
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.server";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table";
|
||||||
|
|
||||||
|
export { loader, action };
|
||||||
|
|
||||||
|
export default function EventResults({
|
||||||
|
loaderData,
|
||||||
|
actionData,
|
||||||
|
}: Route.ComponentProps) {
|
||||||
|
const { sportsSeason, event, participants, results } = loaderData;
|
||||||
|
|
||||||
|
// Create a map of participants with results for easy lookup
|
||||||
|
const participantResultsMap = new Map(
|
||||||
|
results.map((r: { participant: { id: string } }) => [r.participant.id, r])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get participants without results
|
||||||
|
const participantsWithoutResults = participants.filter(
|
||||||
|
(p: { id: string }) => !participantResultsMap.has(p.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort results by placement
|
||||||
|
const sortedResults = [...results].sort((a, b) => (a.placement || 999) - (b.placement || 999));
|
||||||
|
|
||||||
|
const getEventTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "playoff_game":
|
||||||
|
return "Playoff Game";
|
||||||
|
case "major_tournament":
|
||||||
|
return "Major Tournament";
|
||||||
|
case "race":
|
||||||
|
return "Race";
|
||||||
|
case "final_standings":
|
||||||
|
return "Final Standings";
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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}/events`}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Events
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">{event.name}</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{sportsSeason.sport.name} - {sportsSeason.name} •{" "}
|
||||||
|
{getEventTypeLabel(event.eventType)}
|
||||||
|
{event.playoffRound && ` • ${event.playoffRound}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{event.isComplete ? (
|
||||||
|
<Badge variant="default" className="bg-green-500">
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">In Progress</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionData?.success && (
|
||||||
|
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Result Form */}
|
||||||
|
{!event.isComplete && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Result</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter the placement for a participant in this event
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<input type="hidden" name="intent" value="add-result" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="participantId">Participant</Label>
|
||||||
|
<Select name="participantId" required>
|
||||||
|
<SelectTrigger id="participantId">
|
||||||
|
<SelectValue placeholder="Select participant" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{participantsWithoutResults.length === 0 ? (
|
||||||
|
<div className="p-2 text-sm text-muted-foreground">
|
||||||
|
All participants have results
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
participantsWithoutResults.map((participant: { id: string; name: string }) => (
|
||||||
|
<SelectItem
|
||||||
|
key={participant.id}
|
||||||
|
value={participant.id}
|
||||||
|
>
|
||||||
|
{participant.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="placement">Placement</Label>
|
||||||
|
<Input
|
||||||
|
id="placement"
|
||||||
|
name="placement"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
placeholder="e.g., 1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={participantsWithoutResults.length === 0}
|
||||||
|
>
|
||||||
|
<Trophy className="mr-2 h-4 w-4" />
|
||||||
|
Add Result
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current Results */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Results</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{results.length} of {participants.length} participants have
|
||||||
|
results
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{!event.isComplete && results.length > 0 && (
|
||||||
|
<Form method="post">
|
||||||
|
<input type="hidden" name="intent" value="complete" />
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
|
Mark Event Complete
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{sortedResults.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No results added yet. Add results using the form above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-24">Placement</TableHead>
|
||||||
|
<TableHead>Participant</TableHead>
|
||||||
|
{!event.isComplete && <TableHead className="w-32">Actions</TableHead>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedResults.map((result) => (
|
||||||
|
<TableRow key={result.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{result.placement === 1 && (
|
||||||
|
<span className="text-xl">🥇</span>
|
||||||
|
)}
|
||||||
|
{result.placement === 2 && (
|
||||||
|
<span className="text-xl">🥈</span>
|
||||||
|
)}
|
||||||
|
{result.placement === 3 && (
|
||||||
|
<span className="text-xl">🥉</span>
|
||||||
|
)}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{result.placement}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{result.participant.name}</TableCell>
|
||||||
|
{!event.isComplete && (
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Form method="post" className="inline">
|
||||||
|
<input type="hidden" name="intent" value="update-result" />
|
||||||
|
<input type="hidden" name="resultId" value={result.id} />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="placement"
|
||||||
|
defaultValue={result.placement || ""}
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
className="w-16 h-8 text-sm"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Form method="post" className="inline">
|
||||||
|
<input type="hidden" name="intent" value="delete-result" />
|
||||||
|
<input type="hidden" name="resultId" value={result.id} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!confirm(`Delete result for ${result.participant.name}?`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
app/routes/admin.sports-seasons.$id.events.server.ts
Normal file
82
app/routes/admin.sports-seasons.$id.events.server.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.$id.events";
|
||||||
|
import { redirect } from "react-router";
|
||||||
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
|
import {
|
||||||
|
getScoringEventsForSportsSeason,
|
||||||
|
createScoringEvent,
|
||||||
|
deleteScoringEvent,
|
||||||
|
type CreateScoringEventData,
|
||||||
|
} from "~/models/scoring-event";
|
||||||
|
|
||||||
|
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 events = await getScoringEventsForSportsSeason(params.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||||
|
sport: { id: string; name: string; type: string; slug: string };
|
||||||
|
},
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "delete-event") {
|
||||||
|
const eventId = formData.get("eventId");
|
||||||
|
|
||||||
|
if (typeof eventId !== "string" || !eventId) {
|
||||||
|
return { error: "Event ID is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteScoringEvent(eventId);
|
||||||
|
return { success: "Event deleted successfully" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting event:", error);
|
||||||
|
return { error: "Failed to delete event" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = formData.get("name");
|
||||||
|
const eventType = formData.get("eventType");
|
||||||
|
const eventDate = formData.get("eventDate");
|
||||||
|
const playoffRound = formData.get("playoffRound");
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (typeof name !== "string" || !name.trim()) {
|
||||||
|
return { error: "Event name is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
eventType !== "playoff_game" &&
|
||||||
|
eventType !== "major_tournament" &&
|
||||||
|
eventType !== "race" &&
|
||||||
|
eventType !== "final_standings"
|
||||||
|
) {
|
||||||
|
return { error: "Invalid event type" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventData: CreateScoringEventData = {
|
||||||
|
sportsSeasonId: params.id,
|
||||||
|
name: name.trim(),
|
||||||
|
eventType: eventType as "playoff_game" | "major_tournament" | "race" | "final_standings",
|
||||||
|
eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined,
|
||||||
|
playoffRound: typeof playoffRound === "string" && playoffRound ? playoffRound : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await createScoringEvent(eventData);
|
||||||
|
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating scoring event:", error);
|
||||||
|
return { error: "Failed to create scoring event. Please try again." };
|
||||||
|
}
|
||||||
|
}
|
||||||
211
app/routes/admin.sports-seasons.$id.events.tsx
Normal file
211
app/routes/admin.sports-seasons.$id.events.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { Form, Link } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.sports-seasons.$id.events";
|
||||||
|
import { loader, action } from "./admin.sports-seasons.$id.events.server";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Label } from "~/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Calendar, Trophy, ArrowLeft, Trash2 } from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export { loader, action };
|
||||||
|
|
||||||
|
export default function SportsSeasonEvents({
|
||||||
|
loaderData,
|
||||||
|
actionData,
|
||||||
|
}: Route.ComponentProps) {
|
||||||
|
const { sportsSeason, events } = loaderData;
|
||||||
|
|
||||||
|
const getEventTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "playoff_game":
|
||||||
|
return "Playoff Game";
|
||||||
|
case "major_tournament":
|
||||||
|
return "Major Tournament";
|
||||||
|
case "race":
|
||||||
|
return "Race";
|
||||||
|
case "final_standings":
|
||||||
|
return "Final Standings";
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (isComplete: boolean) => {
|
||||||
|
return isComplete ? (
|
||||||
|
<Badge variant="default" className="bg-green-500">
|
||||||
|
Completed
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">In Progress</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">Scoring Events</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{sportsSeason.sport.name} - {sportsSeason.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Create New Event Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create New Event</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add a new scoring event (game, tournament, race, etc.)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form method="post" className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Event Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Super Bowl, Round 1, Game 3"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="eventType">Event Type</Label>
|
||||||
|
<Select name="eventType" defaultValue="playoff_game" required>
|
||||||
|
<SelectTrigger id="eventType">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="playoff_game">Playoff Game</SelectItem>
|
||||||
|
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
||||||
|
<SelectItem value="race">Race</SelectItem>
|
||||||
|
<SelectItem value="final_standings">Final Standings</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="playoffRound">Playoff Round (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="playoffRound"
|
||||||
|
name="playoffRound"
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Quarterfinals, Round 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="eventDate">Event Date (Optional)</Label>
|
||||||
|
<Input id="eventDate" name="eventDate" type="date" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||||
|
{actionData.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
<Trophy className="mr-2 h-4 w-4" />
|
||||||
|
Create Event
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Events List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Events</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{events.length} {events.length === 1 ? "event" : "events"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No events created yet. Create your first event above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{events.map((event: { id: string; name: string; eventType: string; playoffRound?: string | null; eventDate?: string | null; isComplete: boolean }) => (
|
||||||
|
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<Link
|
||||||
|
to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}
|
||||||
|
className="flex-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold">{event.name}</h3>
|
||||||
|
{getStatusBadge(event.isComplete)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{getEventTypeLabel(event.eventType)}
|
||||||
|
</span>
|
||||||
|
{event.playoffRound && <span>{event.playoffRound}</span>}
|
||||||
|
{event.eventDate && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{format(new Date(event.eventDate), "MMM d, yyyy")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Form method="post" className="flex-shrink-0">
|
||||||
|
<input type="hidden" name="intent" value="delete-event" />
|
||||||
|
<input type="hidden" name="eventId" value={event.id} />
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!confirm(`Delete event "${event.name}"? This will also delete all results.`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -959,11 +959,13 @@ All clarification questions (Q1-Q21) have been answered and confirmed:
|
||||||
- [x] Disable editing after draft starts (settings page only)
|
- [x] Disable editing after draft starts (settings page only)
|
||||||
- [x] Show helpful tips and preview
|
- [x] Show helpful tips and preview
|
||||||
|
|
||||||
- [ ] **1.4** Basic admin result entry UI
|
- [x] **1.4** Basic admin result entry UI ✅ *Completed*
|
||||||
- [ ] Create admin route structure for sports seasons
|
- [x] Create admin route structure for sports seasons
|
||||||
- [ ] List scoring events for a sports season
|
- [x] List scoring events for a sports season
|
||||||
- [ ] Create new event form
|
- [x] Create new event form
|
||||||
- [ ] Basic result entry form (placement only)
|
- [x] Basic result entry form (placement only)
|
||||||
|
- [x] Update existing results (inline editing)
|
||||||
|
- [x] Delete results with confirmation
|
||||||
|
|
||||||
### Phase 2: Playoff Scoring (Single Elimination)
|
### Phase 2: Playoff Scoring (Single Elimination)
|
||||||
**Goal**: Implement playoff bracket tracking and scoring for NFL/NBA/MLB
|
**Goal**: Implement playoff bracket tracking and scoring for NFL/NBA/MLB
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue