feat(brief): letter composition + two-person approval (teaching slice)
New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "../tsconfig.app.json",
|
"extends": "../tsconfig.app.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["node"],
|
"types": ["node", "@angular/localize"],
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler"
|
||||||
|
|||||||
@@ -107,3 +107,45 @@ public sealed record SubmitApplicationRequest(
|
|||||||
IReadOnlyList<DocumentRefDto>? Documents = null);
|
IReadOnlyList<DocumentRefDto>? Documents = null);
|
||||||
|
|
||||||
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
|
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
|
||||||
|
|
||||||
|
// --- Brief (letter composition) contracts ---
|
||||||
|
// Rich text is a serialisable node tree; the node union is flattened with a `Type`
|
||||||
|
// discriminator + nullable fields, the same wire convention as AanvraagStatusDto.
|
||||||
|
// The backend treats the content as opaque (stores/returns it) — the FE owns the
|
||||||
|
// rich-text semantics and re-validates at its parse* boundary.
|
||||||
|
|
||||||
|
public sealed record RichTextNodeDto(string Type, string? Text = null, IReadOnlyList<string>? Marks = null, string? Key = null);
|
||||||
|
public sealed record ParagraphDto(IReadOnlyList<RichTextNodeDto> Nodes);
|
||||||
|
public sealed record RichTextBlockDto(IReadOnlyList<ParagraphDto> Paragraphs);
|
||||||
|
|
||||||
|
public sealed record PlaceholderDefDto(string Key, string Label, bool AutoResolvable, bool? Fillable = null, bool? Deprecated = null);
|
||||||
|
|
||||||
|
// LetterBlock union (passage | freeText), flattened: passage-only fields are nullable.
|
||||||
|
public sealed record LetterBlockDto(
|
||||||
|
string Type, string BlockId, RichTextBlockDto Content,
|
||||||
|
string? SourcePassageId = null, int? SourceVersion = null, bool? Edited = null);
|
||||||
|
|
||||||
|
public sealed record LetterSectionDto(string SectionKey, string Title, bool Required, IReadOnlyList<LetterBlockDto> Blocks);
|
||||||
|
|
||||||
|
// BriefStatus union, flattened by Tag (draft | submitted | approved | rejected | sent).
|
||||||
|
public sealed record BriefStatusDto(
|
||||||
|
string Tag,
|
||||||
|
string? SubmittedBy = null, string? SubmittedAt = null,
|
||||||
|
string? ApprovedBy = null, string? ApprovedAt = null,
|
||||||
|
string? RejectedBy = null, string? RejectedAt = null, string? Comments = null,
|
||||||
|
string? SentAt = null);
|
||||||
|
|
||||||
|
public sealed record LibraryPassageDto(
|
||||||
|
string PassageId, string Scope, string SectionKey, string Label,
|
||||||
|
RichTextBlockDto Content, int Version, string? Beroep = null);
|
||||||
|
|
||||||
|
public sealed record BriefDto(
|
||||||
|
string BriefId, string Beroep, string TemplateId,
|
||||||
|
IReadOnlyList<PlaceholderDefDto> Placeholders,
|
||||||
|
IReadOnlyList<LetterSectionDto> Sections,
|
||||||
|
BriefStatusDto Status, string DrafterId);
|
||||||
|
|
||||||
|
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages);
|
||||||
|
|
||||||
|
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
||||||
|
public sealed record RejectBriefRequest(string Comments);
|
||||||
|
|||||||
176
backend/src/BigRegister.Api/Data/BriefStore.cs
Normal file
176
backend/src/BigRegister.Api/Data/BriefStore.cs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The letter (brief) — one demo brief per owner, created from a template on first
|
||||||
|
/// read. In-memory, mirrors <see cref="ApplicationStore"/>. The status machine and
|
||||||
|
/// its guards live here (the server is authoritative for transitions); the FE mirrors
|
||||||
|
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
|
||||||
|
/// stub does not interpret it (no server-side placeholder linting in this slice).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BriefEntity
|
||||||
|
{
|
||||||
|
public required string BriefId { get; init; }
|
||||||
|
public required string Owner { get; init; }
|
||||||
|
public required string Beroep { get; init; }
|
||||||
|
public required string TemplateId { get; init; }
|
||||||
|
public required string DrafterId { get; init; }
|
||||||
|
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
|
||||||
|
public List<LetterSectionDto> Sections { get; set; } = new();
|
||||||
|
public BriefStatusDto Status { get; set; } = new("draft");
|
||||||
|
|
||||||
|
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class BriefStore
|
||||||
|
{
|
||||||
|
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||||
|
public const string DrafterId = "demo-drafter";
|
||||||
|
public const string ApproverId = "demo-approver";
|
||||||
|
|
||||||
|
public enum Outcome { Ok, Forbidden, Conflict }
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
|
||||||
|
private static readonly object _gate = new();
|
||||||
|
|
||||||
|
public static BriefEntity GetOrCreate(string owner)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
|
||||||
|
var created = BriefSeed.NewBrief(owner);
|
||||||
|
_byOwner[owner] = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
|
||||||
|
/// letter reopens to draft on edit (mirrors the FE reducer).
|
||||||
|
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||||
|
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||||
|
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||||
|
e.Sections = sections.ToList();
|
||||||
|
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||||
|
return (Outcome.Ok, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||||
|
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||||
|
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||||
|
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||||
|
return (Outcome.Ok, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
|
||||||
|
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
|
||||||
|
|
||||||
|
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
|
||||||
|
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
|
||||||
|
|
||||||
|
public static (Outcome, BriefEntity?) Send(string owner, string at)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||||
|
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||||
|
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||||
|
return (Outcome.Ok, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test seam: clear the demo brief between tests.
|
||||||
|
public static void Reset()
|
||||||
|
{
|
||||||
|
lock (_gate) _byOwner.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve/reject share the guard: must be submitted, and the approver must differ
|
||||||
|
// from the drafter (a drafter cannot approve their own letter).
|
||||||
|
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||||
|
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
|
||||||
|
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||||
|
e.Status = next(e);
|
||||||
|
return (Outcome.Ok, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
|
||||||
|
public static class BriefSeed
|
||||||
|
{
|
||||||
|
public const string TemplateId = "besluit-arts";
|
||||||
|
public const string Beroep = "arts";
|
||||||
|
|
||||||
|
private static RichTextNodeDto T(string t) => new("text", Text: t);
|
||||||
|
private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
|
||||||
|
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
|
||||||
|
|
||||||
|
// The template's valid placeholder fields. Two are flagged to exercise the linter:
|
||||||
|
// a deprecated field and one not fillable for this beroep.
|
||||||
|
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
|
||||||
|
{
|
||||||
|
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
|
||||||
|
new PlaceholderDefDto("datum", "Datum", true),
|
||||||
|
new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
|
||||||
|
new PlaceholderDefDto("reden_besluit", "Reden besluit", false),
|
||||||
|
new PlaceholderDefDto("oud_kenmerk", "Oud kenmerk", true, Deprecated: true),
|
||||||
|
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static BriefEntity NewBrief(string owner) => new()
|
||||||
|
{
|
||||||
|
BriefId = Guid.NewGuid().ToString(),
|
||||||
|
Owner = owner,
|
||||||
|
Beroep = Beroep,
|
||||||
|
TemplateId = TemplateId,
|
||||||
|
DrafterId = BriefStore.DrafterId,
|
||||||
|
Placeholders = Placeholders,
|
||||||
|
Sections = new()
|
||||||
|
{
|
||||||
|
new("aanhef", "Aanhef", true, new List<LetterBlockDto>()),
|
||||||
|
new("kern", "Kern van het besluit", true, new List<LetterBlockDto>()),
|
||||||
|
new("slot", "Slot", false, new List<LetterBlockDto>()),
|
||||||
|
},
|
||||||
|
Status = new BriefStatusDto("draft"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Global passages plus those scoped to the given beroep.
|
||||||
|
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
|
||||||
|
{
|
||||||
|
var all = new List<LibraryPassageDto>
|
||||||
|
{
|
||||||
|
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
|
||||||
|
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
|
||||||
|
new("p-kern-1", "global", "kern", "Beoordeling ontvangen",
|
||||||
|
Block(T("Op "), P("datum"), T(" hebben wij uw aanvraag beoordeeld.")), 1),
|
||||||
|
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"),
|
||||||
|
new("p-slot-1", "global", "slot", "Standaard slot",
|
||||||
|
Block(T("Met vriendelijke groet,")), 1),
|
||||||
|
// These reference a deprecated / not-fillable field so the linter's
|
||||||
|
// warning + error states are visible in the demo once inserted.
|
||||||
|
new("p-kern-oud", "global", "kern", "Verwijzing oud kenmerk (verouderd)",
|
||||||
|
Block(T("Zie kenmerk "), P("oud_kenmerk"), T(".")), 1),
|
||||||
|
new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)",
|
||||||
|
Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1),
|
||||||
|
};
|
||||||
|
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,10 +239,96 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
|||||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
// --- Brief (letter composition). One demo brief per owner; the server owns the
|
||||||
|
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the
|
||||||
|
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. ---
|
||||||
|
|
||||||
|
api.MapGet("/brief", () =>
|
||||||
|
{
|
||||||
|
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||||
|
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||||
|
})
|
||||||
|
.Produces<BriefViewDto>();
|
||||||
|
|
||||||
|
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var (_, isDrafter) = BriefRole(ctx);
|
||||||
|
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||||
|
})
|
||||||
|
.Produces<BriefDto>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var (_, isDrafter) = BriefRole(ctx);
|
||||||
|
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
||||||
|
LogBrief("submit", r);
|
||||||
|
return BriefResult(r, "Alleen de opsteller mag indienen.");
|
||||||
|
})
|
||||||
|
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
||||||
|
.Produces<BriefDto>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var (acting, _) = BriefRole(ctx);
|
||||||
|
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
|
||||||
|
LogBrief("approve", r);
|
||||||
|
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||||
|
})
|
||||||
|
.Produces<BriefDto>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
var (acting, _) = BriefRole(ctx);
|
||||||
|
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
|
||||||
|
LogBrief("reject", r);
|
||||||
|
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||||
|
})
|
||||||
|
.Produces<BriefDto>()
|
||||||
|
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||||
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
api.MapPost("/brief/send", () =>
|
||||||
|
{
|
||||||
|
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
|
||||||
|
// port); the backend only guards the approved→sent transition.
|
||||||
|
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
|
||||||
|
LogBrief("send", r);
|
||||||
|
return BriefResult(r, "Versturen kan niet in deze status.");
|
||||||
|
})
|
||||||
|
.Produces<BriefDto>()
|
||||||
|
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
||||||
|
|
||||||
|
static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
||||||
|
|
||||||
|
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything
|
||||||
|
// else (default) acts as the drafter.
|
||||||
|
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
|
||||||
|
{
|
||||||
|
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
|
||||||
|
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
|
||||||
|
}
|
||||||
|
|
||||||
|
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
|
||||||
|
{
|
||||||
|
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
|
||||||
|
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
|
||||||
|
};
|
||||||
|
|
||||||
|
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
|
||||||
|
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
|
||||||
|
action, r.outcome, r.entity?.Status.Tag ?? "-");
|
||||||
|
|
||||||
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
|
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
|
||||||
// generated reference and the caller's correlation id (the observability seam — a
|
// generated reference and the caller's correlation id (the observability seam — a
|
||||||
// real system ships this to structured logging / an audit store).
|
// real system ships this to structured logging / an audit store).
|
||||||
|
|||||||
@@ -640,6 +640,229 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/brief": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BriefViewDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SaveBriefRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BriefDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/brief/submit": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"operationId": "briefSubmit",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BriefDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/brief/approve": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BriefDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/brief/reject": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RejectBriefRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BriefDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/brief/send": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BriefDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Conflict",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -787,6 +1010,103 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"BriefDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"briefId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"beroep": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"templateId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PlaceholderDefDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LetterSectionDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/components/schemas/BriefStatusDto"
|
||||||
|
},
|
||||||
|
"drafterId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"BriefStatusDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tag": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"submittedBy": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"submittedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"approvedBy": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"approvedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"rejectedBy": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"rejectedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sentAt": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"BriefViewDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"brief": {
|
||||||
|
"$ref": "#/components/schemas/BriefDto"
|
||||||
|
},
|
||||||
|
"availablePassages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LibraryPassageDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"BrpAddressDto": {
|
"BrpAddressDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1019,6 +1339,93 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"LetterBlockDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"blockId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"$ref": "#/components/schemas/RichTextBlockDto"
|
||||||
|
},
|
||||||
|
"sourcePassageId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sourceVersion": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"edited": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"LetterSectionDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sectionKey": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LetterBlockDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"LibraryPassageDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"passageId": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sectionKey": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"$ref": "#/components/schemas/RichTextBlockDto"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"beroep": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"ManualDiplomaPolicyDto": {
|
"ManualDiplomaPolicyDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1039,6 +1446,19 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"ParagraphDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"nodes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/RichTextNodeDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"PersonDto": {
|
"PersonDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1056,6 +1476,31 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"PlaceholderDefDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"autoResolvable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"fillable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"deprecated": {
|
||||||
|
"type": "boolean",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"PolicyQuestionDto": {
|
"PolicyQuestionDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1183,6 +1628,67 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"RejectBriefRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"comments": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"RichTextBlockDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"paragraphs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ParagraphDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"RichTextNodeDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"marks": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"SaveBriefRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sections": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LetterSectionDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"SubmitApplicationRequest": {
|
"SubmitApplicationRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
137
backend/tests/BigRegister.Tests/BriefEndpointTests.cs
Normal file
137
backend/tests/BigRegister.Tests/BriefEndpointTests.cs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The brief is a single process-global demo entity, so each test resets it first
|
||||||
|
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
||||||
|
/// header: absent = drafter, "approver" = a different identity.
|
||||||
|
/// </summary>
|
||||||
|
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
private static LetterBlockDto FreeText(string id) =>
|
||||||
|
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
|
||||||
|
|
||||||
|
// Fill every REQUIRED section with a block so submit is allowed.
|
||||||
|
private static SaveBriefRequest FilledFrom(BriefDto brief)
|
||||||
|
{
|
||||||
|
var i = 0;
|
||||||
|
var sections = brief.Sections
|
||||||
|
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
|
||||||
|
.ToList();
|
||||||
|
return new SaveBriefRequest(sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BriefDto> Get()
|
||||||
|
{
|
||||||
|
BriefStore.Reset();
|
||||||
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||||
|
return view!.Brief;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpRequestMessage Post(string path, string? role = null, object? body = null)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(HttpMethod.Post, path);
|
||||||
|
if (role is not null) req.Headers.Add("X-Role", role);
|
||||||
|
if (body is not null) req.Content = JsonContent.Create(body);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
|
||||||
|
{
|
||||||
|
var brief = await Get();
|
||||||
|
Assert.Equal("draft", brief.Status.Tag);
|
||||||
|
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
||||||
|
Assert.All(brief.Sections, s => Assert.Empty(s.Blocks));
|
||||||
|
|
||||||
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||||
|
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
||||||
|
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
||||||
|
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Save_is_drafter_only()
|
||||||
|
{
|
||||||
|
var brief = await Get();
|
||||||
|
var save = FilledFrom(brief);
|
||||||
|
|
||||||
|
var approver = Post("/api/v1/brief", role: "approver");
|
||||||
|
approver.Method = HttpMethod.Put;
|
||||||
|
approver.Content = JsonContent.Create(save);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
|
||||||
|
{
|
||||||
|
await Get();
|
||||||
|
// Nothing filled yet → required sections empty → 409.
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
|
||||||
|
|
||||||
|
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
|
||||||
|
Assert.Equal("submitted", submitted!.Status.Tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
|
||||||
|
{
|
||||||
|
var brief = await Get();
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
|
// drafter role approving own letter → 403
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
|
||||||
|
|
||||||
|
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
|
||||||
|
{
|
||||||
|
var brief = await Get();
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
|
var rejected = await (await _client.SendAsync(
|
||||||
|
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
|
||||||
|
Assert.Equal("rejected", rejected!.Status.Tag);
|
||||||
|
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
|
||||||
|
|
||||||
|
// A drafter save on a rejected letter reopens it to draft.
|
||||||
|
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
|
||||||
|
Assert.Equal("draft", reopened!.Status.Tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Send_only_from_approved()
|
||||||
|
{
|
||||||
|
var brief = await Get();
|
||||||
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||||
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||||
|
|
||||||
|
// submitted (not approved) → send 409
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
|
||||||
|
|
||||||
|
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||||
|
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
10660
documentation.json
10660
documentation.json
File diff suppressed because one or more lines are too long
@@ -55,7 +55,7 @@ export default [
|
|||||||
rules: {
|
rules: {
|
||||||
'no-restricted-imports': [
|
'no-restricted-imports': [
|
||||||
'error',
|
'error',
|
||||||
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*'], message: 'shared/ must not depend on a feature context.' }] },
|
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*', '@brief/*'], message: 'shared/ must not depend on a feature context.' }] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,7 +66,7 @@ export default [
|
|||||||
rules: {
|
rules: {
|
||||||
'no-restricted-imports': [
|
'no-restricted-imports': [
|
||||||
'error',
|
'error',
|
||||||
{ patterns: [{ group: ['@registratie/*', '@herregistratie/*'], message: 'auth/ may depend only on shared.' }] },
|
{ patterns: [{ group: ['@registratie/*', '@herregistratie/*', '@brief/*'], message: 'auth/ may depend only on shared.' }] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -77,7 +77,18 @@ export default [
|
|||||||
rules: {
|
rules: {
|
||||||
'no-restricted-imports': [
|
'no-restricted-imports': [
|
||||||
'error',
|
'error',
|
||||||
{ patterns: [{ group: ['@herregistratie/*'], message: 'Dependencies point herregistratie → registratie → shared, never back.' }] },
|
{ patterns: [{ group: ['@herregistratie/*', '@brief/*'], message: 'Dependencies point herregistratie → registratie → shared, never back.' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// brief/ (letter composition) is an independent leaf context: it may depend only on shared.
|
||||||
|
{
|
||||||
|
files: ['src/app/brief/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{ patterns: [{ group: ['@auth/*', '@registratie/*', '@herregistratie/*'], message: 'brief/ may depend only on shared.' }] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import localeNl from '@angular/common/locales/nl';
|
|||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||||
|
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
import { SESSION_PORT } from '@shared/application/session.port';
|
import { SESSION_PORT } from '@shared/application/session.port';
|
||||||
import { SessionStore } from '@auth/application/session.store';
|
import { SessionStore } from '@auth/application/session.store';
|
||||||
@@ -18,7 +19,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideRouter(routes, withViewTransitions()),
|
provideRouter(routes, withViewTransitions()),
|
||||||
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
||||||
// a query param could otherwise force errors on the live app.
|
// a query param could otherwise force errors on the live app.
|
||||||
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor] : [])),
|
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
|
||||||
provideApiClient(),
|
provideApiClient(),
|
||||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||||
{ provide: LOCALE_ID, useValue: 'nl' },
|
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const routes: Routes = [
|
|||||||
{ path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) },
|
{ path: 'registreren', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registratie.page').then(m => m.RegistratiePage) },
|
||||||
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
|
{ path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) },
|
||||||
{ path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) },
|
{ path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) },
|
||||||
|
{ path: 'brief', canActivate: [authGuard], loadComponent: () => import('@brief/ui/brief.page').then(m => m.BriefPage) },
|
||||||
{ path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) },
|
{ path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) },
|
||||||
{ path: '**', redirectTo: 'login' },
|
{ path: '**', redirectTo: 'login' },
|
||||||
],
|
],
|
||||||
|
|||||||
111
src/app/brief/application/brief.store.ts
Normal file
111
src/app/brief/application/brief.store.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import { Result } from '@shared/kernel/fp';
|
||||||
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { Role, currentRole } from '@shared/infrastructure/role';
|
||||||
|
import { Brief, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from '@brief/domain/brief';
|
||||||
|
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||||
|
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
||||||
|
* read-model, and the commands (effects) that call the adapter and dispatch the
|
||||||
|
* outcome. Mirrors `BigProfileStore`. All of `editable`, `diagnostics`,
|
||||||
|
* `unresolved`, `canSubmit` are DERIVED here — never stored.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BriefStore {
|
||||||
|
private adapter = inject(BriefAdapter);
|
||||||
|
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||||
|
|
||||||
|
readonly model = this.store.model;
|
||||||
|
readonly role: Role = currentRole();
|
||||||
|
readonly busy = signal(false);
|
||||||
|
readonly lastError = signal<string | null>(null);
|
||||||
|
|
||||||
|
private brief = computed<Brief | null>(() => {
|
||||||
|
const s = this.model();
|
||||||
|
return s.tag === 'loaded' ? s.brief : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The single source of truth for edit permission: drafter, and a status the reducer
|
||||||
|
treats as editable (draft, or rejected — which reopens to draft on the first edit). */
|
||||||
|
readonly editable = computed(() => {
|
||||||
|
const b = this.brief();
|
||||||
|
return !!b && (b.status.tag === 'draft' || b.status.tag === 'rejected') && this.role === 'drafter';
|
||||||
|
});
|
||||||
|
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||||
|
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
||||||
|
/** Submit is allowed only when required sections are filled AND no blocking errors. */
|
||||||
|
readonly canSubmit = computed(() => {
|
||||||
|
const b = this.brief();
|
||||||
|
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
|
||||||
|
});
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const r = await this.adapter.load();
|
||||||
|
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages });
|
||||||
|
else this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
|
||||||
|
edit(msg: BriefMsg) {
|
||||||
|
this.store.dispatch(msg);
|
||||||
|
this.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private scheduleSave() {
|
||||||
|
if (!this.editable()) return;
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
|
||||||
|
this.saveTimer = setTimeout(() => void this.flushSave(), 600);
|
||||||
|
}
|
||||||
|
private async flushSave() {
|
||||||
|
const b = this.brief();
|
||||||
|
if (!b) return;
|
||||||
|
const r = await this.adapter.save(b.sections);
|
||||||
|
if (!r.ok) this.lastError.set(r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
submit = () => this.transition(() => this.adapter.submit());
|
||||||
|
approve = () => this.transition(() => this.adapter.approve());
|
||||||
|
reject = (comments: string) => this.transition(() => this.adapter.reject(comments));
|
||||||
|
send = () => this.transition(() => this.adapter.send());
|
||||||
|
|
||||||
|
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||||
|
// the returned status through the pure reducer's guarded transition.
|
||||||
|
private async transition(action: () => Promise<Result<string, Brief>>) {
|
||||||
|
this.busy.set(true);
|
||||||
|
this.lastError.set(null);
|
||||||
|
clearTimeout(this.saveTimer);
|
||||||
|
await this.flushSave();
|
||||||
|
const r = await action();
|
||||||
|
this.busy.set(false);
|
||||||
|
if (!r.ok) {
|
||||||
|
this.lastError.set(r.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.applyServerStatus(r.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyServerStatus(brief: Brief) {
|
||||||
|
const s = brief.status;
|
||||||
|
switch (s.tag) {
|
||||||
|
case 'submitted':
|
||||||
|
this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt });
|
||||||
|
break;
|
||||||
|
case 'approved':
|
||||||
|
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt });
|
||||||
|
break;
|
||||||
|
case 'rejected':
|
||||||
|
this.store.dispatch({ tag: 'Rejected', by: s.rejectedBy, at: s.rejectedAt, comments: s.comments });
|
||||||
|
break;
|
||||||
|
case 'sent':
|
||||||
|
this.store.dispatch({ tag: 'Sent', at: s.sentAt });
|
||||||
|
break;
|
||||||
|
case 'draft':
|
||||||
|
// reopened by a save on a rejected letter — reducer already handled it locally.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/app/brief/domain/brief.machine.spec.ts
Normal file
134
src/app/brief/domain/brief.machine.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Brief, BriefStatus, LibraryPassage } from './brief';
|
||||||
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
|
import { PlaceholderDef } from './placeholders';
|
||||||
|
import { BriefState, BriefMsg, reduce } from './brief.machine';
|
||||||
|
|
||||||
|
const placeholders: PlaceholderDef[] = [
|
||||||
|
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||||
|
{ key: 'reden', label: 'Reden', autoResolvable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = (t: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||||
|
|
||||||
|
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({
|
||||||
|
passageId: id,
|
||||||
|
scope: 'global',
|
||||||
|
sectionKey,
|
||||||
|
label: `Passage ${id}`,
|
||||||
|
content: text(`inhoud ${id}`),
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||||
|
return {
|
||||||
|
briefId: 'b1',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 't1',
|
||||||
|
placeholders,
|
||||||
|
sections: sections ?? [
|
||||||
|
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, blocks: [] },
|
||||||
|
{ sectionKey: 'slot', title: 'Slot', required: false, blocks: [] },
|
||||||
|
],
|
||||||
|
status,
|
||||||
|
drafterId: 'u1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState => ({
|
||||||
|
tag: 'loaded',
|
||||||
|
brief: briefWith(status, sections),
|
||||||
|
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sectionBlocks = (s: BriefState, key: string) =>
|
||||||
|
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||||
|
|
||||||
|
describe('brief.machine reduce', () => {
|
||||||
|
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
|
||||||
|
expect(reduce(initialLoading(), { tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [] }).tag).toBe('loaded');
|
||||||
|
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ tag: 'failed', reason: 'x' });
|
||||||
|
const seeded = loaded();
|
||||||
|
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] });
|
||||||
|
const blocks = sectionBlocks(s, 'aanhef');
|
||||||
|
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
|
||||||
|
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
|
||||||
|
expect(blocks[0].type === 'passage' && blocks[0].sourcePassageId).toBe('p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => {
|
||||||
|
const passage = libPassage('p1', 'aanhef');
|
||||||
|
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [passage] });
|
||||||
|
// Mutate the source passage object after insertion.
|
||||||
|
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||||
|
const block = sectionBlocks(s, 'aanhef')[0];
|
||||||
|
expect(block.content.paragraphs[0].nodes[0]).toEqual({ type: 'text', text: 'inhoud p1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FreeTextBlockAdded appends an empty free-text block', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||||
|
const blocks = sectionBlocks(s, 'slot');
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].type).toBe('freeText');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BlockContentEdited replaces content and marks a passage block edited', () => {
|
||||||
|
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] });
|
||||||
|
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
|
||||||
|
const block = sectionBlocks(s, 'aanhef')[0];
|
||||||
|
expect(block.type === 'passage' && block.edited).toBe(true);
|
||||||
|
expect(block.content).toEqual(text('aangepast'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
|
||||||
|
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] });
|
||||||
|
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
|
||||||
|
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
|
||||||
|
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
|
||||||
|
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('edits are no-ops once submitted (status invariant)', () => {
|
||||||
|
const s = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||||
|
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' })).toBe(s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('editing a rejected letter reopens it to draft', () => {
|
||||||
|
const s = loaded({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'graag aanpassen' });
|
||||||
|
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||||
|
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
|
||||||
|
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Submitted fires only from draft and only when required sections are filled', () => {
|
||||||
|
// required 'aanhef' empty → no-op
|
||||||
|
expect(reduce(loaded(), { tag: 'Submitted', by: 'u1', at: 't' })).toEqual(loaded());
|
||||||
|
// fill the required section, then submit
|
||||||
|
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
||||||
|
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' });
|
||||||
|
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('approve/reject fire only from submitted; send only from approved', () => {
|
||||||
|
const submitted = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||||
|
// approve from draft is a no-op
|
||||||
|
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded());
|
||||||
|
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' });
|
||||||
|
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ tag: 'approved', approvedBy: 'u2', approvedAt: 't2' });
|
||||||
|
// reject carries comments
|
||||||
|
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' });
|
||||||
|
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't2', comments: 'nee' });
|
||||||
|
// send only from approved
|
||||||
|
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted);
|
||||||
|
const sent = reduce(approved, { tag: 'Sent', at: 't3' });
|
||||||
|
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function initialLoading(): BriefState {
|
||||||
|
return { tag: 'loading' };
|
||||||
|
}
|
||||||
168
src/app/brief/domain/brief.machine.ts
Normal file
168
src/app/brief/domain/brief.machine.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { assertNever } from '@shared/kernel/fp';
|
||||||
|
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, allBlocks, canSubmit } from './brief';
|
||||||
|
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The letter composition state machine (Model + Msg + pure reduce), modeled on
|
||||||
|
* `herregistratie/domain/intake.machine.ts`.
|
||||||
|
*
|
||||||
|
* Two invariants are enforced *here*, not in the UI:
|
||||||
|
* - Status transitions are total and guarded — an out-of-order transition Msg is a
|
||||||
|
* no-op (`draft→submitted→approved/rejected→draft`, `approved→sent`).
|
||||||
|
* - Edits are only possible in `draft`/`rejected`; editing a `rejected` letter flips
|
||||||
|
* it back to `draft`. Sections can never be added, removed, or reordered — there
|
||||||
|
* is no Msg for it, so it is unrepresentable.
|
||||||
|
*
|
||||||
|
* Role (drafter vs approver) is NOT a reducer concern: the UI derives `editable` from
|
||||||
|
* role+status and simply doesn't dispatch edits when the actor may not edit. The
|
||||||
|
* reducer guards the status invariant; the UI guards the role invariant.
|
||||||
|
*
|
||||||
|
* Note: there is no `PlaceholderInserted` Msg. The editor inserts a placeholder NODE
|
||||||
|
* at the caret and emits the whole new block via `BlockContentEdited`; its insert menu
|
||||||
|
* only offers keys from `brief.placeholders`, so inserting an unknown key is
|
||||||
|
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BriefState =
|
||||||
|
| { tag: 'loading' }
|
||||||
|
| { tag: 'loaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
|
||||||
|
| { tag: 'failed'; reason: string };
|
||||||
|
|
||||||
|
export const initial: BriefState = { tag: 'loading' };
|
||||||
|
|
||||||
|
export type BriefMsg =
|
||||||
|
| { tag: 'BriefLoaded'; brief: Brief; availablePassages: readonly LibraryPassage[] }
|
||||||
|
| { tag: 'BriefLoadFailed'; reason: string }
|
||||||
|
| { tag: 'PassagesInserted'; sectionKey: string; passages: readonly LibraryPassage[] } // multi-select
|
||||||
|
| { tag: 'FreeTextBlockAdded'; sectionKey: string }
|
||||||
|
| { tag: 'BlockContentEdited'; blockId: string; content: RichTextBlock }
|
||||||
|
| { tag: 'BlockRemoved'; blockId: string }
|
||||||
|
| { tag: 'BlockMovedWithinSection'; blockId: string; toIndex: number }
|
||||||
|
| { tag: 'Submitted'; by: string; at: string } // draft → submitted
|
||||||
|
| { tag: 'Approved'; by: string; at: string } // submitted → approved
|
||||||
|
| { tag: 'Rejected'; by: string; at: string; comments: string } // submitted → rejected
|
||||||
|
| { tag: 'Sent'; at: string } // approved → sent
|
||||||
|
| { tag: 'Seed'; state: BriefState };
|
||||||
|
|
||||||
|
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
||||||
|
function isEditable(status: BriefStatus): boolean {
|
||||||
|
return status.tag === 'draft' || status.tag === 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next `local-N` block id — DERIVED from existing ids (max + 1), not a stored counter. */
|
||||||
|
function nextLocalIndex(brief: Brief): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const b of allBlocks(brief)) {
|
||||||
|
const m = /^local-(\d+)$/.exec(b.blockId);
|
||||||
|
if (m) max = Math.max(max, Number(m[1]));
|
||||||
|
}
|
||||||
|
return max + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => LetterSection): Brief {
|
||||||
|
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBlock[]): Brief {
|
||||||
|
return { ...brief, sections: brief.sections.map((s) => ({ ...s, blocks: f(s.blocks) })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
|
||||||
|
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
||||||
|
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
|
||||||
|
let brief = f(s.brief);
|
||||||
|
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
||||||
|
return { ...s, brief };
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertPassages(brief: Brief, sectionKey: string, passages: readonly LibraryPassage[]): Brief {
|
||||||
|
let idx = nextLocalIndex(brief);
|
||||||
|
// The freeze happens HERE: each block gets a deep VALUE copy of the library content,
|
||||||
|
// so later library edits can never mutate this letter (frozen snapshot).
|
||||||
|
const newBlocks: LetterBlock[] = passages.map((p) => ({
|
||||||
|
type: 'passage',
|
||||||
|
blockId: `local-${idx++}`,
|
||||||
|
sourcePassageId: p.passageId,
|
||||||
|
sourceVersion: p.version,
|
||||||
|
content: deepCopyBlock(p.content),
|
||||||
|
edited: false,
|
||||||
|
}));
|
||||||
|
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, ...newBlocks] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFreeText(brief: Brief, sectionKey: string): Brief {
|
||||||
|
const block: LetterBlock = { type: 'freeText', blockId: `local-${nextLocalIndex(brief)}`, content: emptyBlock() };
|
||||||
|
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function editBlockContent(brief: Brief, blockId: string, content: RichTextBlock): Brief {
|
||||||
|
return mapBlocks(brief, (blocks) =>
|
||||||
|
blocks.map((b) =>
|
||||||
|
b.blockId !== blockId
|
||||||
|
? b
|
||||||
|
: b.type === 'passage'
|
||||||
|
? { ...b, content, edited: true } // editing a snapshot marks it, keeps provenance
|
||||||
|
: { ...b, content },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveWithinSection(blocks: readonly LetterBlock[], blockId: string, toIndex: number): LetterBlock[] {
|
||||||
|
const from = blocks.findIndex((b) => b.blockId === blockId);
|
||||||
|
if (from === -1) return [...blocks];
|
||||||
|
const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1));
|
||||||
|
const next = [...blocks];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(clamped, 0, moved);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||||
|
switch (m.tag) {
|
||||||
|
case 'BriefLoaded':
|
||||||
|
return { tag: 'loaded', brief: m.brief, availablePassages: m.availablePassages };
|
||||||
|
case 'BriefLoadFailed':
|
||||||
|
return { tag: 'failed', reason: m.reason };
|
||||||
|
case 'Seed':
|
||||||
|
return m.state;
|
||||||
|
|
||||||
|
case 'PassagesInserted':
|
||||||
|
return withEdit(s, (b) => insertPassages(b, m.sectionKey, m.passages));
|
||||||
|
case 'FreeTextBlockAdded':
|
||||||
|
return withEdit(s, (b) => addFreeText(b, m.sectionKey));
|
||||||
|
case 'BlockContentEdited':
|
||||||
|
return withEdit(s, (b) => editBlockContent(b, m.blockId, m.content));
|
||||||
|
case 'BlockRemoved':
|
||||||
|
return withEdit(s, (b) => mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId)));
|
||||||
|
case 'BlockMovedWithinSection':
|
||||||
|
return withEdit(s, (b) =>
|
||||||
|
mapBlocks(b, (blocks) =>
|
||||||
|
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'Submitted':
|
||||||
|
// Guard the transition AND the completeness invariant.
|
||||||
|
return transition(s, 'draft', () => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }), canSubmit);
|
||||||
|
case 'Approved':
|
||||||
|
return transition(s, 'submitted', () => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }));
|
||||||
|
case 'Rejected':
|
||||||
|
return transition(s, 'submitted', () => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }));
|
||||||
|
case 'Sent':
|
||||||
|
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));
|
||||||
|
|
||||||
|
default:
|
||||||
|
return assertNever(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A guarded status transition: only fires from `from`, and only if `guard` passes. */
|
||||||
|
function transition(
|
||||||
|
s: BriefState,
|
||||||
|
from: BriefStatus['tag'],
|
||||||
|
next: () => BriefStatus,
|
||||||
|
guard: (b: Brief) => boolean = () => true,
|
||||||
|
): BriefState {
|
||||||
|
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||||
|
return { ...s, brief: { ...s.brief, status: next() } };
|
||||||
|
}
|
||||||
49
src/app/brief/domain/brief.spec.ts
Normal file
49
src/app/brief/domain/brief.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Brief, LetterBlock, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from './brief';
|
||||||
|
import { PlaceholderDef } from './placeholders';
|
||||||
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
|
const placeholders: PlaceholderDef[] = [
|
||||||
|
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||||
|
{ key: 'reden', label: 'Reden', autoResolvable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const content = (...keys: string[]): RichTextBlock => ({
|
||||||
|
paragraphs: [{ nodes: keys.map((key) => ({ type: 'placeholder', key })) }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const passage = (blockId: string, ...keys: string[]): LetterBlock => ({
|
||||||
|
type: 'freeText',
|
||||||
|
blockId,
|
||||||
|
content: content(...keys),
|
||||||
|
});
|
||||||
|
|
||||||
|
function brief(sections: Brief['sections']): Brief {
|
||||||
|
return { briefId: 'b1', beroep: 'arts', templateId: 't1', placeholders, sections, status: { tag: 'draft' }, drafterId: 'u1' };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('brief selectors', () => {
|
||||||
|
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
|
||||||
|
const b = brief([
|
||||||
|
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'naam', 'reden')] },
|
||||||
|
{ sectionKey: 's2', title: 'S2', required: false, blocks: [passage('local-2', 'reden')] },
|
||||||
|
]);
|
||||||
|
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allDiagnostics flattens across sections and blocks', () => {
|
||||||
|
const b = brief([
|
||||||
|
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'reden', 'onbekend')] },
|
||||||
|
]);
|
||||||
|
const codes = allDiagnostics(b).map((d) => d.code);
|
||||||
|
expect(codes).toContain('unresolved-at-send'); // reden
|
||||||
|
expect(codes).toContain('unknown-placeholder'); // onbekend
|
||||||
|
expect(hasBlockingErrors(allDiagnostics(b))).toBe(true); // unknown is an error
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canSubmit is false when a required section is empty, true otherwise', () => {
|
||||||
|
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [] }]))).toBe(false);
|
||||||
|
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, blocks: [] }]))).toBe(true);
|
||||||
|
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1')] }]))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
99
src/app/brief/domain/brief.ts
Normal file
99
src/app/brief/domain/brief.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { RichTextBlock, placeholderKeysIn } from '@shared/kernel/rich-text';
|
||||||
|
import { Diagnostic, lintPlaceholders, PlaceholderDef } from './placeholders';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Brief` (letter) entity and its derived selectors.
|
||||||
|
*
|
||||||
|
* A letter has a FIXED section structure (from a template, server-instantiated); the
|
||||||
|
* drafter fills a skeleton, never reorders sections. Each block is either a frozen
|
||||||
|
* snapshot of a library passage (provenance kept) or free text. Everything the UI
|
||||||
|
* needs beyond the stored shape — diagnostics, unresolved placeholders, whether it
|
||||||
|
* can be submitted — is DERIVED here, never stored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PassageScope = 'global' | 'beroep';
|
||||||
|
|
||||||
|
// Re-export placeholderKeysIn for one-import convenience at call sites.
|
||||||
|
export { placeholderKeysIn };
|
||||||
|
|
||||||
|
/** A passage in the library (the source). Snapshotted into a letter on insert. */
|
||||||
|
export interface LibraryPassage {
|
||||||
|
readonly passageId: string;
|
||||||
|
readonly scope: PassageScope;
|
||||||
|
readonly beroep?: string; // set when scope === 'beroep'
|
||||||
|
readonly sectionKey: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly content: RichTextBlock;
|
||||||
|
readonly version: number; // library version, for provenance only
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A block inside a letter section: a frozen passage snapshot, or free text. */
|
||||||
|
export type LetterBlock =
|
||||||
|
| {
|
||||||
|
readonly type: 'passage';
|
||||||
|
readonly blockId: string;
|
||||||
|
readonly sourcePassageId: string; // provenance
|
||||||
|
readonly sourceVersion: number; // library version at snapshot time (audit only)
|
||||||
|
readonly content: RichTextBlock; // FROZEN, possibly edited — source of truth for this block
|
||||||
|
readonly edited: boolean; // changed from the snapshot?
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
readonly type: 'freeText';
|
||||||
|
readonly blockId: string;
|
||||||
|
readonly content: RichTextBlock;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LetterSection {
|
||||||
|
readonly sectionKey: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly required: boolean;
|
||||||
|
readonly blocks: readonly LetterBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The approval state machine as a sum type — transitions are total and guarded in
|
||||||
|
`brief.machine.ts`; illegal transitions are unrepresentable. */
|
||||||
|
export type BriefStatus =
|
||||||
|
| { readonly tag: 'draft' }
|
||||||
|
| { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string }
|
||||||
|
| { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string }
|
||||||
|
| { readonly tag: 'rejected'; readonly rejectedBy: string; readonly rejectedAt: string; readonly comments: string }
|
||||||
|
| { readonly tag: 'sent'; readonly sentAt: string };
|
||||||
|
|
||||||
|
export interface Brief {
|
||||||
|
readonly briefId: string;
|
||||||
|
readonly beroep: string; // drives which beroep-scoped passages apply
|
||||||
|
readonly templateId: string;
|
||||||
|
readonly placeholders: readonly PlaceholderDef[]; // valid fields for this letter
|
||||||
|
readonly sections: readonly LetterSection[]; // instantiated from the template, in order
|
||||||
|
readonly status: BriefStatus;
|
||||||
|
readonly drafterId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Derived selectors (pure; recomputed, never stored) ---
|
||||||
|
|
||||||
|
export function allBlocks(brief: Brief): LetterBlock[] {
|
||||||
|
return brief.sections.flatMap((s) => s.blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every diagnostic in the letter, in section→block→node order. This is what the
|
||||||
|
diagnostics panel renders and what the send gate checks. */
|
||||||
|
export function allDiagnostics(brief: Brief): Diagnostic[] {
|
||||||
|
return allBlocks(brief).flatMap((b) => lintPlaceholders(b.content, brief.placeholders, b.blockId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean {
|
||||||
|
return diagnostics.some((d) => d.severity === 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manual (non-auto-resolvable) placeholder keys still present, deduped. These are the
|
||||||
|
`unresolved-at-send` warnings, surfaced as a completeness list. */
|
||||||
|
export function unresolvedPlaceholders(brief: Brief): string[] {
|
||||||
|
const auto = new Set(brief.placeholders.filter((p) => p.autoResolvable).map((p) => p.key));
|
||||||
|
const used = allBlocks(brief).flatMap((b) => placeholderKeysIn(b.content));
|
||||||
|
return [...new Set(used.filter((k) => !auto.has(k)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A letter can be submitted only when every REQUIRED section has at least one block. */
|
||||||
|
export function canSubmit(brief: Brief): boolean {
|
||||||
|
return brief.sections.every((s) => !s.required || s.blocks.length > 0);
|
||||||
|
}
|
||||||
72
src/app/brief/domain/placeholders.spec.ts
Normal file
72
src/app/brief/domain/placeholders.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
|
import { PlaceholderDef, lintPlaceholders, severityOf } from './placeholders';
|
||||||
|
|
||||||
|
const valid: PlaceholderDef[] = [
|
||||||
|
{ key: 'naam', label: 'Naam', autoResolvable: true }, // clean when used
|
||||||
|
{ key: 'reden', label: 'Reden', autoResolvable: false }, // manual → unresolved-at-send
|
||||||
|
{ key: 'oud_veld', label: 'Oud veld', autoResolvable: true, deprecated: true },
|
||||||
|
{ key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const withPlaceholder = (key: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'placeholder', key }] }] });
|
||||||
|
const withText = (text: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text }] }] });
|
||||||
|
|
||||||
|
describe('lintPlaceholders', () => {
|
||||||
|
it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => {
|
||||||
|
expect(lintPlaceholders(withPlaceholder('naam'), valid, 'b1')).toEqual([]);
|
||||||
|
expect(lintPlaceholders(withText('gewone tekst'), valid, 'b1')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags an unknown key as an error', () => {
|
||||||
|
const [d] = lintPlaceholders(withPlaceholder('onbekend'), valid, 'b1');
|
||||||
|
expect(d.code).toBe('unknown-placeholder');
|
||||||
|
expect(d.severity).toBe('error');
|
||||||
|
expect(d.placeholderKey).toBe('onbekend');
|
||||||
|
expect(d.location).toEqual({ blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a not-fillable key as an error', () => {
|
||||||
|
const [d] = lintPlaceholders(withPlaceholder('niet_invulbaar'), valid, 'b1');
|
||||||
|
expect(d.code).toBe('not-fillable');
|
||||||
|
expect(d.severity).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a deprecated key as a warning', () => {
|
||||||
|
const [d] = lintPlaceholders(withPlaceholder('oud_veld'), valid, 'b1');
|
||||||
|
expect(d.code).toBe('deprecated');
|
||||||
|
expect(d.severity).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a manual placeholder as unresolved-at-send (warning)', () => {
|
||||||
|
const [d] = lintPlaceholders(withPlaceholder('reden'), valid, 'b1');
|
||||||
|
expect(d.code).toBe('unresolved-at-send');
|
||||||
|
expect(d.severity).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags raw braces in text as malformed (paste safety net)', () => {
|
||||||
|
const [d] = lintPlaceholders(withText('Beste {{naam'), valid, 'b1');
|
||||||
|
expect(d.code).toBe('malformed');
|
||||||
|
expect(d.severity).toBe('error');
|
||||||
|
expect(d.placeholderKey).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns diagnostics in document order across paragraphs/nodes', () => {
|
||||||
|
const content: RichTextBlock = {
|
||||||
|
paragraphs: [
|
||||||
|
{ nodes: [{ type: 'placeholder', key: 'onbekend' }] },
|
||||||
|
{ nodes: [{ type: 'text', text: 'ok' }, { type: 'placeholder', key: 'reden' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const codes = lintPlaceholders(content, valid, 'b1').map((d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`);
|
||||||
|
expect(codes).toEqual(['unknown-placeholder@0.0', 'unresolved-at-send@1.1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('severityOf maps each code to its policy', () => {
|
||||||
|
expect(severityOf('malformed')).toBe('error');
|
||||||
|
expect(severityOf('unknown-placeholder')).toBe('error');
|
||||||
|
expect(severityOf('not-fillable')).toBe('error');
|
||||||
|
expect(severityOf('deprecated')).toBe('warning');
|
||||||
|
expect(severityOf('unresolved-at-send')).toBe('warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
115
src/app/brief/domain/placeholders.ts
Normal file
115
src/app/brief/domain/placeholders.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder fields and the PURE linter over them.
|
||||||
|
*
|
||||||
|
* `lintPlaceholders` is a total, effect-free function of (content, valid set). It
|
||||||
|
* runs identically on the client (for live UX) and could run on the server (for
|
||||||
|
* authority) — same rules, same config, so the two agree by construction. The FE
|
||||||
|
* never STORES its output: diagnostics are a `computed()` over content (see
|
||||||
|
* `brief.ts` selectors), the same "derive, don't store" discipline as wizard step
|
||||||
|
* validity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A placeholder field the template knows about. `fillable`/`deprecated` default to
|
||||||
|
the healthy case; the seed flips them to exercise the not-fillable/deprecated rules. */
|
||||||
|
export interface PlaceholderDef {
|
||||||
|
readonly key: string; // e.g. 'naam_zorgverlener'
|
||||||
|
readonly label: string; // human label for the insert menu, e.g. 'Naam zorgverlener'
|
||||||
|
readonly autoResolvable: boolean; // server can fill from case data (name, date, …)
|
||||||
|
readonly fillable?: boolean; // default true; false → not resolvable for this beroep/case type
|
||||||
|
readonly deprecated?: boolean; // default false; true → retired but still referenceable in old snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiagnosticSeverity = 'error' | 'warning';
|
||||||
|
|
||||||
|
export type DiagnosticCode =
|
||||||
|
| 'malformed' // raw braces in a text node (a paste that should have been a chip)
|
||||||
|
| 'unknown-placeholder' // well-formed key not in the valid set
|
||||||
|
| 'not-fillable' // key exists but isn't resolvable for this case type / beroep
|
||||||
|
| 'deprecated' // key was valid once but the template no longer offers it
|
||||||
|
| 'unresolved-at-send'; // manual (non-auto-resolvable) placeholder still to be filled
|
||||||
|
|
||||||
|
export interface DiagnosticLocation {
|
||||||
|
readonly blockId: string;
|
||||||
|
readonly paragraphIndex: number;
|
||||||
|
readonly nodeIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Diagnostic {
|
||||||
|
readonly severity: DiagnosticSeverity;
|
||||||
|
readonly code: DiagnosticCode;
|
||||||
|
readonly placeholderKey?: string;
|
||||||
|
readonly location: DiagnosticLocation;
|
||||||
|
readonly message: string; // human-readable, Dutch
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `error` blocks save (author time) and send (send time); `warning` is surfaced but allowed. */
|
||||||
|
export function severityOf(code: DiagnosticCode): DiagnosticSeverity {
|
||||||
|
switch (code) {
|
||||||
|
case 'malformed':
|
||||||
|
case 'unknown-placeholder':
|
||||||
|
case 'not-fillable':
|
||||||
|
return 'error';
|
||||||
|
case 'deprecated':
|
||||||
|
case 'unresolved-at-send':
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw `{{` or `}}` in text — the only way a malformed placeholder can exist, since
|
||||||
|
// menu insertion always produces a proper placeholder NODE. Paste safety net.
|
||||||
|
const RAW_BRACES = /\{\{|\}\}/;
|
||||||
|
|
||||||
|
function messageFor(code: DiagnosticCode, key?: string): string {
|
||||||
|
switch (code) {
|
||||||
|
case 'malformed':
|
||||||
|
return $localize`:@@brief.lint.malformed:Deze tekst bevat losse accolades ({{ of }}). Voeg een veld toe via het menu in plaats van het te typen.`;
|
||||||
|
case 'unknown-placeholder':
|
||||||
|
return $localize`:@@brief.lint.unknown:Onbekend veld “${key}:key:”. Dit veld hoort niet bij dit sjabloon.`;
|
||||||
|
case 'not-fillable':
|
||||||
|
return $localize`:@@brief.lint.notFillable:Veld “${key}:key:” kan niet worden ingevuld voor dit beroep.`;
|
||||||
|
case 'deprecated':
|
||||||
|
return $localize`:@@brief.lint.deprecated:Veld “${key}:key:” is verouderd en wordt niet meer aangeboden.`;
|
||||||
|
case 'unresolved-at-send':
|
||||||
|
return $localize`:@@brief.lint.unresolved:Veld “${key}:key:” wordt handmatig ingevuld en is nog leeg.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic {
|
||||||
|
return { severity: severityOf(code), code, placeholderKey: key, location, message: messageFor(code, key) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lint one block against the template's placeholder set. Pure: given the same
|
||||||
|
* content + valid set + blockId it always returns the same diagnostics, in
|
||||||
|
* document order. One diagnostic per node at most (most-severe wins).
|
||||||
|
*/
|
||||||
|
export function lintPlaceholders(
|
||||||
|
content: RichTextBlock,
|
||||||
|
valid: readonly PlaceholderDef[],
|
||||||
|
blockId: string,
|
||||||
|
): Diagnostic[] {
|
||||||
|
const byKey = new Map(valid.map((p) => [p.key, p]));
|
||||||
|
const out: Diagnostic[] = [];
|
||||||
|
|
||||||
|
content.paragraphs.forEach((p, paragraphIndex) => {
|
||||||
|
p.nodes.forEach((n, nodeIndex) => {
|
||||||
|
const location: DiagnosticLocation = { blockId, paragraphIndex, nodeIndex };
|
||||||
|
if (n.type === 'text') {
|
||||||
|
if (RAW_BRACES.test(n.text)) out.push(diag('malformed', location));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (n.type !== 'placeholder') return; // lineBreak — nothing to check
|
||||||
|
|
||||||
|
const def = byKey.get(n.key);
|
||||||
|
if (!def) out.push(diag('unknown-placeholder', location, n.key));
|
||||||
|
else if (def.fillable === false) out.push(diag('not-fillable', location, n.key));
|
||||||
|
else if (def.deprecated) out.push(diag('deprecated', location, n.key));
|
||||||
|
else if (!def.autoResolvable) out.push(diag('unresolved-at-send', location, n.key));
|
||||||
|
// auto-resolvable & fillable & not deprecated → clean (filled by the server at send)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
66
src/app/brief/infrastructure/brief.adapter.spec.ts
Normal file
66
src/app/brief/infrastructure/brief.adapter.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { BriefViewDto } from '@shared/infrastructure/api-client';
|
||||||
|
import { parseBrief, parseBriefView, parseNode, parseStatus } from './brief.adapter';
|
||||||
|
|
||||||
|
const view: BriefViewDto = {
|
||||||
|
brief: {
|
||||||
|
briefId: 'b1',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 't1',
|
||||||
|
drafterId: 'demo-drafter',
|
||||||
|
status: { tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' },
|
||||||
|
placeholders: [
|
||||||
|
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||||
|
{ key: 'code', label: 'Code', autoResolvable: true, fillable: false },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
sectionKey: 'aanhef',
|
||||||
|
title: 'Aanhef',
|
||||||
|
required: true,
|
||||||
|
blocks: [
|
||||||
|
{ type: 'passage', blockId: 'local-1', content: { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, sourcePassageId: 'p1', sourceVersion: 2, edited: true },
|
||||||
|
{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'hoi', marks: ['bold'] }] }] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
availablePassages: [
|
||||||
|
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Aanhef', content: { paragraphs: [{ nodes: [] }] }, version: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('brief.adapter parse boundary', () => {
|
||||||
|
it('parses a well-formed view into the domain unions', () => {
|
||||||
|
const r = parseBriefView(view);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.value.brief.status).toEqual({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' });
|
||||||
|
const [passage, free] = r.value.brief.sections[0].blocks;
|
||||||
|
expect(passage.type === 'passage' && passage.edited).toBe(true);
|
||||||
|
expect(free.type).toBe('freeText');
|
||||||
|
expect(r.value.brief.placeholders[1]).toEqual({ key: 'code', label: 'Code', autoResolvable: true, fillable: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('narrows node variants and rejects unknown ones', () => {
|
||||||
|
expect(parseNode({ type: 'text', text: 'x' })).toEqual({ ok: true, value: { type: 'text', text: 'x' } });
|
||||||
|
expect(parseNode({ type: 'placeholder', key: 'k' })).toEqual({ ok: true, value: { type: 'placeholder', key: 'k' } });
|
||||||
|
expect(parseNode({ type: 'lineBreak' })).toEqual({ ok: true, value: { type: 'lineBreak' } });
|
||||||
|
expect(parseNode({ type: 'text' }).ok).toBe(false); // missing text
|
||||||
|
expect(parseNode({ type: 'bogus' } as never).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a status DTO missing its required fields', () => {
|
||||||
|
expect(parseStatus({ tag: 'submitted' }).ok).toBe(false); // no submittedBy/At
|
||||||
|
expect(parseStatus({ tag: 'rejected', rejectedBy: 'x', rejectedAt: 't' }).ok).toBe(false); // no comments
|
||||||
|
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a passage block missing provenance', () => {
|
||||||
|
const r = parseBrief({
|
||||||
|
...view.brief,
|
||||||
|
sections: [{ sectionKey: 's', title: 'S', required: false, blocks: [{ type: 'passage', blockId: 'b', content: { paragraphs: [] } }] }],
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
259
src/app/brief/infrastructure/brief.adapter.ts
Normal file
259
src/app/brief/infrastructure/brief.adapter.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
|
import { runSubmit } from '@shared/application/submit';
|
||||||
|
import {
|
||||||
|
ApiClient,
|
||||||
|
BriefDto,
|
||||||
|
BriefStatusDto,
|
||||||
|
BriefViewDto,
|
||||||
|
LetterBlockDto,
|
||||||
|
LetterSectionDto,
|
||||||
|
LibraryPassageDto,
|
||||||
|
PlaceholderDefDto,
|
||||||
|
RichTextBlockDto,
|
||||||
|
RichTextNodeDto,
|
||||||
|
} from '@shared/infrastructure/api-client';
|
||||||
|
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief';
|
||||||
|
import { PlaceholderDef } from '@brief/domain/placeholders';
|
||||||
|
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
||||||
|
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
|
||||||
|
* the `parse*` boundary narrows them into the domain's proper discriminated unions
|
||||||
|
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails →
|
||||||
|
* error string), then parse the returned brief.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BriefView {
|
||||||
|
readonly brief: Brief;
|
||||||
|
readonly availablePassages: LibraryPassage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||||
|
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BriefAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
|
async load(): Promise<Result<string, BriefView>> {
|
||||||
|
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
|
||||||
|
return r.ok ? parseBriefView(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(sections: readonly LetterSection[]): Promise<Result<string, Brief>> {
|
||||||
|
const r = await runSubmit(() => this.client.briefPUT({ sections: sections.map(sectionToDto) }), BRIEF_ACTION_FAILED);
|
||||||
|
return r.ok ? parseBrief(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(): Promise<Result<string, Brief>> {
|
||||||
|
const r = await runSubmit(() => this.client.briefSubmit(), BRIEF_ACTION_FAILED);
|
||||||
|
return r.ok ? parseBrief(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(): Promise<Result<string, Brief>> {
|
||||||
|
const r = await runSubmit(() => this.client.approve(), BRIEF_ACTION_FAILED);
|
||||||
|
return r.ok ? parseBrief(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(comments: string): Promise<Result<string, Brief>> {
|
||||||
|
const r = await runSubmit(() => this.client.reject({ comments }), BRIEF_ACTION_FAILED);
|
||||||
|
return r.ok ? parseBrief(r.value) : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(): Promise<Result<string, Brief>> {
|
||||||
|
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
||||||
|
return r.ok ? parseBrief(r.value) : r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parse: wire (flat) → domain (discriminated unions), validating at the boundary ---
|
||||||
|
|
||||||
|
const MARKS: readonly string[] = ['bold', 'italic', 'underline'];
|
||||||
|
|
||||||
|
export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
|
||||||
|
switch (dto.type) {
|
||||||
|
case 'text': {
|
||||||
|
if (typeof dto.text !== 'string') return err('node: text missing text');
|
||||||
|
const marks = dto.marks?.filter((m): m is Mark => MARKS.includes(m));
|
||||||
|
return ok(marks && marks.length ? { type: 'text', text: dto.text, marks } : { type: 'text', text: dto.text });
|
||||||
|
}
|
||||||
|
case 'placeholder':
|
||||||
|
return typeof dto.key === 'string' ? ok({ type: 'placeholder', key: dto.key }) : err('node: placeholder missing key');
|
||||||
|
case 'lineBreak':
|
||||||
|
return ok({ type: 'lineBreak' });
|
||||||
|
default:
|
||||||
|
return err(`node: unknown type ${dto.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> {
|
||||||
|
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
|
||||||
|
const paragraphs = [];
|
||||||
|
for (const p of dto.paragraphs) {
|
||||||
|
const nodes: RichTextNode[] = [];
|
||||||
|
for (const n of p.nodes ?? []) {
|
||||||
|
const parsed = parseNode(n);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
nodes.push(parsed.value);
|
||||||
|
}
|
||||||
|
paragraphs.push({ nodes });
|
||||||
|
}
|
||||||
|
return ok({ paragraphs });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlock(dto: LetterBlockDto): Result<string, LetterBlock> {
|
||||||
|
if (typeof dto.blockId !== 'string') return err('block: missing blockId');
|
||||||
|
const content = parseBlockContent(dto.content);
|
||||||
|
if (!content.ok) return content;
|
||||||
|
switch (dto.type) {
|
||||||
|
case 'passage':
|
||||||
|
if (typeof dto.sourcePassageId !== 'string' || typeof dto.sourceVersion !== 'number') return err('block: bad passage provenance');
|
||||||
|
return ok({
|
||||||
|
type: 'passage',
|
||||||
|
blockId: dto.blockId,
|
||||||
|
sourcePassageId: dto.sourcePassageId,
|
||||||
|
sourceVersion: dto.sourceVersion,
|
||||||
|
content: content.value,
|
||||||
|
edited: dto.edited ?? false,
|
||||||
|
});
|
||||||
|
case 'freeText':
|
||||||
|
return ok({ type: 'freeText', blockId: dto.blockId, content: content.value });
|
||||||
|
default:
|
||||||
|
return err(`block: unknown type ${dto.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
|
||||||
|
if (typeof dto.sectionKey !== 'string' || typeof dto.title !== 'string' || typeof dto.required !== 'boolean') {
|
||||||
|
return err('section: bad shape');
|
||||||
|
}
|
||||||
|
const blocks: LetterBlock[] = [];
|
||||||
|
for (const b of dto.blocks ?? []) {
|
||||||
|
const parsed = parseBlock(b);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
blocks.push(parsed.value);
|
||||||
|
}
|
||||||
|
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, blocks });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
|
||||||
|
if (typeof dto.key !== 'string' || typeof dto.label !== 'string' || typeof dto.autoResolvable !== 'boolean') {
|
||||||
|
return err('placeholder: bad shape');
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
key: dto.key,
|
||||||
|
label: dto.label,
|
||||||
|
autoResolvable: dto.autoResolvable,
|
||||||
|
...(dto.fillable != null ? { fillable: dto.fillable } : {}),
|
||||||
|
...(dto.deprecated != null ? { deprecated: dto.deprecated } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStatus(dto: BriefStatusDto | undefined): Result<string, BriefStatus> {
|
||||||
|
switch (dto?.tag) {
|
||||||
|
case 'draft':
|
||||||
|
return ok({ tag: 'draft' });
|
||||||
|
case 'submitted':
|
||||||
|
if (typeof dto.submittedBy !== 'string' || typeof dto.submittedAt !== 'string') return err('status: bad submitted');
|
||||||
|
return ok({ tag: 'submitted', submittedBy: dto.submittedBy, submittedAt: dto.submittedAt });
|
||||||
|
case 'approved':
|
||||||
|
if (typeof dto.approvedBy !== 'string' || typeof dto.approvedAt !== 'string') return err('status: bad approved');
|
||||||
|
return ok({ tag: 'approved', approvedBy: dto.approvedBy, approvedAt: dto.approvedAt });
|
||||||
|
case 'rejected':
|
||||||
|
if (typeof dto.rejectedBy !== 'string' || typeof dto.rejectedAt !== 'string' || typeof dto.comments !== 'string') return err('status: bad rejected');
|
||||||
|
return ok({ tag: 'rejected', rejectedBy: dto.rejectedBy, rejectedAt: dto.rejectedAt, comments: dto.comments });
|
||||||
|
case 'sent':
|
||||||
|
if (typeof dto.sentAt !== 'string') return err('status: bad sent');
|
||||||
|
return ok({ tag: 'sent', sentAt: dto.sentAt });
|
||||||
|
default:
|
||||||
|
return err(`status: unknown tag ${dto?.tag}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
||||||
|
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep')) return err('passage: bad shape');
|
||||||
|
if (typeof dto.sectionKey !== 'string' || typeof dto.label !== 'string' || typeof dto.version !== 'number') return err('passage: bad shape');
|
||||||
|
const content = parseBlockContent(dto.content);
|
||||||
|
if (!content.ok) return content;
|
||||||
|
return ok({
|
||||||
|
passageId: dto.passageId,
|
||||||
|
scope: dto.scope as PassageScope,
|
||||||
|
sectionKey: dto.sectionKey,
|
||||||
|
label: dto.label,
|
||||||
|
content: content.value,
|
||||||
|
version: dto.version,
|
||||||
|
...(dto.beroep != null ? { beroep: dto.beroep } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBrief(dto: BriefDto): Result<string, Brief> {
|
||||||
|
if (typeof dto.briefId !== 'string' || typeof dto.drafterId !== 'string' || typeof dto.beroep !== 'string' || typeof dto.templateId !== 'string') {
|
||||||
|
return err('brief: missing ids');
|
||||||
|
}
|
||||||
|
const status = parseStatus(dto.status);
|
||||||
|
if (!status.ok) return status;
|
||||||
|
|
||||||
|
const placeholders: PlaceholderDef[] = [];
|
||||||
|
for (const p of dto.placeholders ?? []) {
|
||||||
|
const parsed = parsePlaceholderDef(p);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
placeholders.push(parsed.value);
|
||||||
|
}
|
||||||
|
const sections: LetterSection[] = [];
|
||||||
|
for (const s of dto.sections ?? []) {
|
||||||
|
const parsed = parseSection(s);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
sections.push(parsed.value);
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
briefId: dto.briefId,
|
||||||
|
beroep: dto.beroep,
|
||||||
|
templateId: dto.templateId,
|
||||||
|
placeholders,
|
||||||
|
sections,
|
||||||
|
status: status.value,
|
||||||
|
drafterId: dto.drafterId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBriefView(dto: BriefViewDto): Result<string, BriefView> {
|
||||||
|
if (!dto.brief) return err('brief-view: missing brief');
|
||||||
|
const brief = parseBrief(dto.brief);
|
||||||
|
if (!brief.ok) return brief;
|
||||||
|
const availablePassages: LibraryPassage[] = [];
|
||||||
|
for (const p of dto.availablePassages ?? []) {
|
||||||
|
const parsed = parsePassage(p);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
availablePassages.push(parsed.value);
|
||||||
|
}
|
||||||
|
return ok({ brief: brief.value, availablePassages });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- toDto: domain → wire, for save (collapses the union to the flat shape) ---
|
||||||
|
|
||||||
|
function nodeToDto(n: RichTextNode): RichTextNodeDto {
|
||||||
|
switch (n.type) {
|
||||||
|
case 'text':
|
||||||
|
return { type: 'text', text: n.text, ...(n.marks ? { marks: [...n.marks] } : {}) };
|
||||||
|
case 'placeholder':
|
||||||
|
return { type: 'placeholder', key: n.key };
|
||||||
|
case 'lineBreak':
|
||||||
|
return { type: 'lineBreak' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentToDto(content: RichTextBlock): RichTextBlockDto {
|
||||||
|
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto) })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockToDto(b: LetterBlock): LetterBlockDto {
|
||||||
|
return b.type === 'passage'
|
||||||
|
? { type: 'passage', blockId: b.blockId, content: contentToDto(b.content), sourcePassageId: b.sourcePassageId, sourceVersion: b.sourceVersion, edited: b.edited }
|
||||||
|
: { type: 'freeText', blockId: b.blockId, content: contentToDto(b.content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionToDto(s: LetterSection): LetterSectionDto {
|
||||||
|
return { sectionKey: s.sectionKey, title: s.title, required: s.required, blocks: s.blocks.map(blockToDto) };
|
||||||
|
}
|
||||||
74
src/app/brief/ui/brief.page.ts
Normal file
74
src/app/brief/ui/brief.page.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||||
|
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { BriefStore } from '@brief/application/brief.store';
|
||||||
|
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
||||||
|
|
||||||
|
/** Page: thin container. Injects the root store, kicks off the load, and passes its
|
||||||
|
derived read-model to the composer. Business/UI logic lives below in pure pieces;
|
||||||
|
this just wires signals to the organism and events back to store commands. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-brief-page',
|
||||||
|
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent],
|
||||||
|
template: `
|
||||||
|
<app-page-shell
|
||||||
|
[heading]="heading"
|
||||||
|
[intro]="intro"
|
||||||
|
backLink="/dashboard">
|
||||||
|
@if (lastError(); as err) { <app-alert type="error">{{ err }}</app-alert> }
|
||||||
|
|
||||||
|
@switch (model().tag) {
|
||||||
|
@case ('loading') { <app-spinner /> }
|
||||||
|
@case ('failed') {
|
||||||
|
<app-alert type="error">{{ failedText }}</app-alert>
|
||||||
|
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||||
|
}
|
||||||
|
@case ('loaded') {
|
||||||
|
<app-letter-composer
|
||||||
|
[brief]="brief()!"
|
||||||
|
[availablePassages]="availablePassages()"
|
||||||
|
[diagnostics]="store.diagnostics()"
|
||||||
|
[editable]="store.editable()"
|
||||||
|
[role]="store.role"
|
||||||
|
[canSubmit]="store.canSubmit()"
|
||||||
|
[busy]="store.busy()"
|
||||||
|
(edit)="store.edit($event)"
|
||||||
|
(submit)="store.submit()"
|
||||||
|
(approve)="store.approve()"
|
||||||
|
(reject)="store.reject($event)"
|
||||||
|
(send)="store.send()" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</app-page-shell>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class BriefPage {
|
||||||
|
protected store = inject(BriefStore);
|
||||||
|
protected model = this.store.model;
|
||||||
|
protected lastError = this.store.lastError;
|
||||||
|
|
||||||
|
protected heading = $localize`:@@brief.page.heading:Brief opstellen`;
|
||||||
|
protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;
|
||||||
|
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
|
||||||
|
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
void this.store.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrow the loaded state for the template.
|
||||||
|
protected brief() {
|
||||||
|
const s = this.model();
|
||||||
|
return s.tag === 'loaded' ? s.brief : null;
|
||||||
|
}
|
||||||
|
protected availablePassages() {
|
||||||
|
const s = this.model();
|
||||||
|
return s.tag === 'loaded' ? s.availablePassages : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected reload() {
|
||||||
|
void this.store.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
|
||||||
|
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
|
||||||
|
save/send; warnings (deprecated, unresolved-at-send) are surfaced but allowed.
|
||||||
|
Fed by a `computed()` over the letter content — never stored. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-diagnostics-panel',
|
||||||
|
imports: [AlertComponent],
|
||||||
|
styles: [`
|
||||||
|
:host{display:block}
|
||||||
|
ul{margin:0;padding-inline-start:1.1rem;display:grid;gap:0.15rem}
|
||||||
|
button{background:none;border:0;padding:0;color:var(--rhc-color-foreground-link);cursor:pointer;text-align:start;text-decoration:underline}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
@if (errors().length) {
|
||||||
|
<app-alert type="error">
|
||||||
|
<strong>{{ errorsTitle() }}</strong>
|
||||||
|
<ul>
|
||||||
|
@for (d of errors(); track $index) { <li><button type="button" (click)="locate.emit(d)">{{ d.message }}</button></li> }
|
||||||
|
</ul>
|
||||||
|
</app-alert>
|
||||||
|
}
|
||||||
|
@if (warnings().length) {
|
||||||
|
<app-alert type="warning">
|
||||||
|
<strong>{{ warningsTitle() }}</strong>
|
||||||
|
<ul>
|
||||||
|
@for (d of warnings(); track $index) { <li><button type="button" (click)="locate.emit(d)">{{ d.message }}</button></li> }
|
||||||
|
</ul>
|
||||||
|
</app-alert>
|
||||||
|
}
|
||||||
|
@if (!errors().length && !warnings().length) {
|
||||||
|
<app-alert type="ok">{{ cleanText() }}</app-alert>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class DiagnosticsPanelComponent {
|
||||||
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
|
locate = output<Diagnostic>();
|
||||||
|
|
||||||
|
errorsTitle = input($localize`:@@brief.diag.errors:Op te lossen voor indienen/versturen:`);
|
||||||
|
warningsTitle = input($localize`:@@brief.diag.warnings:Aandachtspunten:`);
|
||||||
|
cleanText = input($localize`:@@brief.diag.clean:Geen problemen gevonden in de velden.`);
|
||||||
|
|
||||||
|
protected errors = computed(() => this.diagnostics().filter((d) => d.severity === 'error'));
|
||||||
|
protected warnings = computed(() => this.diagnostics().filter((d) => d.severity === 'warning'));
|
||||||
|
}
|
||||||
53
src/app/brief/ui/letter-block/letter-block.component.ts
Normal file
53
src/app/brief/ui/letter-block/letter-block.component.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
|
import { RichTextEditorComponent, PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { LetterBlock } from '@brief/domain/brief';
|
||||||
|
|
||||||
|
/** Molecule: one block in a section — its editor plus provenance + block controls.
|
||||||
|
Presentational: emits content/remove/move events; the section maps them to messages. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-letter-block',
|
||||||
|
imports: [RichTextEditorComponent, ButtonComponent],
|
||||||
|
styles: [`
|
||||||
|
:host{display:block}
|
||||||
|
.block{border-inline-start:3px solid var(--rhc-color-border-subtle);padding-inline-start:var(--rhc-space-max-md)}
|
||||||
|
.meta{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-sm)}
|
||||||
|
.controls{display:flex;gap:var(--rhc-space-max-sm)}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<div class="block">
|
||||||
|
<div class="meta">
|
||||||
|
<span class="app-text-subtle">{{ provenance() }}</span>
|
||||||
|
@if (editable()) {
|
||||||
|
<span class="controls">
|
||||||
|
<app-button variant="subtle" (click)="moved.emit(-1)" i18n="@@brief.block.moveUp">Omhoog</app-button>
|
||||||
|
<app-button variant="subtle" (click)="moved.emit(1)" i18n="@@brief.block.moveDown">Omlaag</app-button>
|
||||||
|
<app-button variant="subtle" (click)="removed.emit()" i18n="@@brief.block.remove">Verwijderen</app-button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<app-rich-text-editor
|
||||||
|
[content]="block().content"
|
||||||
|
[placeholders]="placeholders()"
|
||||||
|
[editable]="editable()"
|
||||||
|
(contentChanged)="contentChanged.emit($event)" />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LetterBlockComponent {
|
||||||
|
block = input.required<LetterBlock>();
|
||||||
|
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||||
|
editable = input(false);
|
||||||
|
contentChanged = output<RichTextBlock>();
|
||||||
|
removed = output<void>();
|
||||||
|
moved = output<-1 | 1>();
|
||||||
|
|
||||||
|
protected provenance = computed(() => {
|
||||||
|
const b = this.block();
|
||||||
|
if (b.type === 'freeText') return $localize`:@@brief.provenance.free:Vrije tekst`;
|
||||||
|
return b.edited
|
||||||
|
? $localize`:@@brief.provenance.edited:Aangepaste standaardtekst`
|
||||||
|
: $localize`:@@brief.provenance.standard:Standaardtekst`;
|
||||||
|
});
|
||||||
|
}
|
||||||
148
src/app/brief/ui/letter-composer/letter-composer.component.ts
Normal file
148
src/app/brief/ui/letter-composer/letter-composer.component.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Component, computed, input, output } from '@angular/core';
|
||||||
|
import { Role } from '@shared/infrastructure/role';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||||
|
import { Brief, LibraryPassage } from '@brief/domain/brief';
|
||||||
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
|
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||||
|
import { LetterPreviewComponent } from '@brief/ui/letter-preview/letter-preview.component';
|
||||||
|
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||||
|
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
||||||
|
|
||||||
|
/** Organism: the whole letter — status badge, the editable sections (drafter) or a
|
||||||
|
read-only preview (approver / locked), the diagnostics panel, and the action bar
|
||||||
|
appropriate to status × role. Presentational: emits edit + transition intents. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-letter-composer',
|
||||||
|
imports: [
|
||||||
|
HeadingComponent, StatusBadgeComponent, ButtonComponent, AlertComponent,
|
||||||
|
LetterSectionComponent, LetterPreviewComponent, DiagnosticsPanelComponent, RejectionCommentsComponent,
|
||||||
|
],
|
||||||
|
styles: [`
|
||||||
|
:host{display:block}
|
||||||
|
.head{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);flex-wrap:wrap;margin-block-end:var(--rhc-space-max-lg)}
|
||||||
|
.sections{display:grid;gap:var(--rhc-space-max-2xl)}
|
||||||
|
.panel{margin-block:var(--rhc-space-max-xl)}
|
||||||
|
.bar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;margin-block-start:var(--rhc-space-max-xl)}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<div class="head">
|
||||||
|
<app-heading [level]="2">{{ title() }}</app-heading>
|
||||||
|
<app-status-badge [label]="statusLabel()" [color]="statusColor()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (status() === 'rejected') {
|
||||||
|
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (editable()) {
|
||||||
|
<div class="sections">
|
||||||
|
@for (section of brief().sections; track section.sectionKey) {
|
||||||
|
<app-letter-section
|
||||||
|
[section]="section"
|
||||||
|
[availablePassages]="availablePassages()"
|
||||||
|
[placeholders]="menu()"
|
||||||
|
[editable]="true"
|
||||||
|
(edit)="edit.emit($event)" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<app-letter-preview [brief]="brief()" [diagnostics]="diagnostics()" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<app-diagnostics-panel [diagnostics]="diagnostics()" (locate)="locate.emit($event)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bar">
|
||||||
|
@switch (status()) {
|
||||||
|
@case ('draft') {
|
||||||
|
@if (editable()) {
|
||||||
|
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ submitLabel() }}</app-button>
|
||||||
|
@if (!canSubmit()) { <span class="app-text-subtle">{{ submitHint() }}</span> }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@case ('rejected') {
|
||||||
|
@if (editable()) {
|
||||||
|
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{ resubmitLabel() }}</app-button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@case ('submitted') {
|
||||||
|
@if (role() === 'approver') {
|
||||||
|
<app-button variant="primary" [disabled]="busy()" (click)="approve.emit()">{{ approveLabel() }}</app-button>
|
||||||
|
<app-rejection-comments mode="entry" [busy]="busy()" (reject)="reject.emit($event)" />
|
||||||
|
} @else {
|
||||||
|
<app-alert type="info">{{ awaitingText() }}</app-alert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@case ('approved') {
|
||||||
|
<app-button variant="primary" [disabled]="busy()" (click)="send.emit()">{{ sendLabel() }}</app-button>
|
||||||
|
}
|
||||||
|
@case ('sent') {
|
||||||
|
<app-alert type="ok">{{ sentText() }}</app-alert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LetterComposerComponent {
|
||||||
|
brief = input.required<Brief>();
|
||||||
|
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||||
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
|
editable = input(false);
|
||||||
|
role = input.required<Role>();
|
||||||
|
canSubmit = input(false);
|
||||||
|
busy = input(false);
|
||||||
|
|
||||||
|
edit = output<BriefMsg>();
|
||||||
|
submit = output<void>();
|
||||||
|
approve = output<void>();
|
||||||
|
reject = output<string>();
|
||||||
|
send = output<void>();
|
||||||
|
locate = output<Diagnostic>();
|
||||||
|
|
||||||
|
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
||||||
|
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
||||||
|
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
||||||
|
submitHint = input($localize`:@@brief.submitHint:Vul eerst alle verplichte secties en los fouten op.`);
|
||||||
|
approveLabel = input($localize`:@@brief.approve:Goedkeuren`);
|
||||||
|
sendLabel = input($localize`:@@brief.send:Versturen`);
|
||||||
|
awaitingText = input($localize`:@@brief.awaiting:De brief wacht op beoordeling door een collega.`);
|
||||||
|
sentText = input($localize`:@@brief.sent:De brief is verzonden.`);
|
||||||
|
|
||||||
|
protected status = computed(() => this.brief().status.tag);
|
||||||
|
protected rejectComments = computed(() => {
|
||||||
|
const s = this.brief().status;
|
||||||
|
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 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected statusLabel = computed(() => {
|
||||||
|
switch (this.status()) {
|
||||||
|
case 'draft': return $localize`:@@brief.status.draft:Concept`;
|
||||||
|
case 'submitted': return $localize`:@@brief.status.submitted:Ter beoordeling`;
|
||||||
|
case 'approved': return $localize`:@@brief.status.approved:Goedgekeurd`;
|
||||||
|
case 'rejected': return $localize`:@@brief.status.rejected:Afgewezen`;
|
||||||
|
case 'sent': return $localize`:@@brief.status.sent:Verzonden`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
protected statusColor = computed(() => {
|
||||||
|
switch (this.status()) {
|
||||||
|
case 'draft': return 'var(--rhc-color-border-strong)';
|
||||||
|
case 'submitted': return 'var(--rhc-color-oranje-500)';
|
||||||
|
case 'approved':
|
||||||
|
case 'sent': return 'var(--rhc-color-groen-500)';
|
||||||
|
case 'rejected': return 'var(--rhc-color-rood-500)';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
56
src/app/brief/ui/letter-composer/letter-composer.stories.ts
Normal file
56
src/app/brief/ui/letter-composer/letter-composer.stories.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { LetterComposerComponent } from './letter-composer.component';
|
||||||
|
import { Brief, BriefStatus, LibraryPassage } from '@brief/domain/brief';
|
||||||
|
import { allDiagnostics } from '@brief/domain/brief';
|
||||||
|
|
||||||
|
const passages: LibraryPassage[] = [
|
||||||
|
{ passageId: 'p1', scope: 'global', sectionKey: 'aanhef', label: 'Standaard aanhef', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw ' }, { type: 'placeholder', key: 'naam_zorgverlener' }, { type: 'text', text: ',' }] }] } },
|
||||||
|
{ passageId: 'p2', scope: 'beroep', beroep: 'arts', sectionKey: 'kern', label: 'Toelichting arts', version: 1, content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function brief(status: BriefStatus): Brief {
|
||||||
|
return {
|
||||||
|
briefId: 'b1',
|
||||||
|
beroep: 'arts',
|
||||||
|
templateId: 't1',
|
||||||
|
drafterId: 'demo-drafter',
|
||||||
|
status,
|
||||||
|
placeholders: [
|
||||||
|
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||||
|
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||||
|
],
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
sectionKey: 'aanhef',
|
||||||
|
title: 'Aanhef',
|
||||||
|
required: true,
|
||||||
|
blocks: [{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p1', sourceVersion: 1, edited: false, content: passages[0].content }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sectionKey: 'kern',
|
||||||
|
title: 'Kern van het besluit',
|
||||||
|
required: true,
|
||||||
|
blocks: [{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten om reden ' }, { type: 'placeholder', key: 'reden_besluit' }, { type: 'text', text: '.' }] }] } }],
|
||||||
|
},
|
||||||
|
{ sectionKey: 'slot', title: 'Slot', required: false, blocks: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const render = (b: Brief, editable: boolean, role: 'drafter' | 'approver') => ({
|
||||||
|
props: { brief: b, availablePassages: passages, diagnostics: allDiagnostics(b), editable, role, canSubmit: true, busy: false },
|
||||||
|
template: `<app-letter-composer [brief]="brief" [availablePassages]="availablePassages" [diagnostics]="diagnostics"
|
||||||
|
[editable]="editable" [role]="role" [canSubmit]="canSubmit" [busy]="busy"></app-letter-composer>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta: Meta<LetterComposerComponent> = {
|
||||||
|
title: 'Organisms/Letter Composer',
|
||||||
|
component: LetterComposerComponent,
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<LetterComposerComponent>;
|
||||||
|
|
||||||
|
export const DraftDrafter: Story = { render: () => render(brief({ tag: 'draft' }), true, 'drafter') };
|
||||||
|
export const SubmittedApprover: Story = { render: () => render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), false, 'approver') };
|
||||||
|
export const Rejected: Story = { render: () => render(brief({ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de aanhef formeler.' }), true, 'drafter') };
|
||||||
|
export const Sent: Story = { render: () => render(brief({ tag: 'sent', sentAt: '2026-07-01' }), false, 'drafter') };
|
||||||
65
src/app/brief/ui/letter-preview/letter-preview.component.ts
Normal file
65
src/app/brief/ui/letter-preview/letter-preview.component.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||||
|
import { Brief } from '@brief/domain/brief';
|
||||||
|
import { Diagnostic } from '@brief/domain/placeholders';
|
||||||
|
|
||||||
|
/** Organism: read-only rendering of the letter as the approver/recipient sees it.
|
||||||
|
Placeholders show as labelled chips (values are resolved server-side only at send);
|
||||||
|
a chip flagged by the linter shows its error/warning state. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-letter-preview',
|
||||||
|
imports: [HeadingComponent, PlaceholderChipComponent],
|
||||||
|
styles: [`
|
||||||
|
:host{display:block}
|
||||||
|
.letter{background:var(--rhc-color-wit);border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-2xl)}
|
||||||
|
section{margin-block-end:var(--rhc-space-max-xl)}
|
||||||
|
p{margin:0 0 var(--rhc-space-max-sm)}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<div class="letter">
|
||||||
|
@for (section of brief().sections; track section.sectionKey) {
|
||||||
|
<section>
|
||||||
|
<app-heading [level]="3">{{ section.title }}</app-heading>
|
||||||
|
@for (block of section.blocks; track block.blockId) {
|
||||||
|
@for (para of block.content.paragraphs; track $index) {
|
||||||
|
<p>
|
||||||
|
@for (node of para.nodes; track $index) {
|
||||||
|
@switch (node.type) {
|
||||||
|
@case ('text') { <span>{{ node.text }}</span> }
|
||||||
|
@case ('lineBreak') { <br> }
|
||||||
|
@case ('placeholder') {
|
||||||
|
<app-placeholder-chip
|
||||||
|
[label]="labelFor(node.key)"
|
||||||
|
[autoResolvable]="autoFor(node.key)"
|
||||||
|
[state]="stateFor(node.key)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LetterPreviewComponent {
|
||||||
|
brief = input.required<Brief>();
|
||||||
|
diagnostics = input<readonly Diagnostic[]>([]);
|
||||||
|
|
||||||
|
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||||
|
private worst = computed(() => {
|
||||||
|
const m = new Map<string, 'error' | 'warning'>();
|
||||||
|
for (const d of this.diagnostics()) {
|
||||||
|
if (!d.placeholderKey) continue;
|
||||||
|
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
||||||
|
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||||
|
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||||
|
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||||
|
}
|
||||||
83
src/app/brief/ui/letter-section/letter-section.component.ts
Normal file
83
src/app/brief/ui/letter-section/letter-section.component.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Component, computed, input, output, signal } from '@angular/core';
|
||||||
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
|
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||||
|
import { LetterSection, LibraryPassage } from '@brief/domain/brief';
|
||||||
|
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||||
|
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
|
||||||
|
add-passages and add-free-text actions. Maps child events to `BriefMsg`s; sections
|
||||||
|
themselves can never be added/removed/reordered (no message exists for it). */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-letter-section',
|
||||||
|
imports: [ButtonComponent, HeadingComponent, LetterBlockComponent, PassagePickerComponent],
|
||||||
|
styles: [`
|
||||||
|
:host{display:block}
|
||||||
|
.blocks{display:grid;gap:var(--rhc-space-max-lg);margin-block:var(--rhc-space-max-md)}
|
||||||
|
.actions{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm)}
|
||||||
|
.required{color:var(--rhc-color-foreground-subtle)}
|
||||||
|
.empty{color:var(--rhc-color-foreground-subtle);font-style:italic}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<app-heading [level]="3">
|
||||||
|
{{ section().title }}
|
||||||
|
@if (section().required) { <span class="required">· {{ requiredLabel() }}</span> }
|
||||||
|
</app-heading>
|
||||||
|
|
||||||
|
<div class="blocks">
|
||||||
|
@for (block of section().blocks; track block.blockId) {
|
||||||
|
<app-letter-block
|
||||||
|
[block]="block"
|
||||||
|
[placeholders]="placeholders()"
|
||||||
|
[editable]="editable()"
|
||||||
|
(contentChanged)="onContent(block.blockId, $event)"
|
||||||
|
(removed)="edit.emit({ tag: 'BlockRemoved', blockId: block.blockId })"
|
||||||
|
(moved)="onMove(block.blockId, $event)" />
|
||||||
|
} @empty {
|
||||||
|
<p class="empty">{{ emptyLabel() }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (editable()) {
|
||||||
|
<div class="actions">
|
||||||
|
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{ addPassageLabel() }}</app-button>
|
||||||
|
<app-button variant="subtle" (click)="edit.emit({ tag: 'FreeTextBlockAdded', sectionKey: section().sectionKey })">{{ addFreeLabel() }}</app-button>
|
||||||
|
</div>
|
||||||
|
@if (pickerOpen()) {
|
||||||
|
<app-passage-picker [passages]="sectionPassages()" (insert)="onInsert($event)" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class LetterSectionComponent {
|
||||||
|
section = input.required<LetterSection>();
|
||||||
|
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||||
|
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||||
|
editable = input(false);
|
||||||
|
edit = output<BriefMsg>();
|
||||||
|
|
||||||
|
requiredLabel = input($localize`:@@brief.section.required:verplicht`);
|
||||||
|
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
|
||||||
|
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
|
||||||
|
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
|
||||||
|
|
||||||
|
protected pickerOpen = signal(false);
|
||||||
|
protected sectionPassages = computed(() => this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey));
|
||||||
|
|
||||||
|
protected onContent(blockId: string, content: RichTextBlock) {
|
||||||
|
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onMove(blockId: string, direction: -1 | 1) {
|
||||||
|
const i = this.section().blocks.findIndex((b) => b.blockId === blockId);
|
||||||
|
this.edit.emit({ tag: 'BlockMovedWithinSection', blockId, toIndex: i + direction });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onInsert(passages: LibraryPassage[]) {
|
||||||
|
this.edit.emit({ tag: 'PassagesInserted', sectionKey: this.section().sectionKey, passages });
|
||||||
|
this.pickerOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/brief/ui/passage-picker/passage-picker.component.ts
Normal file
55
src/app/brief/ui/passage-picker/passage-picker.component.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { LibraryPassage } from '@brief/domain/brief';
|
||||||
|
|
||||||
|
/** Molecule: multi-select list of the section's library passages. One "Voeg toe"
|
||||||
|
inserts ALL checked passages at once (a single message upstream) — there is no
|
||||||
|
single-insert path. Presentational: emits the chosen passages in list order. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-passage-picker',
|
||||||
|
imports: [FormsModule, CheckboxComponent, ButtonComponent],
|
||||||
|
styles: [`
|
||||||
|
:host{display:block;border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-md)}
|
||||||
|
ul{list-style:none;margin:0 0 var(--rhc-space-max-md);padding:0;display:grid;gap:var(--rhc-space-max-sm)}
|
||||||
|
.scope{color:var(--rhc-color-foreground-subtle)}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<ul>
|
||||||
|
@for (p of passages(); track p.passageId) {
|
||||||
|
<li>
|
||||||
|
<app-checkbox
|
||||||
|
[label]="p.label"
|
||||||
|
[ngModel]="!!checked()[p.passageId]"
|
||||||
|
(ngModelChange)="set(p.passageId, $event)" />
|
||||||
|
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()">{{ addLabel() }} ({{ count() }})</app-button>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class PassagePickerComponent {
|
||||||
|
passages = input.required<readonly LibraryPassage[]>();
|
||||||
|
insert = output<LibraryPassage[]>();
|
||||||
|
|
||||||
|
addLabel = input($localize`:@@brief.picker.add:Voeg toe`);
|
||||||
|
globalLabel = input($localize`:@@brief.picker.global:algemeen`);
|
||||||
|
beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);
|
||||||
|
|
||||||
|
protected checked = signal<Record<string, boolean>>({});
|
||||||
|
protected count = () => Object.values(this.checked()).filter(Boolean).length;
|
||||||
|
|
||||||
|
protected set(id: string, on: boolean) {
|
||||||
|
this.checked.update((c) => ({ ...c, [id]: on }));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected add() {
|
||||||
|
const chosen = this.passages().filter((p) => this.checked()[p.passageId]);
|
||||||
|
if (chosen.length) {
|
||||||
|
this.insert.emit(chosen);
|
||||||
|
this.checked.set({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
|
||||||
|
/** Molecule: shows the rejection comments (drafter view) or collects them from the
|
||||||
|
approver. The approver rejects WITH comments; they never edit the letter. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-rejection-comments',
|
||||||
|
imports: [FormsModule, AlertComponent, ButtonComponent],
|
||||||
|
styles: [`
|
||||||
|
:host{display:block}
|
||||||
|
textarea{inline-size:100%;box-sizing:border-box;min-block-size:4rem;margin-block:var(--rhc-space-max-sm)}
|
||||||
|
label{font-weight:600}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
@if (mode() === 'show') {
|
||||||
|
<app-alert type="warning"><strong>{{ rejectedTitle() }}</strong> {{ comments() }}</app-alert>
|
||||||
|
} @else {
|
||||||
|
<label for="reject-comments">{{ entryLabel() }}</label>
|
||||||
|
<textarea id="reject-comments" [(ngModel)]="draft"></textarea>
|
||||||
|
<app-button variant="danger" [disabled]="!draft().trim() || busy()" (click)="submit()">{{ rejectLabel() }}</app-button>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class RejectionCommentsComponent {
|
||||||
|
mode = input<'show' | 'entry'>('show');
|
||||||
|
comments = input('');
|
||||||
|
busy = input(false);
|
||||||
|
reject = output<string>();
|
||||||
|
|
||||||
|
rejectedTitle = input($localize`:@@brief.reject.title:Afgewezen:`);
|
||||||
|
entryLabel = input($localize`:@@brief.reject.entryLabel:Reden van afwijzing`);
|
||||||
|
rejectLabel = input($localize`:@@brief.reject.button:Afwijzen`);
|
||||||
|
|
||||||
|
protected draft = signal('');
|
||||||
|
|
||||||
|
protected submit() {
|
||||||
|
const c = this.draft().trim();
|
||||||
|
if (c) this.reject.emit(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/registratie/domain/value-objects/big-nummer.spec.ts
Normal file
17
src/app/registratie/domain/value-objects/big-nummer.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseBigNummer } from './big-nummer';
|
||||||
|
|
||||||
|
describe('parseBigNummer', () => {
|
||||||
|
it('accepts exactly 11 digits, trimming whitespace', () => {
|
||||||
|
const r = parseBigNummer(' 12345678901 ');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.value).toBe('12345678901');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong length or non-digits', () => {
|
||||||
|
expect(parseBigNummer('').ok).toBe(false);
|
||||||
|
expect(parseBigNummer('1234567890').ok).toBe(false); // 10 digits
|
||||||
|
expect(parseBigNummer('123456789012').ok).toBe(false); // 12 digits
|
||||||
|
expect(parseBigNummer('1234567890a').ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/app/registratie/domain/value-objects/postcode.spec.ts
Normal file
20
src/app/registratie/domain/value-objects/postcode.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parsePostcode } from './postcode';
|
||||||
|
|
||||||
|
describe('parsePostcode', () => {
|
||||||
|
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => {
|
||||||
|
for (const raw of ['1234ab', '1234 AB', ' 1234ab ', '1234AB']) {
|
||||||
|
const r = parsePostcode(raw);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.value).toBe('1234 AB');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed postcodes', () => {
|
||||||
|
expect(parsePostcode('').ok).toBe(false);
|
||||||
|
expect(parsePostcode('0234AB').ok).toBe(false); // leading zero
|
||||||
|
expect(parsePostcode('123AB').ok).toBe(false); // 3 digits
|
||||||
|
expect(parsePostcode('1234A').ok).toBe(false); // 1 letter
|
||||||
|
expect(parsePostcode('1234ABC').ok).toBe(false); // 3 letters
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/app/registratie/domain/value-objects/uren.spec.ts
Normal file
19
src/app/registratie/domain/value-objects/uren.spec.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseUren } from './uren';
|
||||||
|
|
||||||
|
describe('parseUren', () => {
|
||||||
|
it('accepts non-negative whole numbers, including 0', () => {
|
||||||
|
for (const [raw, n] of [['0', 0], [' 40 ', 40], ['1000', 1000]] as const) {
|
||||||
|
const r = parseUren(raw);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.value).toBe(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty, negative, and non-integer input', () => {
|
||||||
|
expect(parseUren('').ok).toBe(false);
|
||||||
|
expect(parseUren('-1').ok).toBe(false);
|
||||||
|
expect(parseUren('1.5').ok).toBe(false);
|
||||||
|
expect(parseUren('abc').ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -165,6 +165,7 @@ export class DashboardPage {
|
|||||||
{ label: $localize`:@@dashboard.nav.herregistratie:Herregistratie`, to: '/herregistratie' },
|
{ label: $localize`:@@dashboard.nav.herregistratie:Herregistratie`, to: '/herregistratie' },
|
||||||
{ label: $localize`:@@dashboard.nav.inschrijven:Inschrijven`, to: '/registreren' },
|
{ label: $localize`:@@dashboard.nav.inschrijven:Inschrijven`, to: '/registreren' },
|
||||||
{ label: $localize`:@@dashboard.nav.concepten:Functionele patronen`, to: '/concepts' },
|
{ label: $localize`:@@dashboard.nav.concepten:Functionele patronen`, to: '/concepts' },
|
||||||
|
{ label: $localize`:@@dashboard.nav.brief:Brief opstellen`, to: '/brief' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Primary transactional actions, as a card grid. */
|
/** Primary transactional actions, as a card grid. */
|
||||||
|
|||||||
@@ -929,6 +929,284 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
return Promise.resolve<SubmitApplicationResponse>(null as any);
|
return Promise.resolve<SubmitApplicationResponse>(null as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
briefGET(): Promise<BriefViewDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/brief";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processBriefGET(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processBriefGET(response: Response): Promise<BriefViewDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<BriefViewDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
briefPUT(body: SaveBriefRequest): Promise<BriefDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/brief";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
const content_ = JSON.stringify(body);
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
body: content_,
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processBriefPUT(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processBriefPUT(response: Response): Promise<BriefDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 403) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result403: any = null;
|
||||||
|
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||||
|
});
|
||||||
|
} else if (status === 409) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result409: any = null;
|
||||||
|
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<BriefDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
briefSubmit(): Promise<BriefDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/brief/submit";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processBriefSubmit(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processBriefSubmit(response: Response): Promise<BriefDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 403) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result403: any = null;
|
||||||
|
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||||
|
});
|
||||||
|
} else if (status === 409) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result409: any = null;
|
||||||
|
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<BriefDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
approve(): Promise<BriefDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/brief/approve";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processApprove(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processApprove(response: Response): Promise<BriefDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 403) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result403: any = null;
|
||||||
|
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||||
|
});
|
||||||
|
} else if (status === 409) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result409: any = null;
|
||||||
|
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<BriefDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
reject(body: RejectBriefRequest): Promise<BriefDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/brief/reject";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
const content_ = JSON.stringify(body);
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
body: content_,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processReject(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processReject(response: Response): Promise<BriefDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 403) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result403: any = null;
|
||||||
|
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||||
|
});
|
||||||
|
} else if (status === 409) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result409: any = null;
|
||||||
|
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<BriefDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
send(): Promise<BriefDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/v1/brief/send";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processSend(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processSend(response: Response): Promise<BriefDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 409) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result409: any = null;
|
||||||
|
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<BriefDto>(null as any);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AantekeningDto {
|
export interface AantekeningDto {
|
||||||
@@ -973,6 +1251,33 @@ export interface ApplicationSummaryDto {
|
|||||||
submittedAt?: string | undefined;
|
submittedAt?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BriefDto {
|
||||||
|
briefId?: string | undefined;
|
||||||
|
beroep?: string | undefined;
|
||||||
|
templateId?: string | undefined;
|
||||||
|
placeholders?: PlaceholderDefDto[] | undefined;
|
||||||
|
sections?: LetterSectionDto[] | undefined;
|
||||||
|
status?: BriefStatusDto;
|
||||||
|
drafterId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BriefStatusDto {
|
||||||
|
tag?: string | undefined;
|
||||||
|
submittedBy?: string | undefined;
|
||||||
|
submittedAt?: string | undefined;
|
||||||
|
approvedBy?: string | undefined;
|
||||||
|
approvedAt?: string | undefined;
|
||||||
|
rejectedBy?: string | undefined;
|
||||||
|
rejectedAt?: string | undefined;
|
||||||
|
comments?: string | undefined;
|
||||||
|
sentAt?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BriefViewDto {
|
||||||
|
brief?: BriefDto;
|
||||||
|
availablePassages?: LibraryPassageDto[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BrpAddressDto {
|
export interface BrpAddressDto {
|
||||||
gevonden?: boolean;
|
gevonden?: boolean;
|
||||||
adres?: AdresDto;
|
adres?: AdresDto;
|
||||||
@@ -1050,17 +1355,55 @@ export interface IntakeRequest {
|
|||||||
uren?: number;
|
uren?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LetterBlockDto {
|
||||||
|
type?: string | undefined;
|
||||||
|
blockId?: string | undefined;
|
||||||
|
content?: RichTextBlockDto;
|
||||||
|
sourcePassageId?: string | undefined;
|
||||||
|
sourceVersion?: number | undefined;
|
||||||
|
edited?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LetterSectionDto {
|
||||||
|
sectionKey?: string | undefined;
|
||||||
|
title?: string | undefined;
|
||||||
|
required?: boolean;
|
||||||
|
blocks?: LetterBlockDto[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryPassageDto {
|
||||||
|
passageId?: string | undefined;
|
||||||
|
scope?: string | undefined;
|
||||||
|
sectionKey?: string | undefined;
|
||||||
|
label?: string | undefined;
|
||||||
|
content?: RichTextBlockDto;
|
||||||
|
version?: number;
|
||||||
|
beroep?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ManualDiplomaPolicyDto {
|
export interface ManualDiplomaPolicyDto {
|
||||||
beroepen?: string[] | undefined;
|
beroepen?: string[] | undefined;
|
||||||
policyQuestions?: PolicyQuestionDto[] | undefined;
|
policyQuestions?: PolicyQuestionDto[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParagraphDto {
|
||||||
|
nodes?: RichTextNodeDto[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PersonDto {
|
export interface PersonDto {
|
||||||
naam?: string | undefined;
|
naam?: string | undefined;
|
||||||
geboortedatum?: string | undefined;
|
geboortedatum?: string | undefined;
|
||||||
adres?: AdresDto;
|
adres?: AdresDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlaceholderDefDto {
|
||||||
|
key?: string | undefined;
|
||||||
|
label?: string | undefined;
|
||||||
|
autoResolvable?: boolean;
|
||||||
|
fillable?: boolean | undefined;
|
||||||
|
deprecated?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PolicyQuestionDto {
|
export interface PolicyQuestionDto {
|
||||||
id?: string | undefined;
|
id?: string | undefined;
|
||||||
vraag?: string | undefined;
|
vraag?: string | undefined;
|
||||||
@@ -1103,6 +1446,25 @@ export interface RegistrationStatusDto {
|
|||||||
doorgehaaldOp?: string | undefined;
|
doorgehaaldOp?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RejectBriefRequest {
|
||||||
|
comments?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichTextBlockDto {
|
||||||
|
paragraphs?: ParagraphDto[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichTextNodeDto {
|
||||||
|
type?: string | undefined;
|
||||||
|
text?: string | undefined;
|
||||||
|
marks?: string[] | undefined;
|
||||||
|
key?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveBriefRequest {
|
||||||
|
sections?: LetterSectionDto[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubmitApplicationRequest {
|
export interface SubmitApplicationRequest {
|
||||||
diplomaHerkomst?: string | undefined;
|
diplomaHerkomst?: string | undefined;
|
||||||
uren?: number | undefined;
|
uren?: number | undefined;
|
||||||
|
|||||||
12
src/app/shared/infrastructure/role.interceptor.ts
Normal file
12
src/app/shared/infrastructure/role.interceptor.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { currentRole } from './role';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev-only: stamps brief requests with the current `?role=` as an `X-Role` header so
|
||||||
|
* the backend can enforce the drafter/approver rules. Only brief endpoints carry it;
|
||||||
|
* everything else is untouched.
|
||||||
|
*/
|
||||||
|
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
if (!req.url.includes('/api/v1/brief')) return next(req);
|
||||||
|
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
|
||||||
|
};
|
||||||
12
src/app/shared/infrastructure/role.ts
Normal file
12
src/app/shared/infrastructure/role.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Dev-only role stand-in. This POC has one faked self-service user and no real
|
||||||
|
* identities, so the two-person letter workflow (drafter vs approver) is driven by
|
||||||
|
* a `?role=` query param — exactly the pattern of the `?scenario=` toggle. The
|
||||||
|
* backend receives it as an `X-Role` header (see role.interceptor) and enforces the
|
||||||
|
* approver≠drafter rule; the FE derives `editable` from it.
|
||||||
|
*/
|
||||||
|
export type Role = 'drafter' | 'approver';
|
||||||
|
|
||||||
|
export function currentRole(): Role {
|
||||||
|
return new URLSearchParams(window.location.search).get('role') === 'approver' ? 'approver' : 'drafter';
|
||||||
|
}
|
||||||
38
src/app/shared/kernel/rich-text.spec.ts
Normal file
38
src/app/shared/kernel/rich-text.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { RichTextBlock, deepCopyBlock, emptyBlock, isBlockEmpty, placeholderKeysIn } from './rich-text';
|
||||||
|
|
||||||
|
const block = (): RichTextBlock => ({
|
||||||
|
paragraphs: [
|
||||||
|
{ nodes: [{ type: 'text', text: 'Beste ' }, { type: 'placeholder', key: 'naam' }] },
|
||||||
|
{ nodes: [{ type: 'placeholder', key: 'datum' }, { type: 'placeholder', key: 'naam' }] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rich-text', () => {
|
||||||
|
it('emptyBlock is one empty paragraph and reads as empty', () => {
|
||||||
|
expect(emptyBlock()).toEqual({ paragraphs: [{ nodes: [] }] });
|
||||||
|
expect(isBlockEmpty(emptyBlock())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isBlockEmpty is false when any placeholder or non-blank text exists', () => {
|
||||||
|
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: ' ' }] }] })).toBe(true);
|
||||||
|
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(false);
|
||||||
|
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: 'hoi' }] }] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('placeholderKeysIn walks in document order, keeping duplicates', () => {
|
||||||
|
expect(placeholderKeysIn(block())).toEqual(['naam', 'datum', 'naam']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deepCopyBlock is an independent value copy (frozen snapshot)', () => {
|
||||||
|
const original = block();
|
||||||
|
const copy = deepCopyBlock(original);
|
||||||
|
expect(copy).toEqual(original);
|
||||||
|
expect(copy).not.toBe(original);
|
||||||
|
expect(copy.paragraphs[0]).not.toBe(original.paragraphs[0]);
|
||||||
|
// Mutating the copy must not touch the original — proves no shared reference.
|
||||||
|
(copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = { type: 'text', text: 'CHANGED' };
|
||||||
|
expect(placeholderKeysIn(original)).toEqual(['naam', 'datum', 'naam']);
|
||||||
|
expect((original.paragraphs[0].nodes[0] as { text: string }).text).toBe('Beste ');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/app/shared/kernel/rich-text.ts
Normal file
59
src/app/shared/kernel/rich-text.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Rich text as a *serialisable value*, not opaque HTML.
|
||||||
|
*
|
||||||
|
* A block is a node tree. Because a placeholder is a first-class NODE (not a
|
||||||
|
* `{{token}}` substring hidden inside a string), highlighting it, inserting it,
|
||||||
|
* and linting it are all pure functions over data — no regex over markup. This
|
||||||
|
* is the whole reason the letter feature stays in the "impossible states" style:
|
||||||
|
* the value the app holds is always well-shaped, and the imperative editor is
|
||||||
|
* quarantined behind one component that converts to/from this tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Mark = 'bold' | 'italic' | 'underline';
|
||||||
|
|
||||||
|
export type RichTextNode =
|
||||||
|
| { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }
|
||||||
|
| { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send
|
||||||
|
| { readonly type: 'lineBreak' };
|
||||||
|
|
||||||
|
export interface Paragraph {
|
||||||
|
readonly nodes: readonly RichTextNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RichTextBlock {
|
||||||
|
readonly paragraphs: readonly Paragraph[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty editable block is one empty paragraph — never zero paragraphs, so the
|
||||||
|
editor always has a caret line. */
|
||||||
|
export function emptyBlock(): RichTextBlock {
|
||||||
|
return { paragraphs: [{ nodes: [] }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the block carries no visible content (used for "required section empty"). */
|
||||||
|
export function isBlockEmpty(block: RichTextBlock): boolean {
|
||||||
|
return block.paragraphs.every((p) =>
|
||||||
|
p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library
|
||||||
|
passage into a letter copies its tree through here, so the letter never shares a
|
||||||
|
reference with the library — later library edits can't mutate an existing letter. */
|
||||||
|
export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
|
||||||
|
// ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;
|
||||||
|
// a hand-rolled walk would be more code for the same result.
|
||||||
|
return structuredClone(block) as RichTextBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every placeholder key used in a block, in document order (duplicates kept — the
|
||||||
|
caller dedupes when it wants a set). */
|
||||||
|
export function placeholderKeysIn(block: RichTextBlock): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (const p of block.paragraphs) {
|
||||||
|
for (const n of p.nodes) {
|
||||||
|
if (n.type === 'placeholder') keys.push(n.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
39
src/app/shared/ui/checkbox/checkbox.component.ts
Normal file
39
src/app/shared/ui/checkbox/checkbox.component.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Component, forwardRef, input } from '@angular/core';
|
||||||
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
|
||||||
|
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Native
|
||||||
|
input for full keyboard + screen-reader support; the label is the click target. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-checkbox',
|
||||||
|
styles: [`
|
||||||
|
:host{display:block}
|
||||||
|
label{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md);cursor:pointer}
|
||||||
|
input{inline-size:1.1rem;block-size:1.1rem}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" [id]="checkboxId()" [checked]="value" [disabled]="disabled"
|
||||||
|
(change)="onToggle($event)" (blur)="onTouched()" />
|
||||||
|
<span>{{ label() }}</span>
|
||||||
|
</label>
|
||||||
|
`,
|
||||||
|
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true }],
|
||||||
|
})
|
||||||
|
export class CheckboxComponent implements ControlValueAccessor {
|
||||||
|
checkboxId = input<string>();
|
||||||
|
label = input('');
|
||||||
|
|
||||||
|
value = false;
|
||||||
|
disabled = false;
|
||||||
|
onChange: (v: boolean) => void = () => {};
|
||||||
|
onTouched: () => void = () => {};
|
||||||
|
|
||||||
|
onToggle(e: Event) {
|
||||||
|
this.value = (e.target as HTMLInputElement).checked;
|
||||||
|
this.onChange(this.value);
|
||||||
|
}
|
||||||
|
writeValue(v: boolean) { this.value = !!v; }
|
||||||
|
registerOnChange(fn: (v: boolean) => void) { this.onChange = fn; }
|
||||||
|
registerOnTouched(fn: () => void) { this.onTouched = fn; }
|
||||||
|
setDisabledState(d: boolean) { this.disabled = d; }
|
||||||
|
}
|
||||||
15
src/app/shared/ui/checkbox/checkbox.stories.ts
Normal file
15
src/app/shared/ui/checkbox/checkbox.stories.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { CheckboxComponent } from './checkbox.component';
|
||||||
|
|
||||||
|
const meta: Meta<CheckboxComponent> = {
|
||||||
|
title: 'Atoms/Checkbox',
|
||||||
|
component: CheckboxComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `<app-checkbox [label]="label" [checkboxId]="checkboxId"></app-checkbox>`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<CheckboxComponent>;
|
||||||
|
|
||||||
|
export const Default: Story = { args: { label: 'Standaard aanhef', checkboxId: 'cb-1' } };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
|
||||||
|
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
|
||||||
|
for linter error/warning states. Domain-free and presentational — the caller
|
||||||
|
passes label/state; a11y label announces the field name + its resolution status.
|
||||||
|
(The editor renders its own inline chips inside contenteditable; this atom is for
|
||||||
|
everywhere the letter is shown, not edited.) */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-placeholder-chip',
|
||||||
|
styles: [`
|
||||||
|
.chip{
|
||||||
|
display:inline-flex;align-items:center;gap:0.2em;
|
||||||
|
border-radius:var(--rhc-border-radius-sm);
|
||||||
|
padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap;
|
||||||
|
}
|
||||||
|
.chip::before{content:'⌗';opacity:0.6;font-weight:700}
|
||||||
|
.chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)}
|
||||||
|
.chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)}
|
||||||
|
.chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||||
|
.chip--error{background:var(--rhc-color-rood-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||||
|
`],
|
||||||
|
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{ label() }}</span>`,
|
||||||
|
})
|
||||||
|
export class PlaceholderChipComponent {
|
||||||
|
label = input.required<string>();
|
||||||
|
autoResolvable = input(false);
|
||||||
|
state = input<'ok' | 'warning' | 'error'>('ok');
|
||||||
|
|
||||||
|
// Copy is localizable-by-default per the shared-UI convention (like <app-async>).
|
||||||
|
autoText = input($localize`:@@placeholderChip.auto:wordt automatisch ingevuld`);
|
||||||
|
manualText = input($localize`:@@placeholderChip.manual:handmatig in te vullen`);
|
||||||
|
warningText = input($localize`:@@placeholderChip.warning:let op`);
|
||||||
|
errorText = input($localize`:@@placeholderChip.error:fout`);
|
||||||
|
|
||||||
|
protected variant = computed(() => {
|
||||||
|
const s = this.state();
|
||||||
|
return s !== 'ok' ? s : this.autoResolvable() ? 'auto' : 'manual';
|
||||||
|
});
|
||||||
|
|
||||||
|
protected ariaLabel = computed(() => {
|
||||||
|
const status = { auto: this.autoText(), manual: this.manualText(), warning: this.warningText(), error: this.errorText() }[this.variant()];
|
||||||
|
return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { PlaceholderChipComponent } from './placeholder-chip.component';
|
||||||
|
|
||||||
|
const meta: Meta<PlaceholderChipComponent> = {
|
||||||
|
title: 'Atoms/Placeholder Chip',
|
||||||
|
component: PlaceholderChipComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<PlaceholderChipComponent>;
|
||||||
|
|
||||||
|
export const AutoResolvable: Story = { args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' } };
|
||||||
|
export const Manual: Story = { args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' } };
|
||||||
|
export const Warning: Story = { args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' } };
|
||||||
|
export const Error: Story = { args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' } };
|
||||||
51
src/app/shared/ui/rich-text-editor/rich-text-dom.spec.ts
Normal file
51
src/app/shared/ui/rich-text-editor/rich-text-dom.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
|
import { readBlock, renderInto } from './rich-text-dom';
|
||||||
|
|
||||||
|
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
||||||
|
|
||||||
|
function roundTrip(block: RichTextBlock): RichTextBlock {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
renderInto(root, block, labelFor);
|
||||||
|
return readBlock(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('rich-text DOM boundary', () => {
|
||||||
|
it('round-trips text, marks, placeholders, line breaks and multiple paragraphs', () => {
|
||||||
|
const block: RichTextBlock = {
|
||||||
|
paragraphs: [
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{ type: 'text', text: 'Beste ' },
|
||||||
|
{ type: 'placeholder', key: 'naam' },
|
||||||
|
{ type: 'text', text: ' vet', marks: ['bold'] },
|
||||||
|
{ type: 'lineBreak' },
|
||||||
|
{ type: 'text', text: 'nieuwe regel' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(roundTrip(block)).toEqual(block);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips an empty paragraph (filler <br> is not a line break)', () => {
|
||||||
|
const empty: RichTextBlock = { paragraphs: [{ nodes: [] }] };
|
||||||
|
expect(roundTrip(empty)).toEqual(empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a placeholder as a non-editable chip carrying its key and label', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
renderInto(root, { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, labelFor);
|
||||||
|
const chip = root.querySelector('.rte-chip') as HTMLElement;
|
||||||
|
expect(chip.getAttribute('contenteditable')).toBe('false');
|
||||||
|
expect(chip.dataset['phKey']).toBe('naam');
|
||||||
|
expect(chip.textContent).toBe('Naam');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads combined marks in canonical order regardless of nesting', () => {
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
|
||||||
|
expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] });
|
||||||
|
});
|
||||||
|
});
|
||||||
114
src/app/shared/ui/rich-text-editor/rich-text-dom.ts
Normal file
114
src/app/shared/ui/rich-text-editor/rich-text-dom.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The quarantined boundary between the imperative `contenteditable` DOM and the
|
||||||
|
* serialisable `RichTextBlock` value. Pure functions (given a DOM they render /
|
||||||
|
* read) so they round-trip losslessly and can be unit-tested without Angular. The
|
||||||
|
* rest of the app only ever sees `RichTextBlock` — this is the one place DOM leaks.
|
||||||
|
*
|
||||||
|
* ponytail: mark detection covers the tags/styles a browser's execCommand emits
|
||||||
|
* (strong/b, em/i, u, and inline font-weight/style/decoration); exotic pasted markup
|
||||||
|
* degrades to plain text rather than crashing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
|
||||||
|
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
|
||||||
|
|
||||||
|
export function renderInto(root: HTMLElement, block: RichTextBlock, labelFor: (key: string) => string): void {
|
||||||
|
const doc = root.ownerDocument;
|
||||||
|
root.replaceChildren();
|
||||||
|
for (const para of block.paragraphs) {
|
||||||
|
const p = doc.createElement('p');
|
||||||
|
p.className = 'rte-para';
|
||||||
|
if (para.nodes.length === 0) {
|
||||||
|
p.appendChild(doc.createElement('br')); // keep the empty line focusable
|
||||||
|
} else {
|
||||||
|
for (const node of para.nodes) p.appendChild(renderNode(node, labelFor, doc));
|
||||||
|
}
|
||||||
|
root.appendChild(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build one non-editable placeholder chip element (shared by initial render and
|
||||||
|
live caret insertion). setAttribute reflects reliably in jsdom + browsers. */
|
||||||
|
export function createChip(doc: Document, key: string, label: string): HTMLElement {
|
||||||
|
const span = doc.createElement('span');
|
||||||
|
span.dataset['phKey'] = key;
|
||||||
|
span.setAttribute('contenteditable', 'false');
|
||||||
|
span.className = 'rte-chip';
|
||||||
|
span.textContent = label;
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document): Node {
|
||||||
|
if (node.type === 'lineBreak') return doc.createElement('br');
|
||||||
|
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key));
|
||||||
|
let el: Node = doc.createTextNode(node.text);
|
||||||
|
// Nest marks in a canonical order so read-back is deterministic.
|
||||||
|
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
||||||
|
const wrap = doc.createElement(MARK_TAG[m]);
|
||||||
|
wrap.appendChild(el);
|
||||||
|
el = wrap;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBlock(root: HTMLElement): RichTextBlock {
|
||||||
|
const paragraphs: { nodes: RichTextNode[] }[] = [];
|
||||||
|
const blockEls = Array.from(root.children).filter((c) => c.tagName === 'P' || c.tagName === 'DIV');
|
||||||
|
const containers = blockEls.length ? blockEls : [root];
|
||||||
|
for (const el of containers) {
|
||||||
|
const kids = Array.from(el.childNodes);
|
||||||
|
const nodes: RichTextNode[] = [];
|
||||||
|
// A lone <br> is the empty-line filler, not a content line break.
|
||||||
|
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
||||||
|
for (const child of kids) collect(child, [], nodes);
|
||||||
|
}
|
||||||
|
paragraphs.push({ nodes });
|
||||||
|
}
|
||||||
|
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
if (text !== '') out.push(marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
|
const el = node as HTMLElement;
|
||||||
|
if (el.tagName === 'BR') {
|
||||||
|
out.push({ type: 'lineBreak' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = el.dataset?.['phKey'];
|
||||||
|
if (key != null) {
|
||||||
|
out.push({ type: 'placeholder', key });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m = markOf(el);
|
||||||
|
const next = m ? [...marks, m] : marks;
|
||||||
|
for (const child of Array.from(el.childNodes)) collect(child, next, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOf(el: HTMLElement): Mark | null {
|
||||||
|
switch (el.tagName) {
|
||||||
|
case 'STRONG':
|
||||||
|
case 'B':
|
||||||
|
return 'bold';
|
||||||
|
case 'EM':
|
||||||
|
case 'I':
|
||||||
|
return 'italic';
|
||||||
|
case 'U':
|
||||||
|
return 'underline';
|
||||||
|
}
|
||||||
|
const s = el.style;
|
||||||
|
if (s.fontWeight === 'bold' || Number(s.fontWeight) >= 600) return 'bold';
|
||||||
|
if (s.fontStyle === 'italic') return 'italic';
|
||||||
|
if (s.textDecoration.includes('underline')) return 'underline';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonical(marks: Mark[]): Mark[] {
|
||||||
|
return ORDER.filter((m) => marks.includes(m));
|
||||||
|
}
|
||||||
137
src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts
Normal file
137
src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
||||||
|
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||||
|
import { createChip, readBlock, renderInto } from './rich-text-dom';
|
||||||
|
|
||||||
|
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
||||||
|
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
||||||
|
export interface PlaceholderOption {
|
||||||
|
readonly key: string;
|
||||||
|
readonly label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.
|
||||||
|
*
|
||||||
|
* It is the single quarantined boundary to the imperative `contenteditable` DOM:
|
||||||
|
* `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.
|
||||||
|
* Placeholders render as non-editable chips and can only be inserted from the menu
|
||||||
|
* (valid keys only) — never typed as raw braces. Swapping in a real editor library
|
||||||
|
* later (TipTap) means replacing only this component; nothing else sees the DOM.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-rich-text-editor',
|
||||||
|
styles: [`
|
||||||
|
:host{display:block}
|
||||||
|
.rte-toolbar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm);align-items:center;margin-block-end:var(--rhc-space-max-sm)}
|
||||||
|
.rte-toolbar button{min-inline-size:2.2rem}
|
||||||
|
.rte-sep{inline-size:1px;align-self:stretch;background:var(--rhc-color-border-default)}
|
||||||
|
.rte-editable{
|
||||||
|
border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm);
|
||||||
|
padding:var(--rhc-space-max-md);min-block-size:4rem;
|
||||||
|
}
|
||||||
|
.rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)}
|
||||||
|
.rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)}
|
||||||
|
/* Placeholder chips inside the editor: one neutral highlight (the read-only
|
||||||
|
preview distinguishes auto/manual/error via app-placeholder-chip). */
|
||||||
|
.rte-editable .rte-chip{
|
||||||
|
background:var(--rhc-color-cool-grey-100);border-radius:var(--rhc-border-radius-sm);
|
||||||
|
padding:0 0.35em;white-space:nowrap;
|
||||||
|
}
|
||||||
|
.rte-editable .rte-chip::before{content:'⌗';opacity:0.6;font-weight:700;margin-inline-end:0.15em}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
@if (editable()) {
|
||||||
|
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
|
||||||
|
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
|
||||||
|
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
|
||||||
|
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
|
||||||
|
@if (placeholders().length) {
|
||||||
|
<span class="rte-sep" aria-hidden="true"></span>
|
||||||
|
<label>
|
||||||
|
<span class="app-text-subtle">{{ insertLabel() }}</span>
|
||||||
|
<select #ins (change)="insert(ins.value); ins.value = ''">
|
||||||
|
<option value="" selected>{{ insertPrompt() }}</option>
|
||||||
|
@for (p of placeholders(); track p.key) {
|
||||||
|
<option [value]="p.key">{{ p.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()"
|
||||||
|
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class RichTextEditorComponent {
|
||||||
|
content = input<RichTextBlock>(emptyBlock());
|
||||||
|
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||||
|
editable = input(true);
|
||||||
|
contentChanged = output<RichTextBlock>();
|
||||||
|
|
||||||
|
// Localizable-by-default copy (shared-UI convention).
|
||||||
|
fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);
|
||||||
|
toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);
|
||||||
|
boldLabel = input($localize`:@@richTextEditor.bold:Vet`);
|
||||||
|
italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);
|
||||||
|
underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);
|
||||||
|
insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);
|
||||||
|
insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);
|
||||||
|
|
||||||
|
private editorEl = viewChild<ElementRef<HTMLElement>>('editor');
|
||||||
|
private lastEmitted = '';
|
||||||
|
|
||||||
|
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
|
||||||
|
// flowing back (structural compare) so the caret isn't reset while typing.
|
||||||
|
effect(() => {
|
||||||
|
const content = this.content();
|
||||||
|
const el = this.editorEl()?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
const serialized = JSON.stringify(content);
|
||||||
|
if (serialized === this.lastEmitted) return;
|
||||||
|
renderInto(el, content, this.labelFor);
|
||||||
|
this.lastEmitted = serialized;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected emit() {
|
||||||
|
const el = this.editorEl()?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
const block = readBlock(el);
|
||||||
|
this.lastEmitted = JSON.stringify(block);
|
||||||
|
this.contentChanged.emit(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected format(cmd: 'bold' | 'italic' | 'underline') {
|
||||||
|
const el = this.editorEl()?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
// ponytail: execCommand is deprecated but universally supported and zero-dependency;
|
||||||
|
// if a browser drops it, this component is the one place to swap in a range-based impl.
|
||||||
|
el.ownerDocument.execCommand(cmd);
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected insert(key: string) {
|
||||||
|
const el = this.editorEl()?.nativeElement;
|
||||||
|
if (!key || !el) return;
|
||||||
|
el.focus();
|
||||||
|
const chip = createChip(el.ownerDocument, key, this.labelFor(key));
|
||||||
|
const sel = el.ownerDocument.getSelection();
|
||||||
|
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
range.deleteContents();
|
||||||
|
range.insertNode(chip);
|
||||||
|
range.setStartAfter(chip);
|
||||||
|
range.collapse(true);
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
} else {
|
||||||
|
(el.lastElementChild ?? el).appendChild(chip);
|
||||||
|
}
|
||||||
|
this.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { RichTextEditorComponent } from './rich-text-editor.component';
|
||||||
|
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||||
|
|
||||||
|
const sample: RichTextBlock = {
|
||||||
|
paragraphs: [
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||||
|
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||||
|
{ type: 'text', text: ',' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }, { type: 'text', text: ' hebben wij besloten.' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholders = [
|
||||||
|
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener' },
|
||||||
|
{ key: 'datum', label: 'Datum' },
|
||||||
|
{ key: 'big_nummer', label: 'BIG-nummer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta: Meta<RichTextEditorComponent> = {
|
||||||
|
title: 'Molecules/Rich Text Editor',
|
||||||
|
component: RichTextEditorComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `<app-rich-text-editor [content]="content" [placeholders]="placeholders" [editable]="editable"></app-rich-text-editor>`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<RichTextEditorComponent>;
|
||||||
|
|
||||||
|
export const Editing: Story = { args: { content: sample, placeholders, editable: true } };
|
||||||
|
export const Empty: Story = { args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true } };
|
||||||
|
export const ReadOnly: Story = { args: { content: sample, placeholders, editable: false } };
|
||||||
71
src/docs/atomic-design.mdx
Normal file
71
src/docs/atomic-design.mdx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||||
|
import * as ButtonStories from '../app/shared/ui/button/button.stories';
|
||||||
|
import * as FormFieldStories from '../app/shared/ui/form-field/form-field.stories';
|
||||||
|
import * as PageShellStories from '../app/shared/layout/page-shell/page-shell.stories';
|
||||||
|
|
||||||
|
<Meta title="Foundations/Atomic Design" />
|
||||||
|
|
||||||
|
# Atomic design
|
||||||
|
|
||||||
|
Every screen in this app is built from a small set of layers, each composed **only from
|
||||||
|
the layer below it**. Read a screen top-down and you always land on the same handful of
|
||||||
|
atoms — that is the whole point: fewer things to understand, nothing bespoke per page.
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '0.5rem', maxWidth: '32rem', margin: '1.5rem 0' }}>
|
||||||
|
{[
|
||||||
|
['Templates', 'shared/layout', 'shell, page-shell, wizard-shell — the page skeleton', '#1e3a5f'],
|
||||||
|
['Organisms', 'shared/ui/upload/document-upload …', 'self-contained sections that own a bit of behaviour', '#2a5a8a'],
|
||||||
|
['Molecules', 'shared/ui/form-field, async …', 'a label + control + error, grouped', '#3f7cb5'],
|
||||||
|
['Atoms', 'shared/ui/button, text-input …', 'thin wrappers over Utrecht/RHC CSS classes', '#6aa6d8'],
|
||||||
|
].map(([name, where, why, bg], i) => (
|
||||||
|
<div key={name} style={{ background: bg, color: '#fff', padding: '0.75rem 1rem', borderRadius: '6px', marginLeft: `${i * 1.5}rem` }}>
|
||||||
|
<strong>{name}</strong> <span style={{ opacity: 0.85 }}>— {why}</span>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', opacity: 0.8, marginTop: '0.2rem' }}>{where}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## The rule, enforced
|
||||||
|
|
||||||
|
**Each layer only uses layers below it, and dependencies point inward.** This is not a
|
||||||
|
convention you have to remember — `eslint.config.mjs` fails the build if `domain/` imports
|
||||||
|
Angular, or if a context imports "upward". See [the FP-in-the-UI primer](?path=/docs/foundations-fp-in-the-ui--docs)
|
||||||
|
for how the same discipline shapes state and effects.
|
||||||
|
|
||||||
|
## A composition chain, live
|
||||||
|
|
||||||
|
Here is one real chain from atom → molecule → template. Each is a published Storybook
|
||||||
|
story below; click through to the sidebar entries to explore every variant.
|
||||||
|
|
||||||
|
### Atom — `button`
|
||||||
|
|
||||||
|
A thin wrapper: we own a typed `variant` input, the RHC CSS owns the pixels.
|
||||||
|
|
||||||
|
<Canvas of={ButtonStories.Primary} />
|
||||||
|
|
||||||
|
### Molecule — `form-field`
|
||||||
|
|
||||||
|
Label + control + error text, grouped so the error is announced via `role="alert"`. It
|
||||||
|
composes atoms; it adds no new visual primitives of its own.
|
||||||
|
|
||||||
|
<Canvas of={FormFieldStories.WithError} />
|
||||||
|
|
||||||
|
### Organism — `document-upload`
|
||||||
|
|
||||||
|
`shared/ui/upload/document-upload` composes molecules (a file input, status banner,
|
||||||
|
progress bar, chips) into a section that owns real upload behaviour. It has no story yet
|
||||||
|
(Track A backlog); read it at `src/app/shared/ui/upload/document-upload/`.
|
||||||
|
|
||||||
|
### Template — `page-shell`
|
||||||
|
|
||||||
|
The page skeleton — title, optional back-link, content slot. Pages drop composed
|
||||||
|
organisms into it; the template never knows what they are.
|
||||||
|
|
||||||
|
<Canvas of={PageShellStories.WithBackLink} />
|
||||||
|
|
||||||
|
## Why bother
|
||||||
|
|
||||||
|
A new page should be **composition of existing blocks**. Adding a new building block is the
|
||||||
|
exception, not the reflex — if you reach for one, that is a signal to check whether an
|
||||||
|
existing atom/molecule already covers it. Fewer primitives → less to test, less to learn,
|
||||||
|
one place to fix a bug.
|
||||||
76
src/docs/design-tokens.mdx
Normal file
76
src/docs/design-tokens.mdx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Meta } from '@storybook/addon-docs/blocks';
|
||||||
|
import { useState, useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
<Meta title="Foundations/Design Tokens" />
|
||||||
|
|
||||||
|
# Design tokens
|
||||||
|
|
||||||
|
We do not hand-write colours or spacing. The Rijkshuisstijl (RHC) design system ships them
|
||||||
|
as CSS custom properties; `src/styles.scss` imports them and adds a thin `--app-*` layer for
|
||||||
|
the few measures RHC has no token for. `npm run check:tokens` fails the build on any
|
||||||
|
hardcoded hex colour in atoms/molecules/chrome — so these tokens are the *only* source of
|
||||||
|
colour.
|
||||||
|
|
||||||
|
> Resolved values below are read live from the running theme via `getComputedStyle`, so they
|
||||||
|
> can't drift from what ships. All demos are wrapped in `.rhc-theme.lintblauw`, the same scope
|
||||||
|
> the app runs under.
|
||||||
|
|
||||||
|
export const Resolved = ({ token }) => {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const [val, setVal] = useState('');
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (ref.current) setVal(getComputedStyle(ref.current).getPropertyValue(token).trim());
|
||||||
|
}, [token]);
|
||||||
|
return <span ref={ref} style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#666' }}>{val || '…'}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
## When to use which token
|
||||||
|
|
||||||
|
- **Semantic first** — reach for a role token (`--rhc-color-foreground-default`,
|
||||||
|
`--rhc-color-border-default`, `--rhc-color-core`) before a raw palette step. Roles survive
|
||||||
|
a theme swap; palette steps don't.
|
||||||
|
- **`--rhc-space-max-*`** for all spacing/gaps — never a raw `rem`.
|
||||||
|
- **`--app-*`** (in `src/styles.scss`) only for app measures RHC has no token for
|
||||||
|
(`--app-content-max`, `--app-form-max`). If you're tempted to add one, check RHC first.
|
||||||
|
|
||||||
|
<div className="rhc-theme lintblauw">
|
||||||
|
|
||||||
|
## Spacing scale — `--rhc-space-max-*`
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '0.4rem', margin: '1rem 0' }}>
|
||||||
|
{['2xs','xs','sm','md','lg','xl','2xl','3xl','4xl','5xl'].map((step) => {
|
||||||
|
const token = `--rhc-space-max-${step}`;
|
||||||
|
return (
|
||||||
|
<div key={step} style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<code style={{ width: '12rem', fontSize: '0.78rem' }}>{token}</code>
|
||||||
|
<div style={{ height: '1rem', width: `var(${token})`, background: 'var(--rhc-color-core)', borderRadius: '2px' }} />
|
||||||
|
<Resolved token={token} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Semantic colours
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(14rem, 1fr))', gap: '0.75rem', margin: '1rem 0' }}>
|
||||||
|
{[
|
||||||
|
'--rhc-color-foreground-default',
|
||||||
|
'--rhc-color-foreground-subtle',
|
||||||
|
'--rhc-color-foreground-link',
|
||||||
|
'--rhc-color-core',
|
||||||
|
'--rhc-color-core-hover',
|
||||||
|
'--rhc-color-border-default',
|
||||||
|
'--rhc-color-border-strong',
|
||||||
|
'--rhc-color-bg-document',
|
||||||
|
].map((token) => (
|
||||||
|
<div key={token} style={{ border: '1px solid #ddd', borderRadius: '6px', overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '3rem', background: `var(${token})` }} />
|
||||||
|
<div style={{ padding: '0.4rem 0.5rem' }}>
|
||||||
|
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', wordBreak: 'break-all' }}>{token}</div>
|
||||||
|
<Resolved token={token} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
63
src/docs/fp-in-ui.mdx
Normal file
63
src/docs/fp-in-ui.mdx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||||
|
import * as AsyncStories from '../app/shared/ui/async/async.stories';
|
||||||
|
|
||||||
|
<Meta title="Foundations/FP in the UI" />
|
||||||
|
|
||||||
|
# Functional programming in the UI
|
||||||
|
|
||||||
|
The components in this library are the *view*. Behind them, three small functional tools do
|
||||||
|
the heavy lifting — all so that **illegal states can't be represented**. This page is the
|
||||||
|
Storybook front door; the full narrative lives in `docs/fp-tea-atomic-design.md`, and a
|
||||||
|
side-by-side "before/after" runs at the app's **`/concepts`** route.
|
||||||
|
|
||||||
|
## 1. `RemoteData<E,T>` — async has four states, not a boolean soup
|
||||||
|
|
||||||
|
`src/app/shared/application/remote-data.ts`. Instead of juggling `loading`, `error`, and
|
||||||
|
`data` flags (which permit "loading **and** error" nonsense), one tagged union:
|
||||||
|
`Loading | Empty | Failure | Success`. You combine sources with `map`/`map2`/`andThen` and
|
||||||
|
render it through the `async` molecule — exactly one of four templates shows, by
|
||||||
|
construction:
|
||||||
|
|
||||||
|
<Canvas of={AsyncStories.Loading} />
|
||||||
|
<Canvas of={AsyncStories.ErrorState} />
|
||||||
|
|
||||||
|
## 2. The Elm-style store — all state in one Model, changed only by pure `reduce`
|
||||||
|
|
||||||
|
`src/app/shared/application/store.ts` + the `*.machine.ts` files. State is one tagged-union
|
||||||
|
value; the template never mutates it, it `dispatch`es a message and a **pure**
|
||||||
|
`reduce(model, msg)` returns the next state. Side effects live in a *command*, never in the
|
||||||
|
reducer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// reducer = "what the new state is" — pure, testable, no I/O
|
||||||
|
function reduce(model: Model, msg: Msg): Model { … }
|
||||||
|
|
||||||
|
// command = "go do it, then say what happened"
|
||||||
|
async function submit(...) {
|
||||||
|
const res = await http(...);
|
||||||
|
dispatch(res.ok ? { tag: 'Submitted' } : { tag: 'Failed', error: res.error });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Because state is one value, the whole thing is inspectable and every transition has a spec.
|
||||||
|
|
||||||
|
## 3. Parse, don't validate — raw input becomes a branded type once
|
||||||
|
|
||||||
|
`src/app/registratie/domain/value-objects/`. A `Postcode` is a distinct type from `string`,
|
||||||
|
mintable only through `parsePostcode`, which returns a `Result`. Once you hold the type, you
|
||||||
|
never re-check it — the type *is* the proof. Compose the parse pipeline with the `Result`
|
||||||
|
combinators in `src/app/shared/kernel/fp.ts` (`map`, `mapErr`, `andThen`, `fold`) rather than
|
||||||
|
hand-branching `r.ok ? … : …` at every step.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
parsePostcode(raw) // Result<string, Postcode>
|
||||||
|
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|
||||||
|
|> map(toDomain) // only runs on success
|
||||||
|
```
|
||||||
|
|
||||||
|
## How it connects to atomic design
|
||||||
|
|
||||||
|
Atoms and molecules are pure view functions of their inputs; pages are the TEA runtime (the
|
||||||
|
"shell") that holds the store and wires effects. Same inward-pointing discipline as the
|
||||||
|
[layer rule](?path=/docs/foundations-atomic-design--docs), applied to state and effects
|
||||||
|
instead of imports.
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"@shared/*": ["src/app/shared/*"],
|
"@shared/*": ["src/app/shared/*"],
|
||||||
"@auth/*": ["src/app/auth/*"],
|
"@auth/*": ["src/app/auth/*"],
|
||||||
"@registratie/*": ["src/app/registratie/*"],
|
"@registratie/*": ["src/app/registratie/*"],
|
||||||
"@herregistratie/*": ["src/app/herregistratie/*"]
|
"@herregistratie/*": ["src/app/herregistratie/*"],
|
||||||
|
"@brief/*": ["src/app/brief/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
|
|||||||
Reference in New Issue
Block a user