feat(fp): brief v3 — besluit-driven guided drafting
Compose the herregistratie letter from the besluit instead of a library hunt: the behandelaar picks positief/negatief (+ reden-checkboxes for a negatief) and the kern's standaardteksten follow the selection live. Front-end (this increment): - Kern is recomposed reactively from the besluit selection (new BesluitSelected machine msg + composeKern); the "Genereer conceptbrief" button is gone. The drafter's free text is preserved across a selection change. - The editor shows only the editable sections; the locked aanhef/slot render in the preview, not the authoring surface. Slot is a case-type template section (per templateId), documented as such. - The panel re-seeds from the letter via inferSelection() — the besluit + redenen are read back off the kern's passage blocks, so the selection survives reload with no new wire fields (derive, don't store). - letter-section drops the now-redundant per-section passage picker (besluit owns standaardteksten); keeps free-text + block edit/move/remove. Fix: app-checkbox now falls back to a unique per-instance id when checkboxId is omitted. The CIBG styled checkbox routes clicks through the label, so the shared id="undefined" made every reason label toggle the first input — the second checkbox could never be checked. Verified live (Playwright): each reason toggles independently. Backend/seam (brief v3 WIP): besluit/reason passage tags on the wire + seed, carried through the adapter parse boundary. Specs updated (besluit, brief.machine) and the affected stories re-pointed at the new API. FE lint + build + 253 vitest specs green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -137,9 +137,13 @@ public sealed record BriefStatusDto(
|
|||||||
string? RejectedBy = null, string? RejectedAt = null, string? Comments = null,
|
string? RejectedBy = null, string? RejectedAt = null, string? Comments = null,
|
||||||
string? SentAt = null);
|
string? SentAt = null);
|
||||||
|
|
||||||
|
// Besluit/Reason tag the passage for guided drafting (WP-brief-v3): the behandelaar
|
||||||
|
// picks the besluit + reden and the FE filters this library to the matching passages.
|
||||||
|
// null besluit = relevant to any besluit; null reason = not reason-specific.
|
||||||
public sealed record LibraryPassageDto(
|
public sealed record LibraryPassageDto(
|
||||||
string PassageId, string Scope, string SectionKey, string Label,
|
string PassageId, string Scope, string SectionKey, string Label,
|
||||||
RichTextBlockDto Content, int Version, string? Beroep = null, bool IsDefault = false);
|
RichTextBlockDto Content, int Version, string? Beroep = null, bool IsDefault = false,
|
||||||
|
string? Besluit = null, string? Reason = null);
|
||||||
|
|
||||||
public sealed record BriefDto(
|
public sealed record BriefDto(
|
||||||
string BriefId, string Beroep, string TemplateId,
|
string BriefId, string Beroep, string TemplateId,
|
||||||
@@ -154,9 +158,14 @@ public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanRe
|
|||||||
// The brief's screen DTO also carries the org template it renders with (WP-23):
|
// The brief's screen DTO also carries the org template it renders with (WP-23):
|
||||||
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at
|
// the sub-org's current PUBLISHED version — or, once sent, the version pinned at
|
||||||
// send time (sent letters are immutable; a republish never re-renders them).
|
// send time (sent letters are immutable; a republish never re-renders them).
|
||||||
|
// The case this letter is about — the zorgverlener + aanvraag the behandelaar is
|
||||||
|
// handling. Server-joined onto the brief's screen DTO so brief/ stays a shared-only
|
||||||
|
// leaf context (no cross-context import of registratie).
|
||||||
|
public sealed record CaseContextDto(string ZorgverlenerNaam, string BigNummer, string Beroep, string AanvraagReferentie);
|
||||||
|
|
||||||
public sealed record BriefViewDto(
|
public sealed record BriefViewDto(
|
||||||
BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions,
|
BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions,
|
||||||
OrgTemplateDto OrgTemplate);
|
OrgTemplateDto OrgTemplate, CaseContextDto CaseContext);
|
||||||
|
|
||||||
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
||||||
public sealed record RejectBriefRequest(string Comments);
|
public sealed record RejectBriefRequest(string Comments);
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ public static class BriefSeed
|
|||||||
{
|
{
|
||||||
public const string TemplateId = "besluit-arts";
|
public const string TemplateId = "besluit-arts";
|
||||||
public const string Beroep = "arts";
|
public const string Beroep = "arts";
|
||||||
|
// Demo case reference shown in the behandel scherm header (no real aanvraag linkage
|
||||||
|
// in this POC — one demo case).
|
||||||
|
public const string AanvraagReferentie = "HER-2026-000842";
|
||||||
|
|
||||||
private static RichTextNodeDto T(string t) => new("text", Text: t);
|
private static RichTextNodeDto T(string t) => new("text", Text: t);
|
||||||
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
|
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
|
||||||
@@ -226,12 +229,22 @@ public static class BriefSeed
|
|||||||
{
|
{
|
||||||
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
|
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
|
||||||
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
|
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
|
||||||
// The "standaardbrief" kern starter set (IsDefault): one button drops these
|
// Kern guidance set (WP-brief-v3). besluit=null → shown for any besluit (the
|
||||||
// in when the kern is still empty (WP-27).
|
// shared intro); besluit=positief/negatief → inserted when that besluit is
|
||||||
new("p-kern-1", "global", "kern", "Beoordeling ontvangen",
|
// chosen; reason!=null → only when that reden is ticked. The FE filters this
|
||||||
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1, IsDefault: true),
|
// with passagesForBesluit — no server endpoint for guidance.
|
||||||
|
new("p-kern-intro", "global", "kern", "Beoordeling ontvangen",
|
||||||
|
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1),
|
||||||
new("p-kern-arts", "beroep", "kern", "Toelichting arts",
|
new("p-kern-arts", "beroep", "kern", "Toelichting arts",
|
||||||
Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts", IsDefault: true),
|
Block(T("Als arts met BIG-nummer "), P("big_nummer"), T(" delen wij u het volgende mee.")), 1, Beroep: "arts"),
|
||||||
|
new("p-kern-positief", "global", "kern", "Toewijzing",
|
||||||
|
Block(T("Uw aanvraag tot herregistratie is toegewezen. U blijft ingeschreven in het BIG-register.")), 1, Besluit: "positief"),
|
||||||
|
new("p-kern-negatief", "global", "kern", "Afwijzing",
|
||||||
|
Block(T("Uw aanvraag tot herregistratie is afgewezen.")), 1, Besluit: "negatief"),
|
||||||
|
new("p-kern-scholing", "global", "kern", "Onvoldoende scholing",
|
||||||
|
Block(T("U voldoet niet aan de eis van voldoende recente werkervaring en scholing.")), 1, Besluit: "negatief", Reason: "onvoldoende_scholing"),
|
||||||
|
new("p-kern-gegevens", "global", "kern", "Onjuiste gegevens",
|
||||||
|
Block(T("De door u aangeleverde gegevens zijn onjuist of onvolledig gebleken.")), 1, Besluit: "negatief", Reason: "onjuiste_gegevens"),
|
||||||
new("p-slot-1", "global", "slot", "Standaard slot",
|
new("p-slot-1", "global", "slot", "Standaard slot",
|
||||||
Block(T("Met vriendelijke groet,")), 1),
|
Block(T("Met vriendelijke groet,")), 1),
|
||||||
// These reference a deprecated / not-fillable field so the linter's
|
// These reference a deprecated / not-fillable field so the linter's
|
||||||
|
|||||||
@@ -450,7 +450,10 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
|||||||
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
|
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId),
|
||||||
// Sent letters render with the version pinned at send; everything else follows
|
// Sent letters render with the version pinned at send; everything else follows
|
||||||
// the sub-org's current published template (WP-23 immutability invariant).
|
// the sub-org's current published template (WP-23 immutability invariant).
|
||||||
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null));
|
OrgTemplateStore.TemplateForBrief(e.SubOrgId, e.Status.Tag == "sent" ? e.SentOrgTemplateVersion : null),
|
||||||
|
// The case this letter is about — joined from the seeded zorgverlener so the
|
||||||
|
// behandel scherm can show whom/what it concerns without brief/ importing registratie.
|
||||||
|
new CaseContextDto(SeedData.Registration.Naam, SeedData.Registration.BigNummer, e.Beroep, BriefSeed.AanvraagReferentie));
|
||||||
|
|
||||||
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
|
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
|
||||||
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
|
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
|
||||||
|
|||||||
@@ -1398,6 +1398,9 @@
|
|||||||
},
|
},
|
||||||
"orgTemplate": {
|
"orgTemplate": {
|
||||||
"$ref": "#/components/schemas/OrgTemplateDto"
|
"$ref": "#/components/schemas/OrgTemplateDto"
|
||||||
|
},
|
||||||
|
"caseContext": {
|
||||||
|
"$ref": "#/components/schemas/CaseContextDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -1414,6 +1417,28 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"CaseContextDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"zorgverlenerNaam": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"bigNummer": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"beroep": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"aanvraagReferentie": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"ChangeRequestRequest": {
|
"ChangeRequestRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1723,6 +1748,14 @@
|
|||||||
},
|
},
|
||||||
"isDefault": {
|
"isDefault": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"besluit": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
|||||||
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
||||||
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
||||||
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
||||||
|
|
||||||
|
// Guided-drafting tags (WP-brief-v3): positief + negatief + reason-specific negatief.
|
||||||
|
Assert.Contains(view.AvailablePassages, p => p.Besluit == "positief");
|
||||||
|
Assert.Contains(view.AvailablePassages, p => p.Besluit == "negatief" && p.Reason == "onvoldoende_scholing");
|
||||||
|
|
||||||
|
// Case context is joined onto the screen DTO for the behandel scherm header.
|
||||||
|
Assert.Equal("19012345601", view.CaseContext.BigNummer);
|
||||||
|
Assert.Equal("arts", view.CaseContext.Beroep);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.ZorgverlenerNaam));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.AanvraagReferentie));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+3780
-2557
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { Brief, BriefDecisions, LetterBlock } from '@brief/domain/brief';
|
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||||
@@ -37,7 +37,14 @@ const orgTemplate: OrgTemplate = {
|
|||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate };
|
const caseContext: CaseContext = {
|
||||||
|
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||||
|
bigNummer: '19012345601',
|
||||||
|
beroep: 'arts',
|
||||||
|
aanvraagReferentie: 'HER-2026-000842',
|
||||||
|
};
|
||||||
|
|
||||||
|
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
||||||
|
|
||||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RemoteData } from '@shared/application/remote-data';
|
|||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import {
|
import {
|
||||||
Brief,
|
Brief,
|
||||||
|
CaseContext,
|
||||||
allDiagnostics,
|
allDiagnostics,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
hasBlockingErrors,
|
hasBlockingErrors,
|
||||||
@@ -87,6 +88,10 @@ export class BriefStore {
|
|||||||
stays untouched by design). Set from every server view that carries it. */
|
stays untouched by design). Set from every server view that carries it. */
|
||||||
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
||||||
|
|
||||||
|
/** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for
|
||||||
|
the behandel scherm header, not letter state. Set from every server view. */
|
||||||
|
readonly caseContext = signal<CaseContext | null>(null);
|
||||||
|
|
||||||
/** The org logo's content URL for the letterhead, or null when the template has none. */
|
/** The org logo's content URL for the letterhead, or null when the template has none. */
|
||||||
readonly logoUrl = computed<string | null>(() => {
|
readonly logoUrl = computed<string | null>(() => {
|
||||||
const id = this.orgTemplate()?.logoDocumentId;
|
const id = this.orgTemplate()?.logoDocumentId;
|
||||||
@@ -134,6 +139,7 @@ export class BriefStore {
|
|||||||
const r = await this.adapter.load();
|
const r = await this.adapter.load();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.orgTemplate.set(r.value.orgTemplate);
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
|
this.caseContext.set(r.value.caseContext);
|
||||||
this.clearHistory();
|
this.clearHistory();
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
} else {
|
} else {
|
||||||
@@ -212,6 +218,7 @@ export class BriefStore {
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.actionState.set({ tag: 'Idle' });
|
||||||
this.orgTemplate.set(r.value.orgTemplate);
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
|
this.caseContext.set(r.value.caseContext);
|
||||||
this.clearHistory();
|
this.clearHistory();
|
||||||
this.rejectionSnapshot.set(null);
|
this.rejectionSnapshot.set(null);
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
@@ -257,6 +264,7 @@ export class BriefStore {
|
|||||||
private applyServerStatus(view: BriefView) {
|
private applyServerStatus(view: BriefView) {
|
||||||
// `send` pins the org-template version server-side — mirror whatever came back.
|
// `send` pins the org-template version server-side — mirror whatever came back.
|
||||||
this.orgTemplate.set(view.orgTemplate);
|
this.orgTemplate.set(view.orgTemplate);
|
||||||
|
this.caseContext.set(view.caseContext);
|
||||||
const { brief, decisions } = view;
|
const { brief, decisions } = view;
|
||||||
const s = brief.status;
|
const s = brief.status;
|
||||||
switch (s.tag) {
|
switch (s.tag) {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Besluit, LetterBlock, LibraryPassage } from './brief';
|
||||||
|
import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
|
||||||
|
|
||||||
|
const block = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||||
|
|
||||||
|
const p = (over: Partial<LibraryPassage>): LibraryPassage => ({
|
||||||
|
passageId: over.passageId ?? 'x',
|
||||||
|
scope: 'global',
|
||||||
|
sectionKey: 'kern',
|
||||||
|
label: over.label ?? 'x',
|
||||||
|
content: block('x'),
|
||||||
|
version: 1,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib: LibraryPassage[] = [
|
||||||
|
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
|
||||||
|
p({ passageId: 'pos', besluit: 'positief' }),
|
||||||
|
p({ passageId: 'neg', besluit: 'negatief' }),
|
||||||
|
p({ passageId: 'neg-scholing', besluit: 'negatief', reason: 'onvoldoende_scholing', label: 'Onvoldoende scholing' }),
|
||||||
|
p({ passageId: 'neg-gegevens', besluit: 'negatief', reason: 'onjuiste_gegevens', label: 'Onjuiste gegevens' }),
|
||||||
|
p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('passagesForBesluit', () => {
|
||||||
|
it('positief = shared intro + the positief passage, no negatief/reason passages', () => {
|
||||||
|
const ids = passagesForBesluit(lib, 'positief', []).map((x) => x.passageId);
|
||||||
|
expect(ids).toEqual(['intro', 'pos']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negatief without redenen = intro + negatief base, but no reason-specific passages', () => {
|
||||||
|
const ids = passagesForBesluit(lib, 'negatief', []).map((x) => x.passageId);
|
||||||
|
expect(ids).toEqual(['intro', 'neg']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negatief with a reden ticked includes that reason-specific passage only', () => {
|
||||||
|
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map((x) => x.passageId);
|
||||||
|
expect(ids).toEqual(['intro', 'neg', 'neg-scholing']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves library order (= reading order)', () => {
|
||||||
|
const ids = passagesForBesluit(lib, 'negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']).map((x) => x.passageId);
|
||||||
|
expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never offers non-kern passages', () => {
|
||||||
|
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('redenenFor', () => {
|
||||||
|
it('derives reason checkboxes (code + label) from the negatief reason passages', () => {
|
||||||
|
expect(redenenFor(lib, 'negatief')).toEqual([
|
||||||
|
{ code: 'onvoldoende_scholing', label: 'Onvoldoende scholing' },
|
||||||
|
{ code: 'onjuiste_gegevens', label: 'Onjuiste gegevens' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('positief has no reason-specific redenen', () => {
|
||||||
|
expect(redenenFor(lib, 'positief')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferSelection', () => {
|
||||||
|
// Build the kern blocks a besluit would produce, then read the selection back off them.
|
||||||
|
const kern = (besluit: Besluit, reasons: string[]): LetterBlock[] =>
|
||||||
|
passagesForBesluit(lib, besluit, reasons).map((p, i) => ({
|
||||||
|
type: 'passage',
|
||||||
|
blockId: `local-${i + 1}`,
|
||||||
|
sourcePassageId: p.passageId,
|
||||||
|
sourceVersion: p.version,
|
||||||
|
content: p.content,
|
||||||
|
edited: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('round-trips a positief selection', () => {
|
||||||
|
expect(inferSelection(kern('positief', []), lib)).toEqual({ besluit: 'positief', reasons: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a negatief selection with redenen (in order)', () => {
|
||||||
|
const blocks = kern('negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']);
|
||||||
|
expect(inferSelection(blocks, lib)).toEqual({
|
||||||
|
besluit: 'negatief',
|
||||||
|
reasons: ['onvoldoende_scholing', 'onjuiste_gegevens'], // library order
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty kern (nothing chosen) infers no besluit', () => {
|
||||||
|
expect(inferSelection([], lib)).toEqual({ besluit: null, reasons: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores free-text blocks and unknown passage ids', () => {
|
||||||
|
const blocks: LetterBlock[] = [
|
||||||
|
{ type: 'freeText', blockId: 'local-9', content: block('vrij') },
|
||||||
|
...kern('positief', []),
|
||||||
|
];
|
||||||
|
expect(inferSelection(blocks, lib)).toEqual({ besluit: 'positief', reasons: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Besluit, LetterBlock, LibraryPassage } from './brief';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guided drafting: given the behandelaar's besluit + chosen redenen, which library
|
||||||
|
* passages belong in the kern. This is the "don't make them a detective" logic —
|
||||||
|
* pure, so it's unit-tested directly and the UI just renders the result.
|
||||||
|
*
|
||||||
|
* A passage is offered when:
|
||||||
|
* - it has no besluit tag (a shared intro/toelichting, relevant to any besluit), OR
|
||||||
|
* - its besluit matches AND either it isn't reason-specific, or its reason is ticked.
|
||||||
|
*
|
||||||
|
* Kept in library order (server order = reading order), so an inserted set already
|
||||||
|
* flows as a letter.
|
||||||
|
*/
|
||||||
|
export function passagesForBesluit(
|
||||||
|
passages: readonly LibraryPassage[],
|
||||||
|
besluit: Besluit,
|
||||||
|
reasons: readonly string[],
|
||||||
|
): LibraryPassage[] {
|
||||||
|
return passages.filter((p) => {
|
||||||
|
if (p.sectionKey !== 'kern') return false;
|
||||||
|
if (p.besluit === undefined) return true; // shared, any besluit
|
||||||
|
if (p.besluit !== besluit) return false;
|
||||||
|
if (p.reason === undefined) return true; // besluit-level, not reason-specific
|
||||||
|
return reasons.includes(p.reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A selectable reden for a besluit, derived from the reason-specific passages — no
|
||||||
|
separate catalog. `code` drives `passagesForBesluit`; `label` is the checkbox text.
|
||||||
|
ponytail: assumes one passage per reason (true for the seed); dedupes on code if not. */
|
||||||
|
export interface Reden {
|
||||||
|
readonly code: string;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redenenFor(passages: readonly LibraryPassage[], besluit: Besluit): Reden[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: Reden[] = [];
|
||||||
|
for (const p of passages) {
|
||||||
|
if (p.sectionKey !== 'kern' || p.besluit !== besluit || p.reason === undefined) continue;
|
||||||
|
if (seen.has(p.reason)) continue;
|
||||||
|
seen.add(p.reason);
|
||||||
|
out.push({ code: p.reason, label: p.label });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inverse of `passagesForBesluit`: read the current besluit + redenen back off the
|
||||||
|
* kern's passage blocks (each carries its `sourcePassageId`), so the panel can re-seed
|
||||||
|
* itself on reload/undo without persisting the selection separately. Kern passage blocks
|
||||||
|
* are besluit-derived by construction (the only way passages enter the kern), so this
|
||||||
|
* round-trips: `inferSelection(kern(passagesForBesluit(lib, b, r)), lib) === { b, r }`.
|
||||||
|
* Free-text blocks carry no provenance and are ignored.
|
||||||
|
*/
|
||||||
|
export function inferSelection(
|
||||||
|
kernBlocks: readonly LetterBlock[],
|
||||||
|
passages: readonly LibraryPassage[],
|
||||||
|
): { besluit: Besluit | null; reasons: string[] } {
|
||||||
|
const byId = new Map(passages.map((p) => [p.passageId, p]));
|
||||||
|
let besluit: Besluit | null = null;
|
||||||
|
const reasons: string[] = [];
|
||||||
|
for (const b of kernBlocks) {
|
||||||
|
if (b.type !== 'passage') continue;
|
||||||
|
const source = byId.get(b.sourcePassageId);
|
||||||
|
if (!source) continue;
|
||||||
|
if (source.besluit !== undefined) besluit = source.besluit;
|
||||||
|
if (source.reason !== undefined && !reasons.includes(source.reason)) reasons.push(source.reason);
|
||||||
|
}
|
||||||
|
return { besluit, reasons };
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
|
import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './brief';
|
||||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
import { PlaceholderDef } from './placeholders';
|
import { PlaceholderDef } from './placeholders';
|
||||||
import { BriefState, BriefMsg, reduce } from './brief.machine';
|
import { BriefState, reduce } from './brief.machine';
|
||||||
|
|
||||||
const placeholders: PlaceholderDef[] = [
|
const placeholders: PlaceholderDef[] = [
|
||||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||||
@@ -13,15 +13,32 @@ const text = (t: string): RichTextBlock => ({
|
|||||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({
|
const libPassage = (
|
||||||
|
id: string,
|
||||||
|
sectionKey: string,
|
||||||
|
extra: Partial<LibraryPassage> = {},
|
||||||
|
): LibraryPassage => ({
|
||||||
passageId: id,
|
passageId: id,
|
||||||
scope: 'global',
|
scope: 'global',
|
||||||
sectionKey,
|
sectionKey,
|
||||||
label: `Passage ${id}`,
|
label: `Passage ${id}`,
|
||||||
content: text(`inhoud ${id}`),
|
content: text(`inhoud ${id}`),
|
||||||
version: 3,
|
version: 3,
|
||||||
|
...extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A besluit-tagged kern library: `intro` is shared (any besluit), `pos`/`neg` are
|
||||||
|
// besluit-level, `neg-r` is reason-specific. This drives every `BesluitSelected` here.
|
||||||
|
const lib: LibraryPassage[] = [
|
||||||
|
libPassage('intro', 'kern'),
|
||||||
|
libPassage('pos', 'kern', { besluit: 'positief' }),
|
||||||
|
libPassage('neg', 'kern', { besluit: 'negatief' }),
|
||||||
|
libPassage('neg-r', 'kern', { besluit: 'negatief', reason: 'r1', label: 'Reden 1' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const besluit = (b: Besluit | null, reasons: string[] = []) =>
|
||||||
|
({ tag: 'BesluitSelected', besluit: b, reasons }) as const;
|
||||||
|
|
||||||
function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||||
return {
|
return {
|
||||||
briefId: 'b1',
|
briefId: 'b1',
|
||||||
@@ -29,7 +46,7 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
|||||||
templateId: 't1',
|
templateId: 't1',
|
||||||
placeholders,
|
placeholders,
|
||||||
sections: sections ?? [
|
sections: sections ?? [
|
||||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: false, blocks: [] },
|
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
|
||||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
||||||
],
|
],
|
||||||
status,
|
status,
|
||||||
@@ -52,13 +69,18 @@ const loaded = (
|
|||||||
): BriefState => ({
|
): BriefState => ({
|
||||||
tag: 'loaded',
|
tag: 'loaded',
|
||||||
brief: briefWith(status, sections),
|
brief: briefWith(status, sections),
|
||||||
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
availablePassages: lib,
|
||||||
decisions,
|
decisions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionBlocks = (s: BriefState, key: string) =>
|
const sectionBlocks = (s: BriefState, key: string) =>
|
||||||
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||||
|
|
||||||
|
const passageIds = (s: BriefState, key: string) =>
|
||||||
|
sectionBlocks(s, key)
|
||||||
|
.filter((b) => b.type === 'passage')
|
||||||
|
.map((b) => (b.type === 'passage' ? b.sourcePassageId : ''));
|
||||||
|
|
||||||
describe('brief.machine reduce', () => {
|
describe('brief.machine reduce', () => {
|
||||||
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
|
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -77,84 +99,80 @@ describe('brief.machine reduce', () => {
|
|||||||
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
|
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => {
|
it('BesluitSelected composes the kern: the besluit passages, in reading order, as frozen local blocks', () => {
|
||||||
const s = reduce(loaded(), {
|
const s = reduce(loaded(), besluit('positief'));
|
||||||
tag: 'PassagesInserted',
|
const blocks = sectionBlocks(s, 'kern');
|
||||||
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.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
|
||||||
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
|
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
|
||||||
expect(blocks[0].type === 'passage' && blocks[0].sourcePassageId).toBe('p1');
|
expect(passageIds(s, 'kern')).toEqual(['intro', 'pos']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => {
|
it('BesluitSelected swaps the passages when the selection changes, keeping free text', () => {
|
||||||
const passage = libPassage('p1', 'aanhef');
|
let s = reduce(loaded(), besluit('positief'));
|
||||||
const s = reduce(loaded(), {
|
s = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); // drafter's own remark
|
||||||
tag: 'PassagesInserted',
|
s = reduce(s, besluit('negatief', ['r1']));
|
||||||
sectionKey: 'aanhef',
|
const blocks = sectionBlocks(s, 'kern');
|
||||||
passages: [passage],
|
expect(blocks.map((b) => b.type)).toEqual(['passage', 'passage', 'passage', 'freeText']);
|
||||||
|
expect(passageIds(s, 'kern')).toEqual(['intro', 'neg', 'neg-r']);
|
||||||
|
// Deselecting the besluit leaves only the free text.
|
||||||
|
s = reduce(s, besluit(null));
|
||||||
|
expect(sectionBlocks(s, 'kern').map((b) => b.type)).toEqual(['freeText']);
|
||||||
});
|
});
|
||||||
// Mutate the source passage object after insertion.
|
|
||||||
|
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
|
||||||
|
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
|
||||||
|
const st: BriefState = {
|
||||||
|
tag: 'loaded',
|
||||||
|
brief: briefWith({ tag: 'draft' }),
|
||||||
|
availablePassages: [passage],
|
||||||
|
decisions,
|
||||||
|
};
|
||||||
|
const s = reduce(st, besluit('positief'));
|
||||||
|
// Mutate the source passage object after composition.
|
||||||
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||||
const block = sectionBlocks(s, 'aanhef')[0];
|
const block = sectionBlocks(s, 'kern')[0];
|
||||||
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud p1' });
|
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud intro' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FreeTextBlockAdded appends an empty free-text block', () => {
|
it('FreeTextBlockAdded appends an empty free-text block', () => {
|
||||||
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||||
const blocks = sectionBlocks(s, 'slot');
|
const blocks = sectionBlocks(s, 'kern');
|
||||||
expect(blocks).toHaveLength(1);
|
expect(blocks).toHaveLength(1);
|
||||||
expect(blocks[0].type).toBe('freeText');
|
expect(blocks[0].type).toBe('freeText');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('BlockContentEdited replaces content and marks a passage block edited', () => {
|
it('BlockContentEdited replaces content and marks a passage block edited', () => {
|
||||||
let s = reduce(loaded(), {
|
let s = reduce(loaded(), besluit('positief'));
|
||||||
tag: 'PassagesInserted',
|
|
||||||
sectionKey: 'aanhef',
|
|
||||||
passages: [libPassage('p1', 'aanhef')],
|
|
||||||
});
|
|
||||||
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
|
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
|
||||||
const block = sectionBlocks(s, 'aanhef')[0];
|
const block = sectionBlocks(s, 'kern')[0];
|
||||||
expect(block.type === 'passage' && block.edited).toBe(true);
|
expect(block.type === 'passage' && block.edited).toBe(true);
|
||||||
expect(block.content).toEqual(text('aangepast'));
|
expect(block.content).toEqual(text('aangepast'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
|
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
|
||||||
let s = reduce(loaded(), {
|
let s = reduce(loaded(), besluit('positief')); // local-1 intro, local-2 pos
|
||||||
tag: 'PassagesInserted',
|
|
||||||
sectionKey: 'aanhef',
|
|
||||||
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
|
||||||
});
|
|
||||||
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
|
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
|
||||||
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
|
expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
|
||||||
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
|
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
|
||||||
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']);
|
expect(sectionBlocks(s, 'kern').map((b) => b.blockId)).toEqual(['local-1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => {
|
it('edits to a locked section are no-ops (besluit, free-text, content, remove, move)', () => {
|
||||||
const lockedSections: Brief['sections'] = [
|
const lockedSections: Brief['sections'] = [
|
||||||
{
|
{
|
||||||
sectionKey: 'aanhef',
|
sectionKey: 'kern',
|
||||||
title: 'Aanhef',
|
title: 'Kern',
|
||||||
required: true,
|
required: true,
|
||||||
locked: true,
|
locked: true,
|
||||||
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
|
blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }],
|
||||||
},
|
},
|
||||||
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
|
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
||||||
];
|
];
|
||||||
const s = loaded({ tag: 'draft' }, lockedSections);
|
const s = loaded({ tag: 'draft' }, lockedSections);
|
||||||
// The brief value is left untouched (withEdit reallocates state, but the guard returns
|
// 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.
|
// the same brief), so assert on deep equality of the section contents.
|
||||||
expect(
|
expect(reduce(s, besluit('positief'))).toEqual(s);
|
||||||
reduce(s, {
|
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' })).toEqual(s);
|
||||||
tag: 'PassagesInserted',
|
|
||||||
sectionKey: 'aanhef',
|
|
||||||
passages: [libPassage('p1', 'aanhef')],
|
|
||||||
}),
|
|
||||||
).toEqual(s);
|
|
||||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
|
|
||||||
expect(
|
expect(
|
||||||
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
|
reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') }),
|
||||||
).toEqual(s);
|
).toEqual(s);
|
||||||
@@ -163,8 +181,8 @@ describe('brief.machine reduce', () => {
|
|||||||
s,
|
s,
|
||||||
);
|
);
|
||||||
// the unlocked section still accepts edits
|
// the unlocked section still accepts edits
|
||||||
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||||
expect(sectionBlocks(edited, 'kern')).toHaveLength(1);
|
expect(sectionBlocks(edited, 'slot')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('edits are no-ops once submitted (status invariant)', () => {
|
it('edits are no-ops once submitted (status invariant)', () => {
|
||||||
@@ -185,10 +203,10 @@ describe('brief.machine reduce', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Submitted fires only from draft and only when required sections are filled', () => {
|
it('Submitted fires only from draft and only when required sections are filled', () => {
|
||||||
// required 'aanhef' empty → no-op
|
// required 'kern' empty → no-op
|
||||||
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
|
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't', decisions })).toEqual(loaded());
|
||||||
// fill the required section, then submit
|
// fill the required section via the besluit, then submit
|
||||||
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
const filled = reduce(loaded(), besluit('positief'));
|
||||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||||
tag: 'submitted',
|
tag: 'submitted',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { assertNever } from '@shared/kernel/fp';
|
import { assertNever } from '@shared/kernel/fp';
|
||||||
import {
|
import {
|
||||||
|
Besluit,
|
||||||
Brief,
|
Brief,
|
||||||
BriefDecisions,
|
BriefDecisions,
|
||||||
BriefStatus,
|
BriefStatus,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
canSubmit,
|
canSubmit,
|
||||||
} from './brief';
|
} from './brief';
|
||||||
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||||
|
import { passagesForBesluit } from './besluit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The letter composition state machine (Model + Msg + pure reduce), modeled on
|
* The letter composition state machine (Model + Msg + pure reduce), modeled on
|
||||||
@@ -54,7 +56,7 @@ export type BriefMsg =
|
|||||||
decisions: BriefDecisions;
|
decisions: BriefDecisions;
|
||||||
}
|
}
|
||||||
| { tag: 'BriefLoadFailed'; reason: string }
|
| { tag: 'BriefLoadFailed'; reason: string }
|
||||||
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
|
| { tag: 'BesluitSelected'; besluit: Besluit | null; reasons: readonly string[] } // recomposes the kern's passages
|
||||||
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
||||||
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
||||||
| { tag: 'BlockRemoved'; blockId: string }
|
| { tag: 'BlockRemoved'; blockId: string }
|
||||||
@@ -114,15 +116,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
|||||||
return { ...s, brief };
|
return { ...s, brief };
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertPassages(
|
function buildPassageBlocks(brief: Brief, passages: readonly LibraryPassage[]): LetterBlock[] {
|
||||||
brief: Brief,
|
|
||||||
sectionKey: string,
|
|
||||||
passages: readonly LibraryPassage[],
|
|
||||||
): Brief {
|
|
||||||
let idx = nextLocalIndex(brief);
|
let idx = nextLocalIndex(brief);
|
||||||
// The freeze happens HERE: each block gets a deep VALUE copy of the library content,
|
// 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).
|
// so later library edits can never mutate this letter (frozen snapshot).
|
||||||
const newBlocks: LetterBlock[] = passages.map((p) => ({
|
return passages.map((p) => ({
|
||||||
type: 'passage',
|
type: 'passage',
|
||||||
blockId: `local-${idx++}`,
|
blockId: `local-${idx++}`,
|
||||||
sourcePassageId: p.passageId,
|
sourcePassageId: p.passageId,
|
||||||
@@ -130,7 +128,26 @@ function insertPassages(
|
|||||||
content: deepCopyBlock(p.content),
|
content: deepCopyBlock(p.content),
|
||||||
edited: false,
|
edited: false,
|
||||||
}));
|
}));
|
||||||
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, ...newBlocks] }));
|
}
|
||||||
|
|
||||||
|
/** Recompose the kern for a besluit selection: the besluit-driven passages (in reading
|
||||||
|
order) followed by the drafter's free-text blocks. The kern's `passage` blocks are
|
||||||
|
besluit-derived by construction, so replacing them wholesale is the reactive swap; the
|
||||||
|
`freeText` blocks are the drafter's own remarks and survive.
|
||||||
|
ponytail: free text always trails the besluit passages after a recompute. */
|
||||||
|
function composeKern(
|
||||||
|
brief: Brief,
|
||||||
|
availablePassages: readonly LibraryPassage[],
|
||||||
|
besluit: Besluit | null,
|
||||||
|
reasons: readonly string[],
|
||||||
|
): Brief {
|
||||||
|
const besluitBlocks = besluit
|
||||||
|
? buildPassageBlocks(brief, passagesForBesluit(availablePassages, besluit, reasons))
|
||||||
|
: [];
|
||||||
|
return mapSection(brief, 'kern', (s) => ({
|
||||||
|
...s,
|
||||||
|
blocks: [...besluitBlocks, ...s.blocks.filter((b) => b.type === 'freeText')],
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFreeText(brief: Brief, sectionKey: string): Brief {
|
function addFreeText(brief: Brief, sectionKey: string): Brief {
|
||||||
@@ -182,11 +199,13 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
return m.state;
|
||||||
|
|
||||||
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a
|
// The kern is besluit-driven: (re)compose its passages from the selection, keeping the
|
||||||
// Msg reaches the reducer. The UI already hides controls for locked sections.
|
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
|
||||||
case 'PassagesInserted':
|
case 'BesluitSelected':
|
||||||
return withEdit(s, (b) =>
|
return withEdit(s, (b) =>
|
||||||
isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b,
|
s.tag === 'loaded' && isSectionEditable(b, 'kern')
|
||||||
|
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
||||||
|
: b,
|
||||||
);
|
);
|
||||||
case 'FreeTextBlockAdded':
|
case 'FreeTextBlockAdded':
|
||||||
return withEdit(s, (b) =>
|
return withEdit(s, (b) =>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { Diagnostic, lintPlaceholders, PlaceholderDef } from './placeholders';
|
|||||||
|
|
||||||
export type PassageScope = 'global' | 'beroep';
|
export type PassageScope = 'global' | 'beroep';
|
||||||
|
|
||||||
|
/** The decision the behandelaar is communicating. Drives which passages are offered. */
|
||||||
|
export type Besluit = 'positief' | 'negatief';
|
||||||
|
|
||||||
// Re-export placeholderKeysIn for one-import convenience at call sites.
|
// Re-export placeholderKeysIn for one-import convenience at call sites.
|
||||||
export { placeholderKeysIn };
|
export { placeholderKeysIn };
|
||||||
|
|
||||||
@@ -25,7 +28,11 @@ export interface LibraryPassage {
|
|||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly content: RichTextBlock;
|
readonly content: RichTextBlock;
|
||||||
readonly version: number; // library version, for provenance only
|
readonly version: number; // library version, for provenance only
|
||||||
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set
|
// Guided-drafting tags: the behandelaar picks a besluit + reden, and `passagesForBesluit`
|
||||||
|
// (besluit.ts) filters to the matching passages. undefined besluit = shown for any
|
||||||
|
// besluit; undefined reason = not reason-specific. See @brief/domain/besluit.
|
||||||
|
readonly besluit?: Besluit;
|
||||||
|
readonly reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A block inside a letter section: a frozen passage snapshot, or free text. */
|
/** A block inside a letter section: a frozen passage snapshot, or free text. */
|
||||||
@@ -50,6 +57,8 @@ export interface LetterSection {
|
|||||||
readonly required: boolean;
|
readonly required: boolean;
|
||||||
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
|
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
|
||||||
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
|
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
|
||||||
|
// These come from the case-type template (`Brief.templateId`), so e.g. the slot's closing
|
||||||
|
// can differ per case type; the drafter never edits it, and it renders only in the preview.
|
||||||
readonly locked: boolean;
|
readonly locked: boolean;
|
||||||
readonly blocks: readonly LetterBlock[];
|
readonly blocks: readonly LetterBlock[];
|
||||||
}
|
}
|
||||||
@@ -109,6 +118,16 @@ export function canSubmit(brief: Brief): boolean {
|
|||||||
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The case this letter concerns — the zorgverlener + aanvraag the behandelaar is
|
||||||
|
handling. Server-joined onto the brief view (brief/ stays a shared-only leaf, so it
|
||||||
|
can't read the registratie context directly). Header context only. */
|
||||||
|
export interface CaseContext {
|
||||||
|
readonly zorgverlenerNaam: string;
|
||||||
|
readonly bigNummer: string;
|
||||||
|
readonly beroep: string;
|
||||||
|
readonly aanvraagReferentie: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Server-computed decision flags for the acting principal + this brief's live
|
/** Server-computed decision flags for the acting principal + this brief's live
|
||||||
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
|
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
|
||||||
export interface BriefDecisions {
|
export interface BriefDecisions {
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ const view: BriefViewDto = {
|
|||||||
content: { paragraphs: [{ nodes: [] }] },
|
content: { paragraphs: [{ nodes: [] }] },
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
passageId: 'p-neg-scholing',
|
||||||
|
scope: 'global',
|
||||||
|
sectionKey: 'kern',
|
||||||
|
label: 'Onvoldoende scholing',
|
||||||
|
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'x' }] }] },
|
||||||
|
version: 1,
|
||||||
|
besluit: 'negatief',
|
||||||
|
reason: 'onvoldoende_scholing',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
|
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false },
|
||||||
orgTemplate: {
|
orgTemplate: {
|
||||||
@@ -65,6 +75,12 @@ const view: BriefViewDto = {
|
|||||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
|
caseContext: {
|
||||||
|
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||||
|
bigNummer: '19012345601',
|
||||||
|
beroep: 'arts',
|
||||||
|
aanvraagReferentie: 'HER-2026-000842',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('brief.adapter parse boundary', () => {
|
describe('brief.adapter parse boundary', () => {
|
||||||
@@ -92,6 +108,26 @@ describe('brief.adapter parse boundary', () => {
|
|||||||
canReject: true,
|
canReject: true,
|
||||||
canSend: false,
|
canSend: false,
|
||||||
});
|
});
|
||||||
|
// Guided-drafting tags survive the boundary; the untagged passage has neither.
|
||||||
|
expect(r.value.availablePassages[0].besluit).toBeUndefined();
|
||||||
|
expect(r.value.availablePassages[1]).toMatchObject({
|
||||||
|
besluit: 'negatief',
|
||||||
|
reason: 'onvoldoende_scholing',
|
||||||
|
});
|
||||||
|
expect(r.value.caseContext).toEqual({
|
||||||
|
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||||
|
bigNummer: '19012345601',
|
||||||
|
beroep: 'arts',
|
||||||
|
aanvraagReferentie: 'HER-2026-000842',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a view whose case context is missing or malformed', () => {
|
||||||
|
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseBriefView({ ...view, caseContext: { ...view.caseContext!, bigNummer: undefined as never } })
|
||||||
|
.ok,
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses the org template and drops a null logoDocumentId', () => {
|
it('parses the org template and drops a null logoDocumentId', () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
BriefDto,
|
BriefDto,
|
||||||
BriefStatusDto,
|
BriefStatusDto,
|
||||||
BriefViewDto,
|
BriefViewDto,
|
||||||
|
CaseContextDto,
|
||||||
LetterBlockDto,
|
LetterBlockDto,
|
||||||
LetterSectionDto,
|
LetterSectionDto,
|
||||||
LibraryPassageDto,
|
LibraryPassageDto,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
Brief,
|
Brief,
|
||||||
BriefDecisions,
|
BriefDecisions,
|
||||||
BriefStatus,
|
BriefStatus,
|
||||||
|
CaseContext,
|
||||||
LetterBlock,
|
LetterBlock,
|
||||||
LetterSection,
|
LetterSection,
|
||||||
LibraryPassage,
|
LibraryPassage,
|
||||||
@@ -40,6 +42,7 @@ export interface BriefView {
|
|||||||
readonly availablePassages: LibraryPassage[];
|
readonly availablePassages: LibraryPassage[];
|
||||||
readonly decisions: BriefDecisions;
|
readonly decisions: BriefDecisions;
|
||||||
readonly orgTemplate: OrgTemplate;
|
readonly orgTemplate: OrgTemplate;
|
||||||
|
readonly caseContext: CaseContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||||
@@ -249,7 +252,25 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
|||||||
content: content.value,
|
content: content.value,
|
||||||
version: dto.version,
|
version: dto.version,
|
||||||
...(dto.beroep != null ? { beroep: dto.beroep } : {}),
|
...(dto.beroep != null ? { beroep: dto.beroep } : {}),
|
||||||
...(dto.isDefault ? { isDefault: true } : {}),
|
...(dto.besluit === 'positief' || dto.besluit === 'negatief' ? { besluit: dto.besluit } : {}),
|
||||||
|
...(dto.reason != null ? { reason: dto.reason } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCaseContext(dto: CaseContextDto | undefined): Result<string, CaseContext> {
|
||||||
|
if (
|
||||||
|
typeof dto?.zorgverlenerNaam !== 'string' ||
|
||||||
|
typeof dto.bigNummer !== 'string' ||
|
||||||
|
typeof dto.beroep !== 'string' ||
|
||||||
|
typeof dto.aanvraagReferentie !== 'string'
|
||||||
|
) {
|
||||||
|
return err('brief-view: missing/invalid case context');
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
zorgverlenerNaam: dto.zorgverlenerNaam,
|
||||||
|
bigNummer: dto.bigNummer,
|
||||||
|
beroep: dto.beroep,
|
||||||
|
aanvraagReferentie: dto.aanvraagReferentie,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,6 +372,8 @@ export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
|||||||
if (!decisions.ok) return decisions;
|
if (!decisions.ok) return decisions;
|
||||||
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
|
const orgTemplate = parseOrgTemplate(dto.orgTemplate);
|
||||||
if (!orgTemplate.ok) return orgTemplate;
|
if (!orgTemplate.ok) return orgTemplate;
|
||||||
|
const caseContext = parseCaseContext(dto.caseContext);
|
||||||
|
if (!caseContext.ok) return caseContext;
|
||||||
const availablePassages: LibraryPassage[] = [];
|
const availablePassages: LibraryPassage[] = [];
|
||||||
for (const p of dto.availablePassages ?? []) {
|
for (const p of dto.availablePassages ?? []) {
|
||||||
const parsed = parsePassage(p);
|
const parsed = parsePassage(p);
|
||||||
@@ -362,6 +385,7 @@ export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
|||||||
availablePassages,
|
availablePassages,
|
||||||
decisions: decisions.value,
|
decisions: decisions.value,
|
||||||
orgTemplate: orgTemplate.value,
|
orgTemplate: orgTemplate.value,
|
||||||
|
caseContext: caseContext.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
|
||||||
|
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||||
|
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
||||||
|
import { inferSelection } from '@brief/domain/besluit';
|
||||||
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
|
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||||
|
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||||
|
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
||||||
|
import { LetterEditorComponent } from '@brief/ui/letter-editor/letter-editor.component';
|
||||||
|
import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.component';
|
||||||
|
|
||||||
|
/** Organism: the behandelaar's drafting step. Frames "Brief opstellen" as one step in
|
||||||
|
the case workflow — a case-context header + stepper (Beoordelen → Brief opstellen →
|
||||||
|
Indienen, neighbours stubbed) — with the besluit-driven guidance, the lean letter
|
||||||
|
editor, and an on-demand full-letter preview in a modal. Only ever renders for an
|
||||||
|
editable brief (draft/rejected); the approver's read-only flow stays in
|
||||||
|
letter-composer. Presentational: emits edit/submit/preview intents. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-behandel-scherm',
|
||||||
|
imports: [
|
||||||
|
ButtonComponent,
|
||||||
|
HeadingComponent,
|
||||||
|
StepperComponent,
|
||||||
|
LetterCanvasComponent,
|
||||||
|
DiagnosticsPanelComponent,
|
||||||
|
RejectionCommentsComponent,
|
||||||
|
LetterEditorComponent,
|
||||||
|
BesluitPanelComponent,
|
||||||
|
],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.case-head {
|
||||||
|
margin-block-end: var(--rhc-space-max-lg);
|
||||||
|
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
|
||||||
|
border-inline-start: 4px solid var(--rhc-color-primary, var(--rhc-color-border-strong));
|
||||||
|
background: var(--rhc-color-background-subtle, transparent);
|
||||||
|
}
|
||||||
|
.case-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
color: var(--rhc-color-foreground-subtle);
|
||||||
|
}
|
||||||
|
.step-body {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--rhc-space-max-xl);
|
||||||
|
margin-block-start: var(--rhc-space-max-lg);
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
align-items: center;
|
||||||
|
margin-block-start: var(--rhc-space-max-xl);
|
||||||
|
}
|
||||||
|
dialog {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--rhc-radius-md, 4px);
|
||||||
|
padding: 0;
|
||||||
|
max-width: min(900px, 95vw);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
dialog::backdrop {
|
||||||
|
background: rgb(0 0 0 / 45%); /* token-ok: modal scrim, not a palette colour */
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
padding: var(--rhc-space-max-lg);
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.modal-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--rhc-space-max-md);
|
||||||
|
padding: var(--rhc-space-max-md) var(--rhc-space-max-lg);
|
||||||
|
border-block-start: 1px solid var(--rhc-color-border);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="case-head">
|
||||||
|
<app-heading [level]="2">{{ caseHeading() }}</app-heading>
|
||||||
|
<div class="case-meta">
|
||||||
|
<span>{{ caseContext().aanvraagReferentie }}</span>
|
||||||
|
<span>{{ caseContext().zorgverlenerNaam }}</span>
|
||||||
|
<span>{{ bigLabel() }} {{ caseContext().bigNummer }}</span>
|
||||||
|
<span>{{ caseContext().beroep }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-stepper
|
||||||
|
[steps]="steps()"
|
||||||
|
[current]="1"
|
||||||
|
[processName]="processName()"
|
||||||
|
[stepTitle]="stepTitle()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="step-body">
|
||||||
|
@if (status() === 'rejected') {
|
||||||
|
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-besluit-panel
|
||||||
|
[passages]="availablePassages()"
|
||||||
|
[besluit]="selection().besluit"
|
||||||
|
[initialRedenen]="selection().reasons"
|
||||||
|
(selectionChange)="onSelection($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<app-letter-editor [brief]="brief()" [placeholders]="menu()" (edit)="edit.emit($event)" />
|
||||||
|
|
||||||
|
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
|
||||||
|
<app-button
|
||||||
|
variant="primary"
|
||||||
|
[disabled]="!canSubmit() || busy()"
|
||||||
|
(click)="submit.emit()"
|
||||||
|
>{{ submitLabel() }}</app-button
|
||||||
|
>
|
||||||
|
@if (!canSubmit()) {
|
||||||
|
<span class="app-text-subtle">{{ submitHint() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog #previewDialog>
|
||||||
|
<div class="modal-body">
|
||||||
|
<app-letter-canvas
|
||||||
|
[brief]="brief()"
|
||||||
|
[orgTemplate]="orgTemplate()"
|
||||||
|
[logoUrl]="logoUrl()"
|
||||||
|
[editableRegions]="'none'"
|
||||||
|
[diagnostics]="diagnostics()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-bar">
|
||||||
|
<app-button variant="secondary" (click)="preview.emit()">{{ openDocumentLabel() }}</app-button>
|
||||||
|
<app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BehandelSchermComponent {
|
||||||
|
brief = input.required<Brief>();
|
||||||
|
orgTemplate = input.required<OrgTemplate>();
|
||||||
|
logoUrl = input<string | null>(null);
|
||||||
|
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||||
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
|
caseContext = input.required<CaseContext>();
|
||||||
|
canSubmit = input(false);
|
||||||
|
busy = input(false);
|
||||||
|
|
||||||
|
edit = output<BriefMsg>();
|
||||||
|
submit = output<void>();
|
||||||
|
preview = output<void>();
|
||||||
|
locate = output<Diagnostic>();
|
||||||
|
|
||||||
|
private previewDialog = viewChild<ElementRef<HTMLDialogElement>>('previewDialog');
|
||||||
|
|
||||||
|
protected status = computed(() => this.brief().status.tag);
|
||||||
|
protected rejectComments = computed(() => {
|
||||||
|
const s = this.brief().status;
|
||||||
|
return s.tag === 'rejected' ? s.comments : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The besluit + redenen the letter currently reflects, read back off the kern's
|
||||||
|
passages — this seeds the panel so it survives reload/undo (no separate storage). */
|
||||||
|
protected selection = computed(() => {
|
||||||
|
const kern = this.brief().sections.find((s) => s.sectionKey === 'kern');
|
||||||
|
return inferSelection(kern?.blocks ?? [], this.availablePassages());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Same insert menu as the composer: only valid, fillable, non-deprecated fields.
|
||||||
|
protected menu = computed<PlaceholderOption[]>(() =>
|
||||||
|
this.brief()
|
||||||
|
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
|
||||||
|
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Besluit/redenen changed → recompose the kern as one edit (= one undo step). */
|
||||||
|
protected onSelection(sel: { besluit: Besluit | null; reasons: string[] }) {
|
||||||
|
this.edit.emit({ tag: 'BesluitSelected', besluit: sel.besluit, reasons: sel.reasons });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected openPreview() {
|
||||||
|
this.previewDialog()?.nativeElement.showModal();
|
||||||
|
}
|
||||||
|
protected closePreview() {
|
||||||
|
this.previewDialog()?.nativeElement.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected submitLabel = computed(() =>
|
||||||
|
this.status() === 'rejected'
|
||||||
|
? $localize`:@@brief.resubmit:Opnieuw indienen`
|
||||||
|
: $localize`:@@brief.submit:Indienen ter beoordeling`,
|
||||||
|
);
|
||||||
|
|
||||||
|
protected steps = input<string[]>([
|
||||||
|
$localize`:@@brief.step.beoordelen:Beoordelen`,
|
||||||
|
$localize`:@@brief.step.opstellen:Brief opstellen`,
|
||||||
|
$localize`:@@brief.step.indienen:Indienen`,
|
||||||
|
]);
|
||||||
|
protected processName = input($localize`:@@brief.process:Herregistratie behandelen`);
|
||||||
|
protected stepTitle = input($localize`:@@brief.step.opstellen:Brief opstellen`);
|
||||||
|
protected caseHeading = input($localize`:@@brief.case.heading:Aanvraag herregistratie`);
|
||||||
|
protected bigLabel = input($localize`:@@brief.case.big:BIG-nummer`);
|
||||||
|
protected previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||||
|
protected openDocumentLabel = input(
|
||||||
|
$localize`:@@brief.preview.openDocument:Openen als document (PDF)`,
|
||||||
|
);
|
||||||
|
protected closeLabel = input($localize`:@@common.close:Sluiten`);
|
||||||
|
protected submitHint = input(
|
||||||
|
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { Brief, BriefStatus, CaseContext, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
|
||||||
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
|
import { BehandelSchermComponent } from './behandel-scherm.component';
|
||||||
|
|
||||||
|
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||||
|
|
||||||
|
const orgTemplate: OrgTemplate = {
|
||||||
|
subOrgId: 'cibg-registers',
|
||||||
|
orgName: 'CIBG — Registers',
|
||||||
|
returnAddress: 'BIG-register\nPostbus 00000\n2500 AA Den Haag',
|
||||||
|
footerContact: 'www.bigregister.nl',
|
||||||
|
footerLegal: 'Ons kenmerk vermelden bij correspondentie.',
|
||||||
|
signatureName: 'A. de Vries',
|
||||||
|
signatureRole: 'Hoofd Registratie',
|
||||||
|
signatureClosing: 'Met vriendelijke groet,',
|
||||||
|
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const caseContext: CaseContext = {
|
||||||
|
zorgverlenerNaam: 'Dr. A. (Anna) de Vries',
|
||||||
|
bigNummer: '19012345601',
|
||||||
|
beroep: 'arts',
|
||||||
|
aanvraagReferentie: 'HER-2026-000842',
|
||||||
|
};
|
||||||
|
|
||||||
|
const passages: LibraryPassage[] = [
|
||||||
|
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Uw aanvraag is toegewezen.') },
|
||||||
|
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Uw aanvraag is afgewezen.') },
|
||||||
|
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
|
||||||
|
];
|
||||||
|
|
||||||
|
function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief {
|
||||||
|
return {
|
||||||
|
briefId: 'b1',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 't1',
|
||||||
|
drafterId: 'demo-drafter',
|
||||||
|
status,
|
||||||
|
placeholders: [
|
||||||
|
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||||
|
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }] },
|
||||||
|
{ sectionKey: 'kern', title: 'Kern van het besluit', required: true, locked: false, blocks: kernBlocks },
|
||||||
|
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<BehandelSchermComponent> = {
|
||||||
|
title: 'Domein/Brief/Behandel Scherm',
|
||||||
|
component: BehandelSchermComponent,
|
||||||
|
args: { orgTemplate, caseContext, availablePassages: passages, busy: false },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<BehandelSchermComponent>;
|
||||||
|
|
||||||
|
/** Fresh case: no besluit chosen yet, so the kern is empty and only the editable
|
||||||
|
body shows (aanhef/slot appear in the preview). */
|
||||||
|
export const EmptyKern: Story = {
|
||||||
|
args: { brief: brief({ tag: 'draft' }), diagnostics: [], canSubmit: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
// A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it.
|
||||||
|
const negatiefScholingKern: Brief['sections'][number]['blocks'] = [
|
||||||
|
{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p-kern-negatief', sourceVersion: 1, edited: false, content: text('Uw aanvraag is afgewezen.') },
|
||||||
|
{ type: 'passage', blockId: 'local-2', sourcePassageId: 'p-kern-scholing', sourceVersion: 1, edited: false, content: text('Onvoldoende scholing.') },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Draft with a negatief besluit: the kern is filled from the selection and the besluit
|
||||||
|
panel reflects it (negatief + "onvoldoende scholing" ticked), read back off the kern. */
|
||||||
|
export const WithContent: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const b = brief({ tag: 'draft' }, negatiefScholingKern);
|
||||||
|
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rejected: the drafter reopens; the rejection comments show above the editor. */
|
||||||
|
export const Rejected: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const b = brief(
|
||||||
|
{ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de reden concreter.' },
|
||||||
|
negatiefScholingKern,
|
||||||
|
);
|
||||||
|
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { Component, computed, input, linkedSignal, output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
||||||
|
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { Besluit, LibraryPassage } from '@brief/domain/brief';
|
||||||
|
import { redenenFor } from '@brief/domain/besluit';
|
||||||
|
|
||||||
|
/** Organism: the guided-drafting selector. The behandelaar picks the besluit
|
||||||
|
(positief/negatief) and — for a negatief besluit — the reden(en); the kern's
|
||||||
|
standaardteksten follow the selection LIVE (`selectionChange` → the store recomposes
|
||||||
|
the kern). This is the "no detective work" step: which passages belong is decided by
|
||||||
|
the besluit, not by the drafter hunting the library.
|
||||||
|
|
||||||
|
ponytail: view-state signals, not a form-machine — no validation/submission of its own;
|
||||||
|
it just reports the selection. `besluit`/`redenen` inputs re-seed it (via linkedSignal)
|
||||||
|
from the persisted letter on reload/undo, so it always reflects the letter's real state. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-besluit-panel',
|
||||||
|
imports: [FormsModule, CheckboxComponent, RadioGroupComponent, HeadingComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: var(--rhc-space-max-lg);
|
||||||
|
border: 1px solid var(--rhc-color-border);
|
||||||
|
border-radius: var(--rhc-radius-md, 4px);
|
||||||
|
background: var(--rhc-color-background-subtle, transparent);
|
||||||
|
}
|
||||||
|
.redenen {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--rhc-space-max-sm);
|
||||||
|
margin-block-start: var(--rhc-space-max-md);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<app-heading [level]="3">{{ heading() }}</app-heading>
|
||||||
|
<p class="app-text-subtle">{{ intro() }}</p>
|
||||||
|
|
||||||
|
<app-radio-group
|
||||||
|
name="besluit"
|
||||||
|
[options]="besluitOptions()"
|
||||||
|
[ngModel]="selected()"
|
||||||
|
(ngModelChange)="onBesluit($event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (redenen().length > 0) {
|
||||||
|
<div class="redenen" role="group" [attr.aria-label]="redenenLabel()">
|
||||||
|
@for (r of redenen(); track r.code) {
|
||||||
|
<app-checkbox
|
||||||
|
[checkboxId]="'reden-' + r.code"
|
||||||
|
[label]="r.label"
|
||||||
|
[ngModel]="checked().has(r.code)"
|
||||||
|
(ngModelChange)="toggle(r.code, $event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BesluitPanelComponent {
|
||||||
|
/** The passage library — the redenen checkboxes are derived from its negatief tags. */
|
||||||
|
passages = input<readonly LibraryPassage[]>([]);
|
||||||
|
/** The letter's current selection, inferred from its kern passages — re-seeds the panel. */
|
||||||
|
besluit = input<Besluit | null>(null);
|
||||||
|
initialRedenen = input<readonly string[]>([]);
|
||||||
|
|
||||||
|
selectionChange = output<{ besluit: Besluit | null; reasons: string[] }>();
|
||||||
|
|
||||||
|
protected selected = linkedSignal<Besluit | ''>(() => this.besluit() ?? '');
|
||||||
|
protected checked = linkedSignal<ReadonlySet<string>>(() => new Set(this.initialRedenen()));
|
||||||
|
|
||||||
|
/** The reden checkboxes for the chosen besluit — derived from the reason-tagged passages. */
|
||||||
|
protected redenen = computed(() =>
|
||||||
|
this.selected() === '' ? [] : redenenFor(this.passages(), this.selected() as Besluit),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected onBesluit(value: string) {
|
||||||
|
this.selected.set(value === 'positief' || value === 'negatief' ? value : '');
|
||||||
|
this.checked.set(new Set()); // redenen only apply to the chosen besluit
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggle(code: string, on: boolean) {
|
||||||
|
const next = new Set(this.checked());
|
||||||
|
if (on) next.add(code);
|
||||||
|
else next.delete(code);
|
||||||
|
this.checked.set(next);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit() {
|
||||||
|
this.selectionChange.emit({
|
||||||
|
besluit: this.selected() === '' ? null : (this.selected() as Besluit),
|
||||||
|
reasons: [...this.checked()],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected besluitOptions = input<RadioOption[]>([
|
||||||
|
{ value: 'positief', label: $localize`:@@brief.besluit.positief:Positief besluit (toewijzen)` },
|
||||||
|
{ value: 'negatief', label: $localize`:@@brief.besluit.negatief:Negatief besluit (afwijzen)` },
|
||||||
|
]);
|
||||||
|
protected heading = input($localize`:@@brief.besluit.heading:Besluit`);
|
||||||
|
protected intro = input(
|
||||||
|
$localize`:@@brief.besluit.intro:Kies het besluit; de juiste standaardteksten verschijnen meteen in de brief.`,
|
||||||
|
);
|
||||||
|
protected redenenLabel = input($localize`:@@brief.besluit.redenen:Reden(en) voor afwijzing`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { LibraryPassage } from '@brief/domain/brief';
|
||||||
|
import { BesluitPanelComponent } from './besluit-panel.component';
|
||||||
|
|
||||||
|
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||||
|
|
||||||
|
const passages: LibraryPassage[] = [
|
||||||
|
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Toegewezen.') },
|
||||||
|
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Afgewezen.') },
|
||||||
|
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
|
||||||
|
{ passageId: 'p-kern-gegevens', scope: 'global', sectionKey: 'kern', label: 'Onjuiste gegevens', version: 1, besluit: 'negatief', reason: 'onjuiste_gegevens', content: text('Onjuiste gegevens.') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta: Meta<BesluitPanelComponent> = {
|
||||||
|
title: 'Domein/Brief/Besluit Panel',
|
||||||
|
component: BesluitPanelComponent,
|
||||||
|
args: { passages },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<BesluitPanelComponent>;
|
||||||
|
|
||||||
|
/** Pick a besluit; a negatief besluit reveals the reason checkboxes. Each change emits
|
||||||
|
`selectionChange` and the kern's standaardteksten follow live — no generate button. */
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
/** Re-seeded from a persisted letter: a negatief besluit with a reden already ticked. */
|
||||||
|
export const NegatiefMetReden: Story = {
|
||||||
|
args: { besluit: 'negatief', initialRedenen: ['onvoldoende_scholing'] },
|
||||||
|
};
|
||||||
@@ -5,13 +5,21 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
|||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
import { BriefStore } from '@brief/application/brief.store';
|
import { BriefStore } from '@brief/application/brief.store';
|
||||||
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
||||||
|
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
|
||||||
|
|
||||||
/** Page: thin container. Injects the root store, kicks off the load, and passes its
|
/** Page: thin container. Injects the root store, kicks off the load, and passes its
|
||||||
derived read-model to the composer. Business/UI logic lives below in pure pieces;
|
derived read-model to the composer. Business/UI logic lives below in pure pieces;
|
||||||
this just wires signals to the organism and events back to store commands. */
|
this just wires signals to the organism and events back to store commands. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-brief-page',
|
selector: 'app-brief-page',
|
||||||
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
|
imports: [
|
||||||
|
PageShellComponent,
|
||||||
|
AlertComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
...ASYNC,
|
||||||
|
LetterComposerComponent,
|
||||||
|
BehandelSchermComponent,
|
||||||
|
],
|
||||||
host: { '(document:keydown)': 'onKey($event)' },
|
host: { '(document:keydown)': 'onKey($event)' },
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
@@ -76,22 +84,34 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
resetLabel
|
resetLabel
|
||||||
}}</app-button>
|
}}</app-button>
|
||||||
</div>
|
</div>
|
||||||
<app-letter-composer
|
@if (store.canEdit() && store.caseContext(); as caseContext) {
|
||||||
|
<!-- Drafter (behandelaar): guided drafting step in the case workflow. -->
|
||||||
|
<app-behandel-scherm
|
||||||
[brief]="s.brief"
|
[brief]="s.brief"
|
||||||
[orgTemplate]="orgTemplate"
|
[orgTemplate]="orgTemplate"
|
||||||
[logoUrl]="store.logoUrl()"
|
[logoUrl]="store.logoUrl()"
|
||||||
[availablePassages]="s.availablePassages"
|
[availablePassages]="s.availablePassages"
|
||||||
[diagnostics]="store.diagnostics()"
|
[diagnostics]="store.diagnostics()"
|
||||||
[blockDiffs]="store.blockDiffs()"
|
[caseContext]="caseContext"
|
||||||
[removedCount]="store.removedSinceReject()"
|
|
||||||
[canEdit]="store.canEdit()"
|
|
||||||
[canApprove]="store.canApprove()"
|
|
||||||
[canReject]="store.canReject()"
|
|
||||||
[canSend]="store.canSend()"
|
|
||||||
[canSubmit]="store.canSubmit()"
|
[canSubmit]="store.canSubmit()"
|
||||||
[busy]="store.busy()"
|
[busy]="store.busy()"
|
||||||
(edit)="store.edit($event)"
|
(edit)="store.edit($event)"
|
||||||
(submit)="store.submit()"
|
(submit)="store.submit()"
|
||||||
|
(preview)="store.previewLetter()"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<!-- Approver / read-only: review + approve/reject/send. -->
|
||||||
|
<app-letter-composer
|
||||||
|
[brief]="s.brief"
|
||||||
|
[orgTemplate]="orgTemplate"
|
||||||
|
[logoUrl]="store.logoUrl()"
|
||||||
|
[diagnostics]="store.diagnostics()"
|
||||||
|
[blockDiffs]="store.blockDiffs()"
|
||||||
|
[removedCount]="store.removedSinceReject()"
|
||||||
|
[canApprove]="store.canApprove()"
|
||||||
|
[canReject]="store.canReject()"
|
||||||
|
[canSend]="store.canSend()"
|
||||||
|
[busy]="store.busy()"
|
||||||
(approve)="store.approve()"
|
(approve)="store.approve()"
|
||||||
(reject)="store.reject($event)"
|
(reject)="store.reject($event)"
|
||||||
(send)="store.send()"
|
(send)="store.send()"
|
||||||
@@ -99,6 +119,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-async>
|
</app-async>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
|
|||||||
@@ -14,16 +14,13 @@ import {
|
|||||||
import { NgTemplateOutlet } from '@angular/common';
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
|
||||||
import { formatDatumNl } from '@shared/kernel/datum';
|
import { formatDatumNl } from '@shared/kernel/datum';
|
||||||
import { Paragraph } from '@shared/kernel/rich-text';
|
import { Paragraph } from '@shared/kernel/rich-text';
|
||||||
import { Brief, LetterBlock, LibraryPassage } from '@brief/domain/brief';
|
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
|
||||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
|
||||||
|
|
||||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||||
type PreviewSegment = {
|
type PreviewSegment = {
|
||||||
@@ -60,7 +57,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
the same file the backend preview renderer inlines (WP-25). */
|
the same file the backend preview renderer inlines (WP-25). */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-letter-canvas',
|
selector: 'app-letter-canvas',
|
||||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent, LetterSectionComponent],
|
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
:host {
|
:host {
|
||||||
@@ -113,17 +110,6 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
.surface .letter {
|
.surface .letter {
|
||||||
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
|
box-shadow: 0 1px 4px rgb(0 0 0 / 0.15); /* token-ok: paper drop-shadow, not a palette colour */
|
||||||
}
|
}
|
||||||
/* Org-identity regions: visibly not the drafter's to edit. */
|
|
||||||
.from-template {
|
|
||||||
background: var(--rhc-color-grijs-100);
|
|
||||||
outline: 2mm solid var(--rhc-color-grijs-100);
|
|
||||||
}
|
|
||||||
.from-template-caption {
|
|
||||||
font-size: 7.5pt;
|
|
||||||
/* grijs-700, not foreground-subtle: subtle misses AA contrast on the tint. */
|
|
||||||
color: var(--rhc-color-grijs-700);
|
|
||||||
margin: 0 0 2mm;
|
|
||||||
}
|
|
||||||
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
|
/* Admin edit-in-place (editableRegions='template'): the org-identity fields
|
||||||
become controls styled to sit in the letter, with a visible editable affordance. */
|
become controls styled to sit in the letter, with a visible editable affordance. */
|
||||||
.tmpl-input,
|
.tmpl-input,
|
||||||
@@ -208,10 +194,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
|
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
|
||||||
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
|
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
|
||||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||||
<div class="letter__letterhead" [class.from-template]="tintTemplate()">
|
<div class="letter__letterhead">
|
||||||
@if (tintTemplate()) {
|
|
||||||
<p class="from-template-caption">{{ fromTemplateCaption() }}</p>
|
|
||||||
}
|
|
||||||
@if (logoUrl()) {
|
@if (logoUrl()) {
|
||||||
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
||||||
}
|
}
|
||||||
@@ -247,19 +230,6 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="letter__body">
|
<div class="letter__body">
|
||||||
@if (editableRegions() === 'content') {
|
|
||||||
@for (section of brief().sections; track section.sectionKey) {
|
|
||||||
<section>
|
|
||||||
<app-letter-section
|
|
||||||
[section]="section"
|
|
||||||
[availablePassages]="availablePassages()"
|
|
||||||
[placeholders]="placeholders()"
|
|
||||||
[editable]="!section.locked"
|
|
||||||
(edit)="edit.emit($event)"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
@for (section of brief().sections; track section.sectionKey) {
|
@for (section of brief().sections; track section.sectionKey) {
|
||||||
<section>
|
<section>
|
||||||
<h3>{{ section.title }}</h3>
|
<h3>{{ section.title }}</h3>
|
||||||
@@ -307,10 +277,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="letter__signature" [class.from-template]="tintTemplate()">
|
<div class="letter__signature">
|
||||||
@if (editing()) {
|
@if (editing()) {
|
||||||
<input
|
<input
|
||||||
class="tmpl-input"
|
class="tmpl-input"
|
||||||
@@ -337,7 +306,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="letter__footer" [class.from-template]="tintTemplate()">
|
<div class="letter__footer">
|
||||||
@if (editing()) {
|
@if (editing()) {
|
||||||
<textarea
|
<textarea
|
||||||
class="tmpl-textarea footer-contact"
|
class="tmpl-textarea footer-contact"
|
||||||
@@ -370,11 +339,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
|||||||
export class LetterCanvasComponent {
|
export class LetterCanvasComponent {
|
||||||
brief = input.required<Brief>();
|
brief = input.required<Brief>();
|
||||||
orgTemplate = input.required<OrgTemplate>();
|
orgTemplate = input.required<OrgTemplate>();
|
||||||
/** Who edits what on the surface: drafter ('content'), read-only ('none'),
|
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
|
||||||
admin editor ('template', consumer arrives in WP-26). */
|
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
|
||||||
editableRegions = input<'content' | 'template' | 'none'>('none');
|
editableRegions = input<'template' | 'none'>('none');
|
||||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
|
||||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
|
||||||
diagnostics = input<readonly Diagnostic[]>([]);
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
|
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
|
||||||
zoom = input(1);
|
zoom = input(1);
|
||||||
@@ -385,15 +352,11 @@ export class LetterCanvasComponent {
|
|||||||
showDiff = input(false);
|
showDiff = input(false);
|
||||||
/** The org logo's content URL (letterhead), or null when none is set. */
|
/** The org logo's content URL (letterhead), or null when none is set. */
|
||||||
logoUrl = input<string | null>(null);
|
logoUrl = input<string | null>(null);
|
||||||
edit = output<BriefMsg>();
|
|
||||||
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
||||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||||
|
|
||||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||||
fromTemplateCaption = input(
|
|
||||||
$localize`:@@brief.canvas.fromTemplate:Komt uit de huisstijl van de organisatie — niet bewerkbaar in de brief.`,
|
|
||||||
);
|
|
||||||
pageBreakCaption = input(
|
pageBreakCaption = input(
|
||||||
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
||||||
);
|
);
|
||||||
@@ -430,9 +393,6 @@ export class LetterCanvasComponent {
|
|||||||
protected diffLabel = (kind: BlockDiffKind) =>
|
protected diffLabel = (kind: BlockDiffKind) =>
|
||||||
kind === 'added' ? this.addedLabel() : this.changedLabel();
|
kind === 'added' ? this.addedLabel() : this.changedLabel();
|
||||||
|
|
||||||
/** The letterhead/signature/footer are tinted "not yours" only while composing —
|
|
||||||
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
|
|
||||||
protected tintTemplate = computed(() => this.editableRegions() === 'content');
|
|
||||||
/** Admin edit-in-place: the org-identity regions render as controls. */
|
/** Admin edit-in-place: the org-identity regions render as controls. */
|
||||||
protected editing = computed(() => this.editableRegions() === 'template');
|
protected editing = computed(() => this.editableRegions() === 'template');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/angular';
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
import { Brief, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
|
import { Brief, allDiagnostics } from '@brief/domain/brief';
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { LetterCanvasComponent } from './letter-canvas.component';
|
import { LetterCanvasComponent } from './letter-canvas.component';
|
||||||
|
|
||||||
@@ -16,17 +16,6 @@ const orgTemplate: OrgTemplate = {
|
|||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const passages: LibraryPassage[] = [
|
|
||||||
{
|
|
||||||
passageId: 'p1',
|
|
||||||
scope: 'global',
|
|
||||||
sectionKey: 'kern',
|
|
||||||
label: 'Standaard toelichting',
|
|
||||||
version: 1,
|
|
||||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Standaardtekst.' }] }] },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const brief: Brief = {
|
const brief: Brief = {
|
||||||
briefId: 'b1',
|
briefId: 'b1',
|
||||||
beroep: 'arts',
|
beroep: 'arts',
|
||||||
@@ -124,19 +113,14 @@ const meta: Meta<LetterCanvasComponent> = {
|
|||||||
args: {
|
args: {
|
||||||
brief,
|
brief,
|
||||||
orgTemplate,
|
orgTemplate,
|
||||||
availablePassages: passages,
|
|
||||||
placeholders: brief.placeholders,
|
|
||||||
diagnostics: allDiagnostics(brief),
|
diagnostics: allDiagnostics(brief),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<LetterCanvasComponent>;
|
type Story = StoryObj<LetterCanvasComponent>;
|
||||||
|
|
||||||
/** Drafter: content blocks editable in place; org-identity regions tinted read-only. */
|
/** Read-only rendered letter: the drafter's preview modal and the approver view, with the
|
||||||
export const ContentMode: Story = { args: { editableRegions: 'content' } };
|
sample-values toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
|
||||||
|
|
||||||
/** Approver/locked: the identical surface fully read-only, with the sample-values
|
|
||||||
toggle and diagnostic placeholder chips (absorbs the old Letter Preview). */
|
|
||||||
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
|
export const ReadOnly: Story = { args: { editableRegions: 'none' } };
|
||||||
|
|
||||||
export const ReadOnlyZonderBevindingen: Story = {
|
export const ReadOnlyZonderBevindingen: Story = {
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
|||||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
import { Brief } from '@brief/domain/brief';
|
||||||
import { Brief, LibraryPassage } from '@brief/domain/brief';
|
|
||||||
import { OrgTemplate } from '@brief/domain/org-template';
|
import { OrgTemplate } from '@brief/domain/org-template';
|
||||||
import { Diagnostic } from '@brief/domain/placeholders';
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
|
||||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||||
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||||
@@ -90,13 +88,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
[brief]="brief()"
|
[brief]="brief()"
|
||||||
[orgTemplate]="orgTemplate()"
|
[orgTemplate]="orgTemplate()"
|
||||||
[logoUrl]="logoUrl()"
|
[logoUrl]="logoUrl()"
|
||||||
[editableRegions]="canEdit() ? 'content' : 'none'"
|
[editableRegions]="'none'"
|
||||||
[availablePassages]="availablePassages()"
|
|
||||||
[placeholders]="menu()"
|
|
||||||
[diagnostics]="diagnostics()"
|
[diagnostics]="diagnostics()"
|
||||||
[blockDiffs]="blockDiffs()"
|
[blockDiffs]="blockDiffs()"
|
||||||
[showDiff]="showDiff()"
|
[showDiff]="showDiff()"
|
||||||
(edit)="edit.emit($event)"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -105,29 +100,6 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
|||||||
|
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
@switch (status()) {
|
@switch (status()) {
|
||||||
@case ('draft') {
|
|
||||||
@if (canEdit()) {
|
|
||||||
<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 (canEdit()) {
|
|
||||||
<app-button
|
|
||||||
variant="primary"
|
|
||||||
[disabled]="!canSubmit() || busy()"
|
|
||||||
(click)="submit.emit()"
|
|
||||||
>{{ resubmitLabel() }}</app-button
|
|
||||||
>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@case ('submitted') {
|
@case ('submitted') {
|
||||||
@if (canApprove() || canReject()) {
|
@if (canApprove() || canReject()) {
|
||||||
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
|
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{
|
||||||
@@ -156,13 +128,10 @@ export class LetterComposerComponent {
|
|||||||
brief = input.required<Brief>();
|
brief = input.required<Brief>();
|
||||||
orgTemplate = input.required<OrgTemplate>();
|
orgTemplate = input.required<OrgTemplate>();
|
||||||
logoUrl = input<string | null>(null);
|
logoUrl = input<string | null>(null);
|
||||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
|
||||||
diagnostics = input<readonly Diagnostic[]>([]);
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
canEdit = input(false);
|
|
||||||
canApprove = input(false);
|
canApprove = input(false);
|
||||||
canReject = input(false);
|
canReject = input(false);
|
||||||
canSend = input(false);
|
canSend = input(false);
|
||||||
canSubmit = input(false);
|
|
||||||
busy = input(false);
|
busy = input(false);
|
||||||
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
|
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
|
||||||
"Toon wijzigingen" toggle only appears when there's something to show. */
|
"Toon wijzigingen" toggle only appears when there's something to show. */
|
||||||
@@ -171,8 +140,6 @@ export class LetterComposerComponent {
|
|||||||
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||||
protected showDiff = signal(false);
|
protected showDiff = signal(false);
|
||||||
|
|
||||||
edit = output<BriefMsg>();
|
|
||||||
submit = output<void>();
|
|
||||||
approve = output<void>();
|
approve = output<void>();
|
||||||
reject = output<string>();
|
reject = output<string>();
|
||||||
send = output<void>();
|
send = output<void>();
|
||||||
@@ -187,11 +154,6 @@ export class LetterComposerComponent {
|
|||||||
() =>
|
() =>
|
||||||
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
|
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
|
||||||
);
|
);
|
||||||
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
|
||||||
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
|
||||||
submitHint = input(
|
|
||||||
$localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`,
|
|
||||||
);
|
|
||||||
approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
|
approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
|
||||||
sendLabel = input($localize`:@@brief.send:Versturen`);
|
sendLabel = input($localize`:@@brief.send:Versturen`);
|
||||||
awaitingText = input(
|
awaitingText = input(
|
||||||
@@ -205,14 +167,6 @@ export class LetterComposerComponent {
|
|||||||
return s.tag === 'rejected' ? s.comments : '';
|
return s.tag === 'rejected' ? s.comments : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// The insert menu offers only valid, fillable, non-deprecated fields — inserting an
|
|
||||||
// unknown/retired key is structurally impossible.
|
|
||||||
protected menu = computed<PlaceholderOption[]>(() =>
|
|
||||||
this.brief()
|
|
||||||
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
|
|
||||||
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
|
|
||||||
);
|
|
||||||
|
|
||||||
protected statusLabel = computed(() => {
|
protected statusLabel = computed(() => {
|
||||||
switch (this.status()) {
|
switch (this.status()) {
|
||||||
case 'draft':
|
case 'draft':
|
||||||
|
|||||||
@@ -121,15 +121,13 @@ const render = (b: Brief, decisions: BriefDecisions) => ({
|
|||||||
props: {
|
props: {
|
||||||
brief: b,
|
brief: b,
|
||||||
orgTemplate,
|
orgTemplate,
|
||||||
availablePassages: passages,
|
|
||||||
diagnostics: allDiagnostics(b),
|
diagnostics: allDiagnostics(b),
|
||||||
...decisions,
|
...decisions,
|
||||||
canSubmit: true,
|
|
||||||
busy: false,
|
busy: false,
|
||||||
},
|
},
|
||||||
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [availablePassages]="availablePassages"
|
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate"
|
||||||
[diagnostics]="diagnostics" [canEdit]="canEdit" [canApprove]="canApprove" [canReject]="canReject"
|
[diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
|
||||||
[canSend]="canSend" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
[canSend]="canSend" [busy]="busy"></app-letter-composer>`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const meta: Meta<LetterComposerComponent> = {
|
const meta: Meta<LetterComposerComponent> = {
|
||||||
@@ -139,15 +137,6 @@ const meta: Meta<LetterComposerComponent> = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<LetterComposerComponent>;
|
type Story = StoryObj<LetterComposerComponent>;
|
||||||
|
|
||||||
export const DraftDrafter: Story = {
|
|
||||||
render: () =>
|
|
||||||
render(brief({ tag: 'draft' }), {
|
|
||||||
canEdit: true,
|
|
||||||
canApprove: false,
|
|
||||||
canReject: false,
|
|
||||||
canSend: false,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
export const SubmittedApprover: Story = {
|
export const SubmittedApprover: Story = {
|
||||||
render: () =>
|
render: () =>
|
||||||
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
|
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
|
||||||
@@ -157,17 +146,14 @@ export const SubmittedApprover: Story = {
|
|||||||
canSend: false,
|
canSend: false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
export const Rejected: Story = {
|
export const ApprovedSender: Story = {
|
||||||
render: () =>
|
render: () =>
|
||||||
render(
|
render(brief({ tag: 'approved', approvedBy: 'demo-approver', approvedAt: '2026-07-01' }), {
|
||||||
brief({
|
canEdit: false,
|
||||||
tag: 'rejected',
|
canApprove: false,
|
||||||
rejectedBy: 'demo-approver',
|
canReject: false,
|
||||||
rejectedAt: '2026-07-01',
|
canSend: true,
|
||||||
comments: 'Graag de aanhef formeler.',
|
|
||||||
}),
|
}),
|
||||||
{ canEdit: true, canApprove: false, canReject: false, canSend: false },
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
export const Sent: Story = {
|
export const Sent: Story = {
|
||||||
render: () =>
|
render: () =>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||||
|
import { Brief } from '@brief/domain/brief';
|
||||||
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
|
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||||
|
|
||||||
|
/** Organism: the lean authoring surface — just the editable letter sections and their
|
||||||
|
add/edit controls, no letterhead/signature/footer/zoom. The full rendered letter
|
||||||
|
(including the locked aanhef/slot) lives in the preview modal (see behandel-scherm),
|
||||||
|
so the drafter stays focused on composing the body they actually own. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-letter-editor',
|
||||||
|
imports: [LetterSectionComponent],
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--rhc-space-max-xl);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
@for (section of editableSections(); track section.sectionKey) {
|
||||||
|
<app-letter-section
|
||||||
|
[section]="section"
|
||||||
|
[placeholders]="placeholders()"
|
||||||
|
[editable]="true"
|
||||||
|
(edit)="edit.emit($event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LetterEditorComponent {
|
||||||
|
brief = input.required<Brief>();
|
||||||
|
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||||
|
edit = output<BriefMsg>();
|
||||||
|
|
||||||
|
/** Only unlocked sections are authored here; locked aanhef/slot appear in the preview. */
|
||||||
|
protected editableSections = computed(() => this.brief().sections.filter((s) => !s.locked));
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { Brief } from '@brief/domain/brief';
|
||||||
|
import { LetterEditorComponent } from './letter-editor.component';
|
||||||
|
|
||||||
|
const brief: Brief = {
|
||||||
|
briefId: 'b1',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 't1',
|
||||||
|
drafterId: 'demo-drafter',
|
||||||
|
status: { tag: 'draft' },
|
||||||
|
placeholders: [{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
sectionKey: 'aanhef',
|
||||||
|
title: 'Aanhef',
|
||||||
|
required: true,
|
||||||
|
locked: true,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'freeText',
|
||||||
|
blockId: 'aanhef-1',
|
||||||
|
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionKey: 'kern',
|
||||||
|
title: 'Kern van het besluit',
|
||||||
|
required: true,
|
||||||
|
locked: false,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'freeText',
|
||||||
|
blockId: 'kern-1',
|
||||||
|
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten...' }] }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta: Meta<LetterEditorComponent> = {
|
||||||
|
title: 'Domein/Brief/Letter Editor',
|
||||||
|
component: LetterEditorComponent,
|
||||||
|
args: { brief, placeholders: [] },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<LetterEditorComponent>;
|
||||||
|
|
||||||
|
/** The lean authoring surface: only the editable sections (the kern); the locked
|
||||||
|
aanhef/slot are hidden here and appear only in the preview. */
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
/** Empty kern: shows the empty-state hint plus the free-text action, no starter content. */
|
||||||
|
export const EmptyKern: Story = {
|
||||||
|
args: {
|
||||||
|
brief: {
|
||||||
|
...brief,
|
||||||
|
sections: brief.sections.map((s) => (s.sectionKey === 'kern' ? { ...s, blocks: [] } : s)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { Component, computed, input, output, signal } from '@angular/core';
|
import { Component, input, output } from '@angular/core';
|
||||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
import { LetterSection, LibraryPassage } from '@brief/domain/brief';
|
import { LetterSection } from '@brief/domain/brief';
|
||||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
|
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
|
||||||
import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.component';
|
|
||||||
|
|
||||||
/** Organism: one template section — its ordered blocks plus (when editable) the
|
/** Organism: one template section — its ordered blocks plus (when editable) the
|
||||||
add-passages and add-free-text actions. Maps child events to `BriefMsg`s; sections
|
add-free-text action. Standaardteksten enter the kern via the besluit panel, not a
|
||||||
themselves can never be added/removed/reordered (no message exists for it). */
|
per-section picker, so this only offers free text. Maps child events to `BriefMsg`s;
|
||||||
|
sections themselves can never be added/removed/reordered (no message exists for it). */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-letter-section',
|
selector: 'app-letter-section',
|
||||||
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent],
|
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent],
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
:host {
|
:host {
|
||||||
@@ -63,58 +63,24 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
|
|||||||
|
|
||||||
@if (editable()) {
|
@if (editable()) {
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@if (showStandardLetter()) {
|
|
||||||
<app-button variant="primary" (click)="insertStandardLetter()">{{
|
|
||||||
standardLetterLabel()
|
|
||||||
}}</app-button>
|
|
||||||
}
|
|
||||||
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
|
|
||||||
addPassageLabel()
|
|
||||||
}}</app-button>
|
|
||||||
<app-button
|
<app-button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
|
(click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })"
|
||||||
>{{ addFreeLabel() }}</app-button
|
>{{ addFreeLabel() }}</app-button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@if (pickerOpen()) {
|
|
||||||
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class LetterSectionComponent {
|
export class LetterSectionComponent {
|
||||||
section = input.required<LetterSection>();
|
section = input.required<LetterSection>();
|
||||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
|
||||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||||
editable = input(false);
|
editable = input(false);
|
||||||
edit = output<BriefMsg>();
|
edit = output<BriefMsg>();
|
||||||
|
|
||||||
requiredLabel = input($localize`:@@brief.section.required:verplicht`);
|
requiredLabel = input($localize`:@@brief.section.required:verplicht`);
|
||||||
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
|
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
|
||||||
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
|
|
||||||
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
|
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
|
||||||
standardLetterLabel = input($localize`:@@brief.section.standardLetter:Standaardbrief invoegen`);
|
|
||||||
|
|
||||||
protected pickerOpen = signal(false);
|
|
||||||
protected sectionPassages = computed(() =>
|
|
||||||
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
|
|
||||||
);
|
|
||||||
/** The "standaardbrief" starter set for this section (server-flagged defaults). */
|
|
||||||
protected defaultPassages = computed(() => this.sectionPassages().filter((p) => p.isDefault));
|
|
||||||
/** One-click starter, offered only while the section is still empty and defaults exist. */
|
|
||||||
protected showStandardLetter = computed(
|
|
||||||
() => this.section().blocks.length === 0 && this.defaultPassages().length > 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
protected insertStandardLetter() {
|
|
||||||
// One Msg → one undo step (see brief.store `edit`).
|
|
||||||
this.edit.emit({
|
|
||||||
tag: 'PassagesInserted',
|
|
||||||
sectionKey: this.section().sectionKey,
|
|
||||||
passages: this.defaultPassages(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected onContent(blockId: string, content: RichTextBlock) {
|
protected onContent(blockId: string, content: RichTextBlock) {
|
||||||
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
|
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
|
||||||
@@ -124,9 +90,4 @@ export class LetterSectionComponent {
|
|||||||
const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
|
const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
|
||||||
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
|
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onInsert(passages: LibraryPassage[]) {
|
|
||||||
this.edit.emit({ tag: 'PassagesInserted', sectionKey: this.section().sectionKey, passages });
|
|
||||||
this.pickerOpen.set(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/angular';
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
import { LetterSection, LibraryPassage } from '@brief/domain/brief';
|
import { LetterSection } from '@brief/domain/brief';
|
||||||
import { LetterSectionComponent } from './letter-section.component';
|
import { LetterSectionComponent } from './letter-section.component';
|
||||||
|
|
||||||
const section: LetterSection = {
|
const section: LetterSection = {
|
||||||
@@ -28,30 +28,17 @@ const section: LetterSection = {
|
|||||||
|
|
||||||
const emptySection: LetterSection = { ...section, blocks: [] };
|
const emptySection: LetterSection = { ...section, blocks: [] };
|
||||||
|
|
||||||
const passages: LibraryPassage[] = [
|
|
||||||
{
|
|
||||||
passageId: 'p2',
|
|
||||||
scope: 'beroep',
|
|
||||||
beroep: 'arts',
|
|
||||||
sectionKey: 'kern',
|
|
||||||
label: 'Toelichting arts',
|
|
||||||
version: 1,
|
|
||||||
isDefault: true, // part of the standaardbrief starter set (WP-27)
|
|
||||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
||||||
|
|
||||||
const meta: Meta<LetterSectionComponent> = {
|
const meta: Meta<LetterSectionComponent> = {
|
||||||
title: 'Domein/Brief/Letter Section',
|
title: 'Domein/Brief/Letter Section',
|
||||||
component: LetterSectionComponent,
|
component: LetterSectionComponent,
|
||||||
args: { section, availablePassages: passages, placeholders, edit: () => {} },
|
args: { section, placeholders, edit: () => {} },
|
||||||
};
|
};
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<LetterSectionComponent>;
|
type Story = StoryObj<LetterSectionComponent>;
|
||||||
|
|
||||||
export const ReadOnly: Story = { args: { editable: false } };
|
export const ReadOnly: Story = { args: { editable: false } };
|
||||||
export const Editable: Story = { args: { editable: true } };
|
export const Editable: Story = { args: { editable: true } };
|
||||||
/** Empty section: the one-click "Standaardbrief invoegen" starter appears (WP-27). */
|
/** Empty section: shows the empty-state hint plus the free-text action. */
|
||||||
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
|
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
|
||||||
|
|||||||
@@ -1608,6 +1608,7 @@ export interface BriefViewDto {
|
|||||||
availablePassages?: LibraryPassageDto[] | undefined;
|
availablePassages?: LibraryPassageDto[] | undefined;
|
||||||
decisions?: BriefDecisionsDto;
|
decisions?: BriefDecisionsDto;
|
||||||
orgTemplate?: OrgTemplateDto;
|
orgTemplate?: OrgTemplateDto;
|
||||||
|
caseContext?: CaseContextDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrpAddressDto {
|
export interface BrpAddressDto {
|
||||||
@@ -1615,6 +1616,13 @@ export interface BrpAddressDto {
|
|||||||
adres?: AdresDto;
|
adres?: AdresDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CaseContextDto {
|
||||||
|
zorgverlenerNaam?: string | undefined;
|
||||||
|
bigNummer?: string | undefined;
|
||||||
|
beroep?: string | undefined;
|
||||||
|
aanvraagReferentie?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChangeRequestRequest {
|
export interface ChangeRequestRequest {
|
||||||
straat?: string | undefined;
|
straat?: string | undefined;
|
||||||
postcode?: string | undefined;
|
postcode?: string | undefined;
|
||||||
@@ -1713,6 +1721,8 @@ export interface LibraryPassageDto {
|
|||||||
version?: number;
|
version?: number;
|
||||||
beroep?: string | undefined;
|
beroep?: string | undefined;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
besluit?: string | undefined;
|
||||||
|
reason?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ManualDiplomaPolicyDto {
|
export interface ManualDiplomaPolicyDto {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
|
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
|
||||||
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em
|
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
|
||||||
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
|
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
|
||||||
outer grey fieldset would hide the white gaps between the page groups). -->
|
outer grey fieldset would hide the white gaps between the page groups). -->
|
||||||
<ng-content />
|
<ng-content />
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { Component, forwardRef, input } from '@angular/core';
|
import { Component, computed, forwardRef, input } from '@angular/core';
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
|
||||||
|
// Per-instance fallback ids, so the label's `for` always targets THIS checkbox. The
|
||||||
|
// CIBG styled checkbox hides the native input and routes clicks through the label, so a
|
||||||
|
// shared/undefined id silently makes every label toggle the first input — hence a default.
|
||||||
|
let nextCheckboxId = 0;
|
||||||
|
|
||||||
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
|
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
|
||||||
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
|
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
|
||||||
input for full keyboard + screen-reader support. */
|
input for full keyboard + screen-reader support. */
|
||||||
@@ -11,13 +16,13 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|||||||
<input
|
<input
|
||||||
class="form-check-input"
|
class="form-check-input"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
[id]="checkboxId()"
|
[id]="resolvedId()"
|
||||||
[checked]="value"
|
[checked]="value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
(change)="onToggle($event)"
|
(change)="onToggle($event)"
|
||||||
(blur)="onTouched()"
|
(blur)="onTouched()"
|
||||||
/>
|
/>
|
||||||
<label class="form-check-label" [for]="checkboxId()">{{ label() }}</label>
|
<label class="form-check-label" [for]="resolvedId()">{{ label() }}</label>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
providers: [
|
providers: [
|
||||||
@@ -28,6 +33,10 @@ export class CheckboxComponent implements ControlValueAccessor {
|
|||||||
checkboxId = input<string>();
|
checkboxId = input<string>();
|
||||||
label = input('');
|
label = input('');
|
||||||
|
|
||||||
|
/** The caller's id, or a unique fallback — never undefined, so labels never collide. */
|
||||||
|
private autoId = `app-checkbox-${nextCheckboxId++}`;
|
||||||
|
protected resolvedId = computed(() => this.checkboxId() ?? this.autoId);
|
||||||
|
|
||||||
value = false;
|
value = false;
|
||||||
disabled = false;
|
disabled = false;
|
||||||
onChange: (v: boolean) => void = () => {};
|
onChange: (v: boolean) => void = () => {};
|
||||||
|
|||||||
Reference in New Issue
Block a user