Files
atomic-design-poc/src/app/brief/domain/brief.ts
T
ehoandClaude Opus 4.8 5e36d68f11
CI / frontend (push) Failing after 1m15s
CI / storybook-a11y (push) Failing after 4m43s
CI / backend (push) Successful in 1m24s
CI / codeql (csharp) (push) Failing after 2m51s
CI / e2e (push) Failing after 3h4m8s
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / api-client-drift (push) Successful in 1m53s
feat(fp): WP-27 — brief UX layer (undo/redo, standaardbrief, passage search, diff badges)
Brief letter-composition UX improvements:
- undo/redo history in the brief store (snapshot stacks, Ctrl/Cmd+Z) + retry-save
- "Standaardbrief invoegen" starter for empty sections; isDefault library passages
  (backend DTO/seed + adapter parse)
- passage-picker client-side search (rich-text textOf helper)
- rejection diff badges on the letter canvas + show/hide changes toggle
  (pure brief-diff domain fns + spec)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:36:32 +02:00

120 lines
4.7 KiB
TypeScript

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
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set
}
/** 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;
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
readonly locked: 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);
}
/** Server-computed decision flags for the acting principal + this brief's live
status (PRD-0002 phase P1) — rendered as-is, never recomputed here. */
export interface BriefDecisions {
readonly canEdit: boolean;
readonly canApprove: boolean;
readonly canReject: boolean;
readonly canSend: boolean;
}