Files
atomic-design-poc/docs/project/archive/backlog/WP-65-behandelportal-beoordeling.md
ehoandClaude Opus 5 12f17d9d73 docs: archive the finished backlogs (RD-30)
Two backlog trees are complete: `docs/project/backlog/` (75 files, every
WP done) and `docs/project/refactor-backlog-setup/` (the arc before it).
Move both under `docs/project/archive/` with `git mv`, so history stays
intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them,
because it points at the now-archived backlog README.

Add `docs/project/archive/README.md`. It states that these trees are
historical and names the two directories that are still live.

Repoint every inbound reference named in RD-30's Files table: CLAUDE.md,
the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the
`document-feature` and `new-ssp` skills, and the readable-codebase PLAN,
README, and RD-19 ticket. Fix two upward-relative links inside the moved
WP files (WP-68, WP-69) that gained a directory level and would otherwise
break. Repoint `.prettierignore`'s two agent-prompt exclusions to their
new path, so prettier keeps leaving those files' exact wording alone.

Mark RD-30 done and check off its acceptance criteria; flip its README
row to done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:00:38 +02:00

11 KiB
Raw Permalink Blame History

WP-65 — Behandelportal: zaak detail + beoordeling (decision) screen

Status: done (65a — detail read — done; 65b — decision write — done) 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 (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 <app-data-block>/<app-data-row> (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).

Progress notes (65b — done)

Backend (POST /beoordeling/{id}/besluit): runs against ApplicationStore directly (not the IZaakSource seam) — same reasoning as 65a's GET: a new seam method would force an OpenZaakZaakSource write now, which stays WP-66's surface. A new Besluit enum (Goedkeuren | Afwijzen | MeerInfoOpvragen) backs a nullable Aanvraag.BesluitStatus + Aanvraag.BesluitToelichting column pair (EF migration BesluitStatus). Like every other enum in this backend, Besluit never crosses the wire as a raw C# enum — no JsonStringEnumConverter is configured, so RecordBesluitRequest.Besluit is a plain string, parsed with Enum.TryParse (400 on an unknown name) — the same wire convention AanvraagStatusDto.Tag already established. The endpoint reuses BeoordelingRules.CanDecide — the SAME function the read side's canBesluiten flag calls — as the transition-legality check, so the two can never drift (409 on an illegal transition, e.g. deciding an already-Goedgekeurd case again). Toelichting is required (400) for Afwijzen/MeerInfoOpvragen, optional for Goedkeuren — enforced server-side because the published AanvraagStatusDto's Reden field is non-optional on those two tags (the FE's existing parseBeoordelingStatus already required it). Mappers.ToStatusDto gained "a recorded decision wins" between the submit-time Reden check and the auto-approve computation — the two never collide in practice (a submit-time-rejected case is already terminal and never reaches the werkvoorraad/beoordeling screens, so no behandelaar ever records a besluit on one). MeerInfoGevraagd is not terminal: CanDecide still allows a further besluit afterwards, so a behandelaar can ask for info, then later approve/reject once it arrives — the same BesluitStatus column is simply overwritten.

FE: besluit.machine.ts is the same single-step Editing/Submitting/Submitted/Failed union as change-request.machine.ts (form-machine skill) — Draft.besluit stays a raw string (parsed into the narrow BesluitTag union only in validate, "parse, don't validate"), so the generic SetField reducer case needs no per-field typing gymnastics. besluit-form (organism) composes RadioGroupComponent (the three actions) + FormFieldComponent/TextInputComponent (toelichting, plain single-line — no textarea atom exists and this form doesn't justify adding one) — no new shared atom. On a successful decision it emits decided, and BeoordelingPage just calls BeoordelingStore.reload() — the server is the authority on the new status, the page never guesses it. The form only renders when the server's canBesluiten flag is true (ADR-0001: render the decision, don't recompute the lifecycle).

Re-ran the full acceptance-criteria smoke by hand against LocalZaakSource: created a manual registratie case, opened it via werkvoorraad → beoordeling, recorded Afwijzen with a toelichting (status → Afgewezen, reason shown), confirmed a further besluit on that same case now 409s. npm run ci green (lint, dep:check ×2, format:check, check:tokens, all four projects' test suites, both apps' localized nl+en builds, backend dotnet format --verify-no-changes + dotnet test — 201/201 including this WP's 5 new tests, api-client regenerated). One pre-existing, unrelated finding: format:check was already red on this branch before this session touched anything — docs/project/backlog/README.md has a long-standing prettier drift (a big markdown table) untouched by this WP; fixed the same class of drift in this file's own body (*computed*_computed_) since this WP was already editing it, left README.md alone as out of scope.

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.