feat(fp): brief v3 — besluit-driven guided drafting

Compose the herregistratie letter from the besluit instead of a library hunt:
the behandelaar picks positief/negatief (+ reden-checkboxes for a negatief) and
the kern's standaardteksten follow the selection live.

Front-end (this increment):
- Kern is recomposed reactively from the besluit selection (new BesluitSelected
  machine msg + composeKern); the "Genereer conceptbrief" button is gone. The
  drafter's free text is preserved across a selection change.
- The editor shows only the editable sections; the locked aanhef/slot render in
  the preview, not the authoring surface. Slot is a case-type template section
  (per templateId), documented as such.
- The panel re-seeds from the letter via inferSelection() — the besluit + redenen
  are read back off the kern's passage blocks, so the selection survives reload
  with no new wire fields (derive, don't store).
- letter-section drops the now-redundant per-section passage picker (besluit owns
  standaardteksten); keeps free-text + block edit/move/remove.

Fix: app-checkbox now falls back to a unique per-instance id when checkboxId is
omitted. The CIBG styled checkbox routes clicks through the label, so the shared
id="undefined" made every reason label toggle the first input — the second
checkbox could never be checked. Verified live (Playwright): each reason toggles
independently.

Backend/seam (brief v3 WIP): besluit/reason passage tags on the wire + seed,
carried through the adapter parse boundary.

Specs updated (besluit, brief.machine) and the affected stories re-pointed at the
new API. FE lint + build + 253 vitest specs green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-20 18:53:53 +02:00
co-authored by Claude Opus 4.8
parent 62ba0b98c4
commit ba32e3dd9f
31 changed files with 4900 additions and 2878 deletions
+11 -2
View File
@@ -137,9 +137,13 @@ public sealed record BriefStatusDto(
string? RejectedBy = null, string? RejectedAt = null, string? Comments = null, string? RejectedBy = null, string? RejectedAt = null, string? Comments = null,
string? SentAt = null); string? SentAt = null);
// Besluit/Reason tag the passage for guided drafting (WP-brief-v3): the behandelaar
// picks the besluit + reden and the FE filters this library to the matching passages.
// null besluit = relevant to any besluit; null reason = not reason-specific.
public sealed record LibraryPassageDto( public sealed record LibraryPassageDto(
string PassageId, string Scope, string SectionKey, string Label, string PassageId, string Scope, string SectionKey, string Label,
RichTextBlockDto Content, int Version, string? Beroep = null, bool IsDefault = false); RichTextBlockDto Content, int Version, string? Beroep = null, bool IsDefault = false,
string? Besluit = null, string? Reason = null);
public sealed record BriefDto( public sealed record BriefDto(
string BriefId, string Beroep, string TemplateId, string BriefId, string Beroep, string TemplateId,
@@ -154,9 +158,14 @@ public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanRe
// The brief's screen DTO also carries the org template it renders with (WP-23): // The brief's screen DTO also carries the org template it renders with (WP-23):
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at // the sub-org's current PUBLISHED version — or, once sent, the version pinned at
// send time (sent letters are immutable; a republish never re-renders them). // send time (sent letters are immutable; a republish never re-renders them).
// The case this letter is about — the zorgverlener + aanvraag the behandelaar is
// handling. Server-joined onto the brief's screen DTO so brief/ stays a shared-only
// leaf context (no cross-context import of registratie).
public sealed record CaseContextDto(string ZorgverlenerNaam, string BigNummer, string Beroep, string AanvraagReferentie);
public sealed record BriefViewDto( public sealed record BriefViewDto(
BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions, BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions,
OrgTemplateDto OrgTemplate); OrgTemplateDto OrgTemplate, CaseContextDto CaseContext);
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections); public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
public sealed record RejectBriefRequest(string Comments); public sealed record RejectBriefRequest(string Comments);
+18 -5
View File
@@ -176,6 +176,9 @@ public static class BriefSeed
{ {
public const string TemplateId = "besluit-arts"; public const string TemplateId = "besluit-arts";
public const string Beroep = "arts"; public const string Beroep = "arts";
// Demo case reference shown in the behandel scherm header (no real aanvraag linkage
// in this POC — one demo case).
public const string AanvraagReferentie = "HER-2026-000842";
private static RichTextNodeDto T(string t) => new("text", Text: t); private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key); private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
@@ -226,12 +229,22 @@ public static class BriefSeed
{ {
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef", new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1), Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
// The "standaardbrief" kern starter set (IsDefault): one button drops these // Kern guidance set (WP-brief-v3). besluit=null → shown for any besluit (the
// in when the kern is still empty (WP-27). // shared intro); besluit=positief/negatief → inserted when that besluit is
new("p-kern-1", "global", "kern", "Beoordeling ontvangen", // chosen; reason!=null → only when that reden is ticked. The FE filters this
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1, IsDefault: true), // with passagesForBesluit — no server endpoint for guidance.
new("p-kern-intro", "global", "kern", "Beoordeling ontvangen",
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1),
new("p-kern-arts", "beroep", "kern", "Toelichting arts", new("p-kern-arts", "beroep", "kern", "Toelichting arts",
Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts", IsDefault: true), Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts"),
new("p-kern-positief", "global", "kern", "Toewijzing",
Block(T("Uw aanvraag tot herregistratie is toegewezen. U blijft ingeschreven in het BIG-register.")), 1, Besluit: "positief"),
new("p-kern-negatief", "global", "kern", "Afwijzing",
Block(T("Uw aanvraag tot herregistratie is afgewezen.")), 1, Besluit: "negatief"),
new("p-kern-scholing", "global", "kern", "Onvoldoende scholing",
Block(T("U voldoet niet aan de eis van voldoende recente werkervaring en scholing.")), 1, Besluit: "negatief", Reason: "onvoldoende_scholing"),
new("p-kern-gegevens", "global", "kern", "Onjuiste gegevens",
Block(T("De door u aangeleverde gegevens zijn onjuist of onvolledig gebleken.")), 1, Besluit: "negatief", Reason: "onjuiste_gegevens"),
new("p-slot-1", "global", "slot", "Standaard slot", new("p-slot-1", "global", "slot", "Standaard slot",
Block(T("Met vriendelijke groet,")), 1), Block(T("Met vriendelijke groet,")), 1),
// These reference a deprecated / not-fillable field so the linter's // These reference a deprecated / not-fillable field so the linter's
+4 -1
View File
@@ -450,7 +450,10 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId), Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
// Sent letters render with the version pinned at send; everything else follows // Sent letters render with the version pinned at send; everything else follows
// the sub-org's current published template (WP-23 immutability invariant). // the sub-org's current published template (WP-23 immutability invariant).
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null)); OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null),
// The case this letter is about — joined from the seeded zorgverlener so the
// behandel scherm can show whom/what it concerns without brief/ importing registratie.
new CaseContextDto(SeedData.Registration.Naam, SeedData.Registration.BigNummer, e.Beroep, BriefSeed.AanvraagReferentie));
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run // Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift. // through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
+33
View File
@@ -1398,6 +1398,9 @@
}, },
"orgTemplate": { "orgTemplate": {
"$ref": "#/components/schemas/OrgTemplateDto" "$ref": "#/components/schemas/OrgTemplateDto"
},
"caseContext": {
"$ref": "#/components/schemas/CaseContextDto"
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -1414,6 +1417,28 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"CaseContextDto": {
"type": "object",
"properties": {
"zorgverlenerNaam": {
"type": "string",
"nullable": true
},
"bigNummer": {
"type": "string",
"nullable": true
},
"beroep": {
"type": "string",
"nullable": true
},
"aanvraagReferentie": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"ChangeRequestRequest": { "ChangeRequestRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1723,6 +1748,14 @@
}, },
"isDefault": { "isDefault": {
"type": "boolean" "type": "boolean"
},
"besluit": {
"type": "string",
"nullable": true
},
"reason": {
"type": "string",
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@@ -62,6 +62,16 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
// global passages + the arts-scoped one; no other-beroep passages leak in. // global passages + the arts-scoped one; no other-beroep passages leak in.
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts"); Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts")); Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
// Guided-drafting tags (WP-brief-v3): positief + negatief + reason-specific negatief.
Assert.Contains(view.AvailablePassages, p => p.Besluit == "positief");
Assert.Contains(view.AvailablePassages, p => p.Besluit == "negatief" && p.Reason == "onvoldoende_scholing");
// Case context is joined onto the screen DTO for the behandel scherm header.
Assert.Equal("19012345601", view.CaseContext.BigNummer);
Assert.Equal("arts", view.CaseContext.Beroep);
Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.ZorgverlenerNaam));
Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.AanvraagReferentie));
} }
[Fact] [Fact]
+3780 -2557
View File
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions, LetterBlock } from '@brief/domain/brief'; import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
@@ -37,7 +37,14 @@ const orgTemplate: OrgTemplate = {
version: 1, version: 1,
}; };
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate }; const caseContext: CaseContext = {
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
};
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
function setup(adapter: Partial<BriefAdapter>): BriefStore { function setup(adapter: Partial<BriefAdapter>): BriefStore {
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] }); TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
+8
View File
@@ -4,6 +4,7 @@ import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { import {
Brief, Brief,
CaseContext,
allDiagnostics, allDiagnostics,
canSubmit, canSubmit,
hasBlockingErrors, hasBlockingErrors,
@@ -87,6 +88,10 @@ export class BriefStore {
stays untouched by design). Set from every server view that carries it. */ stays untouched by design). Set from every server view that carries it. */
readonly orgTemplate = signal<OrgTemplate | null>(null); readonly orgTemplate = signal<OrgTemplate | null>(null);
/** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for
the behandel scherm header, not letter state. Set from every server view. */
readonly caseContext = signal<CaseContext | null>(null);
/** The org logo's content URL for the letterhead, or null when the template has none. */ /** The org logo's content URL for the letterhead, or null when the template has none. */
readonly logoUrl = computed<string | null>(() => { readonly logoUrl = computed<string | null>(() => {
const id = this.orgTemplate()?.logoDocumentId; const id = this.orgTemplate()?.logoDocumentId;
@@ -134,6 +139,7 @@ export class BriefStore {
const r = await this.adapter.load(); const r = await this.adapter.load();
if (r.ok) { if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate); this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.clearHistory(); this.clearHistory();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else { } else {
@@ -212,6 +218,7 @@ export class BriefStore {
if (r.ok) { if (r.ok) {
this.actionState.set({ tag: 'Idle' }); this.actionState.set({ tag: 'Idle' });
this.orgTemplate.set(r.value.orgTemplate); this.orgTemplate.set(r.value.orgTemplate);
this.caseContext.set(r.value.caseContext);
this.clearHistory(); this.clearHistory();
this.rejectionSnapshot.set(null); this.rejectionSnapshot.set(null);
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
@@ -257,6 +264,7 @@ export class BriefStore {
private applyServerStatus(view: BriefView) { private applyServerStatus(view: BriefView) {
// `send` pins the org-template version server-side — mirror whatever came back. // `send` pins the org-template version server-side — mirror whatever came back.
this.orgTemplate.set(view.orgTemplate); this.orgTemplate.set(view.orgTemplate);
this.caseContext.set(view.caseContext);
const { brief, decisions } = view; const { brief, decisions } = view;
const s = brief.status; const s = brief.status;
switch (s.tag) { switch (s.tag) {
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { Besluit, LetterBlock, LibraryPassage } from './brief';
import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
const block = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
const p = (over: Partial<LibraryPassage>): LibraryPassage => ({
passageId: over.passageId ?? 'x',
scope: 'global',
sectionKey: 'kern',
label: over.label ?? 'x',
content: block('x'),
version: 1,
...over,
});
const lib: LibraryPassage[] = [
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
p({ passageId: 'pos', besluit: 'positief' }),
p({ passageId: 'neg', besluit: 'negatief' }),
p({ passageId: 'neg-scholing', besluit: 'negatief', reason: 'onvoldoende_scholing', label: 'Onvoldoende scholing' }),
p({ passageId: 'neg-gegevens', besluit: 'negatief', reason: 'onjuiste_gegevens', label: 'Onjuiste gegevens' }),
p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered
];
describe('passagesForBesluit', () => {
it('positief = shared intro + the positief passage, no negatief/reason passages', () => {
const ids = passagesForBesluit(lib, 'positief', []).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'pos']);
});
it('negatief without redenen = intro + negatief base, but no reason-specific passages', () => {
const ids = passagesForBesluit(lib, 'negatief', []).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'neg']);
});
it('negatief with a reden ticked includes that reason-specific passage only', () => {
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'neg', 'neg-scholing']);
});
it('preserves library order (= reading order)', () => {
const ids = passagesForBesluit(lib, 'negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']);
});
it('never offers non-kern passages', () => {
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(false);
});
});
describe('redenenFor', () => {
it('derives reason checkboxes (code + label) from the negatief reason passages', () => {
expect(redenenFor(lib, 'negatief')).toEqual([
{ code: 'onvoldoende_scholing', label: 'Onvoldoende scholing' },
{ code: 'onjuiste_gegevens', label: 'Onjuiste gegevens' },
]);
});
it('positief has no reason-specific redenen', () => {
expect(redenenFor(lib, 'positief')).toEqual([]);
});
});
describe('inferSelection', () => {
// Build the kern blocks a besluit would produce, then read the selection back off them.
const kern = (besluit: Besluit, reasons: string[]): LetterBlock[] =>
passagesForBesluit(lib, besluit, reasons).map((p, i) => ({
type: 'passage',
blockId: `local-${i + 1}`,
sourcePassageId: p.passageId,
sourceVersion: p.version,
content: p.content,
edited: false,
}));
it('round-trips a positief selection', () => {
expect(inferSelection(kern('positief', []), lib)).toEqual({ besluit: 'positief', reasons: [] });
});
it('round-trips a negatief selection with redenen (in order)', () => {
const blocks = kern('negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']);
expect(inferSelection(blocks, lib)).toEqual({
besluit: 'negatief',
reasons: ['onvoldoende_scholing', 'onjuiste_gegevens'], // library order
});
});
it('an empty kern (nothing chosen) infers no besluit', () => {
expect(inferSelection([], lib)).toEqual({ besluit: null, reasons: [] });
});
it('ignores free-text blocks and unknown passage ids', () => {
const blocks: LetterBlock[] = [
{ type: 'freeText', blockId: 'local-9', content: block('vrij') },
...kern('positief', []),
];
expect(inferSelection(blocks, lib)).toEqual({ besluit: 'positief', reasons: [] });
});
});
+72
View File
@@ -0,0 +1,72 @@
import { Besluit, LetterBlock, LibraryPassage } from './brief';
/**
* Guided drafting: given the behandelaar's besluit + chosen redenen, which library
* passages belong in the kern. This is the "don't make them a detective" logic
* pure, so it's unit-tested directly and the UI just renders the result.
*
* A passage is offered when:
* - it has no besluit tag (a shared intro/toelichting, relevant to any besluit), OR
* - its besluit matches AND either it isn't reason-specific, or its reason is ticked.
*
* Kept in library order (server order = reading order), so an inserted set already
* flows as a letter.
*/
export function passagesForBesluit(
passages: readonly LibraryPassage[],
besluit: Besluit,
reasons: readonly string[],
): LibraryPassage[] {
return passages.filter((p) => {
if (p.sectionKey !== 'kern') return false;
if (p.besluit === undefined) return true; // shared, any besluit
if (p.besluit !== besluit) return false;
if (p.reason === undefined) return true; // besluit-level, not reason-specific
return reasons.includes(p.reason);
});
}
/** A selectable reden for a besluit, derived from the reason-specific passages no
separate catalog. `code` drives `passagesForBesluit`; `label` is the checkbox text.
ponytail: assumes one passage per reason (true for the seed); dedupes on code if not. */
export interface Reden {
readonly code: string;
readonly label: string;
}
export function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {
const seen = new Set<string>();
const out: Reden[] = [];
for (const p of passages) {
if (p.sectionKey !== 'kern' || p.besluit !== besluit || p.reason === undefined) continue;
if (seen.has(p.reason)) continue;
seen.add(p.reason);
out.push({ code: p.reason, label: p.label });
}
return out;
}
/**
* The inverse of `passagesForBesluit`: read the current besluit + redenen back off the
* kern's passage blocks (each carries its `sourcePassageId`), so the panel can re-seed
* itself on reload/undo without persisting the selection separately. Kern passage blocks
* are besluit-derived by construction (the only way passages enter the kern), so this
* round-trips: `inferSelection(kern(passagesForBesluit(lib, b, r)), lib) === { b, r }`.
* Free-text blocks carry no provenance and are ignored.
*/
export function inferSelection(
kernBlocks: readonly LetterBlock[],
passages: readonly LibraryPassage[],
): { besluit: Besluit | null; reasons: string[] } {
const byId = new Map(passages.map((p) => [p.passageId, p]));
let besluit: Besluit | null = null;
const reasons: string[] = [];
for (const b of kernBlocks) {
if (b.type !== 'passage') continue;
const source = byId.get(b.sourcePassageId);
if (!source) continue;
if (source.besluit !== undefined) besluit = source.besluit;
if (source.reason !== undefined && !reasons.includes(source.reason)) reasons.push(source.reason);
}
return { besluit, reasons };
}
+72 -54
View File
@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief'; import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders'; import { PlaceholderDef } from './placeholders';
import { BriefState, BriefMsg, reduce } from './brief.machine'; import { BriefState, reduce } from './brief.machine';
const placeholders: PlaceholderDef[] = [ const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true }, { key: 'naam', label: 'Naam', autoResolvable: true },
@@ -13,15 +13,32 @@ const text = (t: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }], paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
}); });
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({ const libPassage = (
id: string,
sectionKey: string,
extra: Partial<LibraryPassage> = {},
): LibraryPassage => ({
passageId: id, passageId: id,
scope: 'global', scope: 'global',
sectionKey, sectionKey,
label: `Passage ${id}`, label: `Passage ${id}`,
content: text(`inhoud ${id}`), content: text(`inhoud ${id}`),
version: 3, version: 3,
...extra,
}); });
// A besluit-tagged kern library: `intro` is shared (any besluit), `pos`/`neg` are
// besluit-level, `neg-r` is reason-specific. This drives every `BesluitSelected` here.
const lib: LibraryPassage[] = [
libPassage('intro', 'kern'),
libPassage('pos', 'kern', { besluit: 'positief' }),
libPassage('neg', 'kern', { besluit: 'negatief' }),
libPassage('neg-r', 'kern', { besluit: 'negatief', reason: 'r1', label: 'Reden 1' }),
];
const besluit = (b: Besluit | null, reasons: string[] = []) =>
({ tag: 'BesluitSelected', besluit: b, reasons }) as const;
function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief { function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
return { return {
briefId: 'b1', briefId: 'b1',
@@ -29,7 +46,7 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
templateId: 't1', templateId: 't1',
placeholders, placeholders,
sections: sections ?? [ sections: sections ?? [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: false, blocks: [] }, { sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] }, { sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
], ],
status, status,
@@ -52,13 +69,18 @@ const loaded = (
): BriefState => ({ ): BriefState => ({
tag: 'loaded', tag: 'loaded',
brief: briefWith(status, sections), brief: briefWith(status, sections),
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')], availablePassages: lib,
decisions, decisions,
}); });
const sectionBlocks = (s: BriefState, key: string) => const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
const passageIds = (s: BriefState, key: string) =>
sectionBlocks(s, key)
.filter((b) => b.type === 'passage')
.map((b) => (b.type === 'passage' ? b.sourcePassageId : ''));
describe('brief.machine reduce', () => { describe('brief.machine reduce', () => {
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => { it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
expect( expect(
@@ -77,84 +99,80 @@ describe('brief.machine reduce', () => {
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded); expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
}); });
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => { it('BesluitSelected composes the kern: the besluit passages, in reading order, as frozen local blocks', () => {
const s = reduce(loaded(), { const s = reduce(loaded(), besluit('positief'));
tag: 'PassagesInserted', const blocks = sectionBlocks(s, 'kern');
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
});
const blocks = sectionBlocks(s, 'aanhef');
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']); expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true); expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
expect(blocks[0].type === 'passage' && blocks[0].sourcePassageId).toBe('p1'); expect(passageIds(s, 'kern')).toEqual(['intro', 'pos']);
}); });
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => { it('BesluitSelected swaps the passages when the selection changes, keeping free text', () => {
const passage = libPassage('p1', 'aanhef'); let s = reduce(loaded(), besluit('positief'));
const s = reduce(loaded(), { s = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); // drafter's own remark
tag: 'PassagesInserted', s = reduce(s, besluit('negatief', ['r1']));
sectionKey: 'aanhef', const blocks = sectionBlocks(s, 'kern');
passages: [passage], expect(blocks.map((b) => b.type)).toEqual(['passage', 'passage', 'passage', 'freeText']);
expect(passageIds(s, 'kern')).toEqual(['intro', 'neg', 'neg-r']);
// Deselecting the besluit leaves only the free text.
s = reduce(s, besluit(null));
expect(sectionBlocks(s, 'kern').map((b) => b.type)).toEqual(['freeText']);
}); });
// Mutate the source passage object after insertion.
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
const st: BriefState = {
tag: 'loaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [passage],
decisions,
};
const s = reduce(st, besluit('positief'));
// Mutate the source passage object after composition.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
const block = sectionBlocks(s, 'aanhef')[0]; const block = sectionBlocks(s, 'kern')[0];
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud p1' }); expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud intro' });
}); });
it('FreeTextBlockAdded appends an empty free-text block', () => { it('FreeTextBlockAdded appends an empty free-text block', () => {
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'slot' }); const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
const blocks = sectionBlocks(s, 'slot'); const blocks = sectionBlocks(s, 'kern');
expect(blocks).toHaveLength(1); expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe('freeText'); expect(blocks[0].type).toBe('freeText');
}); });
it('BlockContentEdited replaces content and marks a passage block edited', () => { it('BlockContentEdited replaces content and marks a passage block edited', () => {
let s = reduce(loaded(), { let s = reduce(loaded(), besluit('positief'));
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef')],
});
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') }); s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
const block = sectionBlocks(s, 'aanhef')[0]; const block = sectionBlocks(s, 'kern')[0];
expect(block.type === 'passage' && block.edited).toBe(true); expect(block.type === 'passage' && block.edited).toBe(true);
expect(block.content).toEqual(text('aangepast')); expect(block.content).toEqual(text('aangepast'));
}); });
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => { it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
let s = reduce(loaded(), { let s = reduce(loaded(), besluit('positief')); // local-1 intro, local-2 pos
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
});
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 }); s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']); expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' }); s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']); expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-1']);
}); });
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => { it('edits to a locked section are no-ops (besluit, free-text, content, remove, move)', () => {
const lockedSections: Brief['sections'] = [ const lockedSections: Brief['sections'] = [
{ {
sectionKey: 'aanhef', sectionKey: 'kern',
title: 'Aanhef', title: 'Kern',
required: true, required: true,
locked: true, locked: true,
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }], blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
}, },
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] }, { sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
]; ];
const s = loaded({ tag: 'draft' }, lockedSections); const s = loaded({ tag: 'draft' }, lockedSections);
// The brief value is left untouched (withEdit reallocates state, but the guard returns // The brief value is left untouched (withEdit reallocates state, but the guard returns
// the same brief), so assert on deep equality of the section contents. // the same brief), so assert on deep equality of the section contents.
expect( expect(reduce(s, besluit('positief'))).toEqual(s);
reduce(s, { expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' })).toEqual(s);
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef')],
}),
).toEqual(s);
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
expect( expect(
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }), reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
).toEqual(s); ).toEqual(s);
@@ -163,8 +181,8 @@ describe('brief.machine reduce', () => {
s, s,
); );
// the unlocked section still accepts edits // the unlocked section still accepts edits
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(sectionBlocks(edited, 'kern')).toHaveLength(1); expect(sectionBlocks(edited, 'slot')).toHaveLength(1);
}); });
it('edits are no-ops once submitted (status invariant)', () => { it('edits are no-ops once submitted (status invariant)', () => {
@@ -185,10 +203,10 @@ describe('brief.machine reduce', () => {
}); });
it('Submitted fires only from draft and only when required sections are filled', () => { it('Submitted fires only from draft and only when required sections are filled', () => {
// required 'aanhef' empty → no-op // required 'kern' empty → no-op
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded()); expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
// fill the required section, then submit // fill the required section via the besluit, then submit
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' }); const filled = reduce(loaded(), besluit('positief'));
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions }); const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
tag: 'submitted', tag: 'submitted',
+31 -12
View File
@@ -1,5 +1,6 @@
import { assertNever } from '@shared/kernel/fp'; import { assertNever } from '@shared/kernel/fp';
import { import {
Besluit,
Brief, Brief,
BriefDecisions, BriefDecisions,
BriefStatus, BriefStatus,
@@ -10,6 +11,7 @@ import {
canSubmit, canSubmit,
} from './brief'; } from './brief';
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text'; import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
import { passagesForBesluit } from './besluit';
/** /**
* The letter composition state machine (Model + Msg + pure reduce), modeled on * The letter composition state machine (Model + Msg + pure reduce), modeled on
@@ -54,7 +56,7 @@ export type BriefMsg =
decisions: BriefDecisions; decisions: BriefDecisions;
} }
| { tag: 'BriefLoadFailed'; reason: string } | { tag: 'BriefLoadFailed'; reason: string }
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select | { tag: 'BesluitSelected'; besluit: Besluit | null; reasons: readonly string[] } // recomposes the kern's passages
| { tag: 'FreeTextBlockAdded'; sectionKey: string } | { tag: 'FreeTextBlockAdded'; sectionKey: string }
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock } | { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
| { tag: 'BlockRemoved'; blockId: string } | { tag: 'BlockRemoved'; blockId: string }
@@ -114,15 +116,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
return { ...s, brief }; return { ...s, brief };
} }
function insertPassages( function buildPassageBlocks(brief: Brief, passages: readonly LibraryPassage[]): LetterBlock[] {
brief: Brief,
sectionKey: string,
passages: readonly LibraryPassage[],
): Brief {
let idx = nextLocalIndex(brief); let idx = nextLocalIndex(brief);
// The freeze happens HERE: each block gets a deep VALUE copy of the library content, // The freeze happens HERE: each block gets a deep VALUE copy of the library content,
// so later library edits can never mutate this letter (frozen snapshot). // so later library edits can never mutate this letter (frozen snapshot).
const newBlocks: LetterBlock[] = passages.map((p) => ({ return passages.map((p) => ({
type: 'passage', type: 'passage',
blockId: `local-${idx++}`, blockId: `local-${idx++}`,
sourcePassageId: p.passageId, sourcePassageId: p.passageId,
@@ -130,7 +128,26 @@ function insertPassages(
content: deepCopyBlock(p.content), content: deepCopyBlock(p.content),
edited: false, edited: false,
})); }));
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, ...newBlocks] })); }
/** Recompose the kern for a besluit selection: the besluit-driven passages (in reading
order) followed by the drafter's free-text blocks. The kern's `passage` blocks are
besluit-derived by construction, so replacing them wholesale is the reactive swap; the
`freeText` blocks are the drafter's own remarks and survive.
ponytail: free text always trails the besluit passages after a recompute. */
function composeKern(
brief: Brief,
availablePassages: readonly LibraryPassage[],
besluit: Besluit | null,
reasons: readonly string[],
): Brief {
const besluitBlocks = besluit
? buildPassageBlocks(brief, passagesForBesluit(availablePassages, besluit, reasons))
: [];
return mapSection(brief, 'kern', (s) => ({
...s,
blocks: [...besluitBlocks, ...s.blocks.filter((b) => b.type === 'freeText')],
}));
} }
function addFreeText(brief: Brief, sectionKey: string): Brief { function addFreeText(brief: Brief, sectionKey: string): Brief {
@@ -182,11 +199,13 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
case 'Seed': case 'Seed':
return m.state; return m.state;
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a // The kern is besluit-driven: (re)compose its passages from the selection, keeping the
// Msg reaches the reducer. The UI already hides controls for locked sections. // drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'PassagesInserted': case 'BesluitSelected':
return withEdit(s, (b) => return withEdit(s, (b) =>
isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b, s.tag === 'loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b,
); );
case 'FreeTextBlockAdded': case 'FreeTextBlockAdded':
return withEdit(s, (b) => return withEdit(s, (b) =>
+20 -1
View File
@@ -13,6 +13,9 @@ import { Diagnostic, lintPlaceholders, PlaceholderDef } from './placeholders';
export type PassageScope = 'global' | 'beroep'; export type PassageScope = 'global' | 'beroep';
/** The decision the behandelaar is communicating. Drives which passages are offered. */
export type Besluit = 'positief' | 'negatief';
// Re-export placeholderKeysIn for one-import convenience at call sites. // Re-export placeholderKeysIn for one-import convenience at call sites.
export { placeholderKeysIn }; export { placeholderKeysIn };
@@ -25,7 +28,11 @@ export interface LibraryPassage {
readonly label: string; readonly label: string;
readonly content: RichTextBlock; readonly content: RichTextBlock;
readonly version: number; // library version, for provenance only readonly version: number; // library version, for provenance only
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set // Guided-drafting tags: the behandelaar picks a besluit + reden, and `passagesForBesluit`
// (besluit.ts) filters to the matching passages. undefined besluit = shown for any
// besluit; undefined reason = not reason-specific. See @brief/domain/besluit.
readonly besluit?: Besluit;
readonly reason?: string;
} }
/** A block inside a letter section: a frozen passage snapshot, or free text. */ /** A block inside a letter section: a frozen passage snapshot, or free text. */
@@ -50,6 +57,8 @@ export interface LetterSection {
readonly required: boolean; readonly required: boolean;
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter // Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
// composes only the unlocked section(s). The reducer refuses edits to locked sections. // composes only the unlocked section(s). The reducer refuses edits to locked sections.
// These come from the case-type template (`Brief.templateId`), so e.g. the slot's closing
// can differ per case type; the drafter never edits it, and it renders only in the preview.
readonly locked: boolean; readonly locked: boolean;
readonly blocks: readonly LetterBlock[]; readonly blocks: readonly LetterBlock[];
} }
@@ -109,6 +118,16 @@ export function canSubmit(brief: Brief): boolean {
return brief.sections.every((s) => !s.required || s.blocks.length > 0); return brief.sections.every((s) => !s.required || s.blocks.length > 0);
} }
/** The case this letter concerns the zorgverlener + aanvraag the behandelaar is
handling. Server-joined onto the brief view (brief/ stays a shared-only leaf, so it
can't read the registratie context directly). Header context only. */
export interface CaseContext {
readonly zorgverlenerNaam: string;
readonly bigNummer: string;
readonly beroep: string;
readonly aanvraagReferentie: string;
}
/** Server-computed decision flags for the acting principal + this brief's live /** Server-computed decision flags for the acting principal + this brief's live
status (PRD-0002 phase P1) rendered as-is, never recomputed here. */ status (PRD-0002 phase P1) rendered as-is, never recomputed here. */
export interface BriefDecisions { export interface BriefDecisions {
@@ -51,6 +51,16 @@ const view: BriefViewDto = {
content: { paragraphs: [{ nodes: [] }] }, content: { paragraphs: [{ nodes: [] }] },
version: 1, version: 1,
}, },
{
passageId: 'p-neg-scholing',
scope: 'global',
sectionKey: 'kern',
label: 'Onvoldoende scholing',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'x' }] }] },
version: 1,
besluit: 'negatief',
reason: 'onvoldoende_scholing',
},
], ],
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false }, decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
orgTemplate: { orgTemplate: {
@@ -65,6 +75,12 @@ const view: BriefViewDto = {
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 }, margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1, version: 1,
}, },
caseContext: {
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
},
}; };
describe('brief.adapter parse boundary', () => { describe('brief.adapter parse boundary', () => {
@@ -92,6 +108,26 @@ describe('brief.adapter parse boundary', () => {
canReject: true, canReject: true,
canSend: false, canSend: false,
}); });
// Guided-drafting tags survive the boundary; the untagged passage has neither.
expect(r.value.availablePassages[0].besluit).toBeUndefined();
expect(r.value.availablePassages[1]).toMatchObject({
besluit: 'negatief',
reason: 'onvoldoende_scholing',
});
expect(r.value.caseContext).toEqual({
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
});
});
it('rejects a view whose case context is missing or malformed', () => {
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
expect(
parseBriefView({ ...view, caseContext: { ...view.caseContext!, bigNummer: undefined as never } })
.ok,
).toBe(false);
}); });
it('parses the org template and drops a null logoDocumentId', () => { it('parses the org template and drops a null logoDocumentId', () => {
+25 -1
View File
@@ -7,6 +7,7 @@ import {
BriefDto, BriefDto,
BriefStatusDto, BriefStatusDto,
BriefViewDto, BriefViewDto,
CaseContextDto,
LetterBlockDto, LetterBlockDto,
LetterSectionDto, LetterSectionDto,
LibraryPassageDto, LibraryPassageDto,
@@ -19,6 +20,7 @@ import {
Brief, Brief,
BriefDecisions, BriefDecisions,
BriefStatus, BriefStatus,
CaseContext,
LetterBlock, LetterBlock,
LetterSection, LetterSection,
LibraryPassage, LibraryPassage,
@@ -40,6 +42,7 @@ export interface BriefView {
readonly availablePassages: LibraryPassage[]; readonly availablePassages: LibraryPassage[];
readonly decisions: BriefDecisions; readonly decisions: BriefDecisions;
readonly orgTemplate: OrgTemplate; readonly orgTemplate: OrgTemplate;
readonly caseContext: CaseContext;
} }
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`; export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
@@ -249,7 +252,25 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
content: content.value, content: content.value,
version: dto.version, version: dto.version,
...(dto.beroep != null ? { beroep: dto.beroep } : {}), ...(dto.beroep != null ? { beroep: dto.beroep } : {}),
...(dto.isDefault ? { isDefault: true } : {}), ...(dto.besluit === 'positief' || dto.besluit === 'negatief' ? { besluit: dto.besluit } : {}),
...(dto.reason != null ? { reason: dto.reason } : {}),
});
}
function parseCaseContext(dto: CaseContextDto | undefined): Result<string, CaseContext> {
if (
typeof dto?.zorgverlenerNaam !== 'string' ||
typeof dto.bigNummer !== 'string' ||
typeof dto.beroep !== 'string' ||
typeof dto.aanvraagReferentie !== 'string'
) {
return err('brief-view: missing/invalid case context');
}
return ok({
zorgverlenerNaam: dto.zorgverlenerNaam,
bigNummer: dto.bigNummer,
beroep: dto.beroep,
aanvraagReferentie: dto.aanvraagReferentie,
}); });
} }
@@ -351,6 +372,8 @@ export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
if (!decisions.ok) return decisions; if (!decisions.ok) return decisions;
const orgTemplate = parseOrgTemplate(dto.orgTemplate); const orgTemplate = parseOrgTemplate(dto.orgTemplate);
if (!orgTemplate.ok) return orgTemplate; if (!orgTemplate.ok) return orgTemplate;
const caseContext = parseCaseContext(dto.caseContext);
if (!caseContext.ok) return caseContext;
const availablePassages: LibraryPassage[] = []; const availablePassages: LibraryPassage[] = [];
for (const p of dto.availablePassages ?? []) { for (const p of dto.availablePassages ?? []) {
const parsed = parsePassage(p); const parsed = parsePassage(p);
@@ -362,6 +385,7 @@ export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
availablePassages, availablePassages,
decisions: decisions.value, decisions: decisions.value,
orgTemplate: orgTemplate.value, orgTemplate: orgTemplate.value,
caseContext: caseContext.value,
}); });
} }
@@ -0,0 +1,225 @@
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { inferSelection } from '@brief/domain/besluit';
import { Diagnostic } from '@brief/domain/placeholders';
import { OrgTemplate } from '@brief/domain/org-template';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
import { LetterEditorComponent } from '@brief/ui/letter-editor/letter-editor.component';
import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.component';
/** Organism: the behandelaar's drafting step. Frames "Brief opstellen" as one step in
the case workflow a case-context header + stepper (Beoordelen Brief opstellen
Indienen, neighbours stubbed) with the besluit-driven guidance, the lean letter
editor, and an on-demand full-letter preview in a modal. Only ever renders for an
editable brief (draft/rejected); the approver's read-only flow stays in
letter-composer. Presentational: emits edit/submit/preview intents. */
@Component({
selector: 'app-behandel-scherm',
imports: [
ButtonComponent,
HeadingComponent,
StepperComponent,
LetterCanvasComponent,
DiagnosticsPanelComponent,
RejectionCommentsComponent,
LetterEditorComponent,
BesluitPanelComponent,
],
styles: [
`
:host {
display: block;
}
.case-head {
margin-block-end: var(--rhc-space-max-lg);
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));
background: var(--rhc-color-background-subtle, transparent);
}
.case-meta {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
color: var(--rhc-color-foreground-subtle);
}
.step-body {
display: grid;
gap: var(--rhc-space-max-xl);
margin-block-start: var(--rhc-space-max-lg);
}
.bar {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
margin-block-start: var(--rhc-space-max-xl);
}
dialog {
border: none;
border-radius: var(--rhc-radius-md, 4px);
padding: 0;
max-width: min(900px, 95vw);
width: 100%;
}
dialog::backdrop {
background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */
}
.modal-body {
padding: var(--rhc-space-max-lg);
max-height: 80vh;
overflow: auto;
}
.modal-bar {
display: flex;
justify-content: space-between;
gap: var(--rhc-space-max-md);
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
border-block-start: 1px solid var(--rhc-color-border);
}
`,
],
template: `
<div class="case-head">
<app-heading [level]="2">{{ caseHeading() }}</app-heading>
<div class="case-meta">
<span>{{ caseContext().aanvraagReferentie }}</span>
<span>{{ caseContext().zorgverlenerNaam }}</span>
<span>{{ bigLabel() }} {{ caseContext().bigNummer }}</span>
<span>{{ caseContext().beroep }}</span>
</div>
</div>
<app-stepper
[steps]="steps()"
[current]="1"
[processName]="processName()"
[stepTitle]="stepTitle()"
/>
<div class="step-body">
@if (status() === 'rejected') {
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
<app-besluit-panel
[passages]="availablePassages()"
[besluit]="selection().besluit"
[initialRedenen]="selection().reasons"
(selectionChange)="onSelection($event)"
/>
<app-letter-editor [brief]="brief()" [placeholders]="menu()" (edit)="edit.emit($event)" />
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
<div class="bar">
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ submitLabel() }}</app-button
>
@if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span>
}
</div>
</div>
<dialog #previewDialog>
<div class="modal-body">
<app-letter-canvas
[brief]="brief()"
[orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()"
[editableRegions]="'none'"
[diagnostics]="diagnostics()"
/>
</div>
<div class="modal-bar">
<app-button variant="secondary" (click)="preview.emit()">{{ openDocumentLabel() }}</app-button>
<app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button>
</div>
</dialog>
`,
})
export class BehandelSchermComponent {
brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null);
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
caseContext = input.required<CaseContext>();
canSubmit = input(false);
busy = input(false);
edit = output<BriefMsg>();
submit = output<void>();
preview = output<void>();
locate = output<Diagnostic>();
private previewDialog = viewChild<ElementRef<HTMLDialogElement>>('previewDialog');
protected status = computed(() => this.brief().status.tag);
protected rejectComments = computed(() => {
const s = this.brief().status;
return s.tag === 'rejected' ? s.comments : '';
});
/** The besluit + redenen the letter currently reflects, read back off the kern's
passages this seeds the panel so it survives reload/undo (no separate storage). */
protected selection = computed(() => {
const kern = this.brief().sections.find((s) => s.sectionKey === 'kern');
return inferSelection(kern?.blocks ?? [], this.availablePassages());
});
// Same insert menu as the composer: only valid, fillable, non-deprecated fields.
protected menu = computed<PlaceholderOption[]>(() =>
this.brief()
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
);
/** Besluit/redenen changed → recompose the kern as one edit (= one undo step). */
protected onSelection(sel: { besluit: Besluit | null; reasons: string[] }) {
this.edit.emit({ tag: 'BesluitSelected', besluit: sel.besluit, reasons: sel.reasons });
}
protected openPreview() {
this.previewDialog()?.nativeElement.showModal();
}
protected closePreview() {
this.previewDialog()?.nativeElement.close();
}
protected submitLabel = computed(() =>
this.status() === 'rejected'
? $localize`:@@brief.resubmit:Opnieuw indienen`
: $localize`:@@brief.submit:Indienen ter beoordeling`,
);
protected steps = input<string[]>([
$localize`:@@brief.step.beoordelen:Beoordelen`,
$localize`:@@brief.step.opstellen:Brief opstellen`,
$localize`:@@brief.step.indienen:Indienen`,
]);
protected processName = input($localize`:@@brief.process:Herregistratie behandelen`);
protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`);
protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`);
protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`);
protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
protected openDocumentLabel = input(
$localize`:@@brief.preview.openDocument:Openen als document (PDF)`,
);
protected closeLabel = input($localize`:@@common.close:Sluiten`);
protected submitHint = input(
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
);
}
@@ -0,0 +1,91 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief, BriefStatus, CaseContext, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template';
import { BehandelSchermComponent } from './behandel-scherm.component';
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers',
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
footerContact: 'www.bigregister.nl',
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
signatureName: 'A. de Vries',
signatureRole: 'Hoofd Registratie',
signatureClosing: 'Met vriendelijke groet,',
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
version: 1,
};
const caseContext: CaseContext = {
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
bigNummer: '19012345601',
beroep: 'arts',
aanvraagReferentie: 'HER-2026-000842',
};
const passages: LibraryPassage[] = [
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Uw aanvraag is toegewezen.') },
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Uw aanvraag is afgewezen.') },
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
];
function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief {
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status,
placeholders: [
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
sections: [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }] },
{ sectionKey: 'kern', title: 'Kern van het besluit', required: true, locked: false, blocks: kernBlocks },
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }] },
],
};
}
const meta: Meta<BehandelSchermComponent> = {
title: 'Domein/Brief/Behandel Scherm',
component: BehandelSchermComponent,
args: { orgTemplate, caseContext, availablePassages: passages, busy: false },
};
export default meta;
type Story = StoryObj<BehandelSchermComponent>;
/** Fresh case: no besluit chosen yet, so the kern is empty and only the editable
body shows (aanhef/slot appear in the preview). */
export const EmptyKern: Story = {
args: { brief: brief({ tag: 'draft' }), diagnostics: [], canSubmit: false },
};
// A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it.
const negatiefScholingKern: Brief['sections'][number]['blocks'] = [
{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p-kern-negatief', sourceVersion: 1, edited: false, content: text('Uw aanvraag is afgewezen.') },
{ type: 'passage', blockId: 'local-2', sourcePassageId: 'p-kern-scholing', sourceVersion: 1, edited: false, content: text('Onvoldoende scholing.') },
];
/** Draft with a negatief besluit: the kern is filled from the selection and the besluit
panel reflects it (negatief + "onvoldoende scholing" ticked), read back off the kern. */
export const WithContent: Story = {
render: (args) => {
const b = brief({ tag: 'draft' }, negatiefScholingKern);
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
},
};
/** Rejected: the drafter reopens; the rejection comments show above the editor. */
export const Rejected: Story = {
render: (args) => {
const b = brief(
{ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de reden concreter.' },
negatiefScholingKern,
);
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
},
};
@@ -0,0 +1,109 @@
import { Component, computed, input, linkedSignal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { Besluit, LibraryPassage } from '@brief/domain/brief';
import { redenenFor } from '@brief/domain/besluit';
/** Organism: the guided-drafting selector. The behandelaar picks the besluit
(positief/negatief) and for a negatief besluit the reden(en); the kern's
standaardteksten follow the selection LIVE (`selectionChange` the store recomposes
the kern). This is the "no detective work" step: which passages belong is decided by
the besluit, not by the drafter hunting the library.
ponytail: view-state signals, not a form-machine no validation/submission of its own;
it just reports the selection. `besluit`/`redenen` inputs re-seed it (via linkedSignal)
from the persisted letter on reload/undo, so it always reflects the letter's real state. */
@Component({
selector: 'app-besluit-panel',
imports: [FormsModule, CheckboxComponent, RadioGroupComponent, HeadingComponent],
styles: [
`
:host {
display: block;
padding: var(--rhc-space-max-lg);
border: 1px solid var(--rhc-color-border);
border-radius: var(--rhc-radius-md, 4px);
background: var(--rhc-color-background-subtle, transparent);
}
.redenen {
display: grid;
gap: var(--rhc-space-max-sm);
margin-block-start: var(--rhc-space-max-md);
}
`,
],
template: `
<app-heading [level]="3">{{ heading() }}</app-heading>
<p class="app-text-subtle">{{ intro() }}</p>
<app-radio-group
name="besluit"
[options]="besluitOptions()"
[ngModel]="selected()"
(ngModelChange)="onBesluit($event)"
/>
@if (redenen().length > 0) {
<div class="redenen" role="group" [attr.aria-label]="redenenLabel()">
@for (r of redenen(); track r.code) {
<app-checkbox
[checkboxId]="'reden-' + r.code"
[label]="r.label"
[ngModel]="checked().has(r.code)"
(ngModelChange)="toggle(r.code, $event)"
/>
}
</div>
}
`,
})
export class BesluitPanelComponent {
/** The passage library — the redenen checkboxes are derived from its negatief tags. */
passages = input<readonly LibraryPassage[]>([]);
/** The letter's current selection, inferred from its kern passages — re-seeds the panel. */
besluit = input<Besluit | null>(null);
initialRedenen = input<readonly string[]>([]);
selectionChange = output<{ besluit: Besluit | null; reasons: string[] }>();
protected selected = linkedSignal<Besluit | ''>(() => this.besluit() ?? '');
protected checked = linkedSignal<ReadonlySet<string>>(() => new Set(this.initialRedenen()));
/** The reden checkboxes for the chosen besluit — derived from the reason-tagged passages. */
protected redenen = computed(() =>
this.selected() === '' ? [] : redenenFor(this.passages(), this.selected() as Besluit),
);
protected onBesluit(value: string) {
this.selected.set(value === 'positief' || value === 'negatief' ? value : '');
this.checked.set(new Set()); // redenen only apply to the chosen besluit
this.emit();
}
protected toggle(code: string, on: boolean) {
const next = new Set(this.checked());
if (on) next.add(code);
else next.delete(code);
this.checked.set(next);
this.emit();
}
private emit() {
this.selectionChange.emit({
besluit: this.selected() === '' ? null : (this.selected() as Besluit),
reasons: [...this.checked()],
});
}
protected besluitOptions = input<RadioOption[]>([
{ value: 'positief', label: $localize`:@@brief.besluit.positief:Positief besluit (toewijzen)` },
{ value: 'negatief', label: $localize`:@@brief.besluit.negatief:Negatief besluit (afwijzen)` },
]);
protected heading = input($localize`:@@brief.besluit.heading:Besluit`);
protected intro = input(
$localize`:@@brief.besluit.intro:Kies het besluit; de juiste standaardteksten verschijnen meteen in de brief.`,
);
protected redenenLabel = input($localize`:@@brief.besluit.redenen:Reden(en) voor afwijzing`);
}
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LibraryPassage } from '@brief/domain/brief';
import { BesluitPanelComponent } from './besluit-panel.component';
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
const passages: LibraryPassage[] = [
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Toegewezen.') },
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Afgewezen.') },
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
{ passageId: 'p-kern-gegevens', scope: 'global', sectionKey: 'kern', label: 'Onjuiste gegevens', version: 1, besluit: 'negatief', reason: 'onjuiste_gegevens', content: text('Onjuiste gegevens.') },
];
const meta: Meta<BesluitPanelComponent> = {
title: 'Domein/Brief/Besluit Panel',
component: BesluitPanelComponent,
args: { passages },
};
export default meta;
type Story = StoryObj<BesluitPanelComponent>;
/** Pick a besluit; a negatief besluit reveals the reason checkboxes. Each change emits
`selectionChange` and the kern's standaardteksten follow live no generate button. */
export const Default: Story = {};
/** Re-seeded from a persisted letter: a negatief besluit with a reden already ticked. */
export const NegatiefMetReden: Story = {
args: { besluit: 'negatief', initialRedenen: ['onvoldoende_scholing'] },
};
+29 -8
View File
@@ -5,13 +5,21 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
import { BriefStore } from '@brief/application/brief.store'; import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component'; import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
/** Page: thin container. Injects the root store, kicks off the load, and passes its /** Page: thin container. Injects the root store, kicks off the load, and passes its
derived read-model to the composer. Business/UI logic lives below in pure pieces; derived read-model to the composer. Business/UI logic lives below in pure pieces;
this just wires signals to the organism and events back to store commands. */ this just wires signals to the organism and events back to store commands. */
@Component({ @Component({
selector: 'app-brief-page', selector: 'app-brief-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent], imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
...ASYNC,
LetterComposerComponent,
BehandelSchermComponent,
],
host: { '(document:keydown)': 'onKey($event)' }, host: { '(document:keydown)': 'onKey($event)' },
styles: [ styles: [
` `
@@ -76,22 +84,34 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
resetLabel resetLabel
}}</app-button> }}</app-button>
</div> </div>
<app-letter-composer @if (store.canEdit() && store.caseContext(); as caseContext) {
<!-- Drafter (behandelaar): guided drafting step in the case workflow. -->
<app-behandel-scherm
[brief]="s.brief" [brief]="s.brief"
[orgTemplate]="orgTemplate" [orgTemplate]="orgTemplate"
[logoUrl]="store.logoUrl()" [logoUrl]="store.logoUrl()"
[availablePassages]="s.availablePassages" [availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()" [diagnostics]="store.diagnostics()"
[blockDiffs]="store.blockDiffs()" [caseContext]="caseContext"
[removedCount]="store.removedSinceReject()"
[canEdit]="store.canEdit()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
[canSend]="store.canSend()"
[canSubmit]="store.canSubmit()" [canSubmit]="store.canSubmit()"
[busy]="store.busy()" [busy]="store.busy()"
(edit)="store.edit($event)" (edit)="store.edit($event)"
(submit)="store.submit()" (submit)="store.submit()"
(preview)="store.previewLetter()"
/>
} @else {
<!-- Approver / read-only: review + approve/reject/send. -->
<app-letter-composer
[brief]="s.brief"
[orgTemplate]="orgTemplate"
[logoUrl]="store.logoUrl()"
[diagnostics]="store.diagnostics()"
[blockDiffs]="store.blockDiffs()"
[removedCount]="store.removedSinceReject()"
[canApprove]="store.canApprove()"
[canReject]="store.canReject()"
[canSend]="store.canSend()"
[busy]="store.busy()"
(approve)="store.approve()" (approve)="store.approve()"
(reject)="store.reject($event)" (reject)="store.reject($event)"
(send)="store.send()" (send)="store.send()"
@@ -99,6 +119,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
/> />
} }
} }
}
</ng-template> </ng-template>
</app-async> </app-async>
</app-page-shell> </app-page-shell>
@@ -14,16 +14,13 @@ import {
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component'; import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { formatDatumNl } from '@shared/kernel/datum'; import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text'; import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief'; import { Brief, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { BlockDiffKind } from '@brief/domain/brief-diff'; import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */ /** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = { type PreviewSegment = {
@@ -60,7 +57,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
the same file the backend preview renderer inlines (WP-25). */ the same file the backend preview renderer inlines (WP-25). */
@Component({ @Component({
selector: 'app-letter-canvas', selector: 'app-letter-canvas',
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent], imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
styles: [ styles: [
` `
:host { :host {
@@ -113,17 +110,6 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
.surface .letter { .surface .letter {
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */ box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
} }
/* Org-identity regions: visibly not the drafter's to edit. */
.from-template {
background: var(--rhc-color-grijs-100);
outline: 2mm solid var(--rhc-color-grijs-100);
}
.from-template-caption {
font-size: 7.5pt;
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
color: var(--rhc-color-grijs-700);
margin: 0 0 2mm;
}
/* Admin edit-in-place (editableRegions='template'): the org-identity fields /* Admin edit-in-place (editableRegions='template'): the org-identity fields
become controls styled to sit in the letter, with a visible editable affordance. */ become controls styled to sit in the letter, with a visible editable affordance. */
.tmpl-input, .tmpl-input,
@@ -208,10 +194,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()"> <div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements <!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
(robijn footer background) the letter surface must stay letter.css-only. --> (robijn footer background) the letter surface must stay letter.css-only. -->
<div class="letter__letterhead" [class.from-template]="tintTemplate()"> <div class="letter__letterhead">
@if (tintTemplate()) {
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
}
@if (logoUrl()) { @if (logoUrl()) {
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" /> <img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
} }
@@ -247,19 +230,6 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
</div> </div>
<div class="letter__body"> <div class="letter__body">
@if (editableRegions() === 'content') {
@for (section of brief().sections; track section.sectionKey) {
<section>
<app-letter-section
[section]="section"
[availablePassages]="availablePassages()"
[placeholders]="placeholders()"
[editable]="!section.locked"
(edit)="edit.emit($event)"
/>
</section>
}
} @else {
@for (section of brief().sections; track section.sectionKey) { @for (section of brief().sections; track section.sectionKey) {
<section> <section>
<h3>{{ section.title }}</h3> <h3>{{ section.title }}</h3>
@@ -307,10 +277,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
} }
</section> </section>
} }
}
</div> </div>
<div class="letter__signature" [class.from-template]="tintTemplate()"> <div class="letter__signature">
@if (editing()) { @if (editing()) {
<input <input
class="tmpl-input" class="tmpl-input"
@@ -337,7 +306,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
} }
</div> </div>
<div class="letter__footer" [class.from-template]="tintTemplate()"> <div class="letter__footer">
@if (editing()) { @if (editing()) {
<textarea <textarea
class="tmpl-textarea footer-contact" class="tmpl-textarea footer-contact"
@@ -370,11 +339,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
export class LetterCanvasComponent { export class LetterCanvasComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>(); orgTemplate = input.required<OrgTemplate>();
/** Who edits what on the surface: drafter ('content'), read-only ('none'), /** Who edits what on the surface: read-only ('none', the drafter preview + approver
admin editor ('template', consumer arrives in WP-26). */ view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
editableRegions = input<'content' | 'template' | 'none'>('none'); editableRegions = input<'template' | 'none'>('none');
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]);
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
/** Initial zoom; the in-canvas controls take over from here (WP-27). */ /** Initial zoom; the in-canvas controls take over from here (WP-27). */
zoom = input(1); zoom = input(1);
@@ -385,15 +352,11 @@ export class LetterCanvasComponent {
showDiff = input(false); showDiff = input(false);
/** The org logo's content URL (letterhead), or null when none is set. */ /** The org logo's content URL (letterhead), or null when none is set. */
logoUrl = input<string | null>(null); logoUrl = input<string | null>(null);
edit = output<BriefMsg>();
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */ /** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
templateEdit = output<{ field: OrgTemplateTextField; value: string }>(); templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`); showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`); hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
fromTemplateCaption = input(
$localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`,
);
pageBreakCaption = input( pageBreakCaption = input(
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`, $localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
); );
@@ -430,9 +393,6 @@ export class LetterCanvasComponent {
protected diffLabel = (kind: BlockDiffKind) => protected diffLabel = (kind: BlockDiffKind) =>
kind === 'added' ? this.addedLabel() : this.changedLabel(); kind === 'added' ? this.addedLabel() : this.changedLabel();
/** The letterhead/signature/footer are tinted "not yours" only while composing
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
protected tintTemplate = computed(() => this.editableRegions() === 'content');
/** Admin edit-in-place: the org-identity regions render as controls. */ /** Admin edit-in-place: the org-identity regions render as controls. */
protected editing = computed(() => this.editableRegions() === 'template'); protected editing = computed(() => this.editableRegions() === 'template');
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { Brief, LibraryPassage, allDiagnostics } from '@brief/domain/brief'; import { Brief, allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { LetterCanvasComponent } from './letter-canvas.component'; import { LetterCanvasComponent } from './letter-canvas.component';
@@ -16,17 +16,6 @@ const orgTemplate: OrgTemplate = {
version: 1, version: 1,
}; };
const passages: LibraryPassage[] = [
{
passageId: 'p1',
scope: 'global',
sectionKey: 'kern',
label: 'Standaard toelichting',
version: 1,
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Standaardtekst.' }] }] },
},
];
const brief: Brief = { const brief: Brief = {
briefId: 'b1', briefId: 'b1',
beroep: 'arts', beroep: 'arts',
@@ -124,19 +113,14 @@ const meta: Meta<LetterCanvasComponent> = {
args: { args: {
brief, brief,
orgTemplate, orgTemplate,
availablePassages: passages,
placeholders: brief.placeholders,
diagnostics: allDiagnostics(brief), diagnostics: allDiagnostics(brief),
}, },
}; };
export default meta; export default meta;
type Story = StoryObj<LetterCanvasComponent>; type Story = StoryObj<LetterCanvasComponent>;
/** Drafter: content blocks editable in place; org-identity regions tinted read-only. */ /** Read-only rendered letter: the drafter's preview modal and the approver view, with the
export const ContentMode: Story = { args: { editableRegions: 'content' } }; sample-values toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
/** Approver/locked: the identical surface fully read-only, with the sample-values
toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
export const ReadOnly: Story = { args: { editableRegions: 'none' } }; export const ReadOnly: Story = { args: { editableRegions: 'none' } };
export const ReadOnlyZonderBevindingen: Story = { export const ReadOnlyZonderBevindingen: Story = {
@@ -3,11 +3,9 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { Brief } from '@brief/domain/brief';
import { Brief, LibraryPassage } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { Diagnostic } from '@brief/domain/placeholders'; import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { BlockDiffKind } from '@brief/domain/brief-diff'; import { BlockDiffKind } from '@brief/domain/brief-diff';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component'; import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component'; import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
@@ -90,13 +88,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
[brief]="brief()" [brief]="brief()"
[orgTemplate]="orgTemplate()" [orgTemplate]="orgTemplate()"
[logoUrl]="logoUrl()" [logoUrl]="logoUrl()"
[editableRegions]="canEdit() ? 'content' : 'none'" [editableRegions]="'none'"
[availablePassages]="availablePassages()"
[placeholders]="menu()"
[diagnostics]="diagnostics()" [diagnostics]="diagnostics()"
[blockDiffs]="blockDiffs()" [blockDiffs]="blockDiffs()"
[showDiff]="showDiff()" [showDiff]="showDiff()"
(edit)="edit.emit($event)"
/> />
<div class="panel"> <div class="panel">
@@ -105,29 +100,6 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
<div class="bar"> <div class="bar">
@switch (status()) { @switch (status()) {
@case ('draft') {
@if (canEdit()) {
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ submitLabel() }}</app-button
>
@if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span>
}
}
}
@case ('rejected') {
@if (canEdit()) {
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ resubmitLabel() }}</app-button
>
}
}
@case ('submitted') { @case ('submitted') {
@if (canApprove() || canReject()) { @if (canApprove() || canReject()) {
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{ <app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
@@ -156,13 +128,10 @@ export class LetterComposerComponent {
brief = input.required<Brief>(); brief = input.required<Brief>();
orgTemplate = input.required<OrgTemplate>(); orgTemplate = input.required<OrgTemplate>();
logoUrl = input<string | null>(null); logoUrl = input<string | null>(null);
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]); diagnostics = input<readonly Diagnostic[]>([]);
canEdit = input(false);
canApprove = input(false); canApprove = input(false);
canReject = input(false); canReject = input(false);
canSend = input(false); canSend = input(false);
canSubmit = input(false);
busy = input(false); busy = input(false);
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The /** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
"Toon wijzigingen" toggle only appears when there's something to show. */ "Toon wijzigingen" toggle only appears when there's something to show. */
@@ -171,8 +140,6 @@ export class LetterComposerComponent {
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0); protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
protected showDiff = signal(false); protected showDiff = signal(false);
edit = output<BriefMsg>();
submit = output<void>();
approve = output<void>(); approve = output<void>();
reject = output<string>(); reject = output<string>();
send = output<void>(); send = output<void>();
@@ -187,11 +154,6 @@ export class LetterComposerComponent {
() => () =>
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`, $localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
); );
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input(
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
);
approveLabel = input($localize`:@@brief.approve:Goedkeuren`); approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
sendLabel = input($localize`:@@brief.send:Versturen`); sendLabel = input($localize`:@@brief.send:Versturen`);
awaitingText = input( awaitingText = input(
@@ -205,14 +167,6 @@ export class LetterComposerComponent {
return s.tag === 'rejected' ? s.comments : ''; return s.tag === 'rejected' ? s.comments : '';
}); });
// The insert menu offers only valid, fillable, non-deprecated fields — inserting an
// unknown/retired key is structurally impossible.
protected menu = computed<PlaceholderOption[]>(() =>
this.brief()
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
);
protected statusLabel = computed(() => { protected statusLabel = computed(() => {
switch (this.status()) { switch (this.status()) {
case 'draft': case 'draft':
@@ -121,15 +121,13 @@ const render = (b: Brief, decisions: BriefDecisions) => ({
props: { props: {
brief: b, brief: b,
orgTemplate, orgTemplate,
availablePassages: passages,
diagnostics: allDiagnostics(b), diagnostics: allDiagnostics(b),
...decisions, ...decisions,
canSubmit: true,
busy: false, busy: false,
}, },
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [availablePassages]="availablePassages" template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate"
[diagnostics]="diagnostics" [canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject" [diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
[canSend]="canSend" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`, [canSend]="canSend" [busy]="busy"></app-letter-composer>`,
}); });
const meta: Meta<LetterComposerComponent> = { const meta: Meta<LetterComposerComponent> = {
@@ -139,15 +137,6 @@ const meta: Meta<LetterComposerComponent> = {
export default meta; export default meta;
type Story = StoryObj<LetterComposerComponent>; type Story = StoryObj<LetterComposerComponent>;
export const DraftDrafter: Story = {
render: () =>
render(brief({ tag: 'draft' }), {
canEdit: true,
canApprove: false,
canReject: false,
canSend: false,
}),
};
export const SubmittedApprover: Story = { export const SubmittedApprover: Story = {
render: () => render: () =>
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), { render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
@@ -157,17 +146,14 @@ export const SubmittedApprover: Story = {
canSend: false, canSend: false,
}), }),
}; };
export const Rejected: Story = { export const ApprovedSender: Story = {
render: () => render: () =>
render( render(brief({ tag: 'approved', approvedBy: 'demo-approver', approvedAt: '2026-07-01' }), {
brief({ canEdit: false,
tag: 'rejected', canApprove: false,
rejectedBy: 'demo-approver', canReject: false,
rejectedAt: '2026-07-01', canSend: true,
comments: 'Graag de aanhef formeler.',
}), }),
{ canEdit: true, canApprove: false, canReject: false, canSend: false },
),
}; };
export const Sent: Story = { export const Sent: Story = {
render: () => render: () =>
@@ -0,0 +1,40 @@
import { Component, computed, input, output } from '@angular/core';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { Brief } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
/** Organism: the lean authoring surface just the editable letter sections and their
add/edit controls, no letterhead/signature/footer/zoom. The full rendered letter
(including the locked aanhef/slot) lives in the preview modal (see behandel-scherm),
so the drafter stays focused on composing the body they actually own. */
@Component({
selector: 'app-letter-editor',
imports: [LetterSectionComponent],
styles: [
`
:host {
display: grid;
gap: var(--rhc-space-max-xl);
}
`,
],
template: `
@for (section of editableSections(); track section.sectionKey) {
<app-letter-section
[section]="section"
[placeholders]="placeholders()"
[editable]="true"
(edit)="edit.emit($event)"
/>
}
`,
})
export class LetterEditorComponent {
brief = input.required<Brief>();
placeholders = input<readonly PlaceholderOption[]>([]);
edit = output<BriefMsg>();
/** Only unlocked sections are authored here; locked aanhef/slot appear in the preview. */
protected editableSections = computed(() => this.brief().sections.filter((s) => !s.locked));
}
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { Brief } from '@brief/domain/brief';
import { LetterEditorComponent } from './letter-editor.component';
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
drafterId: 'demo-drafter',
status: { tag: 'draft' },
placeholders: [{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }],
sections: [
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [
{
type: 'freeText',
blockId: 'aanhef-1',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
},
],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: [
{
type: 'freeText',
blockId: 'kern-1',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten...' }] }] },
},
],
},
],
};
const meta: Meta<LetterEditorComponent> = {
title: 'Domein/Brief/Letter Editor',
component: LetterEditorComponent,
args: { brief, placeholders: [] },
};
export default meta;
type Story = StoryObj<LetterEditorComponent>;
/** The lean authoring surface: only the editable sections (the kern); the locked
aanhef/slot are hidden here and appear only in the preview. */
export const Default: Story = {};
/** Empty kern: shows the empty-state hint plus the free-text action, no starter content. */
export const EmptyKern: Story = {
args: {
brief: {
...brief,
sections: brief.sections.map((s) => (s.sectionKey === 'kern' ? { ...s, blocks: [] } : s)),
},
},
};
@@ -1,19 +1,19 @@
import { Component, computed, input, output, signal } from '@angular/core'; import { Component, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component'; import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { LetterSection, LibraryPassage } from '@brief/domain/brief'; import { LetterSection } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine'; import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component'; import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.component';
/** Organism: one template section its ordered blocks plus (when editable) the /** Organism: one template section its ordered blocks plus (when editable) the
add-passages and add-free-text actions. Maps child events to `BriefMsg`s; sections add-free-text action. Standaardteksten enter the kern via the besluit panel, not a
themselves can never be added/removed/reordered (no message exists for it). */ per-section picker, so this only offers free text. Maps child events to `BriefMsg`s;
sections themselves can never be added/removed/reordered (no message exists for it). */
@Component({ @Component({
selector: 'app-letter-section', selector: 'app-letter-section',
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent], imports: [ButtonComponent, HeadingComponent, LetterBlockComponent],
styles: [ styles: [
` `
:host { :host {
@@ -63,58 +63,24 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@if (editable()) { @if (editable()) {
<div class="actions"> <div class="actions">
@if (showStandardLetter()) {
<app-button variant="primary" (click)="insertStandardLetter()">{{
standardLetterLabel()
}}</app-button>
}
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
addPassageLabel()
}}</app-button>
<app-button <app-button
variant="subtle" variant="subtle"
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })" (click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
>{{ addFreeLabel() }}</app-button >{{ addFreeLabel() }}</app-button
> >
</div> </div>
@if (pickerOpen()) {
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
}
} }
`, `,
}) })
export class LetterSectionComponent { export class LetterSectionComponent {
section = input.required<LetterSection>(); section = input.required<LetterSection>();
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]); placeholders = input<readonly PlaceholderOption[]>([]);
editable = input(false); editable = input(false);
edit = output<BriefMsg>(); edit = output<BriefMsg>();
requiredLabel = input($localize`:@@brief.section.required:verplicht`); requiredLabel = input($localize`:@@brief.section.required:verplicht`);
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`); emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`); addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
standardLetterLabel = input($localize`:@@brief.section.standardLetter:Standaardbrief invoegen`);
protected pickerOpen = signal(false);
protected sectionPassages = computed(() =>
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
);
/** The "standaardbrief" starter set for this section (server-flagged defaults). */
protected defaultPassages = computed(() => this.sectionPassages().filter((p) => p.isDefault));
/** One-click starter, offered only while the section is still empty and defaults exist. */
protected showStandardLetter = computed(
() => this.section().blocks.length === 0 && this.defaultPassages().length > 0,
);
protected insertStandardLetter() {
// One Msg → one undo step (see brief.store `edit`).
this.edit.emit({
tag: 'PassagesInserted',
sectionKey: this.section().sectionKey,
passages: this.defaultPassages(),
});
}
protected onContent(blockId: string, content: RichTextBlock) { protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content }); this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
@@ -124,9 +90,4 @@ export class LetterSectionComponent {
const i = this.section().blocks.findIndex((b) => b.blockId === blockId); const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction }); this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
} }
protected onInsert(passages: LibraryPassage[]) {
this.edit.emit({ tag: 'PassagesInserted', sectionKey: this.section().sectionKey, passages });
this.pickerOpen.set(false);
}
} }
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { LetterSection, LibraryPassage } from '@brief/domain/brief'; import { LetterSection } from '@brief/domain/brief';
import { LetterSectionComponent } from './letter-section.component'; import { LetterSectionComponent } from './letter-section.component';
const section: LetterSection = { const section: LetterSection = {
@@ -28,30 +28,17 @@ const section: LetterSection = {
const emptySection: LetterSection = { ...section, blocks: [] }; const emptySection: LetterSection = { ...section, blocks: [] };
const passages: LibraryPassage[] = [
{
passageId: 'p2',
scope: 'beroep',
beroep: 'arts',
sectionKey: 'kern',
label: 'Toelichting arts',
version: 1,
isDefault: true, // part of the standaardbrief starter set (WP-27)
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
},
];
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }]; const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
const meta: Meta<LetterSectionComponent> = { const meta: Meta<LetterSectionComponent> = {
title: 'Domein/Brief/Letter Section', title: 'Domein/Brief/Letter Section',
component: LetterSectionComponent, component: LetterSectionComponent,
args: { section, availablePassages: passages, placeholders, edit: () => {} }, args: { section, placeholders, edit: () => {} },
}; };
export default meta; export default meta;
type Story = StoryObj<LetterSectionComponent>; type Story = StoryObj<LetterSectionComponent>;
export const ReadOnly: Story = { args: { editable: false } }; export const ReadOnly: Story = { args: { editable: false } };
export const Editable: Story = { args: { editable: true } }; export const Editable: Story = { args: { editable: true } };
/** Empty section: the one-click "Standaardbrief invoegen" starter appears (WP-27). */ /** Empty section: shows the empty-state hint plus the free-text action. */
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } }; export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
@@ -1608,6 +1608,7 @@ export interface BriefViewDto {
availablePassages?: LibraryPassageDto[] | undefined; availablePassages?: LibraryPassageDto[] | undefined;
decisions?: BriefDecisionsDto; decisions?: BriefDecisionsDto;
orgTemplate?: OrgTemplateDto; orgTemplate?: OrgTemplateDto;
caseContext?: CaseContextDto;
} }
export interface BrpAddressDto { export interface BrpAddressDto {
@@ -1615,6 +1616,13 @@ export interface BrpAddressDto {
adres?: AdresDto; adres?: AdresDto;
} }
export interface CaseContextDto {
zorgverlenerNaam?: string | undefined;
bigNummer?: string | undefined;
beroep?: string | undefined;
aanvraagReferentie?: string | undefined;
}
export interface ChangeRequestRequest { export interface ChangeRequestRequest {
straat?: string | undefined; straat?: string | undefined;
postcode?: string | undefined; postcode?: string | undefined;
@@ -1713,6 +1721,8 @@ export interface LibraryPassageDto {
version?: number; version?: number;
beroep?: string | undefined; beroep?: string | undefined;
isDefault?: boolean; isDefault?: boolean;
besluit?: string | undefined;
reason?: string | undefined;
} }
export interface ManualDiplomaPolicyDto { export interface ManualDiplomaPolicyDto {
@@ -91,7 +91,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
</div> </div>
</div> </div>
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's <!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em ".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
gap. The shell stays group-agnostic and does NOT add its own fieldset (an gap. The shell stays group-agnostic and does NOT add its own fieldset (an
outer grey fieldset would hide the white gaps between the page groups). --> outer grey fieldset would hide the white gaps between the page groups). -->
<ng-content /> <ng-content />
@@ -1,6 +1,11 @@
import { Component, forwardRef, input } from '@angular/core'; import { Component, computed, forwardRef, input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
// Per-instance fallback ids, so the label's `for` always targets THIS checkbox. The
// CIBG styled checkbox hides the native input and routes clicks through the label, so a
// shared/undefined id silently makes every label toggle the first input — hence a default.
let nextCheckboxId = 0;
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin /** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
input for full keyboard + screen-reader support. */ input for full keyboard + screen-reader support. */
@@ -11,13 +16,13 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
<input <input
class="form-check-input" class="form-check-input"
type="checkbox" type="checkbox"
[id]="checkboxId()" [id]="resolvedId()"
[checked]="value" [checked]="value"
[disabled]="disabled" [disabled]="disabled"
(change)="onToggle($event)" (change)="onToggle($event)"
(blur)="onTouched()" (blur)="onTouched()"
/> />
<label class="form-check-label" [for]="checkboxId()">{{ label() }}</label> <label class="form-check-label" [for]="resolvedId()">{{ label() }}</label>
</div> </div>
`, `,
providers: [ providers: [
@@ -28,6 +33,10 @@ export class CheckboxComponent implements ControlValueAccessor {
checkboxId = input<string>(); checkboxId = input<string>();
label = input(''); label = input('');
/** The caller's id, or a unique fallback — never undefined, so labels never collide. */
private autoId = `app-checkbox-${nextCheckboxId++}`;
protected resolvedId = computed(() => this.checkboxId() ?? this.autoId);
value = false; value = false;
disabled = false; disabled = false;
onChange: (v: boolean) => void = () => {}; onChange: (v: boolean) => void = () => {};