feat(admin): canonical tournament list + result entry routes
New routes /admin/tournaments and /admin/tournaments/:id. The detail page lets admins enter tournament results once via paste-and-parse, then calls syncTournamentResults to fan out to every linked window. Phase 3 Task 4 of canonical tournament layer migration.
This commit is contained in:
parent
05c8699b0c
commit
3a4fe41eb5
5 changed files with 720 additions and 1 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, and, asc } from "drizzle-orm";
|
||||
import { eq, and, asc, desc } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -80,6 +80,50 @@ export async function upsertTournament(
|
|||
return await createTournament(data);
|
||||
}
|
||||
|
||||
export async function findAllTournamentsGroupedBySport(): Promise<
|
||||
Array<{
|
||||
sport: { id: string; name: string };
|
||||
tournaments: Tournament[];
|
||||
}>
|
||||
> {
|
||||
const db = database();
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
tournament: schema.tournaments,
|
||||
sport: {
|
||||
id: schema.sports.id,
|
||||
name: schema.sports.name,
|
||||
},
|
||||
})
|
||||
.from(schema.tournaments)
|
||||
.innerJoin(schema.sports, eq(schema.tournaments.sportId, schema.sports.id))
|
||||
.orderBy(
|
||||
asc(schema.sports.name),
|
||||
desc(schema.tournaments.year),
|
||||
asc(schema.tournaments.startsAt)
|
||||
);
|
||||
|
||||
const groupMap = new Map<
|
||||
string,
|
||||
{ sport: { id: string; name: string }; tournaments: Tournament[] }
|
||||
>();
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = groupMap.get(row.sport.id);
|
||||
if (existing) {
|
||||
existing.tournaments.push(row.tournament);
|
||||
} else {
|
||||
groupMap.set(row.sport.id, {
|
||||
sport: row.sport,
|
||||
tournaments: [row.tournament],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groupMap.values());
|
||||
}
|
||||
|
||||
export async function updateTournamentStatus(
|
||||
id: string,
|
||||
status: TournamentStatus
|
||||
|
|
|
|||
|
|
@ -129,6 +129,8 @@ export default [
|
|||
"routes/admin.sports-seasons.$id.clone.tsx"
|
||||
),
|
||||
route("participants", "routes/admin.participants.tsx"),
|
||||
route("tournaments", "routes/admin.tournaments._index.tsx"),
|
||||
route("tournaments/:id", "routes/admin.tournaments.$id.tsx"),
|
||||
route("templates", "routes/admin.templates.tsx"),
|
||||
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||
route("templates/:id", "routes/admin.templates.$id.tsx"),
|
||||
|
|
|
|||
182
app/routes/__tests__/admin.tournaments.$id.test.tsx
Normal file
182
app/routes/__tests__/admin.tournaments.$id.test.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/models/user", () => ({
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/tournament", () => ({
|
||||
getTournamentById: vi.fn(),
|
||||
updateTournamentStatus: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/tournament-result", () => ({
|
||||
getTournamentResults: vi.fn(),
|
||||
upsertTournamentResult: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant", () => ({
|
||||
findCanonicalParticipantsBySport: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/sync-tournament-results", () => ({
|
||||
syncTournamentResults: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/lib/logger", () => ({
|
||||
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
import { action } from "~/routes/admin.tournaments.$id";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import {
|
||||
getTournamentById,
|
||||
updateTournamentStatus,
|
||||
} from "~/models/tournament";
|
||||
import { upsertTournamentResult } from "~/models/tournament-result";
|
||||
import { syncTournamentResults } from "~/services/sync-tournament-results";
|
||||
|
||||
const TOURNAMENT_ID = "tournament-1";
|
||||
const ADMIN_USER_ID = "admin-1";
|
||||
const NON_ADMIN_USER_ID = "user-1";
|
||||
|
||||
function makeRequest(body: Record<string, string>) {
|
||||
const formData = new FormData();
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
formData.append(k, v);
|
||||
}
|
||||
return new Request(`http://localhost/admin/tournaments/${TOURNAMENT_ID}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
function makeActionArgs(request: Request) {
|
||||
return {
|
||||
request,
|
||||
params: { id: TOURNAMENT_ID },
|
||||
context: {} as never,
|
||||
} as unknown as Parameters<typeof action>[0];
|
||||
}
|
||||
|
||||
describe("admin.tournaments.$id action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("batch-upsert-results: upserts each row, marks tournament completed, and fans out", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({
|
||||
user: { id: ADMIN_USER_ID },
|
||||
} as never);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
vi.mocked(getTournamentById).mockResolvedValue({
|
||||
id: TOURNAMENT_ID,
|
||||
sportId: "sport-1",
|
||||
name: "US Open",
|
||||
year: 2026,
|
||||
status: "scheduled",
|
||||
} as never);
|
||||
vi.mocked(upsertTournamentResult).mockResolvedValue({} as never);
|
||||
vi.mocked(updateTournamentStatus).mockResolvedValue({} as never);
|
||||
const syncReport = {
|
||||
tournamentId: TOURNAMENT_ID,
|
||||
windowsSynced: 2,
|
||||
windowsFailed: 0,
|
||||
failures: [],
|
||||
};
|
||||
vi.mocked(syncTournamentResults).mockResolvedValue(syncReport);
|
||||
|
||||
const results = [
|
||||
{ participantId: "p-1", placement: 1 },
|
||||
{ participantId: "p-2", placement: 2 },
|
||||
{ participantId: "p-3", placement: 3 },
|
||||
];
|
||||
const request = makeRequest({
|
||||
intent: "batch-upsert-results",
|
||||
results: JSON.stringify(results),
|
||||
});
|
||||
|
||||
const response = await action(makeActionArgs(request));
|
||||
|
||||
expect(upsertTournamentResult).toHaveBeenCalledTimes(3);
|
||||
expect(upsertTournamentResult).toHaveBeenCalledWith({
|
||||
tournamentId: TOURNAMENT_ID,
|
||||
participantId: "p-1",
|
||||
placement: 1,
|
||||
});
|
||||
expect(updateTournamentStatus).toHaveBeenCalledWith(
|
||||
TOURNAMENT_ID,
|
||||
"completed"
|
||||
);
|
||||
expect(syncTournamentResults).toHaveBeenCalledWith(TOURNAMENT_ID);
|
||||
expect(response).toMatchObject({
|
||||
success: true,
|
||||
syncReport,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not re-update status when tournament is already completed", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({
|
||||
user: { id: ADMIN_USER_ID },
|
||||
} as never);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
vi.mocked(getTournamentById).mockResolvedValue({
|
||||
id: TOURNAMENT_ID,
|
||||
sportId: "sport-1",
|
||||
name: "US Open",
|
||||
year: 2026,
|
||||
status: "completed",
|
||||
} as never);
|
||||
vi.mocked(upsertTournamentResult).mockResolvedValue({} as never);
|
||||
vi.mocked(syncTournamentResults).mockResolvedValue({
|
||||
tournamentId: TOURNAMENT_ID,
|
||||
windowsSynced: 1,
|
||||
windowsFailed: 0,
|
||||
failures: [],
|
||||
});
|
||||
|
||||
const request = makeRequest({
|
||||
intent: "batch-upsert-results",
|
||||
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
|
||||
});
|
||||
|
||||
await action(makeActionArgs(request));
|
||||
|
||||
expect(updateTournamentStatus).not.toHaveBeenCalled();
|
||||
expect(syncTournamentResults).toHaveBeenCalledWith(TOURNAMENT_ID);
|
||||
});
|
||||
|
||||
it("non-admin user is forbidden (403)", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({
|
||||
user: { id: NON_ADMIN_USER_ID },
|
||||
} as never);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(false);
|
||||
|
||||
const request = makeRequest({
|
||||
intent: "batch-upsert-results",
|
||||
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
|
||||
});
|
||||
|
||||
await expect(action(makeActionArgs(request))).rejects.toMatchObject({
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(upsertTournamentResult).not.toHaveBeenCalled();
|
||||
expect(syncTournamentResults).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("missing tournament returns 404", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue({
|
||||
user: { id: ADMIN_USER_ID },
|
||||
} as never);
|
||||
vi.mocked(isUserAdmin).mockResolvedValue(true);
|
||||
vi.mocked(getTournamentById).mockResolvedValue(null);
|
||||
|
||||
const request = makeRequest({
|
||||
intent: "batch-upsert-results",
|
||||
results: JSON.stringify([{ participantId: "p-1", placement: 1 }]),
|
||||
});
|
||||
|
||||
await expect(action(makeActionArgs(request))).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
353
app/routes/admin.tournaments.$id.tsx
Normal file
353
app/routes/admin.tournaments.$id.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { Link, useFetcher } from "react-router";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
import type { Route } from "./+types/admin.tournaments.$id";
|
||||
|
||||
import { logger } from "~/lib/logger";
|
||||
import {
|
||||
getTournamentById,
|
||||
updateTournamentStatus,
|
||||
} from "~/models/tournament";
|
||||
import {
|
||||
getTournamentResults,
|
||||
upsertTournamentResult,
|
||||
} from "~/models/tournament-result";
|
||||
import { findCanonicalParticipantsBySport } from "~/models/participant";
|
||||
import { isUserAdmin } from "~/models/user";
|
||||
import {
|
||||
syncTournamentResults,
|
||||
type SyncReport,
|
||||
} from "~/services/sync-tournament-results";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { ArrowLeft, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { BatchResultEntry } from "~/components/BatchResultEntry";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [
|
||||
{
|
||||
title: `${data?.tournament?.name ?? "Tournament"} - Brackt Admin`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const tournament = await getTournamentById(params.id);
|
||||
if (!tournament) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const [results, canonicalParticipants] = await Promise.all([
|
||||
getTournamentResults(tournament.id),
|
||||
findCanonicalParticipantsBySport(tournament.sportId),
|
||||
]);
|
||||
|
||||
return { tournament, results, canonicalParticipants };
|
||||
}
|
||||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
const { request, params } = args;
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
const isAdmin = userId ? await isUserAdmin(userId) : false;
|
||||
if (!isAdmin) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const tournament = await getTournamentById(params.id);
|
||||
if (!tournament) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === "batch-upsert-results") {
|
||||
const resultsRaw = formData.get("results");
|
||||
if (typeof resultsRaw !== "string" || !resultsRaw) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Missing results payload",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
let parsed: Array<{ participantId: string; placement: number }>;
|
||||
try {
|
||||
parsed = JSON.parse(resultsRaw);
|
||||
} catch {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Invalid JSON in results payload",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Results payload must be an array",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
for (const row of parsed) {
|
||||
if (
|
||||
!row ||
|
||||
typeof row.participantId !== "string" ||
|
||||
typeof row.placement !== "number"
|
||||
) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Each result must have participantId and placement",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
await upsertTournamentResult({
|
||||
tournamentId: tournament.id,
|
||||
participantId: row.participantId,
|
||||
placement: row.placement,
|
||||
});
|
||||
}
|
||||
|
||||
if (tournament.status !== "completed") {
|
||||
await updateTournamentStatus(tournament.id, "completed");
|
||||
}
|
||||
|
||||
const syncReport = await syncTournamentResults(tournament.id);
|
||||
return {
|
||||
success: true as const,
|
||||
error: null,
|
||||
syncReport,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("batch-upsert-results failed:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to save results",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "retry-window-sync") {
|
||||
try {
|
||||
const syncReport = await syncTournamentResults(tournament.id);
|
||||
return { success: true as const, error: null, syncReport };
|
||||
} catch (error) {
|
||||
logger.error("retry-window-sync failed:", error);
|
||||
return {
|
||||
success: false as const,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to retry sync",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Invalid intent",
|
||||
syncReport: null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminTournamentDetail({
|
||||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { tournament, results, canonicalParticipants } = loaderData;
|
||||
const retryFetcher = useFetcher<typeof action>();
|
||||
|
||||
// Prefer the latest action/retry response for the sync report
|
||||
const liveReport: SyncReport | null =
|
||||
(retryFetcher.data?.syncReport ?? actionData?.syncReport) ?? null;
|
||||
|
||||
const participantById = new Map(
|
||||
canonicalParticipants.map((p) => [p.id, p])
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-5xl">
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||
<Link to="/admin/tournaments">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to tournaments
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold">{tournament.name}</h1>
|
||||
<Badge
|
||||
variant={
|
||||
tournament.status === "completed"
|
||||
? "default"
|
||||
: tournament.status === "in_progress"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{tournament.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{tournament.year}
|
||||
{tournament.location ? ` — ${tournament.location}` : ""}
|
||||
{tournament.surface ? ` — ${tournament.surface}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{liveReport && (
|
||||
<Card className="mb-6 border-emerald-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-emerald-500">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
Synced to {liveReport.windowsSynced}{" "}
|
||||
{liveReport.windowsSynced === 1 ? "window" : "windows"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Canonical results were fanned out to every linked scoring
|
||||
window.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{liveReport.failures.length > 0 && (
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-destructive font-medium">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{liveReport.failures.length}{" "}
|
||||
{liveReport.failures.length === 1 ? "window" : "windows"}{" "}
|
||||
failed to sync
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{liveReport.failures.map((f) => (
|
||||
<div
|
||||
key={f.scoringEventId}
|
||||
className="flex items-start justify-between gap-4 text-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
event: {f.scoringEventId}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">
|
||||
season: {f.sportsSeasonId}
|
||||
</div>
|
||||
<div className="text-destructive">{f.error}</div>
|
||||
</div>
|
||||
<retryFetcher.Form method="post">
|
||||
<input
|
||||
type="hidden"
|
||||
name="intent"
|
||||
value="retry-window-sync"
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="scoringEventId"
|
||||
value={f.scoringEventId}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={retryFetcher.state !== "idle"}
|
||||
>
|
||||
{retryFetcher.state !== "idle"
|
||||
? "Retrying…"
|
||||
: "Retry"}
|
||||
</Button>
|
||||
</retryFetcher.Form>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{actionData?.error && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm mb-6">
|
||||
{actionData.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Current Results</CardTitle>
|
||||
<CardDescription>
|
||||
{results.length}{" "}
|
||||
{results.length === 1 ? "result" : "results"} recorded
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
No results recorded yet. Paste a ranked list to the right to
|
||||
import.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">Placement</TableHead>
|
||||
<TableHead>Participant</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{results.map((r) => {
|
||||
const p = participantById.get(r.participantId);
|
||||
return (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-semibold">
|
||||
{r.placement ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p?.name ?? (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{r.participantId}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<BatchResultEntry
|
||||
participants={canonicalParticipants.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
}))}
|
||||
sportsSeasonId=""
|
||||
existingResultParticipantIds={
|
||||
new Set(results.map((r) => r.participantId))
|
||||
}
|
||||
intent="batch-upsert-results"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
app/routes/admin.tournaments._index.tsx
Normal file
138
app/routes/admin.tournaments._index.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { Link } from "react-router";
|
||||
import type { Route } from "./+types/admin.tournaments._index";
|
||||
|
||||
import { findAllTournamentsGroupedBySport } from "~/models/tournament";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Trophy } from "lucide-react";
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "Tournaments - Brackt Admin" }];
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
const grouped = await findAllTournamentsGroupedBySport();
|
||||
return { grouped };
|
||||
}
|
||||
|
||||
export default function AdminTournamentsIndex({
|
||||
loaderData,
|
||||
}: Route.ComponentProps) {
|
||||
const { grouped } = loaderData;
|
||||
|
||||
const totalCount = grouped.reduce(
|
||||
(acc, g) => acc + g.tournaments.length,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tournaments</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Canonical tournaments across all sports. Enter results once here
|
||||
and they fan out to every linked scoring window.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Trophy className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-semibold">
|
||||
No tournaments yet
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Canonical tournaments are created automatically by the backfill
|
||||
or data-sync flows.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalCount} {totalCount === 1 ? "tournament" : "tournaments"}{" "}
|
||||
across {grouped.length}{" "}
|
||||
{grouped.length === 1 ? "sport" : "sports"}
|
||||
</p>
|
||||
{grouped.map((group) => (
|
||||
<Card key={group.sport.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{group.sport.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{group.tournaments.length}{" "}
|
||||
{group.tournaments.length === 1
|
||||
? "tournament"
|
||||
: "tournaments"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Year</TableHead>
|
||||
<TableHead>Starts</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.tournaments.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell>{t.year}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{t.startsAt
|
||||
? new Date(t.startsAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
t.status === "completed"
|
||||
? "default"
|
||||
: t.status === "in_progress"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{t.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/admin/tournaments/${t.id}`}>
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue