feat(brief): locked sections, list formatting, auto/manual placeholder chips
- brief.machine: reducer refuses edits to locked (predefined) sections as
defense-in-depth; LetterSection gains a `locked` flag
- rich-text: paragraphs gain optional `list` kind; editor gets bullet/numbered
list buttons, keyboard shortcuts, and backspace-deletes-adjacent-chip
- placeholder chips distinguish auto-resolvable (grey) vs manual (yellow), in
both the editor and the read-only preview
- fix: preview chip now renders matching {…} braces (was a one-sided ⌗ glyph),
aligned with the editor's chip styling
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, computed, 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';
|
||||
@@ -12,6 +12,10 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
@Component({
|
||||
selector: 'app-brief-page',
|
||||
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent],
|
||||
styles: [`
|
||||
.brief-toolbar{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-lg)}
|
||||
.save{color:var(--rhc-color-foreground-subtle);font-size:0.9em}
|
||||
`],
|
||||
template: `
|
||||
<app-page-shell
|
||||
[heading]="heading"
|
||||
@@ -26,6 +30,10 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
}
|
||||
@case ('loaded') {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{ resetLabel }}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="brief()!"
|
||||
[availablePassages]="availablePassages()"
|
||||
@@ -53,11 +61,30 @@ export class BriefPage {
|
||||
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`;
|
||||
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;
|
||||
|
||||
private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
|
||||
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;
|
||||
private saveErrorText = $localize`:@@brief.page.saveError:Opslaan mislukt`;
|
||||
|
||||
/** Debounced-save state, surfaced in a polite live region. */
|
||||
protected saveText = computed(() => {
|
||||
switch (this.store.saveState()) {
|
||||
case 'saving': return this.savingText;
|
||||
case 'saved': return this.savedText;
|
||||
case 'error': return this.saveErrorText;
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.store.load();
|
||||
}
|
||||
|
||||
protected resetDemo() {
|
||||
void this.store.resetDemo();
|
||||
}
|
||||
|
||||
// Narrow the loaded state for the template.
|
||||
protected brief() {
|
||||
const s = this.model();
|
||||
|
||||
@@ -46,7 +46,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
[section]="section"
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="menu()"
|
||||
[editable]="true"
|
||||
[editable]="!section.locked"
|
||||
(edit)="edit.emit($event)" />
|
||||
}
|
||||
</div>
|
||||
@@ -123,7 +123,9 @@ export class LetterComposerComponent {
|
||||
// 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 })),
|
||||
this.brief()
|
||||
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
|
||||
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
|
||||
);
|
||||
|
||||
protected statusLabel = computed(() => {
|
||||
|
||||
@@ -24,15 +24,17 @@ function brief(status: BriefStatus): Brief {
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: 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,
|
||||
locked: false,
|
||||
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: [] },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'local-3', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }] } }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,42 +1,83 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { Component, computed, input, signal } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { Paragraph } from '@shared/kernel/rich-text';
|
||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
type PreviewSegment = { readonly list: 'bullet' | 'number' | null; readonly items: readonly Paragraph[] };
|
||||
|
||||
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
|
||||
for (const para of paras) {
|
||||
const kind = para.list ?? null;
|
||||
const last = out[out.length - 1];
|
||||
if (kind && last && last.list === kind) last.items.push(para);
|
||||
else out.push({ list: kind, items: [para] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
naam_zorgverlener: 'J. Jansen',
|
||||
big_nummer: '12345678901',
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
a chip flagged by the linter shows its error/warning state. The "Voorbeeld" toggle
|
||||
fills auto-resolvable placeholders with sample values to preview the sent result. */
|
||||
@Component({
|
||||
selector: 'app-letter-preview',
|
||||
imports: [HeadingComponent, PlaceholderChipComponent],
|
||||
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.toolbar{display:flex;justify-content:flex-end;margin-block-end:var(--rhc-space-max-sm)}
|
||||
.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)}
|
||||
ul,ol{margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em}
|
||||
`],
|
||||
template: `
|
||||
<ng-template #line let-nodes>
|
||||
@for (node of nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') { <span>{{ node.text }}</span> }
|
||||
@case ('lineBreak') { <br> }
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip [label]="labelFor(node.key)" [autoResolvable]="autoFor(node.key)" [state]="stateFor(node.key)" />
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<div class="toolbar">
|
||||
<app-button variant="subtle" (click)="showSample.set(!showSample())" [attr.aria-pressed]="showSample()">
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ul>
|
||||
} @else if (seg.list === 'number') {
|
||||
<ol>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ol>
|
||||
} @else {
|
||||
<p><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }" /></p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -48,6 +89,12 @@ export class LetterPreviewComponent {
|
||||
brief = input.required<Brief>();
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
|
||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
private today = new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
|
||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||
private worst = computed(() => {
|
||||
const m = new Map<string, 'error' | 'warning'>();
|
||||
@@ -59,7 +106,9 @@ export class LetterPreviewComponent {
|
||||
return m;
|
||||
});
|
||||
|
||||
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||
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';
|
||||
protected sampleFor = (key: string) => SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user