diff --git a/CHANGELOG.md b/CHANGELOG.md index c3daad43..0c8e92c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,13 +17,19 @@ 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. +- 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]/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 ? (