feat(brief): letter composition + two-person approval (teaching slice)
CI / backend (push) Failing after 22s
CI / frontend (push) Successful in 1m26s
CI / api-client-drift (push) Successful in 1m45s

New `brief` context — a letter-composition feature with a drafter/approver
approval workflow, built as a teaching vertical slice on the repo's existing
FP + Elm + atomic-design patterns (see plan in ~/.claude/plans).

Domain (pure):
- Rich text as a serialisable value tree (placeholders are first-class nodes),
  moved to @shared/kernel/rich-text.ts so the shared editor can use it.
- lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored.
- brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot =
  deep value copy; derived diagnostics/editability. Full specs.

Backend (.NET stub):
- BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints,
  role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards,
  audit logging. Regenerated typed client via gen:api. +6 backend tests.

Seam:
- brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the
  parse boundary (+ spec).

UI (atomic):
- shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep
  contenteditable, DOM<->RichTextBlock round-trip tested).
- brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments,
  letter-section, letter-composer, letter-preview, brief.page + /brief route.
- Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link.

Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared).

Also included (same session):
- Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap.
- src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI).
- .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully
  broken — $localize unresolved — dev + build).

Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green,
Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-01 21:32:22 +02:00
co-authored by Claude Opus 4.8
parent 0aada9037e
commit 053160c5c9
49 changed files with 13963 additions and 573 deletions
+74
View File
@@ -0,0 +1,74 @@
import { Component, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
/** 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;
this just wires signals to the organism and events back to store commands. */
@Component({
selector: 'app-brief-page',
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent],
template: `
<app-page-shell
[heading]="heading"
[intro]="intro"
backLink="/dashboard">
@if (lastError(); as err) { <app-alert type="error">{{ err }}</app-alert> }
@switch (model().tag) {
@case ('loading') { <app-spinner /> }
@case ('failed') {
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
}
@case ('loaded') {
<app-letter-composer
[brief]="brief()!"
[availablePassages]="availablePassages()"
[diagnostics]="store.diagnostics()"
[editable]="store.editable()"
[role]="store.role"
[canSubmit]="store.canSubmit()"
[busy]="store.busy()"
(edit)="store.edit($event)"
(submit)="store.submit()"
(approve)="store.approve()"
(reject)="store.reject($event)"
(send)="store.send()" />
}
}
</app-page-shell>
`,
})
export class BriefPage {
protected store = inject(BriefStore);
protected model = this.store.model;
protected lastError = this.store.lastError;
protected heading = $localize`:@@brief.page.heading:Brief opstellen`;
protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
constructor() {
void this.store.load();
}
// Narrow the loaded state for the template.
protected brief() {
const s = this.model();
return s.tag === 'loaded' ? s.brief : null;
}
protected availablePassages() {
const s = this.model();
return s.tag === 'loaded' ? s.availablePassages : [];
}
protected reload() {
void this.store.load();
}
}
@@ -0,0 +1,48 @@
import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { Diagnostic } from '@brief/domain/placeholders';
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
save/send; warnings (deprecated, unresolved-at-send) are surfaced but allowed.
Fed by a `computed()` over the letter content — never stored. */
@Component({
selector: 'app-diagnostics-panel',
imports: [AlertComponent],
styles: [`
:host{display:block}
ul{margin:0;padding-inline-start:1.1rem;display:grid;gap:0.15rem}
button{background:none;border:0;padding:0;color:var(--rhc-color-foreground-link);cursor:pointer;text-align:start;text-decoration:underline}
`],
template: `
@if (errors().length) {
<app-alert type="error">
<strong>{{ errorsTitle() }}</strong>
<ul>
@for (d of errors(); track $index) { <li><button type="button" (click)="locate.emit(d)">{{ d.message }}</button></li> }
</ul>
</app-alert>
}
@if (warnings().length) {
<app-alert type="warning">
<strong>{{ warningsTitle() }}</strong>
<ul>
@for (d of warnings(); track $index) { <li><button type="button" (click)="locate.emit(d)">{{ d.message }}</button></li> }
</ul>
</app-alert>
}
@if (!errors().length && !warnings().length) {
<app-alert type="ok">{{ cleanText() }}</app-alert>
}
`,
})
export class DiagnosticsPanelComponent {
diagnostics = input<readonly Diagnostic[]>([]);
locate = output<Diagnostic>();
errorsTitle = input($localize`:@@brief.diag.errors:Op te lossen voor indienen/versturen:`);
warningsTitle = input($localize`:@@brief.diag.warnings:Aandachtspunten:`);
cleanText = input($localize`:@@brief.diag.clean:Geen problemen gevonden in de velden.`);
protected errors = computed(() => this.diagnostics().filter((d) => d.severity === 'error'));
protected warnings = computed(() => this.diagnostics().filter((d) => d.severity === 'warning'));
}
@@ -0,0 +1,53 @@
import { Component, computed, input, output } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text';
import { RichTextEditorComponent, PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { LetterBlock } from '@brief/domain/brief';
/** Molecule: one block in a section — its editor plus provenance + block controls.
Presentational: emits content/remove/move events; the section maps them to messages. */
@Component({
selector: 'app-letter-block',
imports: [RichTextEditorComponent, ButtonComponent],
styles: [`
:host{display:block}
.block{border-inline-start:3px solid var(--rhc-color-border-subtle);padding-inline-start:var(--rhc-space-max-md)}
.meta{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-sm)}
.controls{display:flex;gap:var(--rhc-space-max-sm)}
`],
template: `
<div class="block">
<div class="meta">
<span class="app-text-subtle">{{ provenance() }}</span>
@if (editable()) {
<span class="controls">
<app-button variant="subtle" (click)="moved.emit(-1)" i18n="@@brief.block.moveUp">Omhoog</app-button>
<app-button variant="subtle" (click)="moved.emit(1)" i18n="@@brief.block.moveDown">Omlaag</app-button>
<app-button variant="subtle" (click)="removed.emit()" i18n="@@brief.block.remove">Verwijderen</app-button>
</span>
}
</div>
<app-rich-text-editor
[content]="block().content"
[placeholders]="placeholders()"
[editable]="editable()"
(contentChanged)="contentChanged.emit($event)" />
</div>
`,
})
export class LetterBlockComponent {
block = input.required<LetterBlock>();
placeholders = input<readonly PlaceholderOption[]>([]);
editable = input(false);
contentChanged = output<RichTextBlock>();
removed = output<void>();
moved = output<-1 | 1>();
protected provenance = computed(() => {
const b = this.block();
if (b.type === 'freeText') return $localize`:@@brief.provenance.free:Vrije tekst`;
return b.edited
? $localize`:@@brief.provenance.edited:Aangepaste standaardtekst`
: $localize`:@@brief.provenance.standard:Standaardtekst`;
});
}
@@ -0,0 +1,148 @@
import { Component, computed, input, output } from '@angular/core';
import { Role } from '@shared/infrastructure/role';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { Brief, LibraryPassage } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders';
import { BriefMsg } from '@brief/domain/brief.machine';
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
import { LetterPreviewComponent } from '@brief/ui/letter-preview/letter-preview.component';
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
/** Organism: the whole letter — status badge, the editable sections (drafter) or a
read-only preview (approver / locked), the diagnostics panel, and the action bar
appropriate to status × role. Presentational: emits edit + transition intents. */
@Component({
selector: 'app-letter-composer',
imports: [
HeadingComponent, StatusBadgeComponent, ButtonComponent, AlertComponent,
LetterSectionComponent, LetterPreviewComponent, DiagnosticsPanelComponent, RejectionCommentsComponent,
],
styles: [`
:host{display:block}
.head{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);flex-wrap:wrap;margin-block-end:var(--rhc-space-max-lg)}
.sections{display:grid;gap:var(--rhc-space-max-2xl)}
.panel{margin-block:var(--rhc-space-max-xl)}
.bar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;margin-block-start:var(--rhc-space-max-xl)}
`],
template: `
<div class="head">
<app-heading [level]="2">{{ title() }}</app-heading>
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
</div>
@if (status() === 'rejected') {
<app-rejection-comments mode="show" [comments]="rejectComments()" />
}
@if (editable()) {
<div class="sections">
@for (section of brief().sections; track section.sectionKey) {
<app-letter-section
[section]="section"
[availablePassages]="availablePassages()"
[placeholders]="menu()"
[editable]="true"
(edit)="edit.emit($event)" />
}
</div>
} @else {
<app-letter-preview [brief]="brief()" [diagnostics]="diagnostics()" />
}
<div class="panel">
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
</div>
<div class="bar">
@switch (status()) {
@case ('draft') {
@if (editable()) {
<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 (editable()) {
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ resubmitLabel() }}</app-button>
}
}
@case ('submitted') {
@if (role() === 'approver') {
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{ approveLabel() }}</app-button>
<app-rejection-comments mode="entry" [busy]="busy()" (reject)="reject.emit($event)" />
} @else {
<app-alert type="info">{{ awaitingText() }}</app-alert>
}
}
@case ('approved') {
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{ sendLabel() }}</app-button>
}
@case ('sent') {
<app-alert type="ok">{{ sentText() }}</app-alert>
}
}
</div>
`,
})
export class LetterComposerComponent {
brief = input.required<Brief>();
availablePassages = input<readonly LibraryPassage[]>([]);
diagnostics = input<readonly Diagnostic[]>([]);
editable = input(false);
role = input.required<Role>();
canSubmit = input(false);
busy = input(false);
edit = output<BriefMsg>();
submit = output<void>();
approve = output<void>();
reject = output<string>();
send = output<void>();
locate = output<Diagnostic>();
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
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`);
sendLabel = input($localize`:@@brief.send:Versturen`);
awaitingText = input($localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`);
sentText = input($localize`:@@brief.sent:De brief is verzonden.`);
protected status = computed(() => this.brief().status.tag);
protected rejectComments = computed(() => {
const s = this.brief().status;
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 })),
);
protected statusLabel = computed(() => {
switch (this.status()) {
case 'draft': return $localize`:@@brief.status.draft:Concept`;
case 'submitted': return $localize`:@@brief.status.submitted:Ter beoordeling`;
case 'approved': return $localize`:@@brief.status.approved:Goedgekeurd`;
case 'rejected': return $localize`:@@brief.status.rejected:Afgewezen`;
case 'sent': return $localize`:@@brief.status.sent:Verzonden`;
}
});
protected statusColor = computed(() => {
switch (this.status()) {
case 'draft': return 'var(--rhc-color-border-strong)';
case 'submitted': return 'var(--rhc-color-oranje-500)';
case 'approved':
case 'sent': return 'var(--rhc-color-groen-500)';
case 'rejected': return 'var(--rhc-color-rood-500)';
}
});
}
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { LetterComposerComponent } from './letter-composer.component';
import { Brief, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { allDiagnostics } from '@brief/domain/brief';
const passages: LibraryPassage[] = [
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Standaard aanhef', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw ' }, { type: 'placeholder', key: 'naam_zorgverlener' }, { type: 'text', text: ',' }] }] } },
{ passageId: 'p2', scope: 'beroep', beroep: 'arts', sectionKey: 'kern', label: 'Toelichting arts', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] } },
];
function brief(status: BriefStatus): 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,
blocks: [{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p1', sourceVersion: 1, edited: false, content: passages[0].content }],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
blocks: [{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten om reden ' }, { type: 'placeholder', key: 'reden_besluit' }, { type: 'text', text: '.' }] }] } }],
},
{ sectionKey: 'slot', title: 'Slot', required: false, blocks: [] },
],
};
}
const render = (b: Brief, editable: boolean, role: 'drafter' | 'approver') => ({
props: { brief: b, availablePassages: passages, diagnostics: allDiagnostics(b), editable, role, canSubmit: true, busy: false },
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
[editable]="editable" [role]="role" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
});
const meta: Meta<LetterComposerComponent> = {
title: 'Organisms/Letter Composer',
component: LetterComposerComponent,
};
export default meta;
type Story = StoryObj<LetterComposerComponent>;
export const DraftDrafter: Story = { render: () => render(brief({ tag: 'draft' }), true, 'drafter') };
export const SubmittedApprover: Story = { render: () => render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), false, 'approver') };
export const Rejected: Story = { render: () => render(brief({ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de aanhef formeler.' }), true, 'drafter') };
export const Sent: Story = { render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter') };
@@ -0,0 +1,65 @@
import { Component, computed, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { Brief } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders';
/** Organism: read-only rendering of the letter as the approver/recipient sees it.
Placeholders show as labelled chips (values are resolved server-side only at send);
a chip flagged by the linter shows its error/warning state. */
@Component({
selector: 'app-letter-preview',
imports: [HeadingComponent, PlaceholderChipComponent],
styles: [`
:host{display:block}
.letter{background:var(--rhc-color-wit);border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-2xl)}
section{margin-block-end:var(--rhc-space-max-xl)}
p{margin:0 0 var(--rhc-space-max-sm)}
`],
template: `
<div class="letter">
@for (section of brief().sections; track section.sectionKey) {
<section>
<app-heading [level]="3">{{ section.title }}</app-heading>
@for (block of section.blocks; track block.blockId) {
@for (para of block.content.paragraphs; track $index) {
<p>
@for (node of para.nodes; track $index) {
@switch (node.type) {
@case ('text') { <span>{{ node.text }}</span> }
@case ('lineBreak') { <br> }
@case ('placeholder') {
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)" />
}
}
}
</p>
}
}
</section>
}
</div>
`,
})
export class LetterPreviewComponent {
brief = input.required<Brief>();
diagnostics = input<readonly Diagnostic[]>([]);
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
const m = new Map<string, 'error' | 'warning'>();
for (const d of this.diagnostics()) {
if (!d.placeholderKey) continue;
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
}
return m;
});
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
}
@@ -0,0 +1,83 @@
import { Component, computed, input, output, signal } from '@angular/core';
import { RichTextBlock } from '@shared/kernel/rich-text';
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 { LetterSection, LibraryPassage } from '@brief/domain/brief';
import { BriefMsg } from '@brief/domain/brief.machine';
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
add-passages and add-free-text actions. Maps child events to `BriefMsg`s; sections
themselves can never be added/removed/reordered (no message exists for it). */
@Component({
selector: 'app-letter-section',
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent],
styles: [`
:host{display:block}
.blocks{display:grid;gap:var(--rhc-space-max-lg);margin-block:var(--rhc-space-max-md)}
.actions{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm)}
.required{color:var(--rhc-color-foreground-subtle)}
.empty{color:var(--rhc-color-foreground-subtle);font-style:italic}
`],
template: `
<app-heading [level]="3">
{{ section().title }}
@if (section().required) { <span class="required">· {{ requiredLabel() }}</span> }
</app-heading>
<div class="blocks">
@for (block of section().blocks; track block.blockId) {
<app-letter-block
[block]="block"
[placeholders]="placeholders()"
[editable]="editable()"
(contentChanged)="onContent(block.blockId, $event)"
(removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })"
(moved)="onMove(block.blockId, $event)" />
} @empty {
<p class="empty">{{ emptyLabel() }}</p>
}
</div>
@if (editable()) {
<div class="actions">
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{ addPassageLabel() }}</app-button>
<app-button variant="subtle" (click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })">{{ addFreeLabel() }}</app-button>
</div>
@if (pickerOpen()) {
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
}
}
`,
})
export class LetterSectionComponent {
section = input.required<LetterSection>();
availablePassages = input<readonly LibraryPassage[]>([]);
placeholders = input<readonly PlaceholderOption[]>([]);
editable = input(false);
edit = output<BriefMsg>();
requiredLabel = input($localize`:@@brief.section.required:verplicht`);
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`);
protected pickerOpen = signal(false);
protected sectionPassages = computed(() => this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey));
protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
}
protected onMove(blockId: string, direction: -1 | 1) {
const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
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);
}
}
@@ -0,0 +1,55 @@
import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { LibraryPassage } from '@brief/domain/brief';
/** Molecule: multi-select list of the section's library passages. One "Voeg toe"
inserts ALL checked passages at once (a single message upstream) — there is no
single-insert path. Presentational: emits the chosen passages in list order. */
@Component({
selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent],
styles: [`
:host{display:block;border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-md)}
ul{list-style:none;margin:0 0 var(--rhc-space-max-md);padding:0;display:grid;gap:var(--rhc-space-max-sm)}
.scope{color:var(--rhc-color-foreground-subtle)}
`],
template: `
<ul>
@for (p of passages(); track p.passageId) {
<li>
<app-checkbox
[label]="p.label"
[ngModel]="!!checked()[p.passageId]"
(ngModelChange)="set(p.passageId, $event)" />
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
</li>
}
</ul>
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()">{{ addLabel() }} ({{ count() }})</app-button>
`,
})
export class PassagePickerComponent {
passages = input.required<readonly LibraryPassage[]>();
insert = output<LibraryPassage[]>();
addLabel = input($localize`:@@brief.picker.add:Voeg toe`);
globalLabel = input($localize`:@@brief.picker.global:algemeen`);
beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);
protected checked = signal<Record<string, boolean>>({});
protected count = () => Object.values(this.checked()).filter(Boolean).length;
protected set(id: string, on: boolean) {
this.checked.update((c) => ({ ...c, [id]: on }));
}
protected add() {
const chosen = this.passages().filter((p) => this.checked()[p.passageId]);
if (chosen.length) {
this.insert.emit(chosen);
this.checked.set({});
}
}
}
@@ -0,0 +1,42 @@
import { Component, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
/** Molecule: shows the rejection comments (drafter view) or collects them from the
approver. The approver rejects WITH comments; they never edit the letter. */
@Component({
selector: 'app-rejection-comments',
imports: [FormsModule, AlertComponent, ButtonComponent],
styles: [`
:host{display:block}
textarea{inline-size:100%;box-sizing:border-box;min-block-size:4rem;margin-block:var(--rhc-space-max-sm)}
label{font-weight:600}
`],
template: `
@if (mode() === 'show') {
<app-alert type="warning"><strong>{{ rejectedTitle() }}</strong> {{ comments() }}</app-alert>
} @else {
<label for="reject-comments">{{ entryLabel() }}</label>
<textarea id="reject-comments" [(ngModel)]="draft"></textarea>
<app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{ rejectLabel() }}</app-button>
}
`,
})
export class RejectionCommentsComponent {
mode = input<'show' | 'entry'>('show');
comments = input('');
busy = input(false);
reject = output<string>();
rejectedTitle = input($localize`:@@brief.reject.title:Afgewezen:`);
entryLabel = input($localize`:@@brief.reject.entryLabel:Reden van afwijzing`);
rejectLabel = input($localize`:@@brief.reject.button:Afwijzen`);
protected draft = signal('');
protected submit() {
const c = this.draft().trim();
if (c) this.reject.emit(c);
}
}