# WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen Status: in progress (65a — detail read — done; 65b — decision write — not started) Phase: 11 — Behandelportal ## Why The core case-treatment write path — a medewerker opens one aanvraag's detail (including its documents) and records a decision (goedkeuren/afwijzen/meer info opvragen), advancing the status lifecycle WP-63 published. This is the first genuinely new _write_ capability in the system beyond what the citizen SSP already does to itself. ## Read first - `mutation-command` skill - `form-machine` skill (the decision action is a state-changing form, same idiom as everywhere else in this house) - [WP-63](WP-63-aanvraag-status-lifecycle.md) (the status field being advanced) ## Decisions (pre-made, don't relitigate) - The decision action is modeled as a `*.machine.ts` (Model/Msg/reduce) + a `submit-*` command returning `Result`, per this house's one-idiom-for-forms rule — not a hand-rolled mutable field. - The mutation endpoint is a new BFF-lite write (per `mutation-command` recipe) that transitions the status field from WP-63; it validates the transition is legal server-side (e.g. can't approve an already-approved case) — the backend remains the authority. - Runs against `LocalZaakSource` for this slice; wiring the decision into real OpenZaak is explicitly WP-66, not bundled here — keeps this slice's surface to app-level behavior only. ## Files New mutation endpoint + command in `BigRegister.Api`; `behandeling/ui` detail page + `behandeling/application` decision machine in the behandelportal app. ## Steps 1. Backend: mutation endpoint advancing aanvraag status (goedkeuren/afwijzen/meer-info- opvragen), validating the transition. 2. FE: zaak-detail page (documents + current status) + a decision form machine + submit command. 3. Wire the werkvoorraad list (WP-64) to link into this detail page. 4. Storybook stories + a11y for the new detail/decision UI. ## Progress notes (65a — done) This WP's own Risks note offered an explicit escape hatch ("split detail-view (read) from decision-recording (write) into two sessions if it feels too big"). Taken: 65a below is done and committed; 65b (the mutation + decision machine + form) is the remaining half — see its own separate session notes once started. Don't relitigate the split; do relitigate nothing else pre-made here. **Blocking gap found and fixed, not in this WP's original scope:** the behandelportal's login was still WP-61's copy-pasted citizen/BSN DigiD flow — nothing sent `X-Medewerker`, so `StubIdentityProvider` always resolved a zorgverlener, `GET /me` never contained `aanvraag:beoordelen`, and WP-64's `WerkvoorraadPage` always rendered its denial alert in a real browser. WP-64's own Verification line ("manual: log in as a stub medewerker, see the queue populated") could not have passed as written — CI stayed green because none of its tests exercise the browser gate. Fixed with a dev-only `medewerkerInterceptor` (`apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` + `medewerker.ts`), mirroring `@shared/infrastructure/role.ts`'s `?role=` idiom but app-local (`?rollen=`, sticky per tab, default `behandelaar`) — real employee-SSO login stays out of scope (ADR-0002 §3: the two apps' login flows are expected to diverge). Documented in `docs/reference/roles-and-access.md`. **Backend (`GET /beoordeling/{id}`):** reads through `IZaakSource.ListCases` (no new seam method — one would force an `OpenZaakZaakSource` get-by-id + mapper, which is WP-66's surface), filters to the requested id, 404s a Concept or unknown id. Documents come from `DocumentStore.ByIds` (new method, mirrors `ByLocalIds`) — `DocumentStore` directly, not `IDocumentSource`, since that seam has no read method and its own header comment already says `DocumentStore` stays the record of truth for preview/download/audit regardless of config. The owner BSN is masked (`MaskTail`, same helper `/brief`'s BIG-nummer masking uses) — WP-64's queue row leaks the full BSN via the same `ApplicationSummaryDto.Owner` field; flagging as a follow-up, not fixed here or ssp's `/admin/cases` page moves too. The gate that was `Werkvoorraad(ctx, action)` is now `Beoordelen(ctx, resource, action)` — one gate for every behandelaar endpoint, `resource` feeding the audit row; the one existing `/werkvoorraad` call site was updated to pass `"werkvoorraad"`. **Decision-readiness (`BeoordelingDecisionsDto.canBesluiten`) ships now, not deferred to 65b:** `BeoordelingRules.CanDecide(AanvraagStatusTag)` only inspects the aanvraag's current *computed* status tag (`Ingediend`/`InBehandeling`/`MeerInfoGevraagd` → decidable; `Goedgekeurd`/`Afgewezen` → not) — no persisted "was a decision recorded" field exists yet, so this pure rule needed nothing from 65b's eventual migration to be correct today. 65b adds the mutation, the `Besluit` enum, and the transition-legality check that reuses this same function. **FE:** `BeoordelingStatus` (`domain/beoordeling.ts`) is the five-tag union (all of ssp's `AanvraagStatus` minus `Concept`, which 404s) — wider than WP-64's `WerkvoorraadStatus` (two tags). `TYPE_LABELS`/`statusLabel` moved from `werkvoorraad-item-view.ts` into a new `domain/beoordeling-view.ts` (the file owning the wider union) and are re-exported from the old location so no consumer or existing spec needed to change. Detail page composes ``/`` (mirrors ssp's `aanvraag-detail.page.ts`) plus one new organism, `beoordeling-documenten` (plain links to the existing, pre-existing-unauthenticated `/uploads/{id}/content` endpoint — not `ApplicationLinkComponent`, whose `to` is a `routerLink`, not an external href). The werkvoorraad list's rows now link to `/aanvraag/:id`, gated by the same `aanvraag:beoordelen` capability. No `MaskedValueComponent` reveal affordance — this screen never reveals the owner, so pulling in that atom's unused reveal machinery would be speculative. **Deferred to 65b, deliberately:** the mutation endpoint, the `Aanvraag.BesluitStatus` column + EF migration, `Mappers.ToStatusDto`'s "a recorded decision wins" branch, the `besluit.machine.ts` + `besluit-form` UI, and re-running the werkvoorraad/ssp-dashboard end-to-end smoke this WP's acceptance criteria actually asks for (a decision advancing status, illegal transitions rejected). None of WP-65's acceptance criteria are checked off yet — 65a is infrastructure the decision screen needs, not a slice of the AC itself. `npm run ci` green (lint, dep:check ×2, both apps' localized builds, both Storybook builds, test, backend test — 197/197 including this WP's 9 new tests). Only the api-client-drift step shows the expected pre-commit diff (this WP's own uncommitted endpoint). ## Acceptance criteria - [ ] A medewerker can view one aanvraag's detail and record a decision that advances its status. - [ ] Illegal transitions are rejected server-side (tested). - [ ] End-to-end smoke: werkvoorraad → detail → decision → status change reflected back in the queue. - [ ] `npm run ci` (behandelportal app) + `dotnet test` green. ## Verification Manual/automated smoke test of the full werkvoorraad → beoordeling → besluit flow against `LocalZaakSource`; `npm run ci`; `cd backend && dotnet test`. ## Out of scope Writing the decision to real OpenZaak (WP-66). ## Risks This is the largest FE slice in Phase 11 — if it feels too big at kickoff, split detail-view (read) from decision-recording (write) into two sessions; the WP as scoped already keeps them in one slice because a detail view with no decision action isn't independently useful for a caseworker. Depends on: WP-64.