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