Compare commits
No commits in common. "0c100aa8349a6ccf3c114cb8e78e68dd65599ca0" and "7cbcc6fb1493c075e72795cec6d643b70a9ec613" have entirely different histories.
0c100aa834
...
7cbcc6fb14
7 changed files with 21 additions and 417 deletions
|
|
@ -63,119 +63,4 @@ describe("deleteScoringEvent", () => {
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shared tournament handling", () => {
|
|
||||||
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
|
|
||||||
// so the delete skips the QP/league branches and exercises only the
|
|
||||||
// shared-tournament bookkeeping.
|
|
||||||
function makeSharedDb({
|
|
||||||
deletedEvent,
|
|
||||||
remaining,
|
|
||||||
siblingTournamentId = "tournament-1",
|
|
||||||
}: {
|
|
||||||
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
|
|
||||||
remaining: Array<{ id: string; isPrimary: boolean }>;
|
|
||||||
siblingTournamentId?: string;
|
|
||||||
}) {
|
|
||||||
const findFirst = vi
|
|
||||||
.fn()
|
|
||||||
// 1st call: the event being deleted
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
id: "event-1",
|
|
||||||
sportsSeasonId: "sports-season-1",
|
|
||||||
eventType: "schedule_event",
|
|
||||||
isQualifyingEvent: false,
|
|
||||||
...deletedEvent,
|
|
||||||
})
|
|
||||||
// subsequent calls: setPrimaryEvent looking up the promoted event
|
|
||||||
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
|
|
||||||
|
|
||||||
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const db = {
|
|
||||||
query: {
|
|
||||||
scoringEvents: {
|
|
||||||
findFirst,
|
|
||||||
findMany: vi.fn().mockResolvedValue(remaining),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transaction: vi.fn(async (callback) => callback(db)),
|
|
||||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
|
||||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
|
||||||
} as any;
|
|
||||||
return { db, deleteWhere };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
|
||||||
remaining: [{ id: "event-2", isPrimary: true }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db);
|
|
||||||
|
|
||||||
expect(result.remainingWindows).toBe(1);
|
|
||||||
expect(result.promotedPrimaryId).toBeNull();
|
|
||||||
expect(result.deletedTournament).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
|
|
||||||
// Golf-style shared majors intentionally have no primary window — every
|
|
||||||
// linked event is isPrimary=false. Deleting one must not flip a sibling
|
|
||||||
// into a primary, which would change its scoring/guard behavior.
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
|
||||||
remaining: [
|
|
||||||
{ id: "event-2", isPrimary: false },
|
|
||||||
{ id: "event-3", isPrimary: false },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db);
|
|
||||||
|
|
||||||
expect(result.promotedPrimaryId).toBeNull();
|
|
||||||
expect(result.remainingWindows).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("promotes the earliest remaining window when the primary window is deleted", async () => {
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
||||||
// findMany is ordered asc(createdAt); first is the earliest.
|
|
||||||
remaining: [
|
|
||||||
{ id: "event-2", isPrimary: false },
|
|
||||||
{ id: "event-3", isPrimary: false },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db);
|
|
||||||
|
|
||||||
expect(result.promotedPrimaryId).toBe("event-2");
|
|
||||||
expect(result.remainingWindows).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
||||||
remaining: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db);
|
|
||||||
|
|
||||||
expect(result.remainingWindows).toBe(0);
|
|
||||||
expect(result.deletedTournament).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
|
|
||||||
const { db } = makeSharedDb({
|
|
||||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
|
||||||
remaining: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await deleteScoringEvent("event-1", db, {
|
|
||||||
deleteOrphanTournament: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.deletedTournament).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
findTournamentBySportNameYear,
|
findTournamentBySportNameYear,
|
||||||
upsertTournament,
|
upsertTournament,
|
||||||
updateTournamentStatus,
|
updateTournamentStatus,
|
||||||
deleteTournament,
|
|
||||||
} from "../tournament";
|
} from "../tournament";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
|
|
@ -173,26 +172,3 @@ describe("updateTournamentStatus", () => {
|
||||||
expect(result.status).toBe("in_progress");
|
expect(result.status).toBe("in_progress");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteTournament", () => {
|
|
||||||
it("deletes the tournament row by id", async () => {
|
|
||||||
const where = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const db = { delete: vi.fn().mockReturnValue({ where }) };
|
|
||||||
vi.mocked(database).mockReturnValue(db as never);
|
|
||||||
|
|
||||||
await deleteTournament(TOURNAMENT_ID);
|
|
||||||
|
|
||||||
expect(db.delete).toHaveBeenCalledTimes(1);
|
|
||||||
expect(where).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a provided db when passed (transaction)", async () => {
|
|
||||||
const where = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const providedDb = { delete: vi.fn().mockReturnValue({ where }) };
|
|
||||||
|
|
||||||
await deleteTournament(TOURNAMENT_ID, providedDb as never);
|
|
||||||
|
|
||||||
expect(providedDb.delete).toHaveBeenCalledTimes(1);
|
|
||||||
expect(vi.mocked(database)).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import type { BracketRegion } from "~/lib/bracket-templates";
|
||||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||||
import { findParticipantNamesByIds } from "./season-participant";
|
import { findParticipantNamesByIds } from "./season-participant";
|
||||||
import { deleteTournament } from "./tournament";
|
|
||||||
import type { EventType } from "./scoring-event-types";
|
import type { EventType } from "./scoring-event-types";
|
||||||
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
|
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
|
||||||
|
|
||||||
|
|
@ -202,46 +201,17 @@ export async function completeScoringEvent(
|
||||||
* sports season (placements are stored there with no FK back to the event) and
|
* sports season (placements are stored there with no FK back to the event) and
|
||||||
* recalculates league standings.
|
* recalculates league standings.
|
||||||
*/
|
*/
|
||||||
export interface DeleteScoringEventOptions {
|
|
||||||
/**
|
|
||||||
* When this was the last window linked to its shared tournament, also delete
|
|
||||||
* the now-orphaned canonical tournament (and its results). No-op when other
|
|
||||||
* windows remain. Off by default so deleting one season's event never touches
|
|
||||||
* the shared tournament unless explicitly requested.
|
|
||||||
*/
|
|
||||||
deleteOrphanTournament?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteScoringEventResult {
|
|
||||||
/** The shared tournament this event was linked to, if any. */
|
|
||||||
tournamentId: string | null;
|
|
||||||
/** How many other windows still link to that tournament after the delete. */
|
|
||||||
remainingWindows: number;
|
|
||||||
/** Event promoted to primary because the deleted event was the primary window. */
|
|
||||||
promotedPrimaryId: string | null;
|
|
||||||
/** Whether the orphaned canonical tournament was deleted. */
|
|
||||||
deletedTournament: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteScoringEvent(
|
export async function deleteScoringEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
providedDb?: ReturnType<typeof database>,
|
providedDb?: ReturnType<typeof database>
|
||||||
opts?: DeleteScoringEventOptions
|
) {
|
||||||
): Promise<DeleteScoringEventResult> {
|
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
const event = await db.query.scoringEvents.findFirst({
|
const event = await db.query.scoringEvents.findFirst({
|
||||||
where: eq(schema.scoringEvents.id, eventId),
|
where: eq(schema.scoringEvents.id, eventId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!event) {
|
if (!event) return;
|
||||||
return {
|
|
||||||
tournamentId: null,
|
|
||||||
remainingWindows: 0,
|
|
||||||
promotedPrimaryId: null,
|
|
||||||
deletedTournament: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||||
// eventResults (which is how we know who had QP awarded from this event).
|
// eventResults (which is how we know who had QP awarded from this event).
|
||||||
|
|
@ -290,43 +260,6 @@ export async function deleteScoringEvent(
|
||||||
|
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared-tournament bookkeeping: keep the primary window valid and optionally
|
|
||||||
// clean up a canonical tournament left with no windows.
|
|
||||||
let remainingWindows = 0;
|
|
||||||
let promotedPrimaryId: string | null = null;
|
|
||||||
let deletedTournament = false;
|
|
||||||
|
|
||||||
if (event.tournamentId) {
|
|
||||||
const remaining = await db.query.scoringEvents.findMany({
|
|
||||||
where: eq(schema.scoringEvents.tournamentId, event.tournamentId),
|
|
||||||
orderBy: asc(schema.scoringEvents.createdAt),
|
|
||||||
});
|
|
||||||
remainingWindows = remaining.length;
|
|
||||||
|
|
||||||
if (remaining.length > 0) {
|
|
||||||
// Auto-heal: if the deleted event was the primary window, promote the
|
|
||||||
// earliest remaining window so the shared major stays scorable and
|
|
||||||
// fan-out stays deterministic. Only heal when we actually removed the
|
|
||||||
// primary — golf-style majors intentionally have no primary (scored on the
|
|
||||||
// canonical tournament page), and event.isPrimary is false for them, so
|
|
||||||
// they are left untouched.
|
|
||||||
if (event.isPrimary) {
|
|
||||||
await setPrimaryEvent(remaining[0].id, db);
|
|
||||||
promotedPrimaryId = remaining[0].id;
|
|
||||||
}
|
|
||||||
} else if (opts?.deleteOrphanTournament) {
|
|
||||||
await deleteTournament(event.tournamentId, db);
|
|
||||||
deletedTournament = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
tournamentId: event.tournamentId,
|
|
||||||
remainingWindows,
|
|
||||||
promotedPrimaryId,
|
|
||||||
deletedTournament,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1128,24 +1061,6 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Count the distinct sports-season windows linked to a tournament. Cheaper than
|
|
||||||
* getSportsSeasonsByTournament when only the count is needed (no relations).
|
|
||||||
*/
|
|
||||||
export async function countWindowsByTournament(
|
|
||||||
tournamentId: string,
|
|
||||||
providedDb?: ReturnType<typeof database>
|
|
||||||
): Promise<number> {
|
|
||||||
const db = providedDb || database();
|
|
||||||
const [row] = await db
|
|
||||||
.select({
|
|
||||||
count: sql<number>`count(distinct ${schema.scoringEvents.sportsSeasonId})::int`,
|
|
||||||
})
|
|
||||||
.from(schema.scoringEvents)
|
|
||||||
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
|
|
||||||
return row?.count ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The primary scoring event for a tournament — the single window where the admin
|
* The primary scoring event for a tournament — the single window where the admin
|
||||||
* builds the bracket/stages and scoring happens; its results fan out to siblings.
|
* builds the bracket/stages and scoring happens; its results fan out to siblings.
|
||||||
|
|
|
||||||
|
|
@ -136,17 +136,3 @@ export async function updateTournamentStatus(
|
||||||
.returning();
|
.returning();
|
||||||
return tournament;
|
return tournament;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a canonical tournament. The DB cascades remove its tournament_results;
|
|
||||||
* any surviving scoring_events have their tournamentId set to NULL (onDelete:
|
|
||||||
* "set null"). Callers should only use this once the last linked window has been
|
|
||||||
* removed, so no scoring events are left pointing at it.
|
|
||||||
*/
|
|
||||||
export async function deleteTournament(
|
|
||||||
id: string,
|
|
||||||
providedDb?: ReturnType<typeof database>
|
|
||||||
): Promise<void> {
|
|
||||||
const db = providedDb || database();
|
|
||||||
await db.delete(schema.tournaments).where(eq(schema.tournaments.id, id));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
deleteScoringEvent,
|
deleteScoringEvent,
|
||||||
bulkCreateScoringEvents,
|
bulkCreateScoringEvents,
|
||||||
ensurePrimaryEvent,
|
ensurePrimaryEvent,
|
||||||
countWindowsByTournament,
|
|
||||||
type CreateScoringEventData,
|
type CreateScoringEventData,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { isBracketMajor } from "~/lib/event-utils";
|
import { isBracketMajor } from "~/lib/event-utils";
|
||||||
|
|
@ -60,38 +59,11 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
);
|
);
|
||||||
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
|
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
|
||||||
|
|
||||||
// Shared-tournament context for the delete confirmation dialog: for each event
|
|
||||||
// linked to a canonical tournament, how many OTHER seasons ("windows") also use
|
|
||||||
// it, and the tournament's name. Lets the UI explain exactly what a delete does.
|
|
||||||
const sharedInfo = new Map<string, { name: string; windowCount: number }>();
|
|
||||||
await Promise.all(
|
|
||||||
[...linkedTournamentIds].map(async (tid) => {
|
|
||||||
const cached = allTournamentsForSport.find((t) => t.id === tid);
|
|
||||||
const [tournament, windowCount] = await Promise.all([
|
|
||||||
cached ?? getTournamentById(tid),
|
|
||||||
countWindowsByTournament(tid),
|
|
||||||
]);
|
|
||||||
sharedInfo.set(tid, {
|
|
||||||
name: tournament?.name ?? "shared tournament",
|
|
||||||
windowCount,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const eventsWithSharing = events.map((e) => {
|
|
||||||
const info = e.tournamentId ? sharedInfo.get(e.tournamentId) : null;
|
|
||||||
return {
|
|
||||||
...e,
|
|
||||||
tournamentName: info?.name ?? null,
|
|
||||||
otherWindowCount: info ? Math.max(0, info.windowCount - 1) : 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||||
sport: { id: string; name: string; type: string; slug: string };
|
sport: { id: string; name: string; type: string; slug: string };
|
||||||
},
|
},
|
||||||
events: eventsWithSharing,
|
events,
|
||||||
qpStandings,
|
qpStandings,
|
||||||
scoringRules,
|
scoringRules,
|
||||||
availableTournaments,
|
availableTournaments,
|
||||||
|
|
@ -243,19 +215,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await deleteScoringEvent(eventId, undefined, {
|
await deleteScoringEvent(eventId);
|
||||||
deleteOrphanTournament: formData.get("deleteTournament") === "1",
|
return { success: "Event deleted successfully" };
|
||||||
});
|
|
||||||
let success = "Event deleted";
|
|
||||||
if (result.deletedTournament) {
|
|
||||||
success = "Event deleted and its shared tournament removed";
|
|
||||||
} else if (result.tournamentId && result.remainingWindows > 0) {
|
|
||||||
success = `Event removed from this season (shared tournament kept — still used by ${result.remainingWindows} season${result.remainingWindows !== 1 ? "s" : ""})`;
|
|
||||||
if (result.promotedPrimaryId) {
|
|
||||||
success += "; another season was promoted to primary";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error deleting event:", error);
|
logger.error("Error deleting event:", error);
|
||||||
return { error: "Failed to delete event" };
|
return { error: "Failed to delete event" };
|
||||||
|
|
|
||||||
|
|
@ -305,11 +305,7 @@ export default function SportsSeasonEvents({
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean; tournamentId: string | null; isPrimary: boolean; tournamentName: string | null; otherWindowCount: number }) => {
|
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
|
||||||
const isLinked = !!event.tournamentId;
|
|
||||||
const hasOtherWindows = event.otherWindowCount > 0;
|
|
||||||
const isLastWindow = isLinked && !hasOtherWindows;
|
|
||||||
return (
|
|
||||||
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
|
@ -356,65 +352,28 @@ export default function SportsSeasonEvents({
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<Form method="post">
|
<AlertDialogHeader>
|
||||||
<input type="hidden" name="intent" value="delete-event" />
|
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
||||||
<input type="hidden" name="eventId" value={event.id} />
|
<AlertDialogDescription>
|
||||||
<AlertDialogHeader>
|
This will also delete all results for this event. This action cannot be undone.
|
||||||
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
</AlertDialogDescription>
|
||||||
<AlertDialogDescription asChild>
|
</AlertDialogHeader>
|
||||||
{!isLinked ? (
|
<AlertDialogFooter>
|
||||||
<span>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
This will also delete all results for this event. This action cannot be undone.
|
<Form method="post">
|
||||||
</span>
|
<input type="hidden" name="intent" value="delete-event" />
|
||||||
) : hasOtherWindows ? (
|
<input type="hidden" name="eventId" value={event.id} />
|
||||||
<span>
|
|
||||||
This event is part of the shared tournament{" "}
|
|
||||||
<span className="font-medium">"{event.tournamentName}"</span>, also used by{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{event.otherWindowCount} other season{event.otherWindowCount !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
. Deleting removes it from <span className="font-medium">this season only</span> — the
|
|
||||||
tournament and the other seasons keep their data.
|
|
||||||
{event.isPrimary && (
|
|
||||||
<> This is the primary scoring window, so another season will be promoted to primary automatically.</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
This is the only season linked to the shared tournament{" "}
|
|
||||||
<span className="font-medium">"{event.tournamentName}"</span>. Deleting removes this
|
|
||||||
event and its results. This action cannot be undone.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
{isLastWindow && (
|
|
||||||
<label className="flex items-start gap-2 text-sm my-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="deleteTournament"
|
|
||||||
value="1"
|
|
||||||
className="mt-0.5 h-4 w-4 rounded border-input"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Also delete the shared tournament and its recorded results
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</Form>
|
||||||
</Form>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import {
|
||||||
getSportsSeasonsByTournament,
|
getSportsSeasonsByTournament,
|
||||||
getPrimaryEventForTournament,
|
getPrimaryEventForTournament,
|
||||||
setPrimaryEvent,
|
setPrimaryEvent,
|
||||||
deleteScoringEvent,
|
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import { isBracketMajor } from "~/lib/event-utils";
|
import { isBracketMajor } from "~/lib/event-utils";
|
||||||
import {
|
import {
|
||||||
|
|
@ -31,17 +30,6 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "~/components/ui/alert-dialog";
|
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -226,26 +214,6 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "unlink-window") {
|
|
||||||
const eventId = formData.get("eventId");
|
|
||||||
if (typeof eventId !== "string" || !eventId) {
|
|
||||||
return { success: false as const, error: "Event ID is required", syncReport: null };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Remove that season's scoring event. The tournament stays (we're on its
|
|
||||||
// page); if this was the primary window, another is auto-promoted.
|
|
||||||
await deleteScoringEvent(eventId);
|
|
||||||
return { success: true as const, error: null, syncReport: null };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("unlink-window failed:", error);
|
|
||||||
return {
|
|
||||||
success: false as const,
|
|
||||||
error: error instanceof Error ? error.message : "Failed to remove season",
|
|
||||||
syncReport: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false as const,
|
success: false as const,
|
||||||
error: "Invalid intent",
|
error: "Invalid intent",
|
||||||
|
|
@ -268,7 +236,6 @@ export default function AdminTournamentDetail({
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
const retryFetcher = useFetcher<typeof action>();
|
const retryFetcher = useFetcher<typeof action>();
|
||||||
const primaryFetcher = useFetcher<typeof action>();
|
const primaryFetcher = useFetcher<typeof action>();
|
||||||
const unlinkFetcher = useFetcher<typeof action>();
|
|
||||||
|
|
||||||
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
|
// For bracket majors, scoring lives on the primary window's bracket/stage UI.
|
||||||
const primaryScoringHref =
|
const primaryScoringHref =
|
||||||
|
|
@ -452,51 +419,6 @@ export default function AdminTournamentDetail({
|
||||||
>
|
>
|
||||||
{link.sportsSeason.status}
|
{link.sportsSeason.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
disabled={unlinkFetcher.state !== "idle"}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Remove {link.sportsSeason.sport.name} - {link.sportsSeason.name}?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription asChild>
|
|
||||||
<span>
|
|
||||||
This deletes that season's scoring event (and its results) but keeps{" "}
|
|
||||||
<span className="font-medium">{tournament.name}</span> and the other linked seasons.
|
|
||||||
{link.isPrimary && linkedSportsSeasons.length > 1 && (
|
|
||||||
<> Because this is the primary scoring window, another season will be promoted to primary automatically.</>
|
|
||||||
)}
|
|
||||||
{linkedSportsSeasons.length === 1 && (
|
|
||||||
<> This is the only linked season — the tournament will remain but have no windows until you link one again.</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<unlinkFetcher.Form method="post">
|
|
||||||
<input type="hidden" name="intent" value="unlink-window" />
|
|
||||||
<input type="hidden" name="eventId" value={link.id} />
|
|
||||||
<AlertDialogAction
|
|
||||||
type="submit"
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</AlertDialogAction>
|
|
||||||
</unlinkFetcher.Form>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue