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:
@@ -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 });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -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)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user