diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3871f8..6ff85a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,24 @@ 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. +- Ylläpitäjä voi lisätä ajokoetapahtumaan koirakohtaisia tuloksia yksi kerrallaan ja jatkaa seuraavaan tulokseen tai palata tapahtuman sivulle. + ### Changed +- Ajokoetuloksen lisäyslomakkeen avattavat kortit noudattavat virallisen pöytäkirjan aihealueita ilman pöytäkirjan taulukkoasettelun kopiointia. Lisätietoja muokataan pöytäkirjan mukaisissa yksittäin avattavissa ryhmissä myös mobiilissa. +- Viimeisen koetuloksen poistaminen säilyttää ajokoetapahtuman tyhjänä, kunnes ylläpitäjä poistaa tapahtuman erikseen. +- Uuden ajokoetapahtuman luomisen jälkeen siirrytään suoraan lisäämään tapahtuman ensimmäistä koetulosta. +- Ajokoetuloksen lisäyslomake näyttää 1.8.2023 alkaen käytössä olevan sääntökauden mukaiset kentät. Vanhemmilla tai tunnistamattomilla sääntökausilla kaikki yhteensopivuuskentät säilyvät näkyvissä varoituksen kanssa. + ### Fixed +- Ajokoetuloksen lisätietorivit tallennetaan kelvollisessa kokonaislukujärjestyksessä, ja hylätty lisätietorivi näytetään aiempaa tarkemmin. +- Ajokoetuloksen lisäyslomakkeen selaimen Takaisin-toiminto ei enää yritä perua navigointia epäluotettavalla edestakaisella sivuhistorian käsittelyllä. +- Ylisuuria kokonaislukuarvoja ei enää tallenneta tyhjinä ajokoetuloksen luonnissa tai muokkauksessa. + ### Removed ## [0.19.0] - 2026-07-20 diff --git a/apps/web/app/(admin)/admin/trials/[trialEventId]/__tests__/page.test.tsx b/apps/web/app/(admin)/admin/trials/[trialEventId]/__tests__/page.test.tsx new file mode 100644 index 00000000..195da362 --- /dev/null +++ b/apps/web/app/(admin)/admin/trials/[trialEventId]/__tests__/page.test.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import AdminTrialEventWorkspacePage from "../page"; + +vi.mock("@/components/admin/trials", () => ({ + AdminTrialEventWorkspacePageClient: ({ + trialEventId, + }: { + trialEventId: string; + }) => React.createElement("div", null, trialEventId), +})); + +describe("AdminTrialEventWorkspacePage", () => { + it("forwards the route trial event id", async () => { + const page = await AdminTrialEventWorkspacePage({ + params: Promise.resolve({ trialEventId: "event-1" }), + }); + + expect(renderToStaticMarkup(page)).toContain("event-1"); + }); +}); diff --git a/apps/web/app/(admin)/admin/trials/[trialEventId]/page.tsx b/apps/web/app/(admin)/admin/trials/[trialEventId]/page.tsx new file mode 100644 index 00000000..a7c7d62b --- /dev/null +++ b/apps/web/app/(admin)/admin/trials/[trialEventId]/page.tsx @@ -0,0 +1,11 @@ +import { AdminTrialEventWorkspacePageClient } from "@/components/admin/trials"; + +export default async function AdminTrialEventWorkspacePage({ + params, +}: { + params: Promise<{ trialEventId: string }>; +}) { + const { trialEventId } = await params; + + return ; +} diff --git a/apps/web/app/(admin)/admin/trials/[trialEventId]/results/new/__tests__/page.test.tsx b/apps/web/app/(admin)/admin/trials/[trialEventId]/results/new/__tests__/page.test.tsx new file mode 100644 index 00000000..24af523f --- /dev/null +++ b/apps/web/app/(admin)/admin/trials/[trialEventId]/results/new/__tests__/page.test.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import Page from "../page"; + +vi.mock( + "@/components/admin/trials/admin-trial-entry-create-page-client", + () => ({ + AdminTrialEntryCreatePageClient: ({ + trialEventId, + }: { + trialEventId: string; + }) => React.createElement("div", null, trialEventId), + }), +); + +describe("manual trial result create page", () => { + it("passes the route event id to the client workflow", async () => { + const html = renderToStaticMarkup( + await Page({ params: Promise.resolve({ trialEventId: "event-1" }) }), + ); + expect(html).toContain("event-1"); + }); +}); diff --git a/apps/web/app/(admin)/admin/trials/[trialEventId]/results/new/page.tsx b/apps/web/app/(admin)/admin/trials/[trialEventId]/results/new/page.tsx new file mode 100644 index 00000000..6a37c2c6 --- /dev/null +++ b/apps/web/app/(admin)/admin/trials/[trialEventId]/results/new/page.tsx @@ -0,0 +1,10 @@ +import { AdminTrialEntryCreatePageClient } from "@/components/admin/trials/admin-trial-entry-create-page-client"; + +export default async function AdminTrialEntryCreatePage({ + params, +}: { + params: Promise<{ trialEventId: string }>; +}) { + const { trialEventId } = await params; + return ; +} 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/index.ts b/apps/web/app/actions/admin/trials/index.ts index f4a9801b..75427cf5 100644 --- a/apps/web/app/actions/admin/trials/index.ts +++ b/apps/web/app/actions/admin/trials/index.ts @@ -1,3 +1,4 @@ export { deleteAdminTrialEntryAction } from "./manage"; export { updateAdminTrialEventAction } from "./manage"; export { updateAdminTrialEntryAction } from "./manage"; +export { createAdminTrialEntryAction } from "./manage"; diff --git a/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-entry.test.ts b/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-entry.test.ts new file mode 100644 index 00000000..a14b81f5 --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-entry.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAdminTrialEntryAction } from "../create-admin-trial-entry"; + +const { guard, user, create } = vi.hoisted(() => ({ + guard: vi.fn(), + user: vi.fn(), + create: vi.fn(), +})); +vi.mock("@/lib/server/admin-guard", () => ({ + requireAdminLayoutAccess: guard, +})); +vi.mock("@/lib/server/current-user", () => ({ getSessionCurrentUser: user })); +vi.mock("@beagle/server", () => ({ createAdminTrialEntry: create })); + +const input = { + trialEventId: "event-1", + registrationNo: "FI1/20", + entry: {} as never, + eras: [], + lisatiedotRows: [], +}; + +describe("createAdminTrialEntryAction", () => { + beforeEach(() => { + guard.mockReset(); + user.mockReset(); + create.mockReset(); + }); + + it("rejects missing admin access", async () => { + guard.mockResolvedValue({ ok: false, status: 403 }); + await expect(createAdminTrialEntryAction(input)).resolves.toMatchObject({ + hasError: true, + errorCode: "FORBIDDEN", + }); + }); + + it("passes through stable service errors", async () => { + guard.mockResolvedValue({ ok: true }); + user.mockResolvedValue({ id: "u1", email: "a@example.com", role: "ADMIN" }); + create.mockResolvedValue({ + status: 409, + body: { + ok: false, + code: "TRIAL_ENTRY_REGISTRATION_CONFLICT", + error: "Conflict", + }, + }); + await expect(createAdminTrialEntryAction(input)).resolves.toEqual({ + data: null, + hasError: true, + errorCode: "TRIAL_ENTRY_REGISTRATION_CONFLICT", + message: "Conflict", + }); + }); + + it("passes through safe validation details", async () => { + guard.mockResolvedValue({ ok: true }); + user.mockResolvedValue({ id: "u1", email: "a@example.com", role: "ADMIN" }); + create.mockResolvedValue({ + status: 400, + body: { + ok: false, + code: "INVALID_TRIAL_ADDITIONAL_INFO", + error: "Invalid", + details: { + area: "additional_info", + reason: "invalid_lisatieto_order", + koodi: "25", + osa: "b", + }, + }, + }); + await expect(createAdminTrialEntryAction(input)).resolves.toMatchObject({ + validationIssue: { + reason: "invalid_lisatieto_order", + koodi: "25", + osa: "b", + }, + }); + }); +}); 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-entry.ts b/apps/web/app/actions/admin/trials/manage/create-admin-trial-entry.ts new file mode 100644 index 00000000..34ac0bed --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/create-admin-trial-entry.ts @@ -0,0 +1,90 @@ +"use server"; + +import type { + AdminTrialEntryValidationIssue, + CreateAdminTrialEntryRequest, + CreateAdminTrialEntryResponse, +} from "@beagle/contracts"; +import { createAdminTrialEntry } from "@beagle/server"; +import { requireAdminLayoutAccess } from "@/lib/server/admin-guard"; +import { getSessionCurrentUser } from "@/lib/server/current-user"; + +export type CreateAdminTrialEntryActionResult = { + data: CreateAdminTrialEntryResponse | null; + hasError: boolean; + errorCode?: string; + message?: string; + validationIssue?: AdminTrialEntryValidationIssue; +}; + +const VALIDATION_REASONS = new Set([ + "invalid_write_shape", + "invalid_koetyyppi", + "invalid_huomautus", + "invalid_entry_integer", + "invalid_entry_number", + "missing_eras", + "invalid_era_number", + "duplicate_eras", + "non_continuous_eras", + "invalid_era_integer", + "invalid_era_number_field", + "invalid_lisatieto_code", + "duplicate_lisatieto_key", + "invalid_lisatieto_era", + "duplicate_lisatieto_era_value", + "invalid_lisatieto_order", +]); + +function toValidationIssue( + value: unknown, +): AdminTrialEntryValidationIssue | undefined { + if (!value || typeof value !== "object") return undefined; + const candidate = value as Partial; + if ( + !["entry", "eras", "additional_info"].includes(candidate.area ?? "") || + !candidate.reason || + !VALIDATION_REASONS.has(candidate.reason) + ) + return undefined; + return candidate as AdminTrialEntryValidationIssue; +} + +export async function createAdminTrialEntryAction( + input: CreateAdminTrialEntryRequest, +): Promise { + const access = await requireAdminLayoutAccess(); + if (!access.ok) { + return { + data: null, + hasError: true, + errorCode: access.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN", + message: "Admin access required.", + }; + } + const user = await getSessionCurrentUser(); + if (!user) { + return { + data: null, + hasError: true, + errorCode: "UNAUTHENTICATED", + message: "Admin access required.", + }; + } + const result = await createAdminTrialEntry( + input, + { id: user.id, email: user.email, username: null, role: user.role }, + { actorUserId: user.id }, + ); + if (!result.body.ok) { + const validationIssue = toValidationIssue(result.body.details); + return { + data: null, + hasError: true, + errorCode: result.body.code, + message: result.body.error, + ...(validationIssue ? { validationIssue } : {}), + }; + } + return { data: result.body.data, 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..7732dfce 100644 --- a/apps/web/app/actions/admin/trials/manage/index.ts +++ b/apps/web/app/actions/admin/trials/manage/index.ts @@ -1,3 +1,6 @@ +export { createAdminTrialEventAction } from "./create-admin-trial-event"; +export { createAdminTrialEntryAction } from "./create-admin-trial-entry"; +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-entry-actions.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-entry-actions.test.tsx index a0d60d5e..a1be33dc 100644 --- a/apps/web/components/admin/trials/__tests__/admin-trial-entry-actions.test.tsx +++ b/apps/web/components/admin/trials/__tests__/admin-trial-entry-actions.test.tsx @@ -93,6 +93,7 @@ describe("AdminTrialEntryActions", () => { it("calls delete mutation with correct ids", async () => { vi.stubGlobal("window", { confirm: () => true, alert: vi.fn() }); + const onDeletedTrialEvent = vi.fn(); const mutateAsync = vi.fn().mockResolvedValue({ deletedTrialEntryId: "entry-1", trialEventId: "event-1", @@ -113,7 +114,7 @@ describe("AdminTrialEntryActions", () => { eventPlace: "Helsinki", eventName: "Kevatkoe", onEditEntry: vi.fn(), - onDeletedTrialEvent: vi.fn(), + onDeletedTrialEvent, }), ); @@ -123,6 +124,37 @@ describe("AdminTrialEntryActions", () => { trialEventId: "event-1", trialEntryId: "entry-1", }); + expect(onDeletedTrialEvent).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("keeps the current event selected when deletion fails", async () => { + const alert = vi.fn(); + vi.stubGlobal("window", { confirm: () => true, alert }); + const onDeletedTrialEvent = vi.fn(); + useDeleteAdminTrialEntryMutationMock.mockReturnValue({ + mutateAsync: vi.fn().mockRejectedValue(new Error("Delete failed")), + isPending: false, + }); + + renderToStaticMarkup( + React.createElement(AdminTrialEntryActions, { + trialEventId: "event-1", + trialEntryId: "entry-1", + dogName: "Rex", + registrationNo: "FI123", + eventDate: "2026-04-14", + eventPlace: "Helsinki", + eventName: "Kevatkoe", + onEditEntry: vi.fn(), + onDeletedTrialEvent, + }), + ); + + await rowActionsMock.current[1]?.onSelect(); + + expect(onDeletedTrialEvent).not.toHaveBeenCalled(); + expect(alert).toHaveBeenCalledWith("Delete failed"); vi.unstubAllGlobals(); }); 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..9cd7a967 --- /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/results/new", + ); + }); + + 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-edit-dialog.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-event-edit-dialog.test.tsx index c532fe21..6dc0f26f 100644 --- a/apps/web/components/admin/trials/__tests__/admin-trial-event-edit-dialog.test.tsx +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-edit-dialog.test.tsx @@ -51,6 +51,7 @@ vi.mock("@/components/ui/input", () => ({ const event1 = { trialEventId: "event-1", + trialRuleWindowId: "trw_post_20230801", eventDate: "2026-04-14", eventPlace: "Helsinki", eventName: "Kevatkoe", 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 new file mode 100644 index 00000000..0bf569af --- /dev/null +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AdminTrialEventWorkspacePageClient } from "../admin-trial-event-workspace-page-client"; + +const { + buttonActionsRef, + panelPropsRef, + queryState, + refetchMock, + replaceMock, +} = vi.hoisted(() => ({ + buttonActionsRef: { + current: {} as Record void) | undefined>, + }, + panelPropsRef: { current: null as Record | null }, + queryState: { + data: undefined as { event: { trialEventId: string } } | undefined, + error: null as { errorCode?: string } | null, + isError: false, + isLoading: false, + }, + refetchMock: vi.fn(), + replaceMock: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: replaceMock }), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + asChild, + ...props + }: { + children: React.ReactNode; + asChild?: boolean; + [key: string]: unknown; + }) => { + if (typeof children === "string") { + buttonActionsRef.current[children] = props.onClick as + | (() => void) + | undefined; + } + return asChild + ? React.createElement(React.Fragment, null, children) + : React.createElement("button", props, children); + }, +})); + +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), +})); + +vi.mock("@/hooks/i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/queries/admin/trials", () => ({ + useAdminTrialEventQuery: () => ({ ...queryState, refetch: refetchMock }), +})); + +vi.mock("../admin-trial-selected-event-panel", () => ({ + AdminTrialSelectedEventPanel: (props: Record) => { + panelPropsRef.current = props; + return React.createElement("section", null, "selected-event-panel"); + }, +})); + +describe("AdminTrialEventWorkspacePageClient", () => { + beforeEach(() => { + queryState.data = undefined; + queryState.error = null; + queryState.isError = false; + queryState.isLoading = false; + panelPropsRef.current = null; + buttonActionsRef.current = {}; + refetchMock.mockReset(); + replaceMock.mockReset(); + }); + + it("loads the requested event and renders the reused panel without a self-link", () => { + queryState.data = { event: { trialEventId: "event-1" } }; + + const html = renderToStaticMarkup( + React.createElement(AdminTrialEventWorkspacePageClient, { + trialEventId: "event-1", + }), + ); + + expect(html).toContain("admin.trials.manage.workspace.title"); + expect(html).toContain("selected-event-panel"); + expect(panelPropsRef.current?.selectedEvent).toEqual({ + trialEventId: "event-1", + }); + expect(panelPropsRef.current?.workspaceHref).toBeUndefined(); + expect(panelPropsRef.current?.allowEmptyEventDeletion).toBe(true); + expect(panelPropsRef.current?.createEntryHref).toBe( + "/admin/trials/event-1/results/new", + ); + }); + + it("passes loading state to the reused panel", () => { + queryState.isLoading = true; + + renderToStaticMarkup( + React.createElement(AdminTrialEventWorkspacePageClient, { + trialEventId: "event-1", + }), + ); + + expect(panelPropsRef.current?.isLoading).toBe(true); + }); + + it("renders a dedicated not-found state without another event", () => { + queryState.isError = true; + queryState.error = { errorCode: "TRIAL_EVENT_NOT_FOUND" }; + + const html = renderToStaticMarkup( + React.createElement(AdminTrialEventWorkspacePageClient, { + trialEventId: "missing-event", + }), + ); + + expect(html).toContain("admin.trials.manage.workspace.notFound"); + expect(html).not.toContain("selected-event-panel"); + expect(html).not.toContain("admin.trials.manage.workspace.retry"); + }); + + it("renders a retry action for generic read errors", () => { + queryState.isError = true; + queryState.error = { errorCode: "INTERNAL_ERROR" }; + + const html = renderToStaticMarkup( + React.createElement(AdminTrialEventWorkspacePageClient, { + trialEventId: "event-1", + }), + ); + + expect(html).toContain("admin.trials.manage.workspace.error"); + expect(html).toContain("admin.trials.manage.workspace.retry"); + buttonActionsRef.current["admin.trials.manage.workspace.retry"]?.(); + expect(refetchMock).toHaveBeenCalledOnce(); + }); + + it("returns to the trials page when the current event is deleted", () => { + queryState.data = { event: { trialEventId: "event-1" } }; + + renderToStaticMarkup( + React.createElement(AdminTrialEventWorkspacePageClient, { + trialEventId: "event-1", + }), + ); + const onDeletedTrialEvent = panelPropsRef.current + ?.onDeletedTrialEvent as () => void; + onDeletedTrialEvent(); + + expect(replaceMock).toHaveBeenCalledWith("/admin/trials"); + }); +}); 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 9f33a278..f1edacaf 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 @@ -38,11 +38,16 @@ vi.mock("@/components/ui/card", () => ({ vi.mock("@/components/ui/button", () => ({ Button: ({ children, + asChild, ...props }: { children: React.ReactNode; + asChild?: boolean; [key: string]: unknown; - }) => React.createElement("button", props, children), + }) => + asChild + ? React.createElement(React.Fragment, null, children) + : React.createElement("button", props, children), })); vi.mock("next/link", () => ({ @@ -67,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(), @@ -84,6 +94,7 @@ describe("AdminTrialSelectedEventPanel", () => { React.createElement(AdminTrialSelectedEventPanel, { selectedEvent: { trialEventId: "event-1", + trialRuleWindowId: "trw_post_20230801", eventDate: "2026-04-14", eventPlace: "Helsinki", eventName: "Kevatkoe", @@ -123,5 +134,71 @@ describe("AdminTrialSelectedEventPanel", () => { expect(html).toContain("actions-trial-1"); expect(html).toContain("admin.trials.manage.selected.actions.editEvent"); expect(html).toContain("edit-dialog-false"); + expect(html).not.toContain( + "admin.trials.manage.selected.actions.openWorkspace", + ); + }); + + it("renders an optional event workspace link", () => { + const html = renderToStaticMarkup( + React.createElement(AdminTrialSelectedEventPanel, { + selectedEvent: { + trialEventId: "event-1", + trialRuleWindowId: null, + 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", + workspaceHref: "/admin/trials/event-1", + onDeletedTrialEvent: vi.fn(), + }), + ); + + expect(html).toContain('href="/admin/trials/event-1"'); + expect(html).toContain( + "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", + trialRuleWindowId: null, + 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 3e481b2e..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 @@ -72,17 +72,19 @@ vi.mock("../admin-trial-selected-event-panel", () => ({ isError, errorText, onDeletedTrialEvent, + workspaceHref, }: { selectedEvent: { trialEventId: string } | null; isLoading: boolean; isError: boolean; errorText: string; onDeletedTrialEvent: (deletedTrialEventId: string) => void; + workspaceHref?: string; }) => React.createElement( "section", { "data-testid": "selected" }, - `${selectedEvent?.trialEventId ?? ""}|${isLoading}|${isError}|${errorText}|${typeof onDeletedTrialEvent}`, + `${selectedEvent?.trialEventId ?? ""}|${isLoading}|${isError}|${errorText}|${typeof onDeletedTrialEvent}|${workspaceHref ?? ""}`, ), })); @@ -167,10 +169,12 @@ 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( - "event-1|false|false|admin.trials.manage.selected.error|function", + "event-1|false|false|admin.trials.manage.selected.error|function|/admin/trials/event-1", ); }); diff --git a/apps/web/components/admin/trials/admin-trial-entry-create-page-client.tsx b/apps/web/components/admin/trials/admin-trial-entry-create-page-client.tsx new file mode 100644 index 00000000..0ddcf530 --- /dev/null +++ b/apps/web/components/admin/trials/admin-trial-entry-create-page-client.tsx @@ -0,0 +1,587 @@ +"use client"; + +import React from "react"; +import type { AdminTrialEntryValidationIssue } from "@beagle/contracts"; +import { useRouter } from "next/navigation"; +import { AdminMutationError } from "@/queries/admin/mutation-error"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ConfirmModal } from "@/components/ui/confirm-modal"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/sonner"; +import { useI18n } from "@/hooks/i18n"; +import { useUnsavedTrialEntryGuard } from "@/hooks/admin/trials/manage/use-unsaved-trial-entry-guard"; +import { formatDateForFinland } from "@/lib/admin/core/date"; +import { + areAdminTrialEntryCreateDraftsEqual, + createAdminTrialEntryCreateDraft, + createEmptyEraDraft, + getAdminTrialEventHref, + getAdminTrialsHref, + getNextEraNumber, + resolveResultCreateFieldSet, + toCreateAdminTrialEntryRequest, +} from "@/lib/admin/trials"; +import { + useAdminTrialEventQuery, + useCreateAdminTrialEntryMutation, +} from "@/queries/admin/trials"; +import { EntryMetaSection } from "./internal/entry-meta-section"; +import { EraSection } from "./internal/era-section"; +import { LisatiedotWorkspace } from "./internal/lisatiedot-workspace"; +import { ResultCreateCard } from "./internal/result-create-card"; + +type SaveIntent = "another" | "finish"; +type CardId = + | "basic" + | "time" + | "merit" + | "loss" + | "result" + | "judges" + | "additional"; + +export function AdminTrialEntryCreatePageClient({ + trialEventId, +}: { + trialEventId: string; +}) { + const { t } = useI18n(); + const router = useRouter(); + const eventQuery = useAdminTrialEventQuery({ trialEventId }); + const event = eventQuery.data?.event; + + if (eventQuery.isLoading) + return

{t("admin.trials.manage.resultCreate.loading")}

; + if (eventQuery.isError || !event) { + const missing = eventQuery.error?.errorCode === "TRIAL_EVENT_NOT_FOUND"; + return ( + router.replace(getAdminTrialsHref()) + : () => void eventQuery.refetch() + } + actionLabel={t( + missing + ? "admin.trials.manage.resultCreate.backToList" + : "admin.trials.manage.workspace.retry", + )} + /> + ); + } + if (event.sklKoeId === null) { + return ( + router.replace(getAdminTrialEventHref(trialEventId))} + actionLabel={t("admin.trials.manage.resultCreate.backToWorkspace")} + /> + ); + } + return ( + + ); +} + +function StateCard({ + message, + action, + actionLabel, +}: { + message: string; + action: () => void; + actionLabel: string; +}) { + return ( + + +

{message}

+ +
+
+ ); +} + +function ResultCreateForm({ + event, +}: { + event: NonNullable< + ReturnType["data"] + >["event"]; +}) { + const { t } = useI18n(); + const router = useRouter(); + const mutation = useCreateAdminTrialEntryMutation(); + const fieldSet = React.useMemo( + () => resolveResultCreateFieldSet(event.trialRuleWindowId), + [event.trialRuleWindowId], + ); + const initial = React.useMemo( + () => createAdminTrialEntryCreateDraft(event, fieldSet), + [event, fieldSet], + ); + const [draft, setDraft] = React.useState(initial); + const [openCards, setOpenCards] = React.useState>( + new Set(["basic"]), + ); + const [workspaceResetKey, setWorkspaceResetKey] = React.useState(0); + const [errorText, setErrorText] = React.useState(null); + const submittingRef = React.useRef(false); + const dirty = !areAdminTrialEntryCreateDraftsEqual(initial, draft); + const guard = useUnsavedTrialEntryGuard(dirty); + const workspaceHref = getAdminTrialEventHref(event.trialEventId); + + function addEra() { + const era = getNextEraNumber(draft.eras); + setDraft((current) => ({ + ...current, + eras: [...current.eras, createEmptyEraDraft(era)], + lisatiedotRows: current.lisatiedotRows.map((row) => ({ + ...row, + eraValues: { ...row.eraValues, [era]: "" }, + })), + })); + } + function removeEra(era: number) { + if (era === 1) return; + setDraft((current) => ({ + ...current, + eras: current.eras.filter((item) => item.era !== era), + lisatiedotRows: current.lisatiedotRows.map((row) => { + const values = { ...row.eraValues }; + delete values[era]; + return { ...row, eraValues: values }; + }), + })); + } + async function submit(intent: SaveIntent) { + if (submittingRef.current || mutation.isPending) return; + setErrorText(null); + const parsed = toCreateAdminTrialEntryRequest(event.trialEventId, draft); + if (!parsed.ok) { + setOpenCards( + (current) => + new Set([ + ...current, + parsed.section === "registration" + ? "basic" + : parsed.section === "eras" + ? "time" + : "result", + ]), + ); + setErrorText( + t( + `admin.trials.manage.resultCreate.validation.${parsed.section}` as never, + ), + ); + return; + } + submittingRef.current = true; + try { + await mutation.mutateAsync(parsed.request); + toast.success(t("admin.trials.manage.resultCreate.success")); + if (intent === "another") { + setDraft(createAdminTrialEntryCreateDraft(event, fieldSet)); + setOpenCards(new Set(["basic"])); + setWorkspaceResetKey((current) => current + 1); + } else router.replace(workspaceHref); + } catch (error) { + const code = + error instanceof AdminMutationError ? error.errorCode : undefined; + if (code === "UNAUTHENTICATED" || code === "FORBIDDEN") { + router.refresh(); + return; + } + const map: Record = { + INVALID_REGISTRATION_NUMBER: "registration", + TRIAL_ENTRY_REGISTRATION_CONFLICT: "conflict", + INVALID_TRIAL_ENTRY: "entry", + INVALID_TRIAL_ERAS: "eras", + INVALID_TRIAL_ADDITIONAL_INFO: "additionalInfo", + TRIAL_EVENT_NOT_FOUND: "eventNotFound", + TRIAL_EVENT_MISSING_SKL_ID: "missingSklId", + }; + const issue = + error instanceof AdminMutationError + ? (error.details as AdminTrialEntryValidationIssue | undefined) + : undefined; + if (issue?.area === "additional_info" && issue.koodi) { + setOpenCards((current) => new Set([...current, "additional"])); + const row = `${issue.koodi}${issue.osa ? issue.osa : ""}`; + const issueKey = + issue.reason === "invalid_lisatieto_order" + ? "admin.trials.manage.resultCreate.error.additionalInfoOrder" + : "admin.trials.manage.resultCreate.error.additionalInfoRow"; + setErrorText(`${t(issueKey)}: ${row}.`); + } else { + const errorCard: Partial> = { + INVALID_REGISTRATION_NUMBER: "basic", + TRIAL_ENTRY_REGISTRATION_CONFLICT: "basic", + INVALID_TRIAL_ENTRY: "result", + INVALID_TRIAL_ERAS: "time", + INVALID_TRIAL_ADDITIONAL_INFO: "additional", + }; + if (code && errorCard[code]) { + setOpenCards( + (current) => new Set([...current, errorCard[code] as CardId]), + ); + } + setErrorText( + t( + `admin.trials.manage.resultCreate.error.${map[code ?? ""] ?? "generic"}` as never, + ), + ); + } + } finally { + submittingRef.current = false; + } + } + + function toggleCard(id: CardId) { + setOpenCards((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + const displaySummary = (...values: string[]) => + values + .filter((value) => value.trim()) + .slice(0, 3) + .join(" • ") || t("admin.trials.manage.resultCreate.summary.empty"); + const entryGroups = Object.fromEntries( + fieldSet.presentationGroups.map((group) => [ + group.id, + new Set(group.entryFields), + ]), + ); + const eraGroups = Object.fromEntries( + fieldSet.presentationGroups.map((group) => [ + group.id, + new Set(group.eraFields), + ]), + ); + const changeEntry = ( + update: (current: typeof draft.entry) => typeof draft.entry, + ) => + setDraft((current) => ({ + ...current, + entry: update(current.entry), + })); + const changeEra = ( + era: number, + field: Exclude, + value: string, + ) => + setDraft((current) => ({ + ...current, + eras: current.eras.map((item) => + item.era === era ? { ...item, [field]: value } : item, + ), + })); + + return ( +
+
+

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

+

+ {formatDateForFinland(event.eventDate)} • {event.eventPlace} • SKL{" "} + {event.sklKoeId} +

+

+ {t(fieldSet.rulePeriodMessageKey)} +

+ {!fieldSet.verified ? ( +

+ {t("admin.trials.manage.resultCreate.ruleWindowWarning")} +

+ ) : null} +
+ {errorText ? ( +

+ {errorText} +

+ ) : null} + toggleCard("basic")} + > +
+ + +
+
+ toggleCard("time")} + > +
+ + +
+
+ toggleCard("merit")} + > +
+ + +
+
+ toggleCard("loss")} + > +
+ + +
+
+ toggleCard("result")} + > +
+ + {eraGroups.result.size ? ( + + ) : null} +
+
+ toggleCard("judges")} + > + + + toggleCard("additional")} + > + + setDraft((current) => ({ + ...current, + lisatiedotRows: current.lisatiedotRows.map((row) => + row.koodi === koodi && row.osa === osa + ? { ...row, eraValues: { ...row.eraValues, [era]: value } } + : row, + ), + })) + } + onRemoveRow={(koodi, osa) => + setDraft((current) => ({ + ...current, + lisatiedotRows: current.lisatiedotRows.map((row) => + row.koodi === koodi && row.osa === osa + ? { + ...row, + eraValues: Object.fromEntries( + Object.keys(row.eraValues).map((era) => [era, ""]), + ), + } + : row, + ), + })) + } + /> + + + +
+ + + +
+
+
+ +
+ ); +} 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} -
- - - - - -