style: format frontend, docs and skills with prettier; add .prettierignore

One-time prettier --write so the new format:check CI gate starts green.
.prettierignore excludes generated (api-client.ts, documentation.json),
vendored (public/cibg-huisstijl), and backend (dotnet format owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:39:31 +02:00
parent 546097434d
commit e82309786d
176 changed files with 5069 additions and 1471 deletions

View File

@@ -3,7 +3,13 @@ import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store';
import { Role } from '@shared/domain/role';
import { currentRole } from '@shared/infrastructure/role';
import { Brief, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from '@brief/domain/brief';
import {
Brief,
allDiagnostics,
canSubmit,
hasBlockingErrors,
unresolvedPlaceholders,
} from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
@@ -34,7 +40,9 @@ export class BriefStore {
treats as editable (draft, or rejected — which reopens to draft on the first edit). */
readonly editable = computed(() => {
const b = this.brief();
return !!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter';
return (
!!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter'
);
});
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -46,7 +54,12 @@ export class BriefStore {
async load() {
const r = await this.adapter.load();
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages });
if (r.ok)
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
@@ -84,7 +97,12 @@ export class BriefStore {
const r = await this.adapter.reset();
this.busy.set(false);
this.saveState.set('idle');
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages });
if (r.ok)
this.store.dispatch({
tag: 'BriefLoaded',
brief: r.value.brief,
availablePassages: r.value.availablePassages,
});
else this.lastError.set(r.error);
}
@@ -119,7 +137,12 @@ export class BriefStore {
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
break;
case 'rejected':
this.store.dispatch({ tag: 'Rejected', by: s.rejectedBy, at: s.rejectedAt, comments: s.comments });
this.store.dispatch({
tag: 'Rejected',
by: s.rejectedBy,
at: s.rejectedAt,
comments: s.comments,
});
break;
case 'sent':
this.store.dispatch({ tag: 'Sent', at: s.sentAt });

View File

@@ -9,7 +9,9 @@ const placeholders: PlaceholderDef[] = [
{ key: 'reden', label: 'Reden', autoResolvable: false },
];
const text = (t: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
const text = (t: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
});
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({
passageId: id,
@@ -35,7 +37,10 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
};
}
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState => ({
const loaded = (
status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'],
): BriefState => ({
tag: 'loaded',
brief: briefWith(status, sections),
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
@@ -46,14 +51,27 @@ const sectionBlocks = (s: BriefState, key: string) =>
describe('brief.machine reduce', () => {
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [] }).tag).toBe('loaded');
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ tag: 'failed', reason: 'x' });
expect(
reduce(initialLoading(), {
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [],
}).tag,
).toBe('loaded');
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed',
reason: 'x',
});
const seeded = loaded();
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
});
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => {
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] });
const s = reduce(loaded(), {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
});
const blocks = sectionBlocks(s, 'aanhef');
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
@@ -62,7 +80,11 @@ describe('brief.machine reduce', () => {
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('p1', 'aanhef');
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [passage] });
const s = reduce(loaded(), {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [passage],
});
// Mutate the source passage object after insertion.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
const block = sectionBlocks(s, 'aanhef')[0];
@@ -77,7 +99,11 @@ describe('brief.machine reduce', () => {
});
it('BlockContentEdited replaces content and marks a passage block edited', () => {
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] });
let s = reduce(loaded(), {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef')],
});
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
const block = sectionBlocks(s, 'aanhef')[0];
expect(block.type === 'passage' && block.edited).toBe(true);
@@ -85,7 +111,11 @@ describe('brief.machine reduce', () => {
});
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] });
let s = reduce(loaded(), {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
});
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
@@ -94,17 +124,33 @@ describe('brief.machine reduce', () => {
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => {
const lockedSections: Brief['sections'] = [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }] },
{
sectionKey: 'aanhef',
title: 'Aanhef',
required: true,
locked: true,
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
},
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
];
const s = loaded({ tag: 'draft' }, lockedSections);
// The brief value is left untouched (withEdit reallocates state, but the guard returns
// the same brief), so assert on deep equality of the section contents.
expect(reduce(s, { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] })).toEqual(s);
expect(
reduce(s, {
tag: 'PassagesInserted',
sectionKey: 'aanhef',
passages: [libPassage('p1', 'aanhef')],
}),
).toEqual(s);
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
expect(reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') })).toEqual(s);
expect(
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
).toEqual(s);
expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s);
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(s);
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(
s,
);
// the unlocked section still accepts edits
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
expect(sectionBlocks(edited, 'kern')).toHaveLength(1);
@@ -116,7 +162,12 @@ describe('brief.machine reduce', () => {
});
it('editing a rejected letter reopens it to draft', () => {
const s = loaded({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'graag aanpassen' });
const s = loaded({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't',
comments: 'graag aanpassen',
});
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
@@ -128,7 +179,11 @@ describe('brief.machine reduce', () => {
// fill the required section, then submit
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
tag: 'submitted',
submittedBy: 'u1',
submittedAt: 't',
});
});
it('approve/reject fire only from submitted; send only from approved', () => {
@@ -136,10 +191,19 @@ describe('brief.machine reduce', () => {
// approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ tag: 'approved', approvedBy: 'u2', approvedAt: 't2' });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
tag: 'approved',
approvedBy: 'u2',
approvedAt: 't2',
});
// reject carries comments
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' });
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't2', comments: 'nee' });
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't2',
comments: 'nee',
});
// send only from approved
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3' });

View File

@@ -1,5 +1,13 @@
import { assertNever } from '@shared/kernel/fp';
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, allBlocks, canSubmit } from './brief';
import {
Brief,
BriefStatus,
LetterBlock,
LetterSection,
LibraryPassage,
allBlocks,
canSubmit,
} from './brief';
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
/**
@@ -59,8 +67,15 @@ function nextLocalIndex(brief: Brief): number {
return max + 1;
}
function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => LetterSection): Brief {
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) };
function mapSection(
brief: Brief,
sectionKey: string,
f: (s: LetterSection) => LetterSection,
): Brief {
return {
...brief,
sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)),
};
}
/** The section a block currently lives in, or undefined if the block is gone. */
@@ -86,7 +101,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
return { ...s, brief };
}
function insertPassages(brief: Brief, sectionKey: string, passages: readonly LibraryPassage[]): Brief {
function insertPassages(
brief: Brief,
sectionKey: string,
passages: readonly LibraryPassage[],
): Brief {
let idx = nextLocalIndex(brief);
// The freeze happens HERE: each block gets a deep VALUE copy of the library content,
// so later library edits can never mutate this letter (frozen snapshot).
@@ -102,7 +121,11 @@ function insertPassages(brief: Brief, sectionKey: string, passages: readonly Lib
}
function addFreeText(brief: Brief, sectionKey: string): Brief {
const block: LetterBlock = { type: 'freeText', blockId: `local-${nextLocalIndex(brief)}`, content: emptyBlock() };
const block: LetterBlock = {
type: 'freeText',
blockId: `local-${nextLocalIndex(brief)}`,
content: emptyBlock(),
};
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] }));
}
@@ -118,7 +141,11 @@ function editBlockContent(brief: Brief, blockId: string, content: RichTextBlock)
);
}
function moveWithinSection(blocks: readonly LetterBlock[], blockId: string, toIndex: number): LetterBlock[] {
function moveWithinSection(
blocks: readonly LetterBlock[],
blockId: string,
toIndex: number,
): LetterBlock[] {
const from = blocks.findIndex((b) => b.blockId === blockId);
if (from === -1) return [...blocks];
const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1));
@@ -140,12 +167,18 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a
// Msg reaches the reducer. The UI already hides controls for locked sections.
case 'PassagesInserted':
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b));
return withEdit(s, (b) =>
isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b,
);
case 'FreeTextBlockAdded':
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b));
return withEdit(s, (b) =>
isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b,
);
case 'BlockContentEdited':
return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) ? editBlockContent(b, m.blockId, m.content) : b,
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? editBlockContent(b, m.blockId, m.content)
: b,
);
case 'BlockRemoved':
return withEdit(s, (b) =>
@@ -157,18 +190,34 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
return withEdit(s, (b) =>
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
? mapBlocks(b, (blocks) =>
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
blocks.some((x) => x.blockId === m.blockId)
? moveWithinSection(blocks, m.blockId, m.toIndex)
: [...blocks],
)
: b,
);
case 'Submitted':
// Guard the transition AND the completeness invariant.
return transition(s, 'draft', () => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }), canSubmit);
return transition(
s,
'draft',
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
canSubmit,
);
case 'Approved':
return transition(s, 'submitted', () => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }));
return transition(s, 'submitted', () => ({
tag: 'approved',
approvedBy: m.by,
approvedAt: m.at,
}));
case 'Rejected':
return transition(s, 'submitted', () => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }));
return transition(s, 'submitted', () => ({
tag: 'rejected',
rejectedBy: m.by,
rejectedAt: m.at,
comments: m.comments,
}));
case 'Sent':
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));

View File

@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { Brief, LetterBlock, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from './brief';
import {
Brief,
LetterBlock,
allDiagnostics,
canSubmit,
hasBlockingErrors,
unresolvedPlaceholders,
} from './brief';
import { PlaceholderDef } from './placeholders';
import { RichTextBlock } from '@shared/kernel/rich-text';
@@ -19,21 +26,47 @@ const passage = (blockId: string, ...keys: string[]): LetterBlock => ({
});
function brief(sections: Brief['sections']): Brief {
return { briefId: 'b1', beroep: 'arts', templateId: 't1', placeholders, sections, status: { tag: 'draft' }, drafterId: 'u1' };
return {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders,
sections,
status: { tag: 'draft' },
drafterId: 'u1',
};
}
describe('brief selectors', () => {
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
const b = brief([
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'naam', 'reden')] },
{ sectionKey: 's2', title: 'S2', required: false, locked: false, blocks: [passage('local-2', 'reden')] },
{
sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1', 'naam', 'reden')],
},
{
sectionKey: 's2',
title: 'S2',
required: false,
locked: false,
blocks: [passage('local-2', 'reden')],
},
]);
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
});
it('allDiagnostics flattens across sections and blocks', () => {
const b = brief([
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'reden', 'onbekend')] },
{
sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1', 'reden', 'onbekend')],
},
]);
const codes = allDiagnostics(b).map((d) => d.code);
expect(codes).toContain('unresolved-at-send'); // reden
@@ -42,8 +75,28 @@ describe('brief selectors', () => {
});
it('canSubmit is false when a required section is empty, true otherwise', () => {
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]))).toBe(false);
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]))).toBe(true);
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1')] }]))).toBe(true);
expect(
canSubmit(
brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]),
),
).toBe(false);
expect(
canSubmit(
brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]),
),
).toBe(true);
expect(
canSubmit(
brief([
{
sectionKey: 's1',
title: 'S1',
required: true,
locked: false,
blocks: [passage('local-1')],
},
]),
),
).toBe(true);
});
});

View File

@@ -59,7 +59,12 @@ export type BriefStatus =
| { readonly tag: 'draft' }
| { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string }
| { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string }
| { readonly tag: 'rejected'; readonly rejectedBy: string; readonly rejectedAt: string; readonly comments: string }
| {
readonly tag: 'rejected';
readonly rejectedBy: string;
readonly rejectedAt: string;
readonly comments: string;
}
| { readonly tag: 'sent'; readonly sentAt: string };
export interface Brief {
@@ -81,7 +86,9 @@ export function allBlocks(brief: Brief): LetterBlock[] {
/** Every diagnostic in the letter, in section→block→node order. This is what the
diagnostics panel renders and what the send gate checks. */
export function allDiagnostics(brief: Brief): Diagnostic[] {
return allBlocks(brief).flatMap((b) => lintPlaceholders(b.content, brief.placeholders, b.blockId));
return allBlocks(brief).flatMap((b) =>
lintPlaceholders(b.content, brief.placeholders, b.blockId),
);
}
export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean {

View File

@@ -9,8 +9,12 @@ const valid: PlaceholderDef[] = [
{ key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false },
];
const withPlaceholder = (key: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'placeholder', key }] }] });
const withText = (text: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text }] }] });
const withPlaceholder = (key: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'placeholder', key }] }],
});
const withText = (text: string): RichTextBlock => ({
paragraphs: [{ nodes: [{ type: 'text', text }] }],
});
describe('lintPlaceholders', () => {
it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => {
@@ -55,10 +59,17 @@ describe('lintPlaceholders', () => {
const content: RichTextBlock = {
paragraphs: [
{ nodes: [{ type: 'placeholder', key: 'onbekend' }] },
{ nodes: [{ type: 'text', text: 'ok' }, { type: 'placeholder', key: 'reden' }] },
{
nodes: [
{ type: 'text', text: 'ok' },
{ type: 'placeholder', key: 'reden' },
],
},
],
};
const codes = lintPlaceholders(content, valid, 'b1').map((d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`);
const codes = lintPlaceholders(content, valid, 'b1').map(
(d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`,
);
expect(codes).toEqual(['unknown-placeholder@0.0', 'unresolved-at-send@1.1']);
});

View File

@@ -77,7 +77,13 @@ function messageFor(code: DiagnosticCode, key?: string): string {
}
function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic {
return { severity: severityOf(code), code, placeholderKey: key, location, message: messageFor(code, key) };
return {
severity: severityOf(code),
code,
placeholderKey: key,
location,
message: messageFor(code, key),
};
}
/**

View File

@@ -19,14 +19,32 @@ const view: BriefViewDto = {
title: 'Aanhef',
required: true,
blocks: [
{ type: 'passage', blockId: 'local-1', content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, sourcePassageId: 'p1', sourceVersion: 2, edited: true },
{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] } },
{
type: 'passage',
blockId: 'local-1',
content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] },
sourcePassageId: 'p1',
sourceVersion: 2,
edited: true,
},
{
type: 'freeText',
blockId: 'local-2',
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] },
},
],
},
],
},
availablePassages: [
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Aanhef', content: { paragraphs: [{ nodes: [] }] }, version: 1 },
{
passageId: 'p1',
scope: 'global',
sectionKey: 'aanhef',
label: 'Aanhef',
content: { paragraphs: [{ nodes: [] }] },
version: 1,
},
],
};
@@ -35,16 +53,31 @@ describe('brief.adapter parse boundary', () => {
const r = parseBriefView(view);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.brief.status).toEqual({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' });
expect(r.value.brief.status).toEqual({
tag: 'submitted',
submittedBy: 'demo-drafter',
submittedAt: '2026-07-01',
});
const [passage, free] = r.value.brief.sections[0].blocks;
expect(passage.type === 'passage' && passage.edited).toBe(true);
expect(free.type).toBe('freeText');
expect(r.value.brief.placeholders[1]).toEqual({ key: 'code', label: 'Code', autoResolvable: true, fillable: false });
expect(r.value.brief.placeholders[1]).toEqual({
key: 'code',
label: 'Code',
autoResolvable: true,
fillable: false,
});
});
it('narrows node variants and rejects unknown ones', () => {
expect(parseNode({ type: 'text', text: 'x' })).toEqual({ ok: true, value: { type: 'text', text: 'x' } });
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({ ok: true, value: { type: 'placeholder', key: 'k' } });
expect(parseNode({ type: 'text', text: 'x' })).toEqual({
ok: true,
value: { type: 'text', text: 'x' },
});
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({
ok: true,
value: { type: 'placeholder', key: 'k' },
});
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
@@ -87,13 +120,24 @@ describe('brief.adapter parse boundary', () => {
const [aanhef, kern] = r.value.sections;
expect(aanhef.locked).toBe(true);
expect(kern.locked).toBe(false);
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual(['bullet', 'number', undefined]);
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual([
'bullet',
'number',
undefined,
]);
});
it('rejects a passage block missing provenance', () => {
const r = parseBrief({
...view.brief,
sections: [{ sectionKey: 's', title: 'S', required: false, blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }] }],
sections: [
{
sectionKey: 's',
title: 'S',
required: false,
blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }],
},
],
});
expect(r.ok).toBe(false);
});

View File

@@ -13,7 +13,14 @@ import {
RichTextBlockDto,
RichTextNodeDto,
} from '@shared/infrastructure/api-client';
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief';
import {
Brief,
BriefStatus,
LetterBlock,
LetterSection,
LibraryPassage,
PassageScope,
} from '@brief/domain/brief';
import { PlaceholderDef } from '@brief/domain/placeholders';
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
@@ -43,7 +50,10 @@ export class BriefAdapter {
}
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> {
const r = await runSubmit(() => this.client.briefPUT({ sections: sections.map(sectionToDto) }), BRIEF_ACTION_FAILED);
const r = await runSubmit(
() => this.client.briefPUT({ sections: sections.map(sectionToDto) }),
BRIEF_ACTION_FAILED,
);
return r.ok ? parseBrief(r.value) : r;
}
@@ -83,10 +93,16 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
case 'text': {
if (typeof dto.text !== 'string') return err('node: text missing text');
const marks = dto.marks?.filter((m): m is Mark => MARKS.includes(m));
return ok(marks && marks.length ? { type: 'text', text: dto.text, marks } : { type: 'text', text: dto.text });
return ok(
marks && marks.length
? { type: 'text', text: dto.text, marks }
: { type: 'text', text: dto.text },
);
}
case 'placeholder':
return typeof dto.key === 'string' ? ok({ type: 'placeholder', key: dto.key }) : err('node: placeholder missing key');
return typeof dto.key === 'string'
? ok({ type: 'placeholder', key: dto.key })
: err('node: placeholder missing key');
case 'lineBreak':
return ok({ type: 'lineBreak' });
default:
@@ -94,7 +110,9 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
}
}
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> {
export function parseBlockContent(
dto: RichTextBlockDto | undefined,
): Result<string, RichTextBlock> {
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
const paragraphs: Paragraph[] = [];
for (const p of dto.paragraphs) {
@@ -116,7 +134,8 @@ function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
if (!content.ok) return content;
switch (dto.type) {
case 'passage':
if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number') return err('block: bad passage provenance');
if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number')
return err('block: bad passage provenance');
return ok({
type: 'passage',
blockId: dto.blockId,
@@ -133,7 +152,11 @@ function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
}
function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
if (typeof dto.sectionKey !== 'string' || typeof dto.title !== 'string' || typeof dto.required !== 'boolean') {
if (
typeof dto.sectionKey !== 'string' ||
typeof dto.title !== 'string' ||
typeof dto.required !== 'boolean'
) {
return err('section: bad shape');
}
const blocks: LetterBlock[] = [];
@@ -142,11 +165,21 @@ function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
if (!parsed.ok) return parsed;
blocks.push(parsed.value);
}
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, locked: dto.locked ?? false, blocks });
return ok({
sectionKey: dto.sectionKey,
title: dto.title,
required: dto.required,
locked: dto.locked ?? false,
blocks,
});
}
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
if (typeof dto.key !== 'string' || typeof dto.label !== 'string' || typeof dto.autoResolvable !== 'boolean') {
if (
typeof dto.key !== 'string' ||
typeof dto.label !== 'string' ||
typeof dto.autoResolvable !== 'boolean'
) {
return err('placeholder: bad shape');
}
return ok({
@@ -163,14 +196,26 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
case 'draft':
return ok({ tag: 'draft' });
case 'submitted':
if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string') return err('status: bad submitted');
if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string')
return err('status: bad submitted');
return ok({ tag: 'submitted', submittedBy: dto.submittedBy, submittedAt: dto.submittedAt });
case 'approved':
if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string') return err('status: bad approved');
if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string')
return err('status: bad approved');
return ok({ tag: 'approved', approvedBy: dto.approvedBy, approvedAt: dto.approvedAt });
case 'rejected':
if (typeof dto.rejectedBy !== 'string' || typeof dto.rejectedAt !== 'string' || typeof dto.comments !== 'string') return err('status: bad rejected');
return ok({ tag: 'rejected', rejectedBy: dto.rejectedBy, rejectedAt: dto.rejectedAt, comments: dto.comments });
if (
typeof dto.rejectedBy !== 'string' ||
typeof dto.rejectedAt !== 'string' ||
typeof dto.comments !== 'string'
)
return err('status: bad rejected');
return ok({
tag: 'rejected',
rejectedBy: dto.rejectedBy,
rejectedAt: dto.rejectedAt,
comments: dto.comments,
});
case 'sent':
if (typeof dto.sentAt !== 'string') return err('status: bad sent');
return ok({ tag: 'sent', sentAt: dto.sentAt });
@@ -180,8 +225,14 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
}
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) return err('passage: bad shape');
if (typeof dto.sectionKey !== 'string' || typeof dto.label !== 'string' || typeof dto.version !== 'number') return err('passage: bad shape');
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
return err('passage: bad shape');
if (
typeof dto.sectionKey !== 'string' ||
typeof dto.label !== 'string' ||
typeof dto.version !== 'number'
)
return err('passage: bad shape');
const content = parseBlockContent(dto.content);
if (!content.ok) return content;
return ok({
@@ -196,7 +247,12 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
}
export function parseBrief(dto: BriefDto): Result<string, Brief> {
if (typeof dto.briefId !== 'string' || typeof dto.drafterId !== 'string' || typeof dto.beroep !== 'string' || typeof dto.templateId !== 'string') {
if (
typeof dto.briefId !== 'string' ||
typeof dto.drafterId !== 'string' ||
typeof dto.beroep !== 'string' ||
typeof dto.templateId !== 'string'
) {
return err('brief: missing ids');
}
const status = parseStatus(dto.status);
@@ -252,15 +308,33 @@ function nodeToDto(n: RichTextNode): RichTextNodeDto {
}
function contentToDto(content: RichTextBlock): RichTextBlockDto {
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto), ...(p.list ? { list: p.list } : {}) })) };
return {
paragraphs: content.paragraphs.map((p) => ({
nodes: p.nodes.map(nodeToDto),
...(p.list ? { list: p.list } : {}),
})),
};
}
function blockToDto(b: LetterBlock): LetterBlockDto {
return b.type === 'passage'
? { type: 'passage', blockId: b.blockId, content: contentToDto(b.content), sourcePassageId: b.sourcePassageId, sourceVersion: b.sourceVersion, edited: b.edited }
? {
type: 'passage',
blockId: b.blockId,
content: contentToDto(b.content),
sourcePassageId: b.sourcePassageId,
sourceVersion: b.sourceVersion,
edited: b.edited,
}
: { type: 'freeText', blockId: b.blockId, content: contentToDto(b.content) };
}
function sectionToDto(s: LetterSection): LetterSectionDto {
return { sectionKey: s.sectionKey, title: s.title, required: s.required, locked: s.locked, blocks: s.blocks.map(blockToDto) };
return {
sectionKey: s.sectionKey,
title: s.title,
required: s.required,
locked: s.locked,
blocks: s.blocks.map(blockToDto),
};
}

View File

@@ -11,20 +11,38 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
this just wires signals to the organism and events back to store commands. */
@Component({
selector: 'app-brief-page',
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent],
styles: [`
.brief-toolbar{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-lg)}
.save{color:var(--rhc-color-foreground-subtle);font-size:0.9em}
`],
imports: [
PageShellComponent,
SpinnerComponent,
AlertComponent,
ButtonComponent,
LetterComposerComponent,
],
styles: [
`
.brief-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-md);
margin-block-end: var(--rhc-space-max-lg);
}
.save {
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
}
`,
],
template: `
<app-page-shell
[heading]="heading"
[intro]="intro"
backLink="/dashboard">
@if (lastError(); as err) { <app-alert type="error">{{ err }}</app-alert> }
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
@if (lastError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
@switch (model().tag) {
@case ('loading') { <app-spinner /> }
@case ('loading') {
<app-spinner />
}
@case ('failed') {
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
@@ -32,7 +50,9 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
@case ('loaded') {
<div class="brief-toolbar">
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{ resetLabel }}</app-button>
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
resetLabel
}}</app-button>
</div>
<app-letter-composer
[brief]="brief()!"
@@ -46,7 +66,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
(submit)="store.submit()"
(approve)="store.approve()"
(reject)="store.reject($event)"
(send)="store.send()" />
(send)="store.send()"
/>
}
}
</app-page-shell>
@@ -70,10 +91,14 @@ export class BriefPage {
/** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => {
switch (this.store.saveState()) {
case 'saving': return this.savingText;
case 'saved': return this.savedText;
case 'error': return this.saveErrorText;
default: return '';
case 'saving':
return this.savingText;
case 'saved':
return this.savedText;
case 'error':
return this.saveErrorText;
default:
return '';
}
});

View File

@@ -8,17 +8,38 @@ import { Diagnostic } from '@brief/domain/placeholders';
@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}
`],
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> }
@for (d of errors(); track $index) {
<li>
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
</li>
}
</ul>
</app-alert>
}
@@ -26,7 +47,11 @@ import { Diagnostic } from '@brief/domain/placeholders';
<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> }
@for (d of warnings(); track $index) {
<li>
<button type="button" (click)="locate.emit(d)">{{ d.message }}</button>
</li>
}
</ul>
</app-alert>
}

View File

@@ -1,6 +1,9 @@
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 {
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';
@@ -9,21 +12,43 @@ import { LetterBlock } from '@brief/domain/brief';
@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)}
`],
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>
<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>
@@ -31,7 +56,8 @@ import { LetterBlock } from '@brief/domain/brief';
[content]="block().content"
[placeholders]="placeholders()"
[editable]="editable()"
(contentChanged)="contentChanged.emit($event)" />
(contentChanged)="contentChanged.emit($event)"
/>
</div>
`,
})

View File

@@ -19,16 +19,44 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
@Component({
selector: 'app-letter-composer',
imports: [
HeadingComponent, StatusBadgeComponent, ButtonComponent, AlertComponent,
LetterSectionComponent, LetterPreviewComponent, DiagnosticsPanelComponent, RejectionCommentsComponent,
HeadingComponent,
StatusBadgeComponent,
ButtonComponent,
AlertComponent,
LetterSectionComponent,
LetterPreviewComponent,
DiagnosticsPanelComponent,
RejectionCommentsComponent,
],
styles: [
`
:host {
display: block;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rhc-space-max-md);
flex-wrap: wrap;
margin-block-end: var(--rhc-space-max-lg);
}
.sections {
display: grid;
gap: var(--rhc-space-max-2xl);
}
.panel {
margin-block: var(--rhc-space-max-xl);
}
.bar {
display: flex;
flex-wrap: wrap;
gap: var(--rhc-space-max-md);
align-items: center;
margin-block-start: var(--rhc-space-max-xl);
}
`,
],
styles: [`
:host{display:block}
.head{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);flex-wrap:wrap;margin-block-end:var(--rhc-space-max-lg)}
.sections{display:grid;gap:var(--rhc-space-max-2xl)}
.panel{margin-block:var(--rhc-space-max-xl)}
.bar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;margin-block-start:var(--rhc-space-max-xl)}
`],
template: `
<div class="head">
<app-heading [level]="2">{{ title() }}</app-heading>
@@ -47,7 +75,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
[availablePassages]="availablePassages()"
[placeholders]="menu()"
[editable]="!section.locked"
(edit)="edit.emit($event)" />
(edit)="edit.emit($event)"
/>
}
</div>
} @else {
@@ -62,25 +91,41 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
@switch (status()) {
@case ('draft') {
@if (editable()) {
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ submitLabel() }}</app-button>
@if (!canSubmit()) { <span class="app-text-subtle">{{ submitHint() }}</span> }
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ submitLabel() }}</app-button
>
@if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span>
}
}
}
@case ('rejected') {
@if (editable()) {
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ resubmitLabel() }}</app-button>
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ resubmitLabel() }}</app-button
>
}
}
@case ('submitted') {
@if (role() === 'approver') {
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{ approveLabel() }}</app-button>
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
approveLabel()
}}</app-button>
<app-rejection-comments mode="entry" [busy]="busy()" (reject)="reject.emit($event)" />
} @else {
<app-alert type="info">{{ awaitingText() }}</app-alert>
}
}
@case ('approved') {
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{ sendLabel() }}</app-button>
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{
sendLabel()
}}</app-button>
}
@case ('sent') {
<app-alert type="ok">{{ sentText() }}</app-alert>
@@ -108,10 +153,14 @@ export class LetterComposerComponent {
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
submitHint = input($localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`);
submitHint = input(
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
);
approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
sendLabel = input($localize`:@@brief.send:Versturen`);
awaitingText = input($localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`);
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);
@@ -130,21 +179,30 @@ export class LetterComposerComponent {
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`;
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 '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)';
case 'sent':
return 'var(--rhc-color-groen-500)';
case 'rejected':
return 'var(--rhc-color-rood-500)';
}
});
}

View File

@@ -4,8 +4,33 @@ import { Brief, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { allDiagnostics } from '@brief/domain/brief';
const passages: LibraryPassage[] = [
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Standaard aanhef', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw ' }, { type: 'placeholder', key: 'naam_zorgverlener' }, { type: 'text', text: ',' }] }] } },
{ passageId: 'p2', scope: 'beroep', beroep: 'arts', sectionKey: 'kern', label: 'Toelichting arts', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] } },
{
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 {
@@ -25,22 +50,69 @@ function brief(status: BriefStatus): Brief {
title: 'Aanhef',
required: true,
locked: true,
blocks: [{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p1', sourceVersion: 1, edited: false, content: passages[0].content }],
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: '.' }] }] } }],
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,' }] }],
},
},
],
},
{ 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, editable: boolean, role: 'drafter' | 'approver') => ({
props: { brief: b, availablePassages: passages, diagnostics: allDiagnostics(b), editable, role, canSubmit: true, busy: false },
props: {
brief: b,
availablePassages: passages,
diagnostics: allDiagnostics(b),
editable,
role,
canSubmit: true,
busy: false,
},
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
[editable]="editable" [role]="role" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
});
@@ -52,7 +124,30 @@ const meta: Meta<LetterComposerComponent> = {
export default meta;
type Story = StoryObj<LetterComposerComponent>;
export const DraftDrafter: Story = { render: () => render(brief({ tag: 'draft' }), true, 'drafter') };
export const SubmittedApprover: Story = { render: () => render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), false, 'approver') };
export const Rejected: Story = { render: () => render(brief({ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de aanhef formeler.' }), true, 'drafter') };
export const Sent: Story = { render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter') };
export const DraftDrafter: Story = {
render: () => render(brief({ tag: 'draft' }), true, 'drafter'),
};
export const SubmittedApprover: Story = {
render: () =>
render(
brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }),
false,
'approver',
),
};
export const Rejected: Story = {
render: () =>
render(
brief({
tag: 'rejected',
rejectedBy: 'demo-approver',
rejectedAt: '2026-07-01',
comments: 'Graag de aanhef formeler.',
}),
true,
'drafter',
),
};
export const Sent: Story = {
render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter'),
};

View File

@@ -8,7 +8,10 @@ import { Brief, LetterBlock } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders';
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
type PreviewSegment = { readonly list: 'bullet' | 'number' | null; readonly items: readonly Paragraph[] };
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[] }[] = [];
@@ -34,25 +37,54 @@ const SAMPLE_VALUES: Record<string, string> = {
@Component({
selector: 'app-letter-preview',
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
styles: [`
:host{display:block}
.toolbar{display:flex;justify-content:flex-end;margin-block-end:var(--rhc-space-max-sm)}
.letter{background:var(--rhc-color-wit);border:var(--rhc-border-width-sm) solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-2xl)}
section{margin-block-end:var(--rhc-space-max-xl)}
p{margin:0 0 var(--rhc-space-max-sm)}
ul,ol{margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em}
`],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
justify-content: flex-end;
margin-block-end: var(--rhc-space-max-sm);
}
.letter {
background: var(--rhc-color-wit);
border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
border-radius: var(--rhc-border-radius-md);
padding: var(--rhc-space-max-2xl);
}
section {
margin-block-end: var(--rhc-space-max-xl);
}
p {
margin: 0 0 var(--rhc-space-max-sm);
}
ul,
ol {
margin: 0 0 var(--rhc-space-max-sm);
padding-inline-start: 1.4em;
}
`,
],
template: `
<ng-template #line let-nodes>
@for (node of nodes; track $index) {
@switch (node.type) {
@case ('text') { <span>{{ node.text }}</span> }
@case ('lineBreak') { <br> }
@case ('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)" />
<app-placeholder-chip
[label]="labelFor(node.key)"
[autoResolvable]="autoFor(node.key)"
[state]="stateFor(node.key)"
/>
}
}
}
@@ -60,7 +92,11 @@ const SAMPLE_VALUES: Record<string, string> = {
</ng-template>
<div class="toolbar">
<app-button variant="subtle" (click)="showSample.set(!showSample())" [attr.aria-pressed]="showSample()">
<app-button
variant="subtle"
(click)="showSample.set(!showSample())"
[attr.aria-pressed]="showSample()"
>
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
</app-button>
</div>
@@ -72,11 +108,34 @@ const SAMPLE_VALUES: Record<string, string> = {
@for (block of section.blocks; track block.blockId) {
@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>
<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>
<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>
<p>
<ng-container
[ngTemplateOutlet]="line"
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
/>
</p>
}
}
}
@@ -93,7 +152,11 @@ export class LetterPreviewComponent {
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
protected showSample = signal(false);
private today = new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
private today = new Date().toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {
@@ -110,5 +173,6 @@ export class LetterPreviewComponent {
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
protected sampleFor = (key: string) => SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
protected sampleFor = (key: string) =>
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
}

View File

@@ -14,17 +14,36 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@Component({
selector: 'app-letter-section',
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent],
styles: [`
:host{display:block}
.blocks{display:grid;gap:var(--rhc-space-max-lg);margin-block:var(--rhc-space-max-md)}
.actions{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm)}
.required{color:var(--rhc-color-foreground-subtle)}
.empty{color:var(--rhc-color-foreground-subtle);font-style:italic}
`],
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> }
@if (section().required) {
<span class="required">· {{ requiredLabel() }}</span>
}
</app-heading>
<div class="blocks">
@@ -35,7 +54,8 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
[editable]="editable()"
(contentChanged)="onContent(block.blockId, $event)"
(removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })"
(moved)="onMove(block.blockId, $event)" />
(moved)="onMove(block.blockId, $event)"
/>
} @empty {
<p class="empty">{{ emptyLabel() }}</p>
}
@@ -43,8 +63,14 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
@if (editable()) {
<div class="actions">
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{ addPassageLabel() }}</app-button>
<app-button variant="subtle" (click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })">{{ addFreeLabel() }}</app-button>
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
addPassageLabel()
}}</app-button>
<app-button
variant="subtle"
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
>{{ addFreeLabel() }}</app-button
>
</div>
@if (pickerOpen()) {
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
@@ -65,7 +91,9 @@ export class LetterSectionComponent {
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
protected pickerOpen = signal(false);
protected sectionPassages = computed(() => this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey));
protected sectionPassages = computed(() =>
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
);
protected onContent(blockId: string, content: RichTextBlock) {
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });

View File

@@ -10,11 +10,26 @@ import { LibraryPassage } from '@brief/domain/brief';
@Component({
selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent],
styles: [`
:host{display:block;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)}
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)}
`],
styles: [
`
:host {
display: block;
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);
}
ul {
list-style: none;
margin: 0 0 var(--rhc-space-max-md);
padding: 0;
display: grid;
gap: var(--rhc-space-max-sm);
}
.scope {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<ul>
@for (p of passages(); track p.passageId) {
@@ -22,12 +37,15 @@ import { LibraryPassage } from '@brief/domain/brief';
<app-checkbox
[label]="p.label"
[ngModel]="!!checked()[p.passageId]"
(ngModelChange)="set(p.passageId, $event)" />
(ngModelChange)="set(p.passageId, $event)"
/>
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
</li>
}
</ul>
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()">{{ addLabel() }} ({{ count() }})</app-button>
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()"
>{{ addLabel() }} ({{ count() }})</app-button
>
`,
})
export class PassagePickerComponent {

View File

@@ -8,18 +8,33 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
@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}
`],
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>
<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>
<app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{
rejectLabel()
}}</app-button>
}
`,
})