feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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 { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
||||
import { besluitGuidance, 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,
|
||||
MaskedValueComponent,
|
||||
AlertComponent,
|
||||
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() }}
|
||||
<app-masked-value
|
||||
[value]="caseContext().bigNummer"
|
||||
[canReveal]="canRevealBigNummer()"
|
||||
[revealLabel]="revealLabel()"
|
||||
(reveal)="onReveal()"
|
||||
/>
|
||||
</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)"
|
||||
/>
|
||||
|
||||
@if (guidance(); as g) {
|
||||
@if (g.needsReason) {
|
||||
<app-alert type="warning">{{ needsReasonHint }}</app-alert>
|
||||
} @else {
|
||||
<app-alert type="info">{{ insertedHint(g.insertedCount) }}</app-alert>
|
||||
}
|
||||
}
|
||||
|
||||
<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);
|
||||
/** Server decision (PRD-0002 §5c): may this actor unmask the case BIG-nummer? */
|
||||
canRevealBigNummer = input(false);
|
||||
|
||||
edit = output<BriefMsg>();
|
||||
submit = output<void>();
|
||||
preview = output<void>();
|
||||
locate = output<Diagnostic>();
|
||||
revealBigNummer = output<void>();
|
||||
|
||||
/** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture
|
||||
before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */
|
||||
protected onReveal() {
|
||||
if (confirm(this.stepUpPrompt())) this.revealBigNummer.emit();
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
/** Visible guidance for the current selection — null until a besluit is chosen (the
|
||||
panel's own intro copy prompts that first step). */
|
||||
protected guidance = computed(() => {
|
||||
const s = this.selection();
|
||||
return s.besluit ? besluitGuidance(this.availablePassages(), s.besluit, s.reasons) : null;
|
||||
});
|
||||
protected needsReasonHint = $localize`:@@brief.guidance.needsReason:Kies een reden, zodat de juiste motivering aan de brief wordt toegevoegd.`;
|
||||
protected insertedHint = (n: number) =>
|
||||
$localize`:@@brief.guidance.inserted:${n}:count: standaardtekst(en) toegevoegd op basis van het besluit. Vul aan met vrije tekst waar nodig.`;
|
||||
|
||||
// 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 revealLabel = input($localize`:@@brief.case.reveal:Toon BIG-nummer`);
|
||||
protected stepUpPrompt = input(
|
||||
$localize`:@@brief.case.revealConfirm:Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?`,
|
||||
);
|
||||
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,174 @@
|
||||
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 } };
|
||||
},
|
||||
};
|
||||
|
||||
/** Field-level PII (PRD-0002 §5c): the case BIG-nummer arrives MASKED, as the server
|
||||
ships it. The behandelaar holds the reveal capability, so the "Toon BIG-nummer"
|
||||
action shows — it runs a step-up confirm and an audited server call before unmasking. */
|
||||
export const MaskedBigNummer: Story = {
|
||||
args: {
|
||||
brief: brief({ tag: 'draft' }),
|
||||
diagnostics: [],
|
||||
canSubmit: false,
|
||||
caseContext: { ...caseContext, bigNummer: '********601' },
|
||||
canRevealBigNummer: 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,65 @@
|
||||
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'] },
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { BriefStore } from '@brief/application/brief.store';
|
||||
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
|
||||
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,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
...ASYNC,
|
||||
LetterComposerComponent,
|
||||
BehandelSchermComponent,
|
||||
],
|
||||
host: { '(document:keydown)': 'onKey($event)' },
|
||||
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);
|
||||
}
|
||||
.toolbar-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.save {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||
@if (lastError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
|
||||
<app-async [data]="store.remoteData()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (loaded(); as s) {
|
||||
@if (store.orgTemplate(); as orgTemplate) {
|
||||
<div class="brief-toolbar">
|
||||
<div class="toolbar-start">
|
||||
@if (store.canEdit()) {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="!store.canUndo()"
|
||||
[attr.aria-label]="undoLabel"
|
||||
(click)="store.undo()"
|
||||
>{{ undoLabel }}</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="!store.canRedo()"
|
||||
[attr.aria-label]="redoLabel"
|
||||
(click)="store.redo()"
|
||||
>{{ redoLabel }}</app-button
|
||||
>
|
||||
}
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
@if (store.saveState().tag === 'Error') {
|
||||
<app-button variant="secondary" (click)="store.retrySave()">{{
|
||||
retrySaveLabel
|
||||
}}</app-button>
|
||||
}
|
||||
</div>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
@if (store.canEdit() && store.caseContext(); as caseContext) {
|
||||
<!-- Drafter (behandelaar): guided drafting step in the case workflow. -->
|
||||
<app-behandel-scherm
|
||||
[brief]="s.brief"
|
||||
[orgTemplate]="orgTemplate"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[caseContext]="caseContext"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
[canRevealBigNummer]="store.canRevealBigNummer()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(preview)="store.previewLetter()"
|
||||
(revealBigNummer)="store.revealBigNummer()"
|
||||
/>
|
||||
} @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()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
(preview)="store.previewLetter()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</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`;
|
||||
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;
|
||||
protected undoLabel = $localize`:@@brief.page.undo:Ongedaan maken`;
|
||||
protected redoLabel = $localize`:@@brief.page.redo:Opnieuw uitvoeren`;
|
||||
protected retrySaveLabel = $localize`:@@brief.page.retrySave:Opnieuw proberen`;
|
||||
|
||||
private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
|
||||
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;
|
||||
private saveErrorText = $localize`:@@brief.page.saveError:Niet opgeslagen — opnieuw proberen`;
|
||||
|
||||
/** Debounced-save state, surfaced in a polite live region. */
|
||||
protected saveText = computed(() => {
|
||||
switch (this.store.saveState().tag) {
|
||||
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();
|
||||
}
|
||||
|
||||
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
|
||||
directive's context can't inherit a generic from a sibling host input, so the
|
||||
Success value is unwrapped here instead of through `let-`. */
|
||||
protected readonly loaded = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : undefined;
|
||||
});
|
||||
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
|
||||
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
|
||||
rich-text editor or a form control, so the browser's own text undo keeps working
|
||||
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
|
||||
protected onKey(e: KeyboardEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z' || !this.store.canEdit()) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this.store.redo();
|
||||
else this.store.undo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { DiagnosticsPanelComponent } from './diagnostics-panel.component';
|
||||
|
||||
const location = { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 };
|
||||
|
||||
const errorAndWarning: Diagnostic[] = [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown-placeholder',
|
||||
placeholderKey: 'onbekend_veld',
|
||||
location,
|
||||
message: 'Onbekend veld "onbekend_veld" — controleer de spelling.',
|
||||
},
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'unresolved-at-send',
|
||||
placeholderKey: 'reden_besluit',
|
||||
location,
|
||||
message: '"Reden besluit" is nog niet ingevuld.',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<DiagnosticsPanelComponent> = {
|
||||
title: 'Domein/Brief/Diagnostics Panel',
|
||||
component: DiagnosticsPanelComponent,
|
||||
args: { locate: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DiagnosticsPanelComponent>;
|
||||
|
||||
export const Findings: Story = { args: { diagnostics: errorAndWarning } };
|
||||
export const Clean: Story = { args: { diagnostics: [] } };
|
||||
@@ -0,0 +1,79 @@
|
||||
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: var(--rhc-border-width-lg) 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,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterBlock } from '@brief/domain/brief';
|
||||
import { LetterBlockComponent } from './letter-block.component';
|
||||
|
||||
const passageBlock: LetterBlock = {
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||
};
|
||||
|
||||
const freeTextBlock: LetterBlock = {
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
||||
|
||||
const meta: Meta<LetterBlockComponent> = {
|
||||
title: 'Domein/Brief/Letter Block',
|
||||
component: LetterBlockComponent,
|
||||
args: {
|
||||
block: passageBlock,
|
||||
placeholders,
|
||||
contentChanged: () => {},
|
||||
removed: () => {},
|
||||
moved: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterBlockComponent>;
|
||||
|
||||
export const ReadOnlyPassage: Story = { args: { editable: false } };
|
||||
export const EditableFreeText: Story = { args: { block: freeTextBlock, editable: true } };
|
||||
@@ -0,0 +1,463 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Paragraph } from '@shared/kernel/rich-text';
|
||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
|
||||
/** 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',
|
||||
};
|
||||
|
||||
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
|
||||
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
|
||||
/** Organism: the letter as one surface — the org template's letterhead, signature and
|
||||
footer around the case-type template's sections. `editableRegions` picks who edits
|
||||
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
|
||||
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
||||
`'template'` reserves the org-identity regions for the admin editor (WP-26).
|
||||
Letter typography/geometry come from the shared `public/letter.css` contract —
|
||||
the same file the backend preview renderer inlines (WP-25). */
|
||||
@Component({
|
||||
selector: 'app-letter-canvas',
|
||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.zoom-pct {
|
||||
min-width: 3.5ch;
|
||||
text-align: center;
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
|
||||
.diff-block.diff-changed {
|
||||
border-inline-start: 3px solid var(--rhc-color-oranje-500);
|
||||
padding-inline-start: var(--rhc-space-max-sm);
|
||||
}
|
||||
.diff-badge {
|
||||
display: inline-block;
|
||||
margin-block-end: 1mm;
|
||||
padding: 0 1.5mm;
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
font-size: 7.5pt;
|
||||
/* changed = dark text on oranje-500 (4.79:1); white on oranje-500 fails (3.23:1). */
|
||||
color: var(--rhc-color-foreground-default);
|
||||
background: var(--rhc-color-oranje-500);
|
||||
}
|
||||
.diff-badge.added {
|
||||
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */
|
||||
color: var(--rhc-color-wit);
|
||||
background: var(--rhc-color-groen-700);
|
||||
}
|
||||
/* Portal-side chrome around the letter surface (not part of the contract file). */
|
||||
.surface {
|
||||
background: var(--rhc-color-grijs-100);
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-lg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.surface .letter {
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
|
||||
}
|
||||
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
|
||||
become controls styled to sit in the letter, with a visible editable affordance. */
|
||||
.tmpl-input,
|
||||
.tmpl-textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--rhc-color-geel-100);
|
||||
border: var(--rhc-border-width-sm) dashed var(--rhc-color-border-strong);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: 0.5mm 1mm;
|
||||
}
|
||||
.tmpl-textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
.org-logo {
|
||||
max-height: 20mm;
|
||||
max-width: 60mm;
|
||||
margin-block-end: 3mm;
|
||||
}
|
||||
`,
|
||||
],
|
||||
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>
|
||||
|
||||
@if (editableRegions() !== 'template') {
|
||||
<div class="toolbar">
|
||||
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="zoomLevel() <= 0.5"
|
||||
[attr.aria-label]="zoomOutLabel()"
|
||||
(click)="zoomBy(-0.1)"
|
||||
>−</app-button
|
||||
>
|
||||
<span class="zoom-pct">{{ zoomPct() }}</span>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="zoomLevel() >= 1.5"
|
||||
[attr.aria-label]="zoomInLabel()"
|
||||
(click)="zoomBy(0.1)"
|
||||
>+</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
|
||||
zoomResetLabel()
|
||||
}}</app-button>
|
||||
</div>
|
||||
@if (editableRegions() === 'none') {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="surface">
|
||||
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
|
||||
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
|
||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||
<div class="letter__letterhead">
|
||||
@if (logoUrl()) {
|
||||
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
||||
}
|
||||
@if (editing()) {
|
||||
<input
|
||||
class="tmpl-input org-wordmark"
|
||||
[value]="orgTemplate().orgName"
|
||||
[attr.aria-label]="orgNameLabel()"
|
||||
(input)="emitEdit('orgName', $event)"
|
||||
/>
|
||||
<textarea
|
||||
class="tmpl-textarea return-address"
|
||||
rows="2"
|
||||
[value]="orgTemplate().returnAddress"
|
||||
[attr.aria-label]="returnAddressLabel()"
|
||||
(input)="emitEdit('returnAddress', $event)"
|
||||
></textarea>
|
||||
} @else {
|
||||
<p class="org-wordmark">{{ orgTemplate().orgName }}</p>
|
||||
<address class="return-address">{{ orgTemplate().returnAddress }}</address>
|
||||
}
|
||||
<address class="address-window">{{ recipientText() }}</address>
|
||||
<dl class="reference">
|
||||
<div>
|
||||
<dt>{{ referenceLabel() }}</dt>
|
||||
<dd>{{ brief().briefId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ dateLabel() }}</dt>
|
||||
<dd>{{ letterDate }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="letter__body">
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<h3>{{ section.title }}</h3>
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
||||
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
||||
@if (diffKind) {
|
||||
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
|
||||
diffLabel(diffKind)
|
||||
}}</span>
|
||||
}
|
||||
@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>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="letter__signature">
|
||||
@if (editing()) {
|
||||
<input
|
||||
class="tmpl-input"
|
||||
[value]="orgTemplate().signatureClosing"
|
||||
[attr.aria-label]="signatureClosingLabel()"
|
||||
(input)="emitEdit('signatureClosing', $event)"
|
||||
/>
|
||||
<input
|
||||
class="tmpl-input signature-name"
|
||||
[value]="orgTemplate().signatureName"
|
||||
[attr.aria-label]="signatureNameLabel()"
|
||||
(input)="emitEdit('signatureName', $event)"
|
||||
/>
|
||||
<input
|
||||
class="tmpl-input"
|
||||
[value]="orgTemplate().signatureRole"
|
||||
[attr.aria-label]="signatureRoleLabel()"
|
||||
(input)="emitEdit('signatureRole', $event)"
|
||||
/>
|
||||
} @else {
|
||||
<p>{{ orgTemplate().signatureClosing }}</p>
|
||||
<p class="signature-name">{{ orgTemplate().signatureName }}</p>
|
||||
<p>{{ orgTemplate().signatureRole }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="letter__footer">
|
||||
@if (editing()) {
|
||||
<textarea
|
||||
class="tmpl-textarea footer-contact"
|
||||
rows="2"
|
||||
[value]="orgTemplate().footerContact"
|
||||
[attr.aria-label]="footerContactLabel()"
|
||||
(input)="emitEdit('footerContact', $event)"
|
||||
></textarea>
|
||||
<input
|
||||
class="tmpl-input footer-legal"
|
||||
[value]="orgTemplate().footerLegal"
|
||||
[attr.aria-label]="footerLegalLabel()"
|
||||
(input)="emitEdit('footerLegal', $event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="footer-contact">{{ orgTemplate().footerContact }}</div>
|
||||
<div class="footer-legal">{{ orgTemplate().footerLegal }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@for (top of pageBreaks(); track $index) {
|
||||
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
||||
<span>{{ pageBreakCaption() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LetterCanvasComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
|
||||
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
|
||||
editableRegions = input<'template' | 'none'>('none');
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
|
||||
zoom = input(1);
|
||||
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
|
||||
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
|
||||
exist in the letter — the composer surfaces them as a count. */
|
||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||
showDiff = input(false);
|
||||
/** The org logo's content URL (letterhead), or null when none is set. */
|
||||
logoUrl = input<string | null>(null);
|
||||
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||
|
||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
pageBreakCaption = input(
|
||||
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
||||
);
|
||||
recipientText = input(
|
||||
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
|
||||
);
|
||||
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
|
||||
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
|
||||
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
|
||||
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
|
||||
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
|
||||
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
|
||||
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
|
||||
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
|
||||
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
|
||||
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
|
||||
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
|
||||
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
|
||||
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
|
||||
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
|
||||
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
|
||||
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
protected letterDate = formatDatumNl(new Date());
|
||||
|
||||
/** Zoom seeded from the input; the +/−/reset controls drive it from there. */
|
||||
protected zoomLevel = linkedSignal(() => this.zoom());
|
||||
protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);
|
||||
protected zoomBy(delta: number) {
|
||||
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
|
||||
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
|
||||
}
|
||||
protected diffLabel = (kind: BlockDiffKind) =>
|
||||
kind === 'added' ? this.addedLabel() : this.changedLabel();
|
||||
|
||||
/** Admin edit-in-place: the org-identity regions render as controls. */
|
||||
protected editing = computed(() => this.editableRegions() === 'template');
|
||||
|
||||
protected emitEdit(field: OrgTemplateTextField, event: Event) {
|
||||
this.templateEdit.emit({
|
||||
field,
|
||||
value: (event.target as HTMLInputElement | HTMLTextAreaElement).value,
|
||||
});
|
||||
}
|
||||
|
||||
protected marginStyle = computed(() => {
|
||||
const m = this.orgTemplate().margins;
|
||||
return {
|
||||
'--letter-margin-top': `${m.topMm}mm`,
|
||||
'--letter-margin-right': `${m.rightMm}mm`,
|
||||
'--letter-margin-bottom': `${m.bottomMm}mm`,
|
||||
'--letter-margin-left': `${m.leftMm}mm`,
|
||||
};
|
||||
});
|
||||
|
||||
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
|
||||
|
||||
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 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.letterDate : this.labelFor(key));
|
||||
|
||||
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
|
||||
|
||||
private page = viewChild<ElementRef<HTMLElement>>('page');
|
||||
protected pageBreaks = signal<readonly number[]>([]);
|
||||
|
||||
constructor() {
|
||||
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
|
||||
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
|
||||
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
|
||||
this.pageBreaks.set(
|
||||
Array.from({ length: Math.max(0, pages - 1) }, (_, i) => (i + 1) * A4_HEIGHT_PX),
|
||||
);
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
const el = this.page()?.nativeElement;
|
||||
if (!el) return;
|
||||
observer.observe(el);
|
||||
onCleanup(() => observer.unobserve(el));
|
||||
});
|
||||
inject(DestroyRef).onDestroy(() => observer.disconnect());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief, allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { LetterCanvasComponent } from './letter-canvas.component';
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example\n070 000 00 00',
|
||||
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 brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'draft' },
|
||||
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: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Enough repeated body text to push the surface past one A4 page. */
|
||||
const longBrief: Brief = {
|
||||
...brief,
|
||||
sections: brief.sections.map((s) =>
|
||||
s.sectionKey === 'kern'
|
||||
? {
|
||||
...s,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-long',
|
||||
content: {
|
||||
paragraphs: Array.from({ length: 40 }, (_, i) => ({
|
||||
nodes: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: `Alinea ${i + 1}: de beoordeling van uw aanvraag is uitgevoerd volgens de geldende regels voor herregistratie in het BIG-register.`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: s,
|
||||
),
|
||||
};
|
||||
|
||||
const meta: Meta<LetterCanvasComponent> = {
|
||||
title: 'Domein/Brief/Letter Canvas',
|
||||
component: LetterCanvasComponent,
|
||||
args: {
|
||||
brief,
|
||||
orgTemplate,
|
||||
diagnostics: allDiagnostics(brief),
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterCanvasComponent>;
|
||||
|
||||
/** Read-only rendered letter: the drafter's preview modal and the approver view, with the
|
||||
sample-values toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
|
||||
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
|
||||
|
||||
export const ReadOnlyZonderBevindingen: Story = {
|
||||
args: { editableRegions: 'none', diagnostics: [] },
|
||||
};
|
||||
|
||||
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
|
||||
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
|
||||
|
||||
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
|
||||
|
||||
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
|
||||
export const WithDiff: Story = {
|
||||
args: {
|
||||
editableRegions: 'none',
|
||||
diagnostics: [],
|
||||
showDiff: true,
|
||||
blockDiffs: new Map([
|
||||
['local-1', 'added'],
|
||||
['local-2', 'changed'],
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
/** Long letter: the approximate ±page-break marks appear per A4 interval. */
|
||||
export const PageBreak: Story = {
|
||||
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
||||
};
|
||||
|
||||
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
|
||||
const sampleLogo =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
|
||||
);
|
||||
|
||||
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */
|
||||
export const MetLogo: Story = {
|
||||
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
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 { Brief } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
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';
|
||||
|
||||
/** Organism: the whole letter flow — status badge, the letter canvas (editable in
|
||||
place for the drafter, read-only for approver/locked), the diagnostics panel, and
|
||||
the action bar appropriate to status × permission. Presentational: emits edit +
|
||||
transition intents; the canEdit/canApprove/canReject/canSend inputs are
|
||||
server-computed decision flags (PRD-0002 phase P1) — this component never derives them. */
|
||||
@Component({
|
||||
selector: 'app-letter-composer',
|
||||
imports: [
|
||||
HeadingComponent,
|
||||
StatusBadgeComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
LetterCanvasComponent,
|
||||
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);
|
||||
}
|
||||
.head-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.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>
|
||||
<div class="head-end">
|
||||
@if (hasRejectionDiff()) {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[attr.aria-pressed]="showDiff()"
|
||||
(click)="showDiff.set(!showDiff())"
|
||||
>{{ showDiff() ? hideDiffLabel() : showDiffLabel() }}</app-button
|
||||
>
|
||||
}
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
|
||||
previewLabel()
|
||||
}}</app-button>
|
||||
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (status() === 'rejected') {
|
||||
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||
}
|
||||
|
||||
@if (pureViewer()) {
|
||||
<app-alert type="info">{{ readonlyNotice() }}</app-alert>
|
||||
}
|
||||
|
||||
@if (showDiff() && removedCount() > 0) {
|
||||
<app-alert type="info">{{ removedText() }}</app-alert>
|
||||
}
|
||||
|
||||
<app-letter-canvas
|
||||
[brief]="brief()"
|
||||
[orgTemplate]="orgTemplate()"
|
||||
[logoUrl]="logoUrl()"
|
||||
[editableRegions]="'none'"
|
||||
[diagnostics]="diagnostics()"
|
||||
[blockDiffs]="blockDiffs()"
|
||||
[showDiff]="showDiff()"
|
||||
/>
|
||||
|
||||
<div class="panel">
|
||||
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
||||
</div>
|
||||
|
||||
<div class="bar">
|
||||
@switch (status()) {
|
||||
@case ('submitted') {
|
||||
@if (canApprove() || canReject()) {
|
||||
<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') {
|
||||
@if (canSend()) {
|
||||
<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>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
logoUrl = input<string | null>(null);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
canApprove = input(false);
|
||||
canReject = input(false);
|
||||
canSend = input(false);
|
||||
busy = input(false);
|
||||
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
|
||||
"Toon wijzigingen" toggle only appears when there's something to show. */
|
||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||
removedCount = input(0);
|
||||
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||
protected showDiff = signal(false);
|
||||
|
||||
approve = output<void>();
|
||||
reject = output<string>();
|
||||
send = output<void>();
|
||||
preview = output<void>();
|
||||
locate = output<Diagnostic>();
|
||||
|
||||
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
||||
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||
showDiffLabel = input($localize`:@@brief.diff.show:Toon wijzigingen`);
|
||||
hideDiffLabel = input($localize`:@@brief.diff.hide:Verberg wijzigingen`);
|
||||
removedText = computed(
|
||||
() =>
|
||||
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
|
||||
);
|
||||
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);
|
||||
|
||||
/** A pure viewer has no action on this letter (not the behandelaar, not an approver with
|
||||
approve/reject/send) — e.g. an admin. Show a notice so the read-only letter isn't
|
||||
mistaken for a broken editor. */
|
||||
protected pureViewer = computed(() => !this.canApprove() && !this.canReject() && !this.canSend());
|
||||
readonlyNotice = input(
|
||||
$localize`:@@brief.readonlyNotice:Alleen-lezen weergave. De behandelaar stelt de brief op.`,
|
||||
);
|
||||
protected rejectComments = computed(() => {
|
||||
const s = this.brief().status;
|
||||
return s.tag === 'rejected' ? s.comments : '';
|
||||
});
|
||||
|
||||
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,222 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterComposerComponent } from './letter-composer.component';
|
||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||
import { allDiagnostics } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||
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 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,
|
||||
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,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-3',
|
||||
content: {
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const render = (
|
||||
b: Brief,
|
||||
decisions: BriefDecisions,
|
||||
extra: {
|
||||
blockDiffs?: ReadonlyMap<string, BlockDiffKind>;
|
||||
removedCount?: number;
|
||||
logoUrl?: string | null;
|
||||
} = {},
|
||||
) => ({
|
||||
props: {
|
||||
brief: b,
|
||||
orgTemplate,
|
||||
diagnostics: allDiagnostics(b),
|
||||
...decisions,
|
||||
busy: false,
|
||||
blockDiffs: extra.blockDiffs ?? new Map<string, BlockDiffKind>(),
|
||||
removedCount: extra.removedCount ?? 0,
|
||||
logoUrl: extra.logoUrl ?? null,
|
||||
},
|
||||
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [logoUrl]="logoUrl"
|
||||
[diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
|
||||
[canSend]="canSend" [busy]="busy" [blockDiffs]="blockDiffs"
|
||||
[removedCount]="removedCount"></app-letter-composer>`,
|
||||
});
|
||||
|
||||
const meta: Meta<LetterComposerComponent> = {
|
||||
title: 'Domein/Brief/Letter Composer',
|
||||
component: LetterComposerComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterComposerComponent>;
|
||||
|
||||
export const SubmittedApprover: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
}),
|
||||
};
|
||||
export const ApprovedSender: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'approved', approvedBy: 'demo-approver', approvedAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: true,
|
||||
canRevealBigNummer: false,
|
||||
}),
|
||||
};
|
||||
export const Sent: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'sent', sentAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
}),
|
||||
};
|
||||
|
||||
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
|
||||
added and removed since the last rejection. */
|
||||
export const RejectionDiff: Story = {
|
||||
render: () =>
|
||||
render(
|
||||
brief({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'demo-drafter',
|
||||
submittedAt: '2026-07-02',
|
||||
}),
|
||||
{
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
},
|
||||
{
|
||||
blockDiffs: new Map<string, BlockDiffKind>([
|
||||
['local-2', 'changed'],
|
||||
['local-3', 'added'],
|
||||
]),
|
||||
removedCount: 1,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
/** A pure viewer (e.g. admin) has no approve/reject/send capability on this letter —
|
||||
the read-only notice, not a broken-looking editor. */
|
||||
export const AlleenLezen: Story = {
|
||||
render: () =>
|
||||
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
|
||||
canEdit: false,
|
||||
canApprove: false,
|
||||
canReject: false,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
}),
|
||||
};
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Component, input, output } 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 } from '@brief/domain/brief';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
|
||||
|
||||
/** Organism: one template section — its ordered blocks plus (when editable) the
|
||||
add-free-text action. Standaardteksten enter the kern via the besluit panel, not a
|
||||
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({
|
||||
selector: 'app-letter-section',
|
||||
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent],
|
||||
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="subtle"
|
||||
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
|
||||
>{{ addFreeLabel() }}</app-button
|
||||
>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class LetterSectionComponent {
|
||||
section = input.required<LetterSection>();
|
||||
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.`);
|
||||
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterSection } from '@brief/domain/brief';
|
||||
import { LetterSectionComponent } from './letter-section.component';
|
||||
|
||||
const section: LetterSection = {
|
||||
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: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const emptySection: LetterSection = { ...section, blocks: [] };
|
||||
|
||||
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
||||
|
||||
const meta: Meta<LetterSectionComponent> = {
|
||||
title: 'Domein/Brief/Letter Section',
|
||||
component: LetterSectionComponent,
|
||||
args: { section, placeholders, edit: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterSectionComponent>;
|
||||
|
||||
export const ReadOnly: Story = { args: { editable: false } };
|
||||
export const Editable: Story = { args: { editable: true } };
|
||||
/** Empty section: shows the empty-state hint plus the free-text action. */
|
||||
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
|
||||
@@ -0,0 +1,353 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
|
||||
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
|
||||
import { UploadState } from '@shared/upload/upload.machine';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import {
|
||||
MARGIN_MAX_MM,
|
||||
MARGIN_MIN_MM,
|
||||
Margins,
|
||||
OrgTemplate,
|
||||
OrgTemplateVersion,
|
||||
SubOrgSummary,
|
||||
} from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
|
||||
const LOGO_CATEGORY = 'org-logo';
|
||||
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
|
||||
|
||||
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
||||
while editing (content itself is not the admin's to change). */
|
||||
export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||
briefId: 'VOORBEELD-0001',
|
||||
beroep: 'arts',
|
||||
templateId: 'sample',
|
||||
drafterId: 'sample',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'body',
|
||||
title: 'Voorbeeldinhoud',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'sample-1',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's
|
||||
* composer — the letter canvas runs in `editableRegions='template'` so the
|
||||
* letterhead/signature/footer are edited in place, while the content is a read-only
|
||||
* sample. Margins, logo upload, version history and the publish bar sit around it.
|
||||
* Presentational: every mutation is an output the store turns into a command.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-org-template-editor',
|
||||
imports: [
|
||||
DatePipe,
|
||||
HeadingComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
FileInputComponent,
|
||||
SingleUploadComponent,
|
||||
LetterCanvasComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: var(--rhc-space-max-md);
|
||||
flex-wrap: wrap;
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-2xs);
|
||||
}
|
||||
.save {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.section {
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
.margins {
|
||||
display: flex;
|
||||
gap: var(--rhc-space-max-md);
|
||||
flex-wrap: wrap;
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-md);
|
||||
}
|
||||
.margins input {
|
||||
width: 6rem;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--rhc-space-max-md);
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
padding-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-md);
|
||||
align-items: center;
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
.published {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="toolbar">
|
||||
<label class="field">
|
||||
<span>{{ subOrgLabel() }}</span>
|
||||
<select class="form-select" (change)="onSelectSubOrg($event)">
|
||||
@for (o of subOrgs(); track o.subOrgId) {
|
||||
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
|
||||
{{ o.orgName }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
</div>
|
||||
|
||||
<app-letter-canvas
|
||||
[brief]="sampleBrief()"
|
||||
[orgTemplate]="draft()"
|
||||
[logoUrl]="logoUrl()"
|
||||
editableRegions="template"
|
||||
(templateEdit)="templateEdit.emit($event)"
|
||||
/>
|
||||
|
||||
<fieldset class="section margins">
|
||||
<legend>{{ marginsLegend() }}</legend>
|
||||
@for (edge of edges; track edge) {
|
||||
<label class="field">
|
||||
<span>{{ edgeLabel(edge) }}</span>
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
[min]="MIN"
|
||||
[max]="MAX"
|
||||
[value]="draft().margins[edge]"
|
||||
(input)="onMargin(edge, $event)"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<section class="section">
|
||||
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
|
||||
@if (logoCategory()) {
|
||||
<app-file-input
|
||||
inputId="org-logo-input"
|
||||
[accept]="logoCategory()!.acceptedTypes"
|
||||
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
||||
[label]="logoHeading()"
|
||||
(filesSelected)="logoSelected.emit($event)"
|
||||
/>
|
||||
}
|
||||
@if (logoRejection()) {
|
||||
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
||||
}
|
||||
@if (logoUploads().length) {
|
||||
<ul class="file-list">
|
||||
@for (u of logoUploads(); track u.localId) {
|
||||
<li
|
||||
app-single-upload
|
||||
[upload]="u"
|
||||
[previewUrlFor]="previewUrlFor()"
|
||||
(remove)="logoRemoved.emit(u.localId)"
|
||||
(retry)="logoRetry.emit(u.localId)"
|
||||
></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
|
||||
@if (history().length === 0) {
|
||||
<p class="published">{{ noHistory() }}</p>
|
||||
} @else {
|
||||
<ul class="history-list">
|
||||
@for (v of history(); track v.version) {
|
||||
<li class="history-row">
|
||||
<span
|
||||
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
|
||||
>
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
|
||||
{{ rollbackLabel() }}
|
||||
</app-button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="bar">
|
||||
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
|
||||
@if (pendingPublish()) {
|
||||
<app-alert type="warning">{{ impactText() }}</app-alert>
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
|
||||
{{ confirmLabel() }}
|
||||
</app-button>
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
|
||||
{{ cancelLabel() }}
|
||||
</app-button>
|
||||
} @else {
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!draftValid() || busy()"
|
||||
(click)="requestPublish.emit()"
|
||||
>
|
||||
{{ publishLabel() }}
|
||||
</app-button>
|
||||
@if (!draftValid()) {
|
||||
<span class="published">{{ invalidHint() }}</span>
|
||||
}
|
||||
}
|
||||
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
|
||||
{{ proefbriefLabel() }}
|
||||
</app-button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class OrgTemplateEditorComponent {
|
||||
draft = input.required<OrgTemplate>();
|
||||
logoUrl = input<string | null>(null);
|
||||
uploadState = input.required<UploadState>();
|
||||
subOrgs = input<readonly SubOrgSummary[]>([]);
|
||||
selectedSubOrgId = input<string | null>(null);
|
||||
history = input<readonly OrgTemplateVersion[]>([]);
|
||||
publishedVersion = input(0);
|
||||
unsentBriefs = input(0);
|
||||
draftValid = input(false);
|
||||
busy = input(false);
|
||||
pendingPublish = input(false);
|
||||
saveText = input('');
|
||||
sampleBrief = input<Brief>(SAMPLE_LETTER_BRIEF);
|
||||
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||
|
||||
selectSubOrg = output<string>();
|
||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||
marginEdit = output<{ edge: keyof Margins; value: number }>();
|
||||
logoSelected = output<File[]>();
|
||||
logoRemoved = output<string>();
|
||||
logoRetry = output<string>();
|
||||
requestPublish = output<void>();
|
||||
confirmPublish = output<void>();
|
||||
cancelPublish = output<void>();
|
||||
rollback = output<number>();
|
||||
proefbrief = output<void>();
|
||||
|
||||
protected readonly edges = EDGES;
|
||||
protected readonly MIN = MARGIN_MIN_MM;
|
||||
protected readonly MAX = MARGIN_MAX_MM;
|
||||
|
||||
protected logoCategory = computed(() =>
|
||||
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
||||
);
|
||||
protected logoUploads = computed(() =>
|
||||
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
||||
);
|
||||
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
||||
|
||||
protected onSelectSubOrg(event: Event) {
|
||||
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
|
||||
}
|
||||
protected onMargin(edge: keyof Margins, event: Event) {
|
||||
const value = (event.target as HTMLInputElement).valueAsNumber;
|
||||
if (Number.isFinite(value)) this.marginEdit.emit({ edge, value });
|
||||
}
|
||||
|
||||
protected edgeLabel(edge: keyof Margins): string {
|
||||
switch (edge) {
|
||||
case 'topMm':
|
||||
return $localize`:@@orgTemplate.margin.top:Boven (mm)`;
|
||||
case 'rightMm':
|
||||
return $localize`:@@orgTemplate.margin.right:Rechts (mm)`;
|
||||
case 'bottomMm':
|
||||
return $localize`:@@orgTemplate.margin.bottom:Onder (mm)`;
|
||||
case 'leftMm':
|
||||
return $localize`:@@orgTemplate.margin.left:Links (mm)`;
|
||||
}
|
||||
}
|
||||
|
||||
protected impactText = computed(
|
||||
() =>
|
||||
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
||||
);
|
||||
|
||||
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
|
||||
protected marginsLegend = input(
|
||||
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
|
||||
);
|
||||
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
|
||||
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
|
||||
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
|
||||
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
|
||||
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
|
||||
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
|
||||
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
|
||||
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
|
||||
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
|
||||
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
|
||||
protected invalidHint = input(
|
||||
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { OrgTemplateEditorComponent } from './org-template-editor.component';
|
||||
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
|
||||
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
|
||||
|
||||
const draft: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'www.bigregister.nl\ninfo@voorbeeld.example',
|
||||
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: 3,
|
||||
};
|
||||
|
||||
const subOrgs: SubOrgSummary[] = [
|
||||
{ subOrgId: 'cibg-registers', orgName: 'CIBG — Registers', publishedVersion: 3 },
|
||||
{ subOrgId: 'cibg-vakbekwaamheid', orgName: 'CIBG — Vakbekwaamheid', publishedVersion: 1 },
|
||||
];
|
||||
|
||||
const history: OrgTemplateVersion[] = [
|
||||
{ version: 3, publishedAt: '2026-06-20', template: draft },
|
||||
{ version: 2, publishedAt: '2026-05-11', template: draft },
|
||||
];
|
||||
|
||||
const uploadWithCategory: UploadState = {
|
||||
...initialUpload,
|
||||
categories: [
|
||||
{
|
||||
categoryId: 'org-logo',
|
||||
label: 'Logo',
|
||||
description: 'Logo van de organisatie',
|
||||
required: false,
|
||||
acceptedTypes: ['image/png', 'image/jpeg'],
|
||||
maxSizeMb: 2,
|
||||
multiple: false,
|
||||
allowPostDelivery: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const meta: Meta<OrgTemplateEditorComponent> = {
|
||||
title: 'Domein/Brief/Org Template Editor',
|
||||
component: OrgTemplateEditorComponent,
|
||||
args: {
|
||||
draft,
|
||||
logoUrl: null,
|
||||
uploadState: uploadWithCategory,
|
||||
subOrgs,
|
||||
selectedSubOrgId: 'cibg-registers',
|
||||
history,
|
||||
publishedVersion: 3,
|
||||
unsentBriefs: 4,
|
||||
draftValid: true,
|
||||
busy: false,
|
||||
pendingPublish: false,
|
||||
saveText: '',
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<OrgTemplateEditorComponent>;
|
||||
|
||||
export const Editing: Story = {};
|
||||
|
||||
export const PublishConfirm: Story = {
|
||||
args: { pendingPublish: true },
|
||||
};
|
||||
|
||||
export const Invalid: Story = {
|
||||
args: {
|
||||
draft: { ...draft, orgName: '', margins: { ...draft.margins, topMm: 5 } },
|
||||
draftValid: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoHistory: Story = {
|
||||
args: { history: [], publishedVersion: 0 },
|
||||
};
|
||||
|
||||
// Inline SVG so the story needs no backend/upload round-trip.
|
||||
const sampleLogo =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
|
||||
);
|
||||
|
||||
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */
|
||||
export const MetLogo: Story = {
|
||||
args: { logoUrl: sampleLogo },
|
||||
};
|
||||
|
||||
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) — type/size caught
|
||||
before the file ever reaches the backend. */
|
||||
export const LogoUploadFout: Story = {
|
||||
args: {
|
||||
uploadState: {
|
||||
...uploadWithCategory,
|
||||
rejections: { 'org-logo': 'Alleen PNG of JPEG, maximaal 2 MB.' },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Component, computed, effect, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { OrgTemplateStore } from '@brief/application/org-template.store';
|
||||
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
|
||||
|
||||
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
|
||||
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
|
||||
for admins. Loads once the capability resolves; wires store commands to the organism. */
|
||||
@Component({
|
||||
selector: 'app-org-template-page',
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
...ASYNC,
|
||||
OrgTemplateEditorComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
.save {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/brief">
|
||||
@if (store.lastError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
|
||||
@if (!access.ready()) {
|
||||
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
|
||||
} @else if (!canEdit()) {
|
||||
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||
} @else {
|
||||
<app-async [data]="store.remoteData()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (store.draft(); as draft) {
|
||||
<app-org-template-editor
|
||||
[draft]="draft"
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[uploadState]="store.uploadState()"
|
||||
[subOrgs]="store.subOrgs()"
|
||||
[selectedSubOrgId]="store.selectedSubOrgId()"
|
||||
[history]="store.history()"
|
||||
[publishedVersion]="store.publishedVersion()"
|
||||
[unsentBriefs]="store.unsentBriefs()"
|
||||
[draftValid]="store.draftValid()"
|
||||
[busy]="store.busy()"
|
||||
[pendingPublish]="store.pendingPublish()"
|
||||
[saveText]="saveText()"
|
||||
[previewUrlFor]="previewUrlFor"
|
||||
(selectSubOrg)="store.selectSubOrg($event)"
|
||||
(templateEdit)="
|
||||
store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })
|
||||
"
|
||||
(marginEdit)="
|
||||
store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })
|
||||
"
|
||||
(logoSelected)="store.onLogoSelected($event)"
|
||||
(logoRemoved)="store.onLogoRemoved($event)"
|
||||
(logoRetry)="store.onLogoRetry($event)"
|
||||
(requestPublish)="store.requestPublish()"
|
||||
(confirmPublish)="store.confirmPublish()"
|
||||
(cancelPublish)="store.cancelPublish()"
|
||||
(rollback)="store.rollback($event)"
|
||||
(proefbrief)="store.proefbrief()"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
}
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class OrgTemplatePage {
|
||||
protected store = inject(OrgTemplateStore);
|
||||
protected access = inject(AccessStore);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
|
||||
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
|
||||
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
|
||||
|
||||
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
|
||||
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
|
||||
protected deniedText = $localize`:@@orgTemplate.page.denied:U hebt geen rechten om organisatiesjablonen te beheren.`;
|
||||
protected failedText = $localize`:@@orgTemplate.page.failed:Het sjabloon kon niet worden geladen.`;
|
||||
protected retryText = $localize`:@@orgTemplate.page.retry:Opnieuw proberen`;
|
||||
|
||||
private savingText = $localize`:@@orgTemplate.page.saving:Concept opslaan…`;
|
||||
private savedText = $localize`:@@orgTemplate.page.saved:Concept opgeslagen`;
|
||||
private saveErrorText = $localize`:@@orgTemplate.page.saveError:Opslaan mislukt`;
|
||||
protected saveText = computed(() => {
|
||||
switch (this.store.saveState().tag) {
|
||||
case 'Saving':
|
||||
return this.savingText;
|
||||
case 'Saved':
|
||||
return this.savedText;
|
||||
case 'Error':
|
||||
return this.saveErrorText;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
private loadRequested = false;
|
||||
constructor() {
|
||||
// Load once the capability resolves to `allowed` (a 403 GET would be wasted
|
||||
// otherwise). Depends only on `canEdit()` + a plain flag — never on the store
|
||||
// model, so dispatching `Loading` inside `load()` can't retrigger this effect.
|
||||
effect(() => {
|
||||
if (this.canEdit() && !this.loadRequested) {
|
||||
this.loadRequested = true;
|
||||
void this.store.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Component, computed, 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 { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { textOf } from '@shared/kernel/rich-text';
|
||||
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.
|
||||
|
||||
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in
|
||||
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted
|
||||
in-flight of an unrelated WP; a future cleanup can remove it. */
|
||||
@Component({
|
||||
selector: 'app-passage-picker',
|
||||
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: var(--rhc-color-wit);
|
||||
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-md);
|
||||
}
|
||||
.search {
|
||||
margin-block-end: 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);
|
||||
}
|
||||
.empty {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-style: italic;
|
||||
margin: 0 0 var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="search">
|
||||
<app-text-input
|
||||
[placeholder]="searchLabel()"
|
||||
[attr.aria-label]="searchLabel()"
|
||||
[ngModel]="query()"
|
||||
(ngModelChange)="query.set($event)"
|
||||
/>
|
||||
</div>
|
||||
<ul>
|
||||
@for (p of filtered(); track p.passageId) {
|
||||
<li>
|
||||
<app-checkbox
|
||||
[checkboxId]="'passage-' + p.passageId"
|
||||
[label]="p.label"
|
||||
[ngModel]="!!checked()[p.passageId]"
|
||||
(ngModelChange)="set(p.passageId, $event)"
|
||||
/>
|
||||
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="empty">{{ noMatchLabel() }}</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`);
|
||||
searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);
|
||||
noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);
|
||||
|
||||
protected checked = signal<Record<string, boolean>>({});
|
||||
protected query = signal('');
|
||||
/** Client-side filter on label + rendered content text — the library is small, so no
|
||||
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
|
||||
protected filtered = computed(() => {
|
||||
const q = this.query().trim().toLowerCase();
|
||||
if (!q) return this.passages();
|
||||
return this.passages().filter(
|
||||
(p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),
|
||||
);
|
||||
});
|
||||
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,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
import { PassagePickerComponent } from './passage-picker.component';
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Standaard aanhef',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||
},
|
||||
{
|
||||
passageId: 'p2',
|
||||
scope: 'beroep',
|
||||
beroep: 'arts',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Aanhef, arts-specifiek',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte collega,' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<PassagePickerComponent> = {
|
||||
title: 'Domein/Brief/Passage Picker',
|
||||
component: PassagePickerComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-passage-picker [passages]="passages" (insert)="insert($event)" />`,
|
||||
}),
|
||||
args: { passages, insert: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PassagePickerComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,57 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RejectionCommentsComponent } from './rejection-comments.component';
|
||||
|
||||
const meta: Meta<RejectionCommentsComponent> = {
|
||||
title: 'Domein/Brief/Rejection Comments',
|
||||
component: RejectionCommentsComponent,
|
||||
args: { reject: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RejectionCommentsComponent>;
|
||||
|
||||
export const Show: Story = {
|
||||
args: { mode: 'show', comments: 'Graag de aanhef formeler.' },
|
||||
};
|
||||
export const Entry: Story = { args: { mode: 'entry' } };
|
||||
export const EntryBusy: Story = { args: { mode: 'entry', busy: true } };
|
||||
Reference in New Issue
Block a user