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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user