diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9c7860..c3daad43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,13 @@ This project uses a user-facing changelog format. ### Added - Ylläpitäjä voi avata ajokoetapahtuman omalle sivulleen tarkastelua ja nykyisiä muokkaustoimintoja varten. +- Ylläpitäjä voi luoda ajokoetapahtuman perustietoineen ja avata sen omalle sivulleen ennen koetulosten lisäämistä. +- Ylläpitäjä voi poistaa tyhjän ajokoetapahtuman sen omalta sivulta. ### Changed +- Viimeisen koetuloksen poistaminen säilyttää ajokoetapahtuman tyhjänä, kunnes ylläpitäjä poistaa tapahtuman erikseen. + ### Fixed ### Removed diff --git a/apps/web/app/(admin)/admin/trials/new/__tests__/page.test.tsx b/apps/web/app/(admin)/admin/trials/new/__tests__/page.test.tsx new file mode 100644 index 00000000..c48701b1 --- /dev/null +++ b/apps/web/app/(admin)/admin/trials/new/__tests__/page.test.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import AdminTrialEventCreatePage from "../page"; + +const { clientPropsRef, toBusinessDateInputValueMock } = vi.hoisted(() => ({ + clientPropsRef: { current: null as { initialEventDate: string } | null }, + toBusinessDateInputValueMock: vi.fn(() => "2026-07-21"), +})); + +vi.mock("@/components/admin/trials", () => ({ + AdminTrialEventCreatePageClient: (props: { initialEventDate: string }) => { + clientPropsRef.current = props; + return React.createElement("div", null, "create-trial-event"); + }, +})); + +vi.mock("@/lib/admin/core/date", () => ({ + toBusinessDateInputValue: toBusinessDateInputValueMock, +})); + +describe("AdminTrialEventCreatePage", () => { + it("renders the event creation client", () => { + expect(renderToStaticMarkup()).toContain( + "create-trial-event", + ); + expect(toBusinessDateInputValueMock).toHaveBeenCalledWith(expect.any(Date)); + expect(clientPropsRef.current).toEqual({ + initialEventDate: "2026-07-21", + }); + }); +}); diff --git a/apps/web/app/(admin)/admin/trials/new/page.tsx b/apps/web/app/(admin)/admin/trials/new/page.tsx new file mode 100644 index 00000000..651a4ae3 --- /dev/null +++ b/apps/web/app/(admin)/admin/trials/new/page.tsx @@ -0,0 +1,10 @@ +import { AdminTrialEventCreatePageClient } from "@/components/admin/trials"; +import { toBusinessDateInputValue } from "@/lib/admin/core/date"; + +export default function AdminTrialEventCreatePage() { + return ( + + ); +} diff --git a/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-event.test.ts b/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-event.test.ts new file mode 100644 index 00000000..df1dc044 --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-event.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAdminTrialEventAction } from "../create-admin-trial-event"; + +const { guardMock, userMock, createMock } = vi.hoisted(() => ({ + guardMock: vi.fn(), + userMock: vi.fn(), + createMock: vi.fn(), +})); + +vi.mock("@/lib/server/admin-guard", () => ({ + requireAdminLayoutAccess: guardMock, +})); +vi.mock("@/lib/server/current-user", () => ({ + getSessionCurrentUser: userMock, +})); +vi.mock("@beagle/server", () => ({ createAdminTrialEvent: createMock })); + +const input = { + eventDate: "2026-07-21", + eventPlace: "Helsinki", + jarjestaja: null, + ylituomari: null, + ylituomariNumero: null, + ytKertomus: null, + kennelpiiri: null, + kennelpiirinro: null, + sklKoeId: 123, +}; + +describe("createAdminTrialEventAction", () => { + beforeEach(() => { + guardMock.mockReset(); + userMock.mockReset(); + createMock.mockReset(); + }); + + it("rejects missing admin access", async () => { + guardMock.mockResolvedValue({ ok: false, status: 403 }); + + await expect(createAdminTrialEventAction(input)).resolves.toMatchObject({ + hasError: true, + errorCode: "FORBIDDEN", + }); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("propagates service errors", async () => { + guardMock.mockResolvedValue({ ok: true }); + userMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + role: "ADMIN", + }); + createMock.mockResolvedValue({ + status: 409, + body: { ok: false, code: "SKL_KOE_ID_CONFLICT", error: "Conflict" }, + }); + + await expect(createAdminTrialEventAction(input)).resolves.toEqual({ + data: null, + hasError: true, + errorCode: "SKL_KOE_ID_CONFLICT", + message: "Conflict", + }); + }); + + it("returns navigation data", async () => { + guardMock.mockResolvedValue({ ok: true }); + userMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + role: "ADMIN", + }); + createMock.mockResolvedValue({ + status: 201, + body: { ok: true, data: { trialEventId: "event-1" } }, + }); + + await expect(createAdminTrialEventAction(input)).resolves.toEqual({ + data: { trialEventId: "event-1" }, + hasError: false, + }); + }); +}); diff --git a/apps/web/app/actions/admin/trials/manage/__tests__/delete-admin-trial-event.test.ts b/apps/web/app/actions/admin/trials/manage/__tests__/delete-admin-trial-event.test.ts new file mode 100644 index 00000000..4028ac1f --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/__tests__/delete-admin-trial-event.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deleteAdminTrialEventAction } from "../delete-admin-trial-event"; + +const { guardMock, userMock, deleteMock } = vi.hoisted(() => ({ + guardMock: vi.fn(), + userMock: vi.fn(), + deleteMock: vi.fn(), +})); + +vi.mock("@/lib/server/admin-guard", () => ({ + requireAdminLayoutAccess: guardMock, +})); +vi.mock("@/lib/server/current-user", () => ({ + getSessionCurrentUser: userMock, +})); +vi.mock("@beagle/server", () => ({ deleteAdminTrialEvent: deleteMock })); + +describe("deleteAdminTrialEventAction", () => { + beforeEach(() => { + guardMock.mockReset(); + userMock.mockReset(); + deleteMock.mockReset(); + guardMock.mockResolvedValue({ ok: true }); + userMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + role: "ADMIN", + }); + }); + + it("propagates the non-empty conflict", async () => { + deleteMock.mockResolvedValue({ + status: 409, + body: { ok: false, code: "TRIAL_EVENT_NOT_EMPTY", error: "Not empty" }, + }); + + await expect( + deleteAdminTrialEventAction({ trialEventId: "event-1" }), + ).resolves.toEqual({ + data: null, + hasError: true, + errorCode: "TRIAL_EVENT_NOT_EMPTY", + message: "Not empty", + }); + }); + + it("returns the deleted event id", async () => { + deleteMock.mockResolvedValue({ + status: 200, + body: { ok: true, data: { deletedTrialEventId: "event-1" } }, + }); + + await expect( + deleteAdminTrialEventAction({ trialEventId: "event-1" }), + ).resolves.toEqual({ + data: { deletedTrialEventId: "event-1" }, + hasError: false, + }); + }); +}); diff --git a/apps/web/app/actions/admin/trials/manage/create-admin-trial-event.ts b/apps/web/app/actions/admin/trials/manage/create-admin-trial-event.ts new file mode 100644 index 00000000..e8cf4d9d --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/create-admin-trial-event.ts @@ -0,0 +1,62 @@ +"use server"; + +import type { + CreateAdminTrialEventRequest, + CreateAdminTrialEventResponse, +} from "@beagle/contracts"; +import { createAdminTrialEvent } from "@beagle/server"; +import { requireAdminLayoutAccess } from "@/lib/server/admin-guard"; +import { getSessionCurrentUser } from "@/lib/server/current-user"; + +export type CreateAdminTrialEventActionResult = { + data: CreateAdminTrialEventResponse | null; + hasError: boolean; + errorCode?: string; + message?: string; +}; + +export async function createAdminTrialEventAction( + input: CreateAdminTrialEventRequest, +): Promise { + const adminAccess = await requireAdminLayoutAccess(); + if (!adminAccess.ok) { + return { + data: null, + hasError: true, + errorCode: adminAccess.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN", + message: "Admin access required.", + }; + } + + const currentUser = await getSessionCurrentUser(); + if (!currentUser) { + return { + data: null, + hasError: true, + errorCode: "UNAUTHENTICATED", + message: "Admin access required.", + }; + } + + const result = await createAdminTrialEvent( + input, + { + id: currentUser.id, + email: currentUser.email, + username: null, + role: currentUser.role, + }, + { actorUserId: currentUser.id }, + ); + + if (!result.body.ok) { + return { + data: null, + hasError: true, + errorCode: result.body.code, + message: result.body.error, + }; + } + + return { data: result.body.data, hasError: false }; +} diff --git a/apps/web/app/actions/admin/trials/manage/delete-admin-trial-event.ts b/apps/web/app/actions/admin/trials/manage/delete-admin-trial-event.ts new file mode 100644 index 00000000..eb184920 --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/delete-admin-trial-event.ts @@ -0,0 +1,62 @@ +"use server"; + +import type { + DeleteAdminTrialEventRequest, + DeleteAdminTrialEventResponse, +} from "@beagle/contracts"; +import { deleteAdminTrialEvent } from "@beagle/server"; +import { requireAdminLayoutAccess } from "@/lib/server/admin-guard"; +import { getSessionCurrentUser } from "@/lib/server/current-user"; + +export type DeleteAdminTrialEventActionResult = { + data: DeleteAdminTrialEventResponse | null; + hasError: boolean; + errorCode?: string; + message?: string; +}; + +export async function deleteAdminTrialEventAction( + input: DeleteAdminTrialEventRequest, +): Promise { + const adminAccess = await requireAdminLayoutAccess(); + if (!adminAccess.ok) { + return { + data: null, + hasError: true, + errorCode: adminAccess.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN", + message: "Admin access required.", + }; + } + + const currentUser = await getSessionCurrentUser(); + if (!currentUser) { + return { + data: null, + hasError: true, + errorCode: "UNAUTHENTICATED", + message: "Admin access required.", + }; + } + + const result = await deleteAdminTrialEvent( + input, + { + id: currentUser.id, + email: currentUser.email, + username: null, + role: currentUser.role, + }, + { actorUserId: currentUser.id }, + ); + + if (!result.body.ok) { + return { + data: null, + hasError: true, + errorCode: result.body.code, + message: result.body.error, + }; + } + + return { data: result.body.data, hasError: false }; +} diff --git a/apps/web/app/actions/admin/trials/manage/index.ts b/apps/web/app/actions/admin/trials/manage/index.ts index 5bbd9528..d31db353 100644 --- a/apps/web/app/actions/admin/trials/manage/index.ts +++ b/apps/web/app/actions/admin/trials/manage/index.ts @@ -1,3 +1,5 @@ +export { createAdminTrialEventAction } from "./create-admin-trial-event"; +export { deleteAdminTrialEventAction } from "./delete-admin-trial-event"; export { deleteAdminTrialEntryAction } from "./delete-admin-trial-entry"; export { updateAdminTrialEventAction } from "./update-admin-trial-event"; export { updateAdminTrialEntryAction } from "./update-admin-trial-entry"; diff --git a/apps/web/components/admin/trials/__tests__/admin-trial-event-create-page-client.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-event-create-page-client.test.tsx new file mode 100644 index 00000000..9b492494 --- /dev/null +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-create-page-client.test.tsx @@ -0,0 +1,203 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AdminMutationError } from "@/queries/admin/mutation-error"; +import { submitAdminTrialEventCreation } from "@/lib/admin/trials"; +import { AdminTrialEventCreatePageClient } from "../admin-trial-event-create-page-client"; + +const { formDraftRef, mutationState, mutateAsyncMock, replaceMock } = + vi.hoisted(() => ({ + formDraftRef: { + current: null as { eventDate: string } | null, + }, + mutationState: { isPending: false }, + mutateAsyncMock: vi.fn(), + replaceMock: vi.fn(), + })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: replaceMock }), +})); +vi.mock("@/hooks/i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); +vi.mock("@/queries/admin/trials", () => ({ + useCreateAdminTrialEventMutation: () => ({ + mutateAsync: mutateAsyncMock, + isPending: mutationState.isPending, + }), +})); +vi.mock("../admin-trial-event-form-fields", () => ({ + AdminTrialEventFormFields: (props: { draft: { eventDate: string } }) => { + formDraftRef.current = props.draft; + return React.createElement("div", null, "event-form-fields"); + }, +})); +vi.mock("@/components/ui/card", () => ({ + Card: ({ children }: { children: React.ReactNode }) => + React.createElement("section", null, children), + CardContent: ({ children }: { children: React.ReactNode }) => + React.createElement("div", null, children), +})); + +describe("AdminTrialEventCreatePageClient", () => { + beforeEach(() => { + mutationState.isPending = false; + formDraftRef.current = null; + mutateAsyncMock.mockReset(); + replaceMock.mockReset(); + }); + + it("renders the full-page form and cancel destination", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("admin.trials.manage.create.title"); + expect(html).toContain("event-form-fields"); + expect(html).toContain('href="/admin/trials"'); + expect(html).toContain("admin.trials.manage.create.save"); + expect(formDraftRef.current?.eventDate).toBe("2026-07-21"); + }); + + it("disables submission while the create mutation is pending", () => { + mutationState.isPending = true; + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("admin.trials.manage.create.saving"); + expect(html).toContain('type="submit" disabled=""'); + }); + + const validDraft = { + eventDate: " 2026-07-21 ", + eventPlace: " Helsinki ", + jarjestaja: " Club ", + ylituomari: " Judge ", + ylituomariNumero: " 123 ", + ytKertomus: " Report ", + kennelpiiri: " District ", + kennelpiirinro: " 10 ", + sklKoeId: " 456 ", + }; + const t = (key: string) => key; + + it.each([ + [ + "event date", + { eventDate: " " }, + "admin.trials.manage.eventModal.validation.invalidDate", + ], + [ + "event place", + { eventPlace: " " }, + "admin.trials.manage.eventModal.validation.requiredPlace", + ], + [ + "SKL id", + { sklKoeId: "0" }, + "admin.trials.manage.eventModal.validation.invalidSklKoeId", + ], + ])( + "validates the required %s before mutation", + async (_label, override, error) => { + const setErrorText = vi.fn(); + + await submitAdminTrialEventCreation({ + draft: { ...validDraft, ...override }, + isPending: false, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText, + t, + }); + + expect(mutateAsyncMock).not.toHaveBeenCalled(); + expect(setErrorText).toHaveBeenLastCalledWith(error); + }, + ); + + it("normalizes the request and navigates to the exact workspace", async () => { + mutateAsyncMock.mockResolvedValue({ trialEventId: "event/with spaces" }); + + await submitAdminTrialEventCreation({ + draft: validDraft, + isPending: false, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText: vi.fn(), + t, + }); + + expect(mutateAsyncMock).toHaveBeenCalledWith({ + eventDate: "2026-07-21", + eventPlace: "Helsinki", + jarjestaja: "Club", + ylituomari: "Judge", + ylituomariNumero: "123", + ytKertomus: "Report", + kennelpiiri: "District", + kennelpiirinro: "10", + sklKoeId: 456, + }); + expect(replaceMock).toHaveBeenCalledWith( + "/admin/trials/event%2Fwith%20spaces", + ); + }); + + it("preserves the draft and shows localized duplicate feedback", async () => { + const draft = { ...validDraft }; + const originalDraft = { ...draft }; + const setErrorText = vi.fn(); + mutateAsyncMock.mockRejectedValue( + new AdminMutationError("Conflict", "SKL_KOE_ID_CONFLICT"), + ); + + await submitAdminTrialEventCreation({ + draft, + isPending: false, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText, + t, + }); + + expect(draft).toEqual(originalDraft); + expect(setErrorText).toHaveBeenLastCalledWith( + "admin.trials.manage.create.conflict", + ); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("maps unexpected failures and blocks duplicate pending submissions", async () => { + const setErrorText = vi.fn(); + mutateAsyncMock.mockRejectedValue(new Error("boom")); + + await submitAdminTrialEventCreation({ + draft: validDraft, + isPending: false, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText, + t, + }); + expect(setErrorText).toHaveBeenLastCalledWith( + "admin.trials.manage.create.error", + ); + + mutateAsyncMock.mockClear(); + setErrorText.mockClear(); + await submitAdminTrialEventCreation({ + draft: validDraft, + isPending: true, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText, + t, + }); + expect(mutateAsyncMock).not.toHaveBeenCalled(); + expect(setErrorText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/admin/trials/__tests__/admin-trial-event-delete-action.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-event-delete-action.test.tsx new file mode 100644 index 00000000..72e0bb7a --- /dev/null +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-delete-action.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AdminMutationError } from "@/queries/admin/mutation-error"; +import { AdminTrialEventDeleteAction } from "../admin-trial-event-delete-action"; + +const { + confirmModalPropsRef, + mutationState, + mutateAsyncMock, + toastErrorMock, + toastSuccessMock, +} = vi.hoisted(() => ({ + confirmModalPropsRef: { current: null as Record | null }, + mutationState: { isPending: false }, + mutateAsyncMock: vi.fn(), + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn(), +})); + +vi.mock("@/hooks/i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/queries/admin/trials", () => ({ + useDeleteAdminTrialEventMutation: () => ({ + mutateAsync: mutateAsyncMock, + isPending: mutationState.isPending, + }), +})); + +vi.mock("@/components/ui/sonner", () => ({ + toast: { + error: toastErrorMock, + success: toastSuccessMock, + }, +})); + +vi.mock("@/components/ui/confirm-modal", () => ({ + ConfirmModal: (props: Record) => { + confirmModalPropsRef.current = props; + return React.createElement("div", null, String(props.description)); + }, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => + React.createElement("button", props, children), +})); + +function renderAction(overrides?: { + onDeleted?: (trialEventId: string) => void; + onNotEmpty?: () => void; +}) { + const onDeleted = overrides?.onDeleted ?? vi.fn(); + const onNotEmpty = overrides?.onNotEmpty ?? vi.fn(); + const html = renderToStaticMarkup( + , + ); + + return { html, onDeleted, onNotEmpty }; +} + +function confirmDeletion() { + const onConfirm = confirmModalPropsRef.current?.onConfirm as () => void; + onConfirm(); +} + +describe("AdminTrialEventDeleteAction", () => { + beforeEach(() => { + confirmModalPropsRef.current = null; + mutationState.isPending = false; + mutateAsyncMock.mockReset(); + toastErrorMock.mockReset(); + toastSuccessMock.mockReset(); + }); + + it("wires the destructive confirmation and pending state", () => { + mutationState.isPending = true; + + const { html } = renderAction(); + + expect(html).toContain("admin.trials.manage.deleteEvent.action"); + expect(html).not.toContain("admin.trials.manage.deleteEvent.error"); + expect(confirmModalPropsRef.current).toMatchObject({ + open: false, + isConfirming: true, + title: "admin.trials.manage.deleteEvent.confirmTitle", + description: "admin.trials.manage.deleteEvent.confirmBody", + }); + }); + + it("shows success feedback and reports the deleted event", async () => { + mutateAsyncMock.mockResolvedValue({ deletedTrialEventId: "event-1" }); + const { onDeleted, onNotEmpty } = renderAction(); + + confirmDeletion(); + + await vi.waitFor(() => { + expect(toastSuccessMock).toHaveBeenCalledWith( + "admin.trials.manage.deleteEvent.success", + ); + }); + expect(onDeleted).toHaveBeenCalledWith("event-1"); + expect(onNotEmpty).not.toHaveBeenCalled(); + expect(toastErrorMock).not.toHaveBeenCalled(); + }); + + it("shows one generic error toast without reporting deletion", async () => { + mutateAsyncMock.mockRejectedValue(new Error("boom")); + const { onDeleted, onNotEmpty } = renderAction(); + + confirmDeletion(); + + await vi.waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "admin.trials.manage.deleteEvent.error", + ); + }); + expect(toastErrorMock).toHaveBeenCalledOnce(); + expect(onDeleted).not.toHaveBeenCalled(); + expect(onNotEmpty).not.toHaveBeenCalled(); + }); + + it("refreshes and shows localized feedback for a non-empty conflict", async () => { + mutateAsyncMock.mockRejectedValue( + new AdminMutationError("Conflict", "TRIAL_EVENT_NOT_EMPTY"), + ); + const { onDeleted, onNotEmpty } = renderAction(); + + confirmDeletion(); + + await vi.waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "admin.trials.manage.deleteEvent.notEmpty", + ); + }); + expect(onNotEmpty).toHaveBeenCalledOnce(); + expect(onDeleted).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx index 2d3e23a6..8fd60994 100644 --- a/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx @@ -98,6 +98,7 @@ describe("AdminTrialEventWorkspacePageClient", () => { trialEventId: "event-1", }); expect(panelPropsRef.current?.workspaceHref).toBeUndefined(); + expect(panelPropsRef.current?.allowEmptyEventDeletion).toBe(true); }); it("passes loading state to the reused panel", () => { diff --git a/apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx index a4ff0fb8..41dd1c23 100644 --- a/apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx +++ b/apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx @@ -72,6 +72,11 @@ vi.mock("../admin-trial-event-edit-dialog", () => ({ React.createElement("div", null, `edit-dialog-${open}`), })); +vi.mock("../admin-trial-event-delete-action", () => ({ + AdminTrialEventDeleteAction: ({ trialEventId }: { trialEventId: string }) => + React.createElement("div", null, `delete-event-${trialEventId}`), +})); + vi.mock("@/queries/admin/trials", () => ({ useUpdateAdminTrialEventMutation: () => ({ mutateAsync: vi.fn(), @@ -164,4 +169,33 @@ describe("AdminTrialSelectedEventPanel", () => { "admin.trials.manage.selected.actions.openWorkspace", ); }); + + it("shows event deletion only for an empty event when explicitly allowed", () => { + const html = renderToStaticMarkup( + React.createElement(AdminTrialSelectedEventPanel, { + selectedEvent: { + trialEventId: "event-1", + eventDate: "2026-04-14", + eventPlace: "Helsinki", + eventName: null, + jarjestaja: null, + ylituomari: null, + ylituomariNumero: null, + ytKertomus: null, + kennelpiiri: null, + kennelpiirinro: null, + sklKoeId: 12345, + dogCount: 0, + entries: [], + }, + isLoading: false, + isError: false, + errorText: "error", + onDeletedTrialEvent: vi.fn(), + allowEmptyEventDeletion: true, + }), + ); + + expect(html).toContain("delete-event-event-1"); + }); }); diff --git a/apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx index a7ffb103..5418cf17 100644 --- a/apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx +++ b/apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx @@ -169,6 +169,8 @@ describe("AdminTrialsPageClient", () => { expect(html).toContain("admin.trials.title"); expect(html).toContain("admin.trials.description"); + expect(html).toContain("admin.trials.manage.create.action"); + expect(html).toContain("/admin/trials/new"); expect(html).toContain("year|||"); expect(html).toContain("2|1|1|event-1|admin.trials.manage.error"); expect(html).toContain( diff --git a/apps/web/components/admin/trials/admin-trial-event-create-page-client.tsx b/apps/web/components/admin/trials/admin-trial-event-create-page-client.tsx new file mode 100644 index 00000000..df510cd5 --- /dev/null +++ b/apps/web/components/admin/trials/admin-trial-event-create-page-client.tsx @@ -0,0 +1,84 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useI18n } from "@/hooks/i18n"; +import { + getAdminTrialsHref, + submitAdminTrialEventCreation, +} from "@/lib/admin/trials"; +import { useCreateAdminTrialEventMutation } from "@/queries/admin/trials"; +import { createEmptyTrialEventDraft } from "./admin-trial-event-edit-dialog-helpers"; +import { AdminTrialEventFormFields } from "./admin-trial-event-form-fields"; + +// Renders and submits the full-page manual trial event creation flow. +export function AdminTrialEventCreatePageClient({ + initialEventDate, +}: { + initialEventDate: string; +}) { + const { t } = useI18n(); + const router = useRouter(); + const createMutation = useCreateAdminTrialEventMutation(); + const [draft, setDraft] = useState(() => ({ + ...createEmptyTrialEventDraft(), + eventDate: initialEventDate, + })); + const [errorText, setErrorText] = useState(null); + + return ( +
+
+

+ {t("admin.trials.manage.create.title")} +

+

+ {t("admin.trials.manage.create.description")} +

+
+ + +
{ + event.preventDefault(); + void submitAdminTrialEventCreation({ + draft, + isPending: createMutation.isPending, + mutateAsync: createMutation.mutateAsync, + replace: router.replace, + setErrorText, + t, + }); + }} + > + {errorText ? ( +

{errorText}

+ ) : null} + +
+ + +
+ +
+
+
+ ); +} diff --git a/apps/web/components/admin/trials/admin-trial-event-delete-action.tsx b/apps/web/components/admin/trials/admin-trial-event-delete-action.tsx new file mode 100644 index 00000000..41501818 --- /dev/null +++ b/apps/web/components/admin/trials/admin-trial-event-delete-action.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ConfirmModal } from "@/components/ui/confirm-modal"; +import { toast } from "@/components/ui/sonner"; +import { useI18n } from "@/hooks/i18n"; +import { AdminMutationError } from "@/queries/admin/mutation-error"; +import { useDeleteAdminTrialEventMutation } from "@/queries/admin/trials"; + +type AdminTrialEventDeleteActionProps = { + trialEventId: string; + onDeleted: (deletedTrialEventId: string) => void; + onNotEmpty: () => void; +}; + +// Coordinates confirmation, mutation feedback, and workspace callbacks for empty-event deletion. +export function AdminTrialEventDeleteAction({ + trialEventId, + onDeleted, + onNotEmpty, +}: AdminTrialEventDeleteActionProps) { + const { t } = useI18n(); + const deleteMutation = useDeleteAdminTrialEventMutation(); + const [isOpen, setIsOpen] = useState(false); + + async function handleDelete() { + try { + const result = await deleteMutation.mutateAsync({ trialEventId }); + toast.success(t("admin.trials.manage.deleteEvent.success")); + setIsOpen(false); + onDeleted(result.deletedTrialEventId); + } catch (error) { + if ( + error instanceof AdminMutationError && + error.errorCode === "TRIAL_EVENT_NOT_EMPTY" + ) { + onNotEmpty(); + } + toast.error( + error instanceof AdminMutationError + ? error.errorCode === "TRIAL_EVENT_NOT_EMPTY" + ? t("admin.trials.manage.deleteEvent.notEmpty") + : error.message + : t("admin.trials.manage.deleteEvent.error"), + ); + } + } + + return ( + <> +
+ +
+ void handleDelete()} + onCancel={() => { + if (!deleteMutation.isPending) setIsOpen(false); + }} + /> + + ); +} diff --git a/apps/web/components/admin/trials/admin-trial-event-edit-dialog-helpers.ts b/apps/web/components/admin/trials/admin-trial-event-edit-dialog-helpers.ts index 718b1e21..d4284e87 100644 --- a/apps/web/components/admin/trials/admin-trial-event-edit-dialog-helpers.ts +++ b/apps/web/components/admin/trials/admin-trial-event-edit-dialog-helpers.ts @@ -1,4 +1,5 @@ import type { AdminTrialEventDetails } from "@beagle/contracts"; +export { parseSklKoeIdDraft } from "@/lib/admin/trials/submit-admin-trial-event-creation"; export type TrialEventDraft = { eventDate: string; @@ -12,6 +13,20 @@ export type TrialEventDraft = { sklKoeId: string; }; +export function createEmptyTrialEventDraft(): TrialEventDraft { + return { + eventDate: "", + eventPlace: "", + jarjestaja: "", + ylituomari: "", + ylituomariNumero: "", + ytKertomus: "", + kennelpiiri: "", + kennelpiirinro: "", + sklKoeId: "", + }; +} + export function toTrialEventDraft( event: AdminTrialEventDetails, ): TrialEventDraft { @@ -44,19 +59,3 @@ export function areTrialEventDraftsEqual( (left.sklKoeId === null ? "" : String(left.sklKoeId)) === right.sklKoeId ); } - -const VALID_SKL_KOE_ID_PATTERN = /^[1-9]\d*$/; - -export function parseSklKoeIdDraft(value: string): number | null { - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - if (!VALID_SKL_KOE_ID_PATTERN.test(trimmed)) { - return null; - } - - const parsed = Number(trimmed); - return Number.isSafeInteger(parsed) ? parsed : null; -} diff --git a/apps/web/components/admin/trials/admin-trial-event-edit-dialog.tsx b/apps/web/components/admin/trials/admin-trial-event-edit-dialog.tsx index cb1e0f20..2d63bead 100644 --- a/apps/web/components/admin/trials/admin-trial-event-edit-dialog.tsx +++ b/apps/web/components/admin/trials/admin-trial-event-edit-dialog.tsx @@ -3,7 +3,6 @@ import React, { useMemo, useState } from "react"; import { AdminFormModalShell } from "@/components/admin"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { useI18n } from "@/hooks/i18n"; import type { AdminTrialEventDetails } from "@beagle/contracts"; import { @@ -11,6 +10,7 @@ import { parseSklKoeIdDraft, toTrialEventDraft, } from "./admin-trial-event-edit-dialog-helpers"; +import { AdminTrialEventFormFields } from "./admin-trial-event-form-fields"; export type UpdateAdminTrialEventPayload = { trialEventId: string; @@ -197,135 +197,12 @@ function AdminTrialEventEditDialogContent({

{errorText}

) : null} -
- - - - - -