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>
11 KiB
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-commandskillform-machineskill (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) + asubmit-*command returningResult, 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-commandrecipe) 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
LocalZaakSourcefor 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
- Backend: mutation endpoint advancing aanvraag status (goedkeuren/afwijzen/meer-info- opvragen), validating the transition.
- FE: zaak-detail page (documents + current status) + a decision form machine + submit command.
- Wire the werkvoorraad list (WP-64) to link into this detail page.
- 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 testgreen.
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.