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 (
+
{
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 (