From 598871d81c8ec7f0475205293ea4ae120d74cc90 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:47:27 +0300 Subject: [PATCH 1/3] feat(admin-trials): add event-scoped manual result entry workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the full-page result creation route with reusable entry, era, and lisätieto sections. Support saving another result or returning to the event workspace, prefill event judge details, and protect unsaved form changes during navigation and page unload. Route newly created events directly to their first result form and invalidate affected admin, trial, dog-profile, and statistics queries after successful creation. Return safe structured validation details, identify rejected lisätieto rows, persist integer row ordering, and omit unused rows from create requests. Add Finnish and Swedish messages, workflow tests, feature documentation, and the follow-up plan for rule-window-aware result fields. ref bej-103 --- CHANGELOG.md | 4 + .../results/new/__tests__/page.test.tsx | 24 ++ .../[trialEventId]/results/new/page.tsx | 10 + .../create-admin-trial-entry.test.ts | 26 ++ .../trials/manage/create-admin-trial-entry.ts | 37 ++ ...in-trial-event-create-page-client.test.tsx | 2 +- ...trial-event-workspace-page-client.test.tsx | 3 + .../admin-trial-entry-create-page-client.tsx | 316 ++++++++++++++++++ ...dmin-trial-event-workspace-page-client.tsx | 6 +- .../admin-trial-selected-event-panel.tsx | 9 + apps/web/components/admin/trials/index.ts | 1 + .../manage/use-unsaved-trial-entry-guard.ts | 59 ++++ .../__tests__/entry-create-model.test.ts | 95 ++++++ .../__tests__/entry-edit-dialog-model.test.ts | 1 + .../trials/__tests__/trial-route.test.ts | 7 + .../lib/admin/trials/entry-create-model.ts | 183 ++++++++++ .../web/lib/admin/trials/entry-edit-config.ts | 16 +- .../admin/trials/entry-edit-dialog-model.ts | 3 +- apps/web/lib/admin/trials/index.ts | 5 + .../submit-admin-trial-event-creation.ts | 4 +- apps/web/lib/admin/trials/trial-route.ts | 4 + .../lib/i18n/messages/admin/trials/manage.ts | 95 ++++++ apps/web/queries/admin/mutation-error.ts | 6 +- ...-create-admin-trial-entry-mutation.test.ts | 61 ++++ apps/web/queries/admin/trials/manage/index.ts | 1 + .../use-create-admin-trial-entry-mutation.ts | 62 ++++ docs/features/admin-trial-management.md | 16 +- docs/planning/trials/README.md | 6 + docs/planning/trials/result-creation-r2.md | 2 + docs/planning/trials/result-creation.md | 3 + .../trials/result-fields-by-rule-window.md | 108 ++++++ .../trials/manage/create-admin-trial-entry.ts | 24 ++ .../contracts/admin/trials/manage/index.ts | 1 + packages/contracts/index.ts | 1 + .../__tests__/create-trial-entry.test.ts | 31 ++ .../admin/trials/manage/create-trial-entry.ts | 1 + ...arse-admin-trial-entry-write-input.test.ts | 4 + .../parse-admin-trial-entry-write-input.ts | 35 +- 38 files changed, 1250 insertions(+), 22 deletions(-) create mode 100644 apps/web/app/(admin)/admin/trials/[trialEventId]/results/new/__tests__/page.test.tsx create mode 100644 apps/web/app/(admin)/admin/trials/[trialEventId]/results/new/page.tsx create mode 100644 apps/web/components/admin/trials/admin-trial-entry-create-page-client.tsx create mode 100644 apps/web/hooks/admin/trials/manage/use-unsaved-trial-entry-guard.ts create mode 100644 apps/web/lib/admin/trials/__tests__/entry-create-model.test.ts create mode 100644 apps/web/lib/admin/trials/entry-create-model.ts create mode 100644 apps/web/queries/admin/trials/manage/__tests__/use-create-admin-trial-entry-mutation.test.ts create mode 100644 apps/web/queries/admin/trials/manage/use-create-admin-trial-entry-mutation.ts create mode 100644 docs/planning/trials/result-fields-by-rule-window.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c3daad43..f0d6ffb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,13 +17,17 @@ This project uses a user-facing changelog format. - 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 - 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. ### Fixed +- Ajokoetuloksen lisätietorivit tallennetaan kelvollisessa kokonaislukujärjestyksessä, ja hylätty lisätietorivi näytetään aiempaa tarkemmin. + ### Removed ## [0.19.0] - 2026-07-20 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/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 index 10c2d43e..a14b81f5 100644 --- 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 @@ -53,4 +53,30 @@ describe("createAdminTrialEntryAction", () => { 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/create-admin-trial-entry.ts b/apps/web/app/actions/admin/trials/manage/create-admin-trial-entry.ts index 2c46291a..34ac0bed 100644 --- 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 @@ -1,6 +1,7 @@ "use server"; import type { + AdminTrialEntryValidationIssue, CreateAdminTrialEntryRequest, CreateAdminTrialEntryResponse, } from "@beagle/contracts"; @@ -13,8 +14,42 @@ export type CreateAdminTrialEntryActionResult = { 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 { @@ -42,11 +77,13 @@ export async function createAdminTrialEntryAction( { 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/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 index 9b492494..9cd7a967 100644 --- 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 @@ -143,7 +143,7 @@ describe("AdminTrialEventCreatePageClient", () => { sklKoeId: 456, }); expect(replaceMock).toHaveBeenCalledWith( - "/admin/trials/event%2Fwith%20spaces", + "/admin/trials/event%2Fwith%20spaces/results/new", ); }); 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 8fd60994..0bf569af 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 @@ -99,6 +99,9 @@ describe("AdminTrialEventWorkspacePageClient", () => { }); 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", () => { 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..8cf7f2ad --- /dev/null +++ b/apps/web/components/admin/trials/admin-trial-entry-create-page-client.tsx @@ -0,0 +1,316 @@ +"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, + 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 { LisatiedotMatrix } from "./internal/lisatiedot-matrix"; + +type SaveIntent = "another" | "finish"; + +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 initial = React.useMemo( + () => createAdminTrialEntryCreateDraft(event), + [event], + ); + const [draft, setDraft] = React.useState(initial); + 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) { + 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)); + 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) { + 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 { + setErrorText( + t( + `admin.trials.manage.resultCreate.error.${map[code ?? ""] ?? "generic"}` as never, + ), + ); + } + } finally { + submittingRef.current = false; + } + } + + return ( +
+
+

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

+

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

+
+ + + {errorText ? ( +

+ {errorText} +

+ ) : null} + + + setDraft((current) => ({ + ...current, + entry: update(current.entry), + })) + } + /> + + setDraft((current) => ({ + ...current, + eras: current.eras.map((item) => + item.era === era ? { ...item, [field]: value } : item, + ), + })) + } + /> + + setDraft((current) => ({ + ...current, + lisatiedotRows: current.lisatiedotRows.map((row) => + row.koodi === koodi && row.osa === osa + ? { ...row, eraValues: { ...row.eraValues, [era]: value } } + : row, + ), + })) + } + /> +
+ + + +
+
+
+ +
+ ); +} diff --git a/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx b/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx index e049c019..ad60d85c 100644 --- a/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx +++ b/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx @@ -4,7 +4,10 @@ import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { useI18n } from "@/hooks/i18n"; -import { getAdminTrialsHref } from "@/lib/admin/trials"; +import { + getAdminTrialEntryCreateHref, + getAdminTrialsHref, +} from "@/lib/admin/trials"; import { useAdminTrialEventQuery } from "@/queries/admin/trials"; import { AdminTrialSelectedEventPanel } from "./admin-trial-selected-event-panel"; @@ -62,6 +65,7 @@ export function AdminTrialEventWorkspacePageClient({ onDeletedTrialEvent={() => router.replace(getAdminTrialsHref())} onTrialEventDeleteConflict={() => void eventQuery.refetch()} allowEmptyEventDeletion + createEntryHref={getAdminTrialEntryCreateHref(trialEventId)} /> )} diff --git a/apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx b/apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx index 11dd63c0..486c389b 100644 --- a/apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx +++ b/apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx @@ -38,6 +38,7 @@ type AdminTrialSelectedEventPanelProps = { onTrialEventDeleteConflict?: () => void; allowEmptyEventDeletion?: boolean; workspaceHref?: string; + createEntryHref?: string; }; const EMPTY_ENTRIES: AdminTrialEventEntry[] = []; @@ -51,6 +52,7 @@ export function AdminTrialSelectedEventPanel({ onTrialEventDeleteConflict, allowEmptyEventDeletion = false, workspaceHref, + createEntryHref, }: AdminTrialSelectedEventPanelProps) { const { t } = useI18n(); const [isEditOpen, setIsEditOpen] = React.useState(false); @@ -172,6 +174,13 @@ export function AdminTrialSelectedEventPanel({
+ {createEntryHref ? ( + + ) : null} {workspaceHref ? (