From 70b28adbd11b152c218d5176d1dc08470bb46cdc Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:53:27 +0300 Subject: [PATCH 01/24] feat/create-trials-planning ref bej-103 --- docs/planning/trials/README.md | 99 ++++++++++++ docs/planning/trials/event-creation.md | 162 +++++++++++++++++++ docs/planning/trials/later-ux.md | 34 ++++ docs/planning/trials/result-creation.md | 197 ++++++++++++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 docs/planning/trials/README.md create mode 100644 docs/planning/trials/event-creation.md create mode 100644 docs/planning/trials/later-ux.md create mode 100644 docs/planning/trials/result-creation.md diff --git a/docs/planning/trials/README.md b/docs/planning/trials/README.md new file mode 100644 index 00000000..9180c064 --- /dev/null +++ b/docs/planning/trials/README.md @@ -0,0 +1,99 @@ +# BEJ-103 Manual Trial Creation + +## Purpose + +These documents replace the earlier monolithic conversational plan for +BEJ-103 (`create trials`) with small implementation gates that can be reviewed +and approved independently. Future BEJ-103 implementation pull requests must +reference this planning set and identify the single gate they implement. + +Creating or changing these planning documents does not approve any +implementation gate. Passing a gate's validation also does not authorize work +on the next gate. + +## Planning set + +- [Event creation](./event-creation.md) defines the event workspace, manual + event creation, and the persisted empty-event lifecycle. +- [Result creation](./result-creation.md) defines the manual result write path + and the full-page result form. +- [Later UX](./later-ux.md) records deferred ideas only and does not authorize + their implementation. + +Repository guardrails and current feature documentation: + +- [Architecture guardrails](../../../ARCHITECTURE.md) +- [Documentation rules](../../documentation-rules.md) +- [Current admin trial management](../../features/admin-trial-management.md) +- [Koiratietokanta AJOK upsert](../../features/trials/koiratietokanta-api-ajok-upsert.md) + +## Grounded current state + +- The admin trials page is an event-first master-detail flow backed by + canonical `TrialEvent` and `TrialEntry` rows. +- Admin event metadata and existing result rows can currently be edited, and + existing result rows can be deleted. The current feature documentation still + describes an older read-only state and must be updated when an implementation + slice changes durable feature behavior. +- Admin event searches include persisted events even when they have no entries. +- Public trial search explicitly requires an event to have at least one entry, + and public trial detail returns no result for an empty event. +- Deleting the final `TrialEntry` currently deletes its `TrialEvent` in the + same transaction. [Event creation](./event-creation.md) defines the planned + replacement lifecycle. +- Koiratietokanta ingestion currently resolves a `TrialEvent` by + `sklKoeId`, resolves a result within that event by registration snapshot, + and writes a source-derived `yksilointiAvain` in + `SKL:|REG:` form. + +## Accepted target direction + +- Manual creation is event-first and page-based. +- A manually created event requires an SKL trial ID, event date, and place. +- An empty event is valid persisted admin-only state, not a separate draft + state. +- Manual result entry starts with a typed registration number. The write path + links an existing dog when the normalized registration resolves locally and + otherwise permits an unlinked result. +- Manual results use identity compatible with Koiratietokanta ingestion. A + later matching Koiratietokanta upsert becomes authoritative. +- Results are saved one dog at a time. The intended continuation supports both + adding another result to the same event and finishing at the event workspace. +- The existing trials master-detail list and existing result-edit modal remain + in place for BEJ-103. + +## Implementation order and review rules + +Implement gates in this order: + +1. `E1` - event workspace +2. `E2` - event creation and empty-event lifecycle +3. `R1` - manual result backend and schema foundation +4. `R2` - full-page manual result form + +For every gate: + +- Approve the gate explicitly before implementation begins. +- Implement only that gate's stated scope. +- Run its targeted validation and report the actual results. +- Review the behavior and documentation diff before approving another gate. +- Stop after the review; approval of one gate does not imply approval of the + next gate. +- If review changes a product or architecture decision, update these planning + documents before implementation continues. + +## Non-goals + +BEJ-103 does not authorize: + +- changes to the public trial UI; +- changes to trial statistics or their calculation; +- redesign of legacy import or Koiratietokanta ingestion; +- redesign of existing result editing; +- batch entry of several unsaved dog results; +- a draft/publish workflow; +- autosave; +- reconciliation or conflict-resolution UI. + +Potential future UX work is recorded in [Later UX](./later-ux.md) without +implementation authorization. diff --git a/docs/planning/trials/event-creation.md b/docs/planning/trials/event-creation.md new file mode 100644 index 00000000..017650e4 --- /dev/null +++ b/docs/planning/trials/event-creation.md @@ -0,0 +1,162 @@ +# BEJ-103 Event Creation + +## Purpose + +This plan introduces a stable event workspace before adding manual event +creation. It deliberately leaves the existing `/admin/trials` master-detail +flow intact. The accepted cross-slice decisions and review rules are defined in +the [planning overview](./README.md). + +## Target event lifecycle + +The planned lifecycle is: + +`Empty event -> results added -> event with results -> final result removed -> empty event remains persisted` + +Rules: + +- An empty `TrialEvent` is valid persisted admin-only state. +- Empty state is not a separate draft or publish status. +- Adding the first result makes the event eligible for the existing public + trial reads; no public UI behavior is changed here. +- Removing the final result must not automatically delete the `TrialEvent`. +- Explicit event deletion is permitted only while the event has zero results. +- The server and database enforce the empty-only delete rule; hiding a UI + action is not sufficient authorization or consistency protection. + +## Gate E1: Event workspace + +### Scope + +- Add a dedicated admin event workspace route for one `trialEventId`. +- Reuse the existing admin event detail query, event summary, result list, + event-edit action, result-edit modal, PDF action, and result-delete action + where practical. +- Add an explicit way to open the workspace from the current trials page. +- Provide loading, missing-event, and read-error states on the workspace. +- Keep authorization at the existing admin layout and backend boundaries. + +### Exclusions + +- No event or result creation. +- No event deletion or change to final-result deletion. +- No redesign or removal of the current master-detail interface. +- No change to existing result editing. +- No public UI, statistics, import, schema, or API behavior changes. + +### Dependencies + +- Existing admin event list/detail contracts and HTTP read transport. +- Existing admin event and result actions. +- No dependency on E2, R1, or R2. + +### Acceptance criteria + +- An admin can open a stable URL for an existing event and see the same event + metadata and result rows available in the master-detail panel. +- Existing edit, PDF, and result-delete actions retain their current behavior. +- Missing and inaccessible event states are handled without falling back to a + different event. +- The current trials list continues to work as before. + +### Targeted validation + +- Add focused route/component tests for loading, success, missing, and error + states. +- Run the existing selected-event component and action tests affected by reuse. +- Run targeted web type checking and linting for touched code. +- Manually verify the workspace at desktop and mobile widths when browser + tooling is available. +- Do not run cycle lint in the agent workflow. + +### Merge independence and review gate + +E1 can be merged safely on its own. It adds an alternate route to current +behavior and does not leave a partially enabled creation workflow. + +Stop after validation and request explicit review. Do not start E2 without +separate approval. + +## Gate E2: Event creation and empty-event lifecycle + +### Scope + +- Add admin event-create request/response contracts, a server use-case, a + focused DB write, and a Server Action mutation path. +- Add a full-page event-create form linked from the existing trials page. +- Require a positive `sklKoeId`, a valid event date, and a non-empty event + place. Keep the remaining existing event metadata fields optional. +- Reject creation when the unique `TrialEvent.sklKoeId` already belongs to an + event; do not turn a create request into an update. +- Resolve `trialRuleWindowId` from the event date using the existing active + rule-window behavior. When the event date is edited later, keep its resolved + rule window consistent with the new date. +- Change result deletion so removing the final `TrialEntry` leaves the + `TrialEvent` persisted and empty. +- Add explicit event deletion that succeeds only when the event has zero + entries and rejects a non-empty event. +- Show the valid empty state and empty-only delete action in the event + workspace. + +### Temporary continuation behavior + +When E2 is implemented without R2, successful event creation returns to the +new event workspace. This is intentional, complete temporary behavior: the +admin can inspect, edit, or explicitly delete the empty event. + +Gate R2 later changes the primary successful continuation to the first manual +result form. Canceling or leaving that later form still returns to the same +persisted event workspace. + +### Exclusions + +- No manual result write path or result-create form. +- No separate draft/publish state. +- No automatic cleanup or expiry for empty events. +- No cascade deletion of a non-empty event. +- No redesign of the trials index or existing result-edit modal. +- No public UI, statistics, or import redesign. + +### Dependencies + +- E1 must be approved and merged so event creation has a stable destination. +- Existing event validation, date parsing, rule-window resolution, admin + authorization, and structured logging conventions must be reused. + +### Acceptance criteria + +- An authorized admin can create an event with the required identity fields + and optional existing event metadata. +- Invalid required values and an already-used SKL ID return stable validation + or conflict errors without creating an event. +- The created event is visible to admin reads, opens in its workspace, and is + absent from existing public trial search/detail while empty. +- Removing the final result leaves the event empty and persisted. +- Explicit deletion succeeds for an empty event and rejects an event that has + results. +- Event creation and deletion are logged through the shared structured logger. + +### Targeted validation + +- DB tests for create, duplicate SKL ID, rule-window assignment, empty-only + delete, and final-result removal. +- Server tests for authorization, normalization, validation, conflicts, + not-found behavior, and error mapping. +- Server Action and mutation tests for authentication, error propagation, + navigation data, and query invalidation. +- Web form/workspace tests for required fields, failure preservation, success, + empty state, and empty-only deletion. +- Regression tests proving public search/detail continue to exclude an empty + event. +- Targeted type checking and linting for touched packages; do not run cycle + lint in the agent workflow. + +### Merge independence and review gate + +E2 can be merged safely after E1 without R1 or R2. Empty events have a complete +admin lifecycle, remain hidden from public reads, and can be removed explicitly. +The temporary post-create workspace continuation must be included in the E2 +implementation and release communication. + +Stop after validation and request explicit review. The planned schema work in +R1 requires separate approval before it begins. diff --git a/docs/planning/trials/later-ux.md b/docs/planning/trials/later-ux.md new file mode 100644 index 00000000..76170f9a --- /dev/null +++ b/docs/planning/trials/later-ux.md @@ -0,0 +1,34 @@ +# Deferred Trial Management UX + +## Status + +This document records deferred ideas only. It does not authorize design, +implementation, refactoring, schema work, or preparatory changes. BEJ-103 can +be completed without any item in this file. + +To move an item into implementation, first update the relevant planning gate, +define its scope and acceptance criteria, and obtain explicit approval under +the review rules in the [BEJ-103 planning overview](./README.md). + +## Deferred ideas + +- Replace the current trials master-detail list with a navigation-only event + index. +- Move editing of existing results from the modal to the reusable full-page + result form. +- Add searchable dog selection to manual result creation. +- Allow inline dog creation from the result flow. +- Add an explicit draft/publish state for trial events or results. +- Enter and submit several dog results as one batch. +- Autosave event or result forms. +- Add reconciliation UI for manual and Koiratietokanta data conflicts. + +## Explicit exclusions from current gates + +- E1 and E2 retain the existing trials master-detail list. +- R1 and R2 retain the existing result-edit modal. +- R2 uses a free-text registration field and saves one complete result at a + time. +- Matching Koiratietokanta upserts are resolved by the authoritative backend + behavior documented in [Result creation](./result-creation.md), without a + manual reconciliation screen. diff --git a/docs/planning/trials/result-creation.md b/docs/planning/trials/result-creation.md new file mode 100644 index 00000000..49802df2 --- /dev/null +++ b/docs/planning/trials/result-creation.md @@ -0,0 +1,197 @@ +# BEJ-103 Result Creation + +## Purpose + +This plan adds one-at-a-time manual `TrialEntry` creation after the event +workspace and empty-event lifecycle are complete. It preserves the existing +result-edit modal. Cross-slice decisions and review rules are defined in the +[planning overview](./README.md), and event dependencies are defined in +[Event creation](./event-creation.md). + +## Existing identity and upsert behavior + +The current Koiratietokanta write path: + +- resolves a `TrialEvent` through unique `TrialEvent.sklKoeId`; +- resolves an entry inside that event through the existing + `TrialEvent` plus `TrialEntry.rekisterinumeroSnapshot` uniqueness; +- writes `TrialEntry.yksilointiAvain` as + `SKL:|REG:`; and +- upserts the entry in place before replacing its era and lisatieto rows. + +Manual result creation must use these existing identity semantics. This plan +does not add another database uniqueness constraint. + +## Authoritative matching upsert + +When a later Koiratietokanta payload has the same `TrialEvent.sklKoeId` and +normalized registration as a manually created result: + +- the existing manual `TrialEntry` is updated in place; +- a duplicate result is not created; +- incoming Koiratietokanta result, era, lisatieto, snapshot, and event data + become authoritative according to the existing ingestion mapper and upsert; +- `TrialEntry.lahde` changes to `KOIRATIETOKANTA_API`; and +- `dogId` is updated when the authoritative registration resolves through + `DogRegistration`. If it does not resolve, the current upsert behavior + preserves an existing dog link rather than clearing it. + +This is automatic matching, not a reconciliation UI or import redesign. + +## Gate R1: Manual result backend and schema foundation + +### Scope + +- Add `MANUAL_ADMIN` to the existing `TrialSourceTag` enum in an explicitly + approved Prisma migration. +- Add admin manual-result request/response contracts, a server use-case, a + focused transactional DB write, and a Server Action mutation path. +- Accept `trialEventId`, a registration number, and the existing entry, era, + and lisatieto write shape used by result editing. +- Normalize and validate registration using existing server-side registration + rules. +- Load the target event and require its `sklKoeId` for API-compatible manual + identity. +- Reject a second result with the same normalized registration in the event. +- Resolve `dogId` through `DogRegistration`; save an unlinked entry when no + local dog owns the normalized registration. +- Store `lahde=MANUAL_ADMIN`, the normalized + `rekisterinumeroSnapshot`, and the API-compatible `yksilointiAvain`. +- Create the entry, eras, and lisatiedot atomically. +- Share validation and normalization with result update where doing so avoids + divergent business rules, while keeping create and update as separate + use-cases and DB writes. +- Add a regression test for the authoritative matching upsert described above. + +### Exclusions + +- No result-create UI or navigation changes. +- No existing result-editing redesign. +- No batch creation, autosave, draft/publish state, or reconciliation UI. +- No new ingestion endpoint or change to the external Koiratietokanta + contract. +- No new database uniqueness constraint. The only planned schema change in + this gate is the new source enum value. + +### Dependencies + +- E2 must be approved and merged so the target event and empty-event lifecycle + exist. +- The `MANUAL_ADMIN` migration requires explicit schema-change approval before + implementation. +- Existing registration normalization, admin authorization, structured + logging, entry validation, and transaction conventions must be reused. + +### Acceptance criteria + +- An authorized call creates exactly one manual entry with its complete nested + writes or creates nothing if the transaction fails. +- A matching local dog is linked; an unknown registration remains a valid + unlinked result. +- Invalid registration, missing event, event without an SKL ID, duplicate + event registration, and invalid entry/era/lisatieto data return stable errors. +- Manual source and identity fields match the documented API-compatible + semantics. +- A later matching Koiratietokanta upsert updates the same entry without a + duplicate and makes the incoming data authoritative. + +### Targeted validation + +- Migration/schema checks for the new enum value and regenerated Prisma client. +- DB tests for linked and unlinked creation, identity fields, duplicate + rejection, nested writes, and rollback. +- Server tests for authorization, registration normalization, event + validation, entry/era/lisatieto validation, conflict mapping, and logging. +- Server Action tests for authentication and service-result mapping. +- Existing Koiratietokanta DB/service tests plus a manual-to-API replacement + regression case. +- Targeted type checking and linting for contracts, server, DB, and web action + code; do not run cycle lint in the agent workflow. + +### Merge independence and review gate + +R1 can be merged safely after E2 without R2. The write path has no linked admin +UI yet, so it does not expose an unfinished user workflow. Its migration and +backend behavior must nevertheless be complete, documented, and validated. + +Stop after validation and request explicit review. Do not begin R2 without +separate approval. + +## Gate R2: Full-page manual result form + +### Scope + +- Add a dedicated full-page result-create route under one event workspace. +- Keep the event identity visible while entering a dog result. +- Add a required free-text registration field. Do not require selecting an + existing dog. +- Reuse the existing result, era, lisatieto, judge, and note sections where + practical, with one initial era and the existing validation semantics. +- Preserve entered values after server or validation failure and prevent + duplicate submissions while a save is pending. +- Add two successful submission paths: + - **Save and add another** creates one result, then opens a clean result form + for the same event. + - **Save and finish** creates one result, then returns to the event workspace. +- Change E2's temporary successful event-create continuation so the primary + action opens the first result form for the newly persisted event. +- Define cancel and browser navigation: + - Cancel returns to the event workspace. + - Cancel asks for confirmation before discarding a dirty form. + - Browser back follows the same dirty-form protection and otherwise returns + to the event workspace. + - A failed or rejected save stays on the populated form. +- Add Finnish and Swedish labels, helper text, errors, and success feedback. +- Keep existing result editing in its current modal. + +### Exclusions + +- No migration of existing result editing to a page. +- No searchable dog picker or inline dog creation. +- No batch form, autosave, draft/publish state, or reconciliation UI. +- No redesign of the existing trials master-detail list. +- No public UI or trial-statistics changes. + +### Dependencies + +- E1 and E2 must be merged for the workspace and persisted event lifecycle. +- R1 must be merged for the manual-result action and persistence contract. +- Existing entry form sections and form-model behavior are the reuse baseline; + any extraction must preserve the current edit-modal behavior. + +### Acceptance criteria + +- Successful event creation continues to the first result form for that exact + event. +- An admin can save a result for a known or unknown registration. +- Save and add another clears dog-specific state while retaining the event + context and produces no duplicate submission. +- Save and finish returns to the event workspace where the new result appears. +- Cancel and browser back protect dirty input and return to the correct event + when confirmed. +- Validation and server errors preserve entered data and provide actionable + localized feedback. +- The existing result-edit modal behaves as before. + +### Targeted validation + +- Component/form-model tests for initial state, validation, dirty state, + reset, and localized feedback. +- Mutation tests for success, failure, query invalidation, and duplicate-submit + protection. +- Route/workflow tests for event-create continuation, add-another, finish, + cancel, and browser-back handling. +- Regression tests for the existing result-edit modal and selected-event panel. +- Targeted web type checking and linting; do not run cycle lint in the agent + workflow. +- Manual desktop and mobile checks for the long form and both continuation + paths when browser tooling is available. + +### Merge independence and review gate + +R2 is the final BEJ-103 creation gate and can merge safely after E1, E2, and R1. +It replaces E2's documented temporary redirect with the final continuation +behavior and leaves no dependency on deferred UX work. + +Stop after validation and request final BEJ-103 review. Deferred items remain +unauthorized unless they are moved into an approved planning gate. From 29fd214c7c7ac3f9ae33fc0f5f30bed4c7c5a449 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:00:49 +0300 Subject: [PATCH 02/24] docs(trials): limit E2 rule-window resolution to creation ref bej-103 --- docs/planning/trials/event-creation.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/planning/trials/event-creation.md b/docs/planning/trials/event-creation.md index 017650e4..66c09756 100644 --- a/docs/planning/trials/event-creation.md +++ b/docs/planning/trials/event-creation.md @@ -88,9 +88,8 @@ separate approval. place. Keep the remaining existing event metadata fields optional. - Reject creation when the unique `TrialEvent.sklKoeId` already belongs to an event; do not turn a create request into an update. -- Resolve `trialRuleWindowId` from the event date using the existing active - rule-window behavior. When the event date is edited later, keep its resolved - rule window consistent with the new date. +- During event creation, resolve the initial `trialRuleWindowId` from the + entered event date using the existing active rule-window behavior. - Change result deletion so removing the final `TrialEntry` leaves the `TrialEvent` persisted and empty. - Add explicit event deletion that succeeds only when the event has zero @@ -115,6 +114,9 @@ persisted event workspace. - No automatic cleanup or expiry for empty events. - No cascade deletion of a non-empty event. - No redesign of the trials index or existing result-edit modal. +- No change to later event-edit behavior, including rule-window recalculation + when an existing event date is edited. Any such change requires its own + approved planning change. - No public UI, statistics, or import redesign. ### Dependencies @@ -126,7 +128,8 @@ persisted event workspace. ### Acceptance criteria - An authorized admin can create an event with the required identity fields - and optional existing event metadata. + and optional existing event metadata, with its initial `trialRuleWindowId` + resolved from the entered event date. - Invalid required values and an already-used SKL ID return stable validation or conflict errors without creating an event. - The created event is visible to admin reads, opens in its workspace, and is From c519d268bcdebe097b73af6c3383b62f16070971 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:36:14 +0300 Subject: [PATCH 03/24] feat(admin-trials): add dedicated event workspace Implement BEJ-103 Gate E1 with a stable full-page workspace for an existing trial event at /admin/trials/[trialEventId]. - reuse the existing event summary, result list, edit dialogs, PDF action, and result deletion - add loading, not-found, generic error, and retry states - link the selected event to its workspace with centralized route helpers - preserve the current final-result deletion lifecycle - add Finnish and Swedish translations and focused tests - update trial-management documentation and the changelog Refs: BEJ-103 --- CHANGELOG.md | 2 + .../[trialEventId]/__tests__/page.test.tsx | 22 +++ .../admin/trials/[trialEventId]/page.tsx | 11 ++ .../admin-trial-entry-actions.test.tsx | 34 +++- ...trial-event-workspace-page-client.test.tsx | 160 ++++++++++++++++++ .../admin-trial-selected-event-panel.test.tsx | 42 ++++- .../admin-trials-page-client.test.tsx | 6 +- ...dmin-trial-event-workspace-page-client.tsx | 69 ++++++++ .../admin-trial-selected-event-panel.tsx | 34 ++-- .../admin/trials/admin-trials-page-client.tsx | 6 + apps/web/components/admin/trials/index.ts | 1 + .../trials/__tests__/trial-route.test.ts | 18 ++ apps/web/lib/admin/trials/index.ts | 1 + apps/web/lib/admin/trials/trial-route.ts | 9 + .../admin/trials/__tests__/index.test.ts | 16 ++ .../lib/i18n/messages/admin/trials/manage.ts | 13 ++ .../manage/use-admin-trial-event-query.ts | 12 +- docs/features/admin-trial-management.md | 29 +++- docs/planning/trials/event-creation.md | 11 +- 19 files changed, 467 insertions(+), 29 deletions(-) create mode 100644 apps/web/app/(admin)/admin/trials/[trialEventId]/__tests__/page.test.tsx create mode 100644 apps/web/app/(admin)/admin/trials/[trialEventId]/page.tsx create mode 100644 apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx create mode 100644 apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx create mode 100644 apps/web/lib/admin/trials/__tests__/trial-route.test.ts create mode 100644 apps/web/lib/admin/trials/index.ts create mode 100644 apps/web/lib/admin/trials/trial-route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3871f8..5f9c7860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ This project uses a user-facing changelog format. ### Added +- Ylläpitäjä voi avata ajokoetapahtuman omalle sivulleen tarkastelua ja nykyisiä muokkaustoimintoja varten. + ### Changed ### Fixed 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/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-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..2d3e23a6 --- /dev/null +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx @@ -0,0 +1,160 @@ +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(); + }); + + 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..a4ff0fb8 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", () => ({ @@ -123,5 +128,40 @@ 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", + 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", + ); }); }); 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..a7ffb103 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 ?? ""}`, ), })); @@ -170,7 +172,7 @@ describe("AdminTrialsPageClient", () => { 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-event-workspace-page-client.tsx b/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx new file mode 100644 index 00000000..1741900d --- /dev/null +++ b/apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx @@ -0,0 +1,69 @@ +"use client"; + +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 { useAdminTrialEventQuery } from "@/queries/admin/trials"; +import { AdminTrialSelectedEventPanel } from "./admin-trial-selected-event-panel"; + +type AdminTrialEventWorkspacePageClientProps = { + trialEventId: string; +}; + +export function AdminTrialEventWorkspacePageClient({ + trialEventId, +}: AdminTrialEventWorkspacePageClientProps) { + const { t } = useI18n(); + const router = useRouter(); + const eventQuery = useAdminTrialEventQuery({ trialEventId }); + const errorCode = eventQuery.error?.errorCode; + + return ( +
+
+

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

+
+ + {eventQuery.isError ? ( + + +

+ {t( + errorCode === "TRIAL_EVENT_NOT_FOUND" + ? "admin.trials.manage.workspace.notFound" + : "admin.trials.manage.workspace.error", + )} +

+ {errorCode === "TRIAL_EVENT_NOT_FOUND" ? null : ( + + )} +
+
+ ) : ( + router.replace(getAdminTrialsHref())} + /> + )} +
+ ); +} 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 9cf740e2..5c6f3b8c 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 @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import Link from "next/link"; import { ListingSectionShell, ListingResponsiveResults, @@ -33,6 +34,7 @@ type AdminTrialSelectedEventPanelProps = { isError: boolean; errorText: string; onDeletedTrialEvent: (deletedTrialEventId: string) => void; + workspaceHref?: string; }; const EMPTY_ENTRIES: AdminTrialEventEntry[] = []; @@ -43,6 +45,7 @@ export function AdminTrialSelectedEventPanel({ isError, errorText, onDeletedTrialEvent, + workspaceHref, }: AdminTrialSelectedEventPanelProps) { const { t } = useI18n(); const [isEditOpen, setIsEditOpen] = React.useState(false); @@ -163,16 +166,27 @@ export function AdminTrialSelectedEventPanel({

- +
+ {workspaceHref ? ( + + ) : null} + +
{ setSelectedEventIdInput(""); setBlockedAutoSelectedEventId(deletedTrialEventId); diff --git a/apps/web/components/admin/trials/index.ts b/apps/web/components/admin/trials/index.ts index 68d53bb6..f015b28d 100644 --- a/apps/web/components/admin/trials/index.ts +++ b/apps/web/components/admin/trials/index.ts @@ -1 +1,2 @@ +export { AdminTrialEventWorkspacePageClient } from "./admin-trial-event-workspace-page-client"; export { AdminTrialsPageClient } from "./admin-trials-page-client"; diff --git a/apps/web/lib/admin/trials/__tests__/trial-route.test.ts b/apps/web/lib/admin/trials/__tests__/trial-route.test.ts new file mode 100644 index 00000000..31d1388b --- /dev/null +++ b/apps/web/lib/admin/trials/__tests__/trial-route.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { getAdminTrialEventHref, getAdminTrialsHref } from "../trial-route"; + +describe("admin trial route helpers", () => { + it("returns the admin trials root", () => { + expect(getAdminTrialsHref()).toBe("/admin/trials"); + }); + + it("builds an event workspace href", () => { + expect(getAdminTrialEventHref("event-1")).toBe("/admin/trials/event-1"); + }); + + it("encodes the event id as one path segment", () => { + expect(getAdminTrialEventHref("event/with spaces")).toBe( + "/admin/trials/event%2Fwith%20spaces", + ); + }); +}); diff --git a/apps/web/lib/admin/trials/index.ts b/apps/web/lib/admin/trials/index.ts new file mode 100644 index 00000000..2f3dfb12 --- /dev/null +++ b/apps/web/lib/admin/trials/index.ts @@ -0,0 +1 @@ +export * from "./trial-route"; diff --git a/apps/web/lib/admin/trials/trial-route.ts b/apps/web/lib/admin/trials/trial-route.ts new file mode 100644 index 00000000..fc4f9324 --- /dev/null +++ b/apps/web/lib/admin/trials/trial-route.ts @@ -0,0 +1,9 @@ +const ADMIN_TRIALS_ROUTE_ROOT = "/admin/trials"; + +export function getAdminTrialsHref(): string { + return ADMIN_TRIALS_ROUTE_ROOT; +} + +export function getAdminTrialEventHref(trialEventId: string): string { + return `${ADMIN_TRIALS_ROUTE_ROOT}/${encodeURIComponent(trialEventId)}`; +} diff --git a/apps/web/lib/i18n/messages/admin/trials/__tests__/index.test.ts b/apps/web/lib/i18n/messages/admin/trials/__tests__/index.test.ts index 2396b14b..2861a72a 100644 --- a/apps/web/lib/i18n/messages/admin/trials/__tests__/index.test.ts +++ b/apps/web/lib/i18n/messages/admin/trials/__tests__/index.test.ts @@ -15,6 +15,14 @@ describe("admin trial message index", () => { expect( fiAdminTrialsMessages["admin.trials.manage.selected.actions.editEvent"], ).toBe("Muokkaa tapahtumaa"); + expect( + fiAdminTrialsMessages["admin.trials.manage.workspace.notFound"], + ).toBe("Ajokoetapahtumaa ei löytynyt."); + expect( + fiAdminTrialsMessages[ + "admin.trials.manage.selected.actions.openWorkspace" + ], + ).toBe("Avaa tapahtuman sivu"); expect(svAdminTrialsMessages["admin.trials.manage.events.title"]).toBe( "Evenemang", ); @@ -27,5 +35,13 @@ describe("admin trial message index", () => { expect( svAdminTrialsMessages["admin.trials.manage.selected.actions.editEvent"], ).toBe("Redigera evenemang"); + expect( + svAdminTrialsMessages["admin.trials.manage.workspace.notFound"], + ).toBe("Jaktprovsevenemanget hittades inte."); + expect( + svAdminTrialsMessages[ + "admin.trials.manage.selected.actions.openWorkspace" + ], + ).toBe("Öppna evenemangssidan"); }); }); diff --git a/apps/web/lib/i18n/messages/admin/trials/manage.ts b/apps/web/lib/i18n/messages/admin/trials/manage.ts index 3d9f0b1b..c07f2248 100644 --- a/apps/web/lib/i18n/messages/admin/trials/manage.ts +++ b/apps/web/lib/i18n/messages/admin/trials/manage.ts @@ -40,6 +40,11 @@ export const fiAdminTrialsManageMessages = { "admin.trials.manage.events.columns.name": "Tapahtuma", "admin.trials.manage.events.columns.judge": "Ylituomari", "admin.trials.manage.events.columns.dogs": "Koiria", + "admin.trials.manage.workspace.title": "Ajokoetapahtuma", + "admin.trials.manage.workspace.notFound": "Ajokoetapahtumaa ei löytynyt.", + "admin.trials.manage.workspace.error": + "Ajokoetapahtuman lataaminen epäonnistui.", + "admin.trials.manage.workspace.retry": "Yritä uudelleen", "admin.trials.manage.selected.title": "Valittu tapahtuma", "admin.trials.manage.selected.subtitle": "Tapahtuman koirarivit ja rivikohtaiset toiminnot.", @@ -60,6 +65,7 @@ export const fiAdminTrialsManageMessages = { "admin.trials.manage.selected.columns.actions": "Toiminnot", "admin.trials.manage.selected.actions.openPdf": "Avaa pöytäkirja", "admin.trials.manage.selected.actions.more": "Lisää toimintoja", + "admin.trials.manage.selected.actions.openWorkspace": "Avaa tapahtuman sivu", "admin.trials.manage.selected.actions.editEvent": "Muokkaa tapahtumaa", "admin.trials.manage.selected.actions.editEntry": "Muokkaa riviä", "admin.trials.manage.entryModal.header.title": "Muokattava koerivi", @@ -167,6 +173,12 @@ export const svAdminTrialsManageMessages = { "admin.trials.manage.events.columns.name": "Evenemang", "admin.trials.manage.events.columns.judge": "Överdomare", "admin.trials.manage.events.columns.dogs": "Hundar", + "admin.trials.manage.workspace.title": "Jaktprovsevenemang", + "admin.trials.manage.workspace.notFound": + "Jaktprovsevenemanget hittades inte.", + "admin.trials.manage.workspace.error": + "Det gick inte att ladda jaktprovsevenemanget.", + "admin.trials.manage.workspace.retry": "Försök igen", "admin.trials.manage.selected.title": "Valt evenemang", "admin.trials.manage.selected.subtitle": "Hundrader och radåtgärder för valt evenemang.", @@ -186,6 +198,7 @@ export const svAdminTrialsManageMessages = { "admin.trials.manage.selected.columns.actions": "Åtgärder", "admin.trials.manage.selected.actions.openPdf": "Öppna protokoll", "admin.trials.manage.selected.actions.more": "Fler åtgärder", + "admin.trials.manage.selected.actions.openWorkspace": "Öppna evenemangssidan", "admin.trials.manage.selected.actions.editEvent": "Redigera evenemang", "admin.trials.manage.selected.actions.editEntry": "Redigera rad", "admin.trials.manage.entryModal.header.title": "Redigerad provrad", diff --git a/apps/web/queries/admin/trials/manage/use-admin-trial-event-query.ts b/apps/web/queries/admin/trials/manage/use-admin-trial-event-query.ts index 6293fe99..9a20ea17 100644 --- a/apps/web/queries/admin/trials/manage/use-admin-trial-event-query.ts +++ b/apps/web/queries/admin/trials/manage/use-admin-trial-event-query.ts @@ -10,13 +10,13 @@ import { adminTrialEventQueryKey } from "./query-keys"; const adminTrialsApiClient = createAdminTrialsApiClient(); -class AdminTrialEventQueryError extends Error { - errorCode?: string; - - constructor(message: string, errorCode?: string) { +export class AdminTrialEventQueryError extends Error { + constructor( + message: string, + readonly errorCode?: string, + ) { super(message); this.name = "AdminTrialEventQueryError"; - this.errorCode = errorCode; } } @@ -26,7 +26,7 @@ type UseAdminTrialEventQueryInput = AdminTrialEventDetailsRequest & { export function useAdminTrialEventQuery(input: UseAdminTrialEventQueryInput) { const normalizedTrialEventId = input.trialEventId.trim(); - return useQuery({ + return useQuery({ queryKey: adminTrialEventQueryKey(normalizedTrialEventId), queryFn: async () => { const result = await adminTrialsApiClient.getAdminTrialEvent({ diff --git a/docs/features/admin-trial-management.md b/docs/features/admin-trial-management.md index b1dcb6d9..17c90851 100644 --- a/docs/features/admin-trial-management.md +++ b/docs/features/admin-trial-management.md @@ -1,21 +1,24 @@ # Admin Trial Management -Developer notes for the admin trial read-only event flow (`BEJ-76`, `BEJ-81` +Developer notes for the admin trial event flow (`BEJ-76`, `BEJ-81`, `BEJ-103` and follow-up admin flow redesign). ## Primary purpose - The admin list page is an event-first master-detail workflow for canonical AJOK `TrialEvent` + `TrialEntry` rows. -- The main user task is to search events, select one event, then inspect its - dog rows without editing. -- Per-dog inspection opens the generated trial PDF. There is no separate admin - trial row detail page. +- An admin can search events, select one event, inspect and edit its metadata + and dog rows, delete a result, or open the event at the stable + `/admin/trials/[trialEventId]` workspace URL. +- Per-dog inspection opens the generated trial PDF. Existing event and result + editing remains modal-based. ## Main files - `apps/web/app/(admin)/admin/trials/page.tsx`: route entrypoint for event master-detail list +- `apps/web/app/(admin)/admin/trials/[trialEventId]/page.tsx`: stable event workspace route - `apps/web/components/admin/trials/admin-trials-page-client.tsx`: event filters + event list + selected-event rows +- `apps/web/components/admin/trials/admin-trial-event-workspace-page-client.tsx`: one-event workspace states and navigation - `apps/web/components/admin/trials/admin-trial-selected-event-panel.tsx`: selected-event rows and row actions - `apps/web/components/admin/trials/admin-trial-entry-actions.tsx`: per-dog PDF action - `apps/web/queries/admin/trials/manage/use-admin-trial-events-query.ts`: event list query hook @@ -32,7 +35,10 @@ and follow-up admin flow redesign). 1. List page fetches event summaries through `useAdminTrialEventsQuery`. 2. Event selection fetches selected event rows through `useAdminTrialEventQuery`. -3. Row action opens `/api/trials/[trialId]/pdf` in a new tab. +3. The selected-event panel links to the stable event workspace, which fetches + only the `trialEventId` from its route. +4. Event and result changes use the existing admin mutations; the PDF action + opens `/api/trials/[trialEntryId]/pdf` in a new tab. ## Contract rules @@ -48,11 +54,18 @@ and follow-up admin flow redesign). ## Render rules - Event rows/cards are the interaction target for choosing a selected event. -- Selected event dog rows have one action: open the dog-specific trial PDF. -- The admin flow has no update/remove controls and no separate row detail page. +- The selected-event header has event edit and workspace navigation actions. +- Selected dog rows have PDF, edit, and result-delete actions. +- A missing workspace event is shown explicitly and never falls back to a + different event. +- Until BEJ-103 Gate E2 changes the lifecycle, deleting the final result also + deletes its event. The workspace then returns to `/admin/trials`; non-final + and failed deletions remain on the workspace. ## Tests +- `apps/web/app/(admin)/admin/trials/[trialEventId]/__tests__/page.test.tsx` +- `apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx` - `apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx` - `apps/web/components/admin/trials/__tests__/admin-trial-entry-actions.test.tsx` - `apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx` diff --git a/docs/planning/trials/event-creation.md b/docs/planning/trials/event-creation.md index 66c09756..21d53989 100644 --- a/docs/planning/trials/event-creation.md +++ b/docs/planning/trials/event-creation.md @@ -28,12 +28,16 @@ Rules: ### Scope -- Add a dedicated admin event workspace route for one `trialEventId`. +- Add a dedicated `/admin/trials/[trialEventId]` workspace route for one + `trialEventId`. - Reuse the existing admin event detail query, event summary, result list, event-edit action, result-edit modal, PDF action, and result-delete action where practical. - Add an explicit way to open the workspace from the current trials page. - Provide loading, missing-event, and read-error states on the workspace. +- Provide retry for generic read failures. +- Never select or display a different event when the requested event is + missing. - Keep authorization at the existing admin layout and backend boundaries. ### Exclusions @@ -57,12 +61,17 @@ Rules: - Existing edit, PDF, and result-delete actions retain their current behavior. - Missing and inaccessible event states are handled without falling back to a different event. +- Deleting a non-final result or failing to delete a result leaves the admin on + the workspace. If current final-result deletion also deletes the event, the + workspace returns to `/admin/trials`. - The current trials list continues to work as before. ### Targeted validation - Add focused route/component tests for loading, success, missing, and error states. +- Cover retry, workspace navigation, no self-link, no missing-event fallback, + and all current result-deletion outcomes. - Run the existing selected-event component and action tests affected by reuse. - Run targeted web type checking and linting for touched code. - Manually verify the workspace at desktop and mobile widths when browser From 8f25a9e67815937cf51d64705b9ad65aa22d0f28 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:45:16 +0300 Subject: [PATCH 04/24] chore(admin-trials): remove redundant workspace heading wrapper Remove an unnecessary layout wrapper around the trial event workspace heading without changing its appearance or behaviour. ref bej-103 --- .../trials/admin-trial-event-workspace-page-client.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 1741900d..793fdaff 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 @@ -22,11 +22,9 @@ export function AdminTrialEventWorkspacePageClient({ return (
-
-

- {t("admin.trials.manage.workspace.title")} -

-
+

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

{eventQuery.isError ? ( From d7cc9200563b4e1391d220fd4bc2827bd5baa245 Mon Sep 17 00:00:00 2001 From: Aki Kuivas <91662678+asku1990@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:03:53 +0300 Subject: [PATCH 05/24] feat(admin-trials): add event creation and empty-event lifecycle Implement BEJ-103 Gate E2 with full-page trial event creation and explicit management of empty events. - add contracts, Server Actions, mutations, and persistence use cases for creating and deleting trial events - require a positive SKL event ID, date, and place during creation - default new events to the current Helsinki business date - resolve active rule windows through the shared DB repository - preserve an event when its final result is deleted - allow explicit deletion only for events without results - add localized confirmation and toast feedback for event deletion - reuse event form fields without changing existing edit behavior - retain empty events in admin views while excluding them publicly - add focused web, server, DB, and integration coverage - update trial-management documentation and the changelog Refs: BEJ-103 --- CHANGELOG.md | 4 + .../admin/trials/new/__tests__/page.test.tsx | 32 +++ .../web/app/(admin)/admin/trials/new/page.tsx | 10 + .../create-admin-trial-event.test.ts | 84 ++++++++ .../delete-admin-trial-event.test.ts | 60 ++++++ .../trials/manage/create-admin-trial-event.ts | 62 ++++++ .../trials/manage/delete-admin-trial-event.ts | 62 ++++++ .../app/actions/admin/trials/manage/index.ts | 2 + ...in-trial-event-create-page-client.test.tsx | 203 ++++++++++++++++++ .../admin-trial-event-delete-action.test.tsx | 148 +++++++++++++ ...trial-event-workspace-page-client.test.tsx | 1 + .../admin-trial-selected-event-panel.test.tsx | 34 +++ .../admin-trials-page-client.test.tsx | 2 + .../admin-trial-event-create-page-client.tsx | 84 ++++++++ .../admin-trial-event-delete-action.tsx | 77 +++++++ .../admin-trial-event-edit-dialog-helpers.ts | 31 ++- .../trials/admin-trial-event-edit-dialog.tsx | 137 +----------- .../trials/admin-trial-event-form-fields.tsx | 120 +++++++++++ ...dmin-trial-event-workspace-page-client.tsx | 2 + .../admin-trial-selected-event-panel.tsx | 12 ++ .../admin/trials/admin-trials-page-client.tsx | 28 ++- apps/web/components/admin/trials/index.ts | 1 + .../trials/__tests__/trial-route.test.ts | 10 +- apps/web/lib/admin/trials/index.ts | 1 + .../submit-admin-trial-event-creation.ts | 106 +++++++++ apps/web/lib/admin/trials/trial-route.ts | 4 + .../admin/trials/__tests__/index.test.ts | 6 + .../lib/i18n/messages/admin/trials/manage.ts | 48 ++++- ...-create-admin-trial-event-mutation.test.ts | 66 ++++++ ...-delete-admin-trial-event-mutation.test.ts | 52 +++++ apps/web/queries/admin/trials/manage/index.ts | 2 + .../use-create-admin-trial-event-mutation.ts | 42 ++++ .../use-delete-admin-trial-event-mutation.ts | 42 ++++ docs/features/admin-trial-management.md | 25 ++- .../trials/manage/create-admin-trial-event.ts | 15 ++ .../trials/manage/delete-admin-trial-event.ts | 7 + .../contracts/admin/trials/manage/index.ts | 8 + packages/contracts/index.ts | 4 + .../__tests__/create-trial-event.test.ts | 68 ++++++ .../__tests__/delete-trial-entry.test.ts | 49 +---- .../__tests__/delete-trial-event.test.ts | 65 ++++++ .../admin/trials/manage/create-trial-event.ts | 43 ++++ .../admin/trials/manage/delete-trial-entry.ts | 22 +- .../admin/trials/manage/delete-trial-event.ts | 39 ++++ packages/db/admin/trials/manage/index.ts | 10 + packages/db/index.ts | 8 + .../list-active-trial-rule-windows.test.ts | 32 +++ packages/db/trials/index.ts | 4 + .../trials/list-active-trial-rule-windows.ts | 22 ++ packages/server/admin/index.ts | 2 + .../create-trial-event.integration.test.ts | 92 ++++++++ .../__tests__/create-trial-event.test.ts | 109 ++++++++++ .../__tests__/delete-trial-entry.test.ts | 4 +- .../__tests__/delete-trial-event.test.ts | 64 ++++++ .../admin/trials/manage/create-trial-event.ts | 155 +++++++++++++ .../admin/trials/manage/delete-trial-event.ts | 124 +++++++++++ packages/server/admin/trials/manage/index.ts | 2 + .../parse-admin-trial-event-write-input.ts | 108 ++++++++++ .../admin/trials/manage/update-trial-event.ts | 88 ++------ packages/server/index.ts | 2 + .../__tests__/upsert-ajok-result.test.ts | 14 +- .../koiratietokanta/upsert-ajok-result.ts | 10 +- 62 files changed, 2458 insertions(+), 312 deletions(-) create mode 100644 apps/web/app/(admin)/admin/trials/new/__tests__/page.test.tsx create mode 100644 apps/web/app/(admin)/admin/trials/new/page.tsx create mode 100644 apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-event.test.ts create mode 100644 apps/web/app/actions/admin/trials/manage/__tests__/delete-admin-trial-event.test.ts create mode 100644 apps/web/app/actions/admin/trials/manage/create-admin-trial-event.ts create mode 100644 apps/web/app/actions/admin/trials/manage/delete-admin-trial-event.ts create mode 100644 apps/web/components/admin/trials/__tests__/admin-trial-event-create-page-client.test.tsx create mode 100644 apps/web/components/admin/trials/__tests__/admin-trial-event-delete-action.test.tsx create mode 100644 apps/web/components/admin/trials/admin-trial-event-create-page-client.tsx create mode 100644 apps/web/components/admin/trials/admin-trial-event-delete-action.tsx create mode 100644 apps/web/components/admin/trials/admin-trial-event-form-fields.tsx create mode 100644 apps/web/lib/admin/trials/submit-admin-trial-event-creation.ts create mode 100644 apps/web/queries/admin/trials/manage/__tests__/use-create-admin-trial-event-mutation.test.ts create mode 100644 apps/web/queries/admin/trials/manage/__tests__/use-delete-admin-trial-event-mutation.test.ts create mode 100644 apps/web/queries/admin/trials/manage/use-create-admin-trial-event-mutation.ts create mode 100644 apps/web/queries/admin/trials/manage/use-delete-admin-trial-event-mutation.ts create mode 100644 packages/contracts/admin/trials/manage/create-admin-trial-event.ts create mode 100644 packages/contracts/admin/trials/manage/delete-admin-trial-event.ts create mode 100644 packages/db/admin/trials/manage/__tests__/create-trial-event.test.ts create mode 100644 packages/db/admin/trials/manage/__tests__/delete-trial-event.test.ts create mode 100644 packages/db/admin/trials/manage/create-trial-event.ts create mode 100644 packages/db/admin/trials/manage/delete-trial-event.ts create mode 100644 packages/db/trials/__tests__/list-active-trial-rule-windows.test.ts create mode 100644 packages/db/trials/list-active-trial-rule-windows.ts create mode 100644 packages/server/admin/trials/manage/__tests__/create-trial-event.integration.test.ts create mode 100644 packages/server/admin/trials/manage/__tests__/create-trial-event.test.ts create mode 100644 packages/server/admin/trials/manage/__tests__/delete-trial-event.test.ts create mode 100644 packages/server/admin/trials/manage/create-trial-event.ts create mode 100644 packages/server/admin/trials/manage/delete-trial-event.ts create mode 100644 packages/server/admin/trials/manage/internal/parse-admin-trial-event-write-input.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9c7860..c3daad43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,13 @@ This project uses a user-facing changelog format. ### Added - Ylläpitäjä voi avata ajokoetapahtuman omalle sivulleen tarkastelua ja nykyisiä muokkaustoimintoja varten. +- Ylläpitäjä voi luoda ajokoetapahtuman perustietoineen ja avata sen omalle sivulleen ennen koetulosten lisäämistä. +- Ylläpitäjä voi poistaa tyhjän ajokoetapahtuman sen omalta sivulta. ### Changed +- Viimeisen koetuloksen poistaminen säilyttää ajokoetapahtuman tyhjänä, kunnes ylläpitäjä poistaa tapahtuman erikseen. + ### Fixed ### Removed diff --git a/apps/web/app/(admin)/admin/trials/new/__tests__/page.test.tsx b/apps/web/app/(admin)/admin/trials/new/__tests__/page.test.tsx new file mode 100644 index 00000000..c48701b1 --- /dev/null +++ b/apps/web/app/(admin)/admin/trials/new/__tests__/page.test.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import AdminTrialEventCreatePage from "../page"; + +const { clientPropsRef, toBusinessDateInputValueMock } = vi.hoisted(() => ({ + clientPropsRef: { current: null as { initialEventDate: string } | null }, + toBusinessDateInputValueMock: vi.fn(() => "2026-07-21"), +})); + +vi.mock("@/components/admin/trials", () => ({ + AdminTrialEventCreatePageClient: (props: { initialEventDate: string }) => { + clientPropsRef.current = props; + return React.createElement("div", null, "create-trial-event"); + }, +})); + +vi.mock("@/lib/admin/core/date", () => ({ + toBusinessDateInputValue: toBusinessDateInputValueMock, +})); + +describe("AdminTrialEventCreatePage", () => { + it("renders the event creation client", () => { + expect(renderToStaticMarkup()).toContain( + "create-trial-event", + ); + expect(toBusinessDateInputValueMock).toHaveBeenCalledWith(expect.any(Date)); + expect(clientPropsRef.current).toEqual({ + initialEventDate: "2026-07-21", + }); + }); +}); diff --git a/apps/web/app/(admin)/admin/trials/new/page.tsx b/apps/web/app/(admin)/admin/trials/new/page.tsx new file mode 100644 index 00000000..651a4ae3 --- /dev/null +++ b/apps/web/app/(admin)/admin/trials/new/page.tsx @@ -0,0 +1,10 @@ +import { AdminTrialEventCreatePageClient } from "@/components/admin/trials"; +import { toBusinessDateInputValue } from "@/lib/admin/core/date"; + +export default function AdminTrialEventCreatePage() { + return ( + + ); +} diff --git a/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-event.test.ts b/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-event.test.ts new file mode 100644 index 00000000..df1dc044 --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/__tests__/create-admin-trial-event.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createAdminTrialEventAction } from "../create-admin-trial-event"; + +const { guardMock, userMock, createMock } = vi.hoisted(() => ({ + guardMock: vi.fn(), + userMock: vi.fn(), + createMock: vi.fn(), +})); + +vi.mock("@/lib/server/admin-guard", () => ({ + requireAdminLayoutAccess: guardMock, +})); +vi.mock("@/lib/server/current-user", () => ({ + getSessionCurrentUser: userMock, +})); +vi.mock("@beagle/server", () => ({ createAdminTrialEvent: createMock })); + +const input = { + eventDate: "2026-07-21", + eventPlace: "Helsinki", + jarjestaja: null, + ylituomari: null, + ylituomariNumero: null, + ytKertomus: null, + kennelpiiri: null, + kennelpiirinro: null, + sklKoeId: 123, +}; + +describe("createAdminTrialEventAction", () => { + beforeEach(() => { + guardMock.mockReset(); + userMock.mockReset(); + createMock.mockReset(); + }); + + it("rejects missing admin access", async () => { + guardMock.mockResolvedValue({ ok: false, status: 403 }); + + await expect(createAdminTrialEventAction(input)).resolves.toMatchObject({ + hasError: true, + errorCode: "FORBIDDEN", + }); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("propagates service errors", async () => { + guardMock.mockResolvedValue({ ok: true }); + userMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + role: "ADMIN", + }); + createMock.mockResolvedValue({ + status: 409, + body: { ok: false, code: "SKL_KOE_ID_CONFLICT", error: "Conflict" }, + }); + + await expect(createAdminTrialEventAction(input)).resolves.toEqual({ + data: null, + hasError: true, + errorCode: "SKL_KOE_ID_CONFLICT", + message: "Conflict", + }); + }); + + it("returns navigation data", async () => { + guardMock.mockResolvedValue({ ok: true }); + userMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + role: "ADMIN", + }); + createMock.mockResolvedValue({ + status: 201, + body: { ok: true, data: { trialEventId: "event-1" } }, + }); + + await expect(createAdminTrialEventAction(input)).resolves.toEqual({ + data: { trialEventId: "event-1" }, + hasError: false, + }); + }); +}); diff --git a/apps/web/app/actions/admin/trials/manage/__tests__/delete-admin-trial-event.test.ts b/apps/web/app/actions/admin/trials/manage/__tests__/delete-admin-trial-event.test.ts new file mode 100644 index 00000000..4028ac1f --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/__tests__/delete-admin-trial-event.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { deleteAdminTrialEventAction } from "../delete-admin-trial-event"; + +const { guardMock, userMock, deleteMock } = vi.hoisted(() => ({ + guardMock: vi.fn(), + userMock: vi.fn(), + deleteMock: vi.fn(), +})); + +vi.mock("@/lib/server/admin-guard", () => ({ + requireAdminLayoutAccess: guardMock, +})); +vi.mock("@/lib/server/current-user", () => ({ + getSessionCurrentUser: userMock, +})); +vi.mock("@beagle/server", () => ({ deleteAdminTrialEvent: deleteMock })); + +describe("deleteAdminTrialEventAction", () => { + beforeEach(() => { + guardMock.mockReset(); + userMock.mockReset(); + deleteMock.mockReset(); + guardMock.mockResolvedValue({ ok: true }); + userMock.mockResolvedValue({ + id: "u_1", + email: "admin@example.com", + role: "ADMIN", + }); + }); + + it("propagates the non-empty conflict", async () => { + deleteMock.mockResolvedValue({ + status: 409, + body: { ok: false, code: "TRIAL_EVENT_NOT_EMPTY", error: "Not empty" }, + }); + + await expect( + deleteAdminTrialEventAction({ trialEventId: "event-1" }), + ).resolves.toEqual({ + data: null, + hasError: true, + errorCode: "TRIAL_EVENT_NOT_EMPTY", + message: "Not empty", + }); + }); + + it("returns the deleted event id", async () => { + deleteMock.mockResolvedValue({ + status: 200, + body: { ok: true, data: { deletedTrialEventId: "event-1" } }, + }); + + await expect( + deleteAdminTrialEventAction({ trialEventId: "event-1" }), + ).resolves.toEqual({ + data: { deletedTrialEventId: "event-1" }, + hasError: false, + }); + }); +}); diff --git a/apps/web/app/actions/admin/trials/manage/create-admin-trial-event.ts b/apps/web/app/actions/admin/trials/manage/create-admin-trial-event.ts new file mode 100644 index 00000000..e8cf4d9d --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/create-admin-trial-event.ts @@ -0,0 +1,62 @@ +"use server"; + +import type { + CreateAdminTrialEventRequest, + CreateAdminTrialEventResponse, +} from "@beagle/contracts"; +import { createAdminTrialEvent } from "@beagle/server"; +import { requireAdminLayoutAccess } from "@/lib/server/admin-guard"; +import { getSessionCurrentUser } from "@/lib/server/current-user"; + +export type CreateAdminTrialEventActionResult = { + data: CreateAdminTrialEventResponse | null; + hasError: boolean; + errorCode?: string; + message?: string; +}; + +export async function createAdminTrialEventAction( + input: CreateAdminTrialEventRequest, +): Promise { + const adminAccess = await requireAdminLayoutAccess(); + if (!adminAccess.ok) { + return { + data: null, + hasError: true, + errorCode: adminAccess.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN", + message: "Admin access required.", + }; + } + + const currentUser = await getSessionCurrentUser(); + if (!currentUser) { + return { + data: null, + hasError: true, + errorCode: "UNAUTHENTICATED", + message: "Admin access required.", + }; + } + + const result = await createAdminTrialEvent( + input, + { + id: currentUser.id, + email: currentUser.email, + username: null, + role: currentUser.role, + }, + { actorUserId: currentUser.id }, + ); + + if (!result.body.ok) { + return { + data: null, + hasError: true, + errorCode: result.body.code, + message: result.body.error, + }; + } + + return { data: result.body.data, hasError: false }; +} diff --git a/apps/web/app/actions/admin/trials/manage/delete-admin-trial-event.ts b/apps/web/app/actions/admin/trials/manage/delete-admin-trial-event.ts new file mode 100644 index 00000000..eb184920 --- /dev/null +++ b/apps/web/app/actions/admin/trials/manage/delete-admin-trial-event.ts @@ -0,0 +1,62 @@ +"use server"; + +import type { + DeleteAdminTrialEventRequest, + DeleteAdminTrialEventResponse, +} from "@beagle/contracts"; +import { deleteAdminTrialEvent } from "@beagle/server"; +import { requireAdminLayoutAccess } from "@/lib/server/admin-guard"; +import { getSessionCurrentUser } from "@/lib/server/current-user"; + +export type DeleteAdminTrialEventActionResult = { + data: DeleteAdminTrialEventResponse | null; + hasError: boolean; + errorCode?: string; + message?: string; +}; + +export async function deleteAdminTrialEventAction( + input: DeleteAdminTrialEventRequest, +): Promise { + const adminAccess = await requireAdminLayoutAccess(); + if (!adminAccess.ok) { + return { + data: null, + hasError: true, + errorCode: adminAccess.status === 401 ? "UNAUTHENTICATED" : "FORBIDDEN", + message: "Admin access required.", + }; + } + + const currentUser = await getSessionCurrentUser(); + if (!currentUser) { + return { + data: null, + hasError: true, + errorCode: "UNAUTHENTICATED", + message: "Admin access required.", + }; + } + + const result = await deleteAdminTrialEvent( + input, + { + id: currentUser.id, + email: currentUser.email, + username: null, + role: currentUser.role, + }, + { actorUserId: currentUser.id }, + ); + + if (!result.body.ok) { + return { + data: null, + hasError: true, + errorCode: result.body.code, + message: result.body.error, + }; + } + + return { data: result.body.data, hasError: false }; +} diff --git a/apps/web/app/actions/admin/trials/manage/index.ts b/apps/web/app/actions/admin/trials/manage/index.ts index 5bbd9528..d31db353 100644 --- a/apps/web/app/actions/admin/trials/manage/index.ts +++ b/apps/web/app/actions/admin/trials/manage/index.ts @@ -1,3 +1,5 @@ +export { createAdminTrialEventAction } from "./create-admin-trial-event"; +export { deleteAdminTrialEventAction } from "./delete-admin-trial-event"; export { deleteAdminTrialEntryAction } from "./delete-admin-trial-entry"; export { updateAdminTrialEventAction } from "./update-admin-trial-event"; export { updateAdminTrialEntryAction } from "./update-admin-trial-entry"; diff --git a/apps/web/components/admin/trials/__tests__/admin-trial-event-create-page-client.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-event-create-page-client.test.tsx new file mode 100644 index 00000000..9b492494 --- /dev/null +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-create-page-client.test.tsx @@ -0,0 +1,203 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AdminMutationError } from "@/queries/admin/mutation-error"; +import { submitAdminTrialEventCreation } from "@/lib/admin/trials"; +import { AdminTrialEventCreatePageClient } from "../admin-trial-event-create-page-client"; + +const { formDraftRef, mutationState, mutateAsyncMock, replaceMock } = + vi.hoisted(() => ({ + formDraftRef: { + current: null as { eventDate: string } | null, + }, + mutationState: { isPending: false }, + mutateAsyncMock: vi.fn(), + replaceMock: vi.fn(), + })); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: replaceMock }), +})); +vi.mock("@/hooks/i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); +vi.mock("@/queries/admin/trials", () => ({ + useCreateAdminTrialEventMutation: () => ({ + mutateAsync: mutateAsyncMock, + isPending: mutationState.isPending, + }), +})); +vi.mock("../admin-trial-event-form-fields", () => ({ + AdminTrialEventFormFields: (props: { draft: { eventDate: string } }) => { + formDraftRef.current = props.draft; + return React.createElement("div", null, "event-form-fields"); + }, +})); +vi.mock("@/components/ui/card", () => ({ + Card: ({ children }: { children: React.ReactNode }) => + React.createElement("section", null, children), + CardContent: ({ children }: { children: React.ReactNode }) => + React.createElement("div", null, children), +})); + +describe("AdminTrialEventCreatePageClient", () => { + beforeEach(() => { + mutationState.isPending = false; + formDraftRef.current = null; + mutateAsyncMock.mockReset(); + replaceMock.mockReset(); + }); + + it("renders the full-page form and cancel destination", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("admin.trials.manage.create.title"); + expect(html).toContain("event-form-fields"); + expect(html).toContain('href="/admin/trials"'); + expect(html).toContain("admin.trials.manage.create.save"); + expect(formDraftRef.current?.eventDate).toBe("2026-07-21"); + }); + + it("disables submission while the create mutation is pending", () => { + mutationState.isPending = true; + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("admin.trials.manage.create.saving"); + expect(html).toContain('type="submit" disabled=""'); + }); + + const validDraft = { + eventDate: " 2026-07-21 ", + eventPlace: " Helsinki ", + jarjestaja: " Club ", + ylituomari: " Judge ", + ylituomariNumero: " 123 ", + ytKertomus: " Report ", + kennelpiiri: " District ", + kennelpiirinro: " 10 ", + sklKoeId: " 456 ", + }; + const t = (key: string) => key; + + it.each([ + [ + "event date", + { eventDate: " " }, + "admin.trials.manage.eventModal.validation.invalidDate", + ], + [ + "event place", + { eventPlace: " " }, + "admin.trials.manage.eventModal.validation.requiredPlace", + ], + [ + "SKL id", + { sklKoeId: "0" }, + "admin.trials.manage.eventModal.validation.invalidSklKoeId", + ], + ])( + "validates the required %s before mutation", + async (_label, override, error) => { + const setErrorText = vi.fn(); + + await submitAdminTrialEventCreation({ + draft: { ...validDraft, ...override }, + isPending: false, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText, + t, + }); + + expect(mutateAsyncMock).not.toHaveBeenCalled(); + expect(setErrorText).toHaveBeenLastCalledWith(error); + }, + ); + + it("normalizes the request and navigates to the exact workspace", async () => { + mutateAsyncMock.mockResolvedValue({ trialEventId: "event/with spaces" }); + + await submitAdminTrialEventCreation({ + draft: validDraft, + isPending: false, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText: vi.fn(), + t, + }); + + expect(mutateAsyncMock).toHaveBeenCalledWith({ + eventDate: "2026-07-21", + eventPlace: "Helsinki", + jarjestaja: "Club", + ylituomari: "Judge", + ylituomariNumero: "123", + ytKertomus: "Report", + kennelpiiri: "District", + kennelpiirinro: "10", + sklKoeId: 456, + }); + expect(replaceMock).toHaveBeenCalledWith( + "/admin/trials/event%2Fwith%20spaces", + ); + }); + + it("preserves the draft and shows localized duplicate feedback", async () => { + const draft = { ...validDraft }; + const originalDraft = { ...draft }; + const setErrorText = vi.fn(); + mutateAsyncMock.mockRejectedValue( + new AdminMutationError("Conflict", "SKL_KOE_ID_CONFLICT"), + ); + + await submitAdminTrialEventCreation({ + draft, + isPending: false, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText, + t, + }); + + expect(draft).toEqual(originalDraft); + expect(setErrorText).toHaveBeenLastCalledWith( + "admin.trials.manage.create.conflict", + ); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("maps unexpected failures and blocks duplicate pending submissions", async () => { + const setErrorText = vi.fn(); + mutateAsyncMock.mockRejectedValue(new Error("boom")); + + await submitAdminTrialEventCreation({ + draft: validDraft, + isPending: false, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText, + t, + }); + expect(setErrorText).toHaveBeenLastCalledWith( + "admin.trials.manage.create.error", + ); + + mutateAsyncMock.mockClear(); + setErrorText.mockClear(); + await submitAdminTrialEventCreation({ + draft: validDraft, + isPending: true, + mutateAsync: mutateAsyncMock, + replace: replaceMock, + setErrorText, + t, + }); + expect(mutateAsyncMock).not.toHaveBeenCalled(); + expect(setErrorText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/admin/trials/__tests__/admin-trial-event-delete-action.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-event-delete-action.test.tsx new file mode 100644 index 00000000..72e0bb7a --- /dev/null +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-delete-action.test.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AdminMutationError } from "@/queries/admin/mutation-error"; +import { AdminTrialEventDeleteAction } from "../admin-trial-event-delete-action"; + +const { + confirmModalPropsRef, + mutationState, + mutateAsyncMock, + toastErrorMock, + toastSuccessMock, +} = vi.hoisted(() => ({ + confirmModalPropsRef: { current: null as Record | null }, + mutationState: { isPending: false }, + mutateAsyncMock: vi.fn(), + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn(), +})); + +vi.mock("@/hooks/i18n", () => ({ + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/queries/admin/trials", () => ({ + useDeleteAdminTrialEventMutation: () => ({ + mutateAsync: mutateAsyncMock, + isPending: mutationState.isPending, + }), +})); + +vi.mock("@/components/ui/sonner", () => ({ + toast: { + error: toastErrorMock, + success: toastSuccessMock, + }, +})); + +vi.mock("@/components/ui/confirm-modal", () => ({ + ConfirmModal: (props: Record) => { + confirmModalPropsRef.current = props; + return React.createElement("div", null, String(props.description)); + }, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes) => + React.createElement("button", props, children), +})); + +function renderAction(overrides?: { + onDeleted?: (trialEventId: string) => void; + onNotEmpty?: () => void; +}) { + const onDeleted = overrides?.onDeleted ?? vi.fn(); + const onNotEmpty = overrides?.onNotEmpty ?? vi.fn(); + const html = renderToStaticMarkup( + , + ); + + return { html, onDeleted, onNotEmpty }; +} + +function confirmDeletion() { + const onConfirm = confirmModalPropsRef.current?.onConfirm as () => void; + onConfirm(); +} + +describe("AdminTrialEventDeleteAction", () => { + beforeEach(() => { + confirmModalPropsRef.current = null; + mutationState.isPending = false; + mutateAsyncMock.mockReset(); + toastErrorMock.mockReset(); + toastSuccessMock.mockReset(); + }); + + it("wires the destructive confirmation and pending state", () => { + mutationState.isPending = true; + + const { html } = renderAction(); + + expect(html).toContain("admin.trials.manage.deleteEvent.action"); + expect(html).not.toContain("admin.trials.manage.deleteEvent.error"); + expect(confirmModalPropsRef.current).toMatchObject({ + open: false, + isConfirming: true, + title: "admin.trials.manage.deleteEvent.confirmTitle", + description: "admin.trials.manage.deleteEvent.confirmBody", + }); + }); + + it("shows success feedback and reports the deleted event", async () => { + mutateAsyncMock.mockResolvedValue({ deletedTrialEventId: "event-1" }); + const { onDeleted, onNotEmpty } = renderAction(); + + confirmDeletion(); + + await vi.waitFor(() => { + expect(toastSuccessMock).toHaveBeenCalledWith( + "admin.trials.manage.deleteEvent.success", + ); + }); + expect(onDeleted).toHaveBeenCalledWith("event-1"); + expect(onNotEmpty).not.toHaveBeenCalled(); + expect(toastErrorMock).not.toHaveBeenCalled(); + }); + + it("shows one generic error toast without reporting deletion", async () => { + mutateAsyncMock.mockRejectedValue(new Error("boom")); + const { onDeleted, onNotEmpty } = renderAction(); + + confirmDeletion(); + + await vi.waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "admin.trials.manage.deleteEvent.error", + ); + }); + expect(toastErrorMock).toHaveBeenCalledOnce(); + expect(onDeleted).not.toHaveBeenCalled(); + expect(onNotEmpty).not.toHaveBeenCalled(); + }); + + it("refreshes and shows localized feedback for a non-empty conflict", async () => { + mutateAsyncMock.mockRejectedValue( + new AdminMutationError("Conflict", "TRIAL_EVENT_NOT_EMPTY"), + ); + const { onDeleted, onNotEmpty } = renderAction(); + + confirmDeletion(); + + await vi.waitFor(() => { + expect(toastErrorMock).toHaveBeenCalledWith( + "admin.trials.manage.deleteEvent.notEmpty", + ); + }); + expect(onNotEmpty).toHaveBeenCalledOnce(); + expect(onDeleted).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx index 2d3e23a6..8fd60994 100644 --- a/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx +++ b/apps/web/components/admin/trials/__tests__/admin-trial-event-workspace-page-client.test.tsx @@ -98,6 +98,7 @@ describe("AdminTrialEventWorkspacePageClient", () => { trialEventId: "event-1", }); expect(panelPropsRef.current?.workspaceHref).toBeUndefined(); + expect(panelPropsRef.current?.allowEmptyEventDeletion).toBe(true); }); it("passes loading state to the reused panel", () => { diff --git a/apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx index a4ff0fb8..41dd1c23 100644 --- a/apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx +++ b/apps/web/components/admin/trials/__tests__/admin-trial-selected-event-panel.test.tsx @@ -72,6 +72,11 @@ vi.mock("../admin-trial-event-edit-dialog", () => ({ React.createElement("div", null, `edit-dialog-${open}`), })); +vi.mock("../admin-trial-event-delete-action", () => ({ + AdminTrialEventDeleteAction: ({ trialEventId }: { trialEventId: string }) => + React.createElement("div", null, `delete-event-${trialEventId}`), +})); + vi.mock("@/queries/admin/trials", () => ({ useUpdateAdminTrialEventMutation: () => ({ mutateAsync: vi.fn(), @@ -164,4 +169,33 @@ describe("AdminTrialSelectedEventPanel", () => { "admin.trials.manage.selected.actions.openWorkspace", ); }); + + it("shows event deletion only for an empty event when explicitly allowed", () => { + const html = renderToStaticMarkup( + React.createElement(AdminTrialSelectedEventPanel, { + selectedEvent: { + trialEventId: "event-1", + eventDate: "2026-04-14", + eventPlace: "Helsinki", + eventName: null, + jarjestaja: null, + ylituomari: null, + ylituomariNumero: null, + ytKertomus: null, + kennelpiiri: null, + kennelpiirinro: null, + sklKoeId: 12345, + dogCount: 0, + entries: [], + }, + isLoading: false, + isError: false, + errorText: "error", + onDeletedTrialEvent: vi.fn(), + allowEmptyEventDeletion: true, + }), + ); + + expect(html).toContain("delete-event-event-1"); + }); }); diff --git a/apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx b/apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx index a7ffb103..5418cf17 100644 --- a/apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx +++ b/apps/web/components/admin/trials/__tests__/admin-trials-page-client.test.tsx @@ -169,6 +169,8 @@ describe("AdminTrialsPageClient", () => { expect(html).toContain("admin.trials.title"); expect(html).toContain("admin.trials.description"); + expect(html).toContain("admin.trials.manage.create.action"); + expect(html).toContain("/admin/trials/new"); expect(html).toContain("year|||"); expect(html).toContain("2|1|1|event-1|admin.trials.manage.error"); expect(html).toContain( diff --git a/apps/web/components/admin/trials/admin-trial-event-create-page-client.tsx b/apps/web/components/admin/trials/admin-trial-event-create-page-client.tsx new file mode 100644 index 00000000..df510cd5 --- /dev/null +++ b/apps/web/components/admin/trials/admin-trial-event-create-page-client.tsx @@ -0,0 +1,84 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useI18n } from "@/hooks/i18n"; +import { + getAdminTrialsHref, + submitAdminTrialEventCreation, +} from "@/lib/admin/trials"; +import { useCreateAdminTrialEventMutation } from "@/queries/admin/trials"; +import { createEmptyTrialEventDraft } from "./admin-trial-event-edit-dialog-helpers"; +import { AdminTrialEventFormFields } from "./admin-trial-event-form-fields"; + +// Renders and submits the full-page manual trial event creation flow. +export function AdminTrialEventCreatePageClient({ + initialEventDate, +}: { + initialEventDate: string; +}) { + const { t } = useI18n(); + const router = useRouter(); + const createMutation = useCreateAdminTrialEventMutation(); + const [draft, setDraft] = useState(() => ({ + ...createEmptyTrialEventDraft(), + eventDate: initialEventDate, + })); + const [errorText, setErrorText] = useState(null); + + return ( +
+
+

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

+

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

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

{errorText}

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

{errorText}

) : null}
-
- - - - - -