diff --git a/public/mock/brp.json b/public/mock/brp.json new file mode 100644 index 0000000..dda2dca --- /dev/null +++ b/public/mock/brp.json @@ -0,0 +1,9 @@ +{ + "naam": "Dr. A. (Anna) de Vries", + "geboortedatum": "1985-03-14", + "adres": { + "straat": "Lange Voorhout 9", + "postcode": "2514 EA", + "woonplaats": "Den Haag" + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 7ae2732..5f4451e 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -5,7 +5,7 @@ import { registerLocaleData } from '@angular/common'; import localeNl from '@angular/common/locales/nl'; import { routes } from './app.routes'; -import { scenarioInterceptor } from './core/scenario.interceptor'; +import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor'; registerLocaleData(localeNl); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 975cb88..7406f30 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,5 +1,6 @@ import { Routes } from '@angular/router'; -import { ShellComponent } from './templates/shell/shell.component'; +import { ShellComponent } from '@shared/layout/shell/shell.component'; +import { authGuard } from '@auth/auth.guard'; export const routes: Routes = [ { @@ -7,11 +8,11 @@ export const routes: Routes = [ component: ShellComponent, // persistent header/footer; only children swap children: [ { path: '', pathMatch: 'full', redirectTo: 'login' }, - { path: 'login', loadComponent: () => import('./pages/login/login.page').then(m => m.LoginPage) }, - { path: 'dashboard', loadComponent: () => import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage) }, - { path: 'registratie', loadComponent: () => import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage) }, - { path: 'herregistratie', loadComponent: () => import('./pages/herregistratie/herregistratie.page').then(m => m.HerregistratiePage) }, - { path: 'concepts', loadComponent: () => import('./pages/concepts/concepts.page').then(m => m.ConceptsPage) }, + { path: 'login', loadComponent: () => import('@auth/ui/login.page').then(m => m.LoginPage) }, + { path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) }, + { path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) }, + { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) }, + { path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) }, { path: '**', redirectTo: 'login' }, ], }, diff --git a/src/app/atoms/status-badge/status-badge.component.ts b/src/app/atoms/status-badge/status-badge.component.ts deleted file mode 100644 index 2e47a09..0000000 --- a/src/app/atoms/status-badge/status-badge.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Component, computed, input } from '@angular/core'; -import { StatusTag } from '../../core/models'; -import { assertNever } from '../../core/fp'; - -/** Atom: status badge = colored RHC dot-badge + label. The color is a total - function of the status tag — assertNever makes a new status fail to compile - until a color is chosen for it. */ -@Component({ - selector: 'app-status-badge', - template: ` - - - {{ status() }} - - `, -}) -export class StatusBadgeComponent { - status = input.required(); - color = computed(() => { - const tag = this.status(); - switch (tag) { - case 'Geregistreerd': - return 'var(--rhc-color-groen-500)'; - case 'Doorgehaald': - return 'var(--rhc-color-rood-500)'; - case 'Geschorst': - return 'var(--rhc-color-oranje-500)'; - default: - return assertNever(tag); - } - }); -} diff --git a/src/app/atoms/status-badge/status-badge.stories.ts b/src/app/atoms/status-badge/status-badge.stories.ts deleted file mode 100644 index 5af7067..0000000 --- a/src/app/atoms/status-badge/status-badge.stories.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/angular'; -import { StatusBadgeComponent } from './status-badge.component'; - -const meta: Meta = { - title: 'Atoms/Status Badge', - component: StatusBadgeComponent, -}; -export default meta; -type Story = StoryObj; - -export const Geregistreerd: Story = { args: { status: 'Geregistreerd' } }; -export const Geschorst: Story = { args: { status: 'Geschorst' } }; -export const Doorgehaald: Story = { args: { status: 'Doorgehaald' } }; diff --git a/src/app/auth/application/session.store.ts b/src/app/auth/application/session.store.ts new file mode 100644 index 0000000..2aec239 --- /dev/null +++ b/src/app/auth/application/session.store.ts @@ -0,0 +1,30 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Result } from '@shared/kernel/fp'; +import { Session } from '../domain/session'; +import { DigidAdapter } from '../infrastructure/digid.adapter'; + +/** + * Holds the current session for the whole app. Because it is providedIn:'root' + * there is exactly one instance — every component that injects it sees the same + * session signal, so logging in is instantly visible everywhere (the guard, the + * header, etc.). ponytail: in-memory only; a refresh logs you out. + */ +@Injectable({ providedIn: 'root' }) +export class SessionStore { + private digid = inject(DigidAdapter); + private _session = signal(null); + + readonly session = this._session.asReadonly(); + readonly isAuthenticated = computed(() => this._session() !== null); + + /** Effectful command: authenticate, then store the session on success. */ + async login(bsn: string): Promise> { + const r = await this.digid.authenticate(bsn); + if (r.ok) this._session.set(r.value); + return r; + } + + logout() { + this._session.set(null); + } +} diff --git a/src/app/auth/auth.guard.ts b/src/app/auth/auth.guard.ts new file mode 100644 index 0000000..4fbb384 --- /dev/null +++ b/src/app/auth/auth.guard.ts @@ -0,0 +1,10 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { SessionStore } from './application/session.store'; + +/** Route guard: only let authenticated users in; otherwise redirect to /login. */ +export const authGuard: CanActivateFn = () => { + const store = inject(SessionStore); + const router = inject(Router); + return store.isAuthenticated() ? true : router.createUrlTree(['/login']); +}; diff --git a/src/app/auth/domain/session.ts b/src/app/auth/domain/session.ts new file mode 100644 index 0000000..486dc85 --- /dev/null +++ b/src/app/auth/domain/session.ts @@ -0,0 +1,9 @@ +/** Who is logged in. Framework-free domain type. */ +export interface Session { + readonly bsn: string; + readonly naam: string; +} + +export function isAuthenticated(s: Session | null): s is Session { + return s !== null; +} diff --git a/src/app/auth/infrastructure/digid.adapter.ts b/src/app/auth/infrastructure/digid.adapter.ts new file mode 100644 index 0000000..fb30287 --- /dev/null +++ b/src/app/auth/infrastructure/digid.adapter.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@angular/core'; +import { Result, ok, err } from '@shared/kernel/fp'; +import { Session } from '../domain/session'; + +/** Infrastructure: talks to the (mock) DigiD identity provider. */ +@Injectable({ providedIn: 'root' }) +export class DigidAdapter { + // ponytail: fake DigiD — any 9-digit BSN authenticates to a fixed identity. + // Swap for a real OIDC redirect flow when there's a backend. + async authenticate(bsn: string): Promise> { + const t = bsn.trim(); + if (!/^\d{9}$/.test(t)) return err('Voer een geldig BSN van 9 cijfers in.'); + return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' }); + } +} diff --git a/src/app/organisms/login-form/login-form.component.ts b/src/app/auth/ui/login-form/login-form.component.ts similarity index 75% rename from src/app/organisms/login-form/login-form.component.ts rename to src/app/auth/ui/login-form/login-form.component.ts index 3cc7029..50af1d2 100644 --- a/src/app/organisms/login-form/login-form.component.ts +++ b/src/app/auth/ui/login-form/login-form.component.ts @@ -1,15 +1,15 @@ import { Component, output } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { FormFieldComponent } from '../../molecules/form-field/form-field.component'; -import { TextInputComponent } from '../../atoms/text-input/text-input.component'; -import { ButtonComponent } from '../../atoms/button/button.component'; +import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; +import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; /** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */ @Component({ selector: 'app-login-form', imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], template: ` -
+ @@ -27,5 +27,5 @@ import { ButtonComponent } from '../../atoms/button/button.component'; export class LoginFormComponent { bsn = ''; password = ''; - submit = output(); + submit = output(); } diff --git a/src/app/organisms/login-form/login-form.stories.ts b/src/app/auth/ui/login-form/login-form.stories.ts similarity index 100% rename from src/app/organisms/login-form/login-form.stories.ts rename to src/app/auth/ui/login-form/login-form.stories.ts diff --git a/src/app/auth/ui/login.page.ts b/src/app/auth/ui/login.page.ts new file mode 100644 index 0000000..05b83e2 --- /dev/null +++ b/src/app/auth/ui/login.page.ts @@ -0,0 +1,29 @@ +import { Component, inject, signal } from '@angular/core'; +import { Router } from '@angular/router'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { LoginFormComponent } from '@auth/ui/login-form/login-form.component'; +import { SessionStore } from '@auth/application/session.store'; + +@Component({ + selector: 'app-login-page', + imports: [PageShellComponent, AlertComponent, LoginFormComponent], + template: ` + + @if (error()) { {{ error() }} } + + + `, +}) +export class LoginPage { + private store = inject(SessionStore); + private router = inject(Router); + error = signal(''); + + async login(bsn: string) { + const r = await this.store.login(bsn); + if (r.ok) this.router.navigate(['/dashboard']); + else this.error.set(r.error); + } +} diff --git a/src/app/core/parse.ts b/src/app/core/parse.ts deleted file mode 100644 index 179d298..0000000 --- a/src/app/core/parse.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Brand, Result, ok, err } from './fp'; - -/** - * "Parse, don't validate." A validated value gets its own branded type, and the - * smart constructor is the ONLY way to mint one. So once you hold a Postcode you - * know it's well-formed — validity is carried in the type, not re-checked - * everywhere or tracked in a parallel error flag. - */ -export type Postcode = Brand; -export type Uren = Brand; - -export function parsePostcode(raw: string): Result { - const t = raw.trim().toUpperCase(); - if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) { - return err('Voer een geldige postcode in, bijv. 1234 AB.'); - } - // Normalise to "1234 AB" — the parser also cleans up. - return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode); -} - -export function parseUren(raw: string): Result { - const t = raw.trim(); - const n = Number(t); - // Number('') is 0 — guard the empty string explicitly. - if (t === '' || !Number.isInteger(n) || n < 0) { - return err('Vul een geheel aantal in (0 of meer).'); - } - return ok(n as Uren); -} diff --git a/src/app/core/registration.service.ts b/src/app/core/registration.service.ts deleted file mode 100644 index ffce9ef..0000000 --- a/src/app/core/registration.service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Injectable } from '@angular/core'; -import { httpResource } from '@angular/common/http'; -import { Registration, Aantekening } from './models'; - -/** - * Exposes signal-based resources (Angular's httpResource). Each returns a - * Resource with status()/value()/error()/reload() — consumed by , - * which turns those signals into the loading/empty/error/loaded UI. - * Call these from a component field initializer (injection context required). - */ -@Injectable({ providedIn: 'root' }) -export class RegistrationService { - registrationResource() { - return httpResource(() => 'mock/registration.json'); - } - - aantekeningenResource() { - return httpResource(() => 'mock/notes.json', { defaultValue: [] }); - } -} diff --git a/src/app/core/remote-data.ts b/src/app/core/remote-data.ts deleted file mode 100644 index d5e96e2..0000000 --- a/src/app/core/remote-data.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Resource } from '@angular/core'; -import { assertNever } from './fp'; - -/** - * The four mutually-exclusive states of an async fetch, as a tagged union. - * Crucially the data lives ON the state: only `Failure` has an `error`, only - * `Success` has a `value`. "Loaded but no value" or "error with stale value" - * are unrepresentable — Richard Feldman's RemoteData. - */ -export type RemoteData = - | { tag: 'Loading' } - | { tag: 'Empty' } - | { tag: 'Failure'; error: E } - | { tag: 'Success'; value: T }; - -/** Project Angular's loosely-typed Resource into a RemoteData value. */ -export function fromResource( - r: Resource, - isEmpty: (v: T) => boolean = () => false, -): RemoteData { - if (r.status() === 'error') return { tag: 'Failure', error: r.error() }; - if (r.status() === 'loading') return { tag: 'Loading' }; - if (r.hasValue()) { - const v = r.value(); - return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v }; - } - return { tag: 'Loading' }; -} - -/** Exhaustive fold: you must handle every case, checked at compile time. */ -export function foldRemote( - rd: RemoteData, - h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R }, -): R { - switch (rd.tag) { - case 'Loading': - return h.loading(); - case 'Empty': - return h.empty(); - case 'Failure': - return h.failure(rd.error); - case 'Success': - return h.success(rd.value); - default: - return assertNever(rd); - } -} diff --git a/src/app/herregistratie/application/submit-herregistratie.ts b/src/app/herregistratie/application/submit-herregistratie.ts new file mode 100644 index 0000000..d566653 --- /dev/null +++ b/src/app/herregistratie/application/submit-herregistratie.ts @@ -0,0 +1,14 @@ +import { Result, ok, err } from '@shared/kernel/fp'; +import { Valid } from '../domain/herregistratie.machine'; + +/** + * The mutation/command: send a herregistratie application to the backend. + * Returns a Result so the caller can branch on success/failure without + * try/catch. ponytail: faked with a timer; swap for a real POST when there's + * an API. The "uren must be > 0" rule lets the demo show a failure path. + */ +export async function submitHerregistratie(data: Valid): Promise> { + await new Promise((r) => setTimeout(r, 800)); + if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.'); + return ok(undefined); +} diff --git a/src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts b/src/app/herregistratie/domain/herregistratie.machine.spec.ts similarity index 54% rename from src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts rename to src/app/herregistratie/domain/herregistratie.machine.spec.ts index 767516d..cea8ca3 100644 --- a/src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts +++ b/src/app/herregistratie/domain/herregistratie.machine.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { ok, err } from '../../core/fp'; -import { initial, next, back, submit, resolve, WizardState } from './wizard.machine'; +import { ok, err } from '@shared/kernel/fp'; +import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine'; const editing1 = (uren: string, punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, punten }, errors: {} }); const editing2 = (uren: string, punten: string): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, punten }, errors: {} }); @@ -30,3 +30,29 @@ describe('wizard.machine', () => { expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); }); + +describe('reduce (message-driven)', () => { + it('drives the full happy path via messages', () => { + let s: WizardState = initial; + s = reduce(s, { tag: 'SetField', key: 'uren', value: '4160' }); + s = reduce(s, { tag: 'Next' }); + expect(s.tag === 'Editing' && s.step).toBe(2); + s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' }); + s = reduce(s, { tag: 'Submit' }); + expect(s.tag).toBe('Submitting'); + s = reduce(s, { tag: 'SubmitConfirmed' }); + expect(s.tag).toBe('Submitted'); + }); + + it('SubmitFailed then Retry returns to Submitting with the same data', () => { + let s = reduce(reduce(editing2('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' }); + expect(s.tag).toBe('Failed'); + s = reduce(s, { tag: 'Retry' }); + expect(s.tag).toBe('Submitting'); + expect((s as any).data).toEqual({ uren: 4160, punten: 200 }); + }); + + it('Seed mounts an arbitrary state', () => { + expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing'); + }); +}); diff --git a/src/app/organisms/herregistratie-wizard/wizard.machine.ts b/src/app/herregistratie/domain/herregistratie.machine.ts similarity index 61% rename from src/app/organisms/herregistratie-wizard/wizard.machine.ts rename to src/app/herregistratie/domain/herregistratie.machine.ts index 5da2e47..b1badfd 100644 --- a/src/app/organisms/herregistratie-wizard/wizard.machine.ts +++ b/src/app/herregistratie/domain/herregistratie.machine.ts @@ -1,5 +1,5 @@ -import { Result } from '../../core/fp'; -import { Uren, parseUren } from '../../core/parse'; +import { Result, assertNever } from '@shared/kernel/fp'; +import { Uren, parseUren } from '@registratie/domain/value-objects/uren'; /** What the user is typing (raw, possibly invalid). */ export interface Draft { @@ -64,3 +64,47 @@ export function resolve(s: WizardState, r: Result): WizardState { if (s.tag !== 'Submitting') return s; return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error }; } + +/** Update one draft field while editing; ignored in any other state. */ +export function setField(s: WizardState, key: keyof Draft, value: string): WizardState { + if (s.tag !== 'Editing') return s; + return { ...s, draft: { ...s.draft, [key]: value } }; +} + +/** + * Every event that can happen to the wizard, as one message type. The component + * sends a WizardMsg; `reduce` decides the next state. This is the Elm + * Model+Msg+update pattern: ONE pure function describes all state changes. + */ +export type WizardMsg = + | { tag: 'SetField'; key: keyof Draft; value: string } + | { tag: 'Next' } + | { tag: 'Back' } + | { tag: 'Submit' } + | { tag: 'Retry' } + | { tag: 'SubmitConfirmed' } + | { tag: 'SubmitFailed'; error: string } + | { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase) + +export function reduce(s: WizardState, m: WizardMsg): WizardState { + switch (m.tag) { + case 'SetField': + return setField(s, m.key, m.value); + case 'Next': + return next(s); + case 'Back': + return back(s); + case 'Submit': + return submit(s); + case 'Retry': + return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; + case 'SubmitConfirmed': + return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s; + case 'SubmitFailed': + return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; + case 'Seed': + return m.state; + default: + return assertNever(m); + } +} diff --git a/src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts similarity index 50% rename from src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts rename to src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index 37e6991..2dbab38 100644 --- a/src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -1,17 +1,21 @@ -import { Component, computed, input, signal } from '@angular/core'; +import { Component, computed, inject, input } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { FormFieldComponent } from '../../molecules/form-field/form-field.component'; -import { TextInputComponent } from '../../atoms/text-input/text-input.component'; -import { ButtonComponent } from '../../atoms/button/button.component'; -import { AlertComponent } from '../../atoms/alert/alert.component'; -import { SpinnerComponent } from '../../atoms/spinner/spinner.component'; -import { ok } from '../../core/fp'; -import { WizardState, Draft, initial, next, back, submit, resolve } from './wizard.machine'; +import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; +import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; +import { createStore } from '@shared/application/store'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine'; +import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie'; -/** Organism: multi-step herregistratie wizard driven entirely by a state - machine (wizard.machine.ts). The signal IS the union, so the UI just folds - over its tag — no booleans like `submitting`/`submitted` that could contradict - each other. Composition-only: reuses existing form-field/input/button/alert. */ +/** Organism: multi-step herregistratie wizard. ALL state lives in one signal + driven by the pure `reduce` function (see herregistratie.machine.ts) via an + Elm-style store. The UI just sends messages and folds over the state's tag — + no booleans like `submitting`/`submitted` that could contradict each other. + Submitting also flips an optimistic flag on the shared BigProfileStore, so + the dashboard shows "in behandeling" immediately. */ @Component({ selector: 'app-herregistratie-wizard', imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent], @@ -22,17 +26,17 @@ import { WizardState, Draft, initial, next, back, submit, resolve } from './wiza @if (step() === 1) { - Volgende } @else { -
- Vorige + Vorige Herregistratie aanvragen
} @@ -45,7 +49,7 @@ import { WizardState, Draft, initial, next, back, submit, resolve } from './wiza Uw aanvraag tot herregistratie is ontvangen. } @case ('Failed') { - Indienen mislukt. Probeer het opnieuw. + Indienen mislukt: {{ failedError() }}
Opnieuw proberen
@@ -54,45 +58,51 @@ import { WizardState, Draft, initial, next, back, submit, resolve } from './wiza `, }) export class HerregistratieWizardComponent { + private profile = inject(BigProfileStore); + private store = createStore(initial, reduce); + /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); - state = signal(initial); - back = back; + protected state = this.store.model; + protected dispatch = this.store.dispatch; private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null)); protected step = computed(() => this.editing()?.step ?? 1); protected draft = computed(() => this.editing()?.draft ?? { uren: '', punten: '' }); protected errUren = computed(() => this.editing()?.errors.uren ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); + protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); constructor() { - queueMicrotask(() => this.state.set(this.seed())); - } - - set(key: keyof Draft, value: string) { - const s = this.state(); - if (s.tag !== 'Editing') return; - this.state.set({ ...s, draft: { ...s.draft, [key]: value } }); + queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() })); } onPrimary() { const s = this.state(); if (s.tag !== 'Editing') return; - this.state.set(s.step === 1 ? next(s) : submit(s)); + this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' }); this.runIfSubmitting(); } onRetry() { - const s = this.state(); - if (s.tag !== 'Failed') return; - this.state.set({ tag: 'Submitting', data: s.data }); + this.dispatch({ tag: 'Retry' }); this.runIfSubmitting(); } - // ponytail: fake the backend with a timer; swap for a real httpResource call later. - private runIfSubmitting() { - if (this.state().tag !== 'Submitting') return; - setTimeout(() => this.state.set(resolve(this.state(), ok(undefined))), 800); + /** The effect: when we entered Submitting, call the backend command, flip the + optimistic cross-page flag, then dispatch the result (and commit/rollback). */ + private async runIfSubmitting() { + const s = this.state(); + if (s.tag !== 'Submitting') return; + this.profile.beginHerregistratie(); + const r = await submitHerregistratie(s.data); + if (r.ok) { + this.dispatch({ tag: 'SubmitConfirmed' }); + this.profile.confirmHerregistratie(); + } else { + this.dispatch({ tag: 'SubmitFailed', error: r.error }); + this.profile.rollbackHerregistratie(); + } } } diff --git a/src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts similarity index 70% rename from src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts rename to src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts index dcb3af1..c283857 100644 --- a/src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts @@ -1,13 +1,18 @@ import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { provideHttpClient } from '@angular/common/http'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; -import { WizardState } from './wizard.machine'; -import { Uren } from '../../core/parse'; +import { WizardState } from '@herregistratie/domain/herregistratie.machine'; +import { Uren } from '@registratie/domain/value-objects/uren'; const validData = { uren: 4160 as Uren, punten: 200 }; const meta: Meta = { - title: 'Organisms/Herregistratie Wizard', + title: 'Herregistratie/Wizard', component: HerregistratieWizardComponent, + // The wizard injects BigProfileStore (for the optimistic cross-page flag), + // which creates httpResources — so the story needs an HttpClient. + decorators: [applicationConfig({ providers: [provideHttpClient()] })], }; export default meta; type Story = StoryObj; diff --git a/src/app/herregistratie/ui/herregistratie.page.ts b/src/app/herregistratie/ui/herregistratie.page.ts new file mode 100644 index 0000000..a78b02b --- /dev/null +++ b/src/app/herregistratie/ui/herregistratie.page.ts @@ -0,0 +1,42 @@ +import { Component, computed, inject } from '@angular/core'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { map } from '@shared/application/remote-data'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { isHerregistratieEligible } from '@registratie/domain/registration.policy'; +import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; + +/** A whole new page built from existing building blocks. Eligibility is a pure + domain rule (registration.policy) read from the shared profile state. */ +@Component({ + selector: 'app-herregistratie-page', + imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent], + template: ` + + + + @if (eligible) { + + Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan. + +
+ +
+ } @else { + + Voor uw huidige registratiestatus is herregistratie niet mogelijk. + + } +
+
+
+ `, +}) +export class HerregistratiePage { + private store = inject(BigProfileStore); + // Derive a boolean RemoteData from the combined profile via map (pure). + protected eligibility = computed(() => + map(this.store.profile(), (p) => isHerregistratieEligible(p.registration, new Date())), + ); +} diff --git a/src/app/pages/dashboard/dashboard.page.ts b/src/app/pages/dashboard/dashboard.page.ts deleted file mode 100644 index c07e801..0000000 --- a/src/app/pages/dashboard/dashboard.page.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; -import { HeadingComponent } from '../../atoms/heading/heading.component'; -import { LinkComponent } from '../../atoms/link/link.component'; -import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component'; -import { ASYNC } from '../../molecules/async/async.component'; -import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component'; -import { RegistrationTableComponent } from '../../organisms/registration-table/registration-table.component'; -import { RegistrationService } from '../../core/registration.service'; -import { Aantekening } from '../../core/models'; - -@Component({ - selector: 'app-dashboard-page', - imports: [ - PageShellComponent, HeadingComponent, LinkComponent, SkeletonComponent, ...ASYNC, - RegistrationSummaryComponent, RegistrationTableComponent, - ], - template: ` - - - - - - - - - - - -
- Specialismen en aantekeningen - - - - - - - - -

U heeft nog geen specialismen of aantekeningen.

-
-
-
- -

- Gegevens bekijken of een wijziging doorgeven → -

-

- Herregistratie aanvragen → -

-

- Functionele patronen (impossible states) → -

-
- `, -}) -export class DashboardPage { - private svc = inject(RegistrationService); - reg = this.svc.registrationResource(); - notes = this.svc.aantekeningenResource(); - notesEmpty = (v: Aantekening[]) => v.length === 0; - regEmpty = (v: unknown) => !v || Object.keys(v).length === 0; -} diff --git a/src/app/pages/herregistratie/herregistratie.page.ts b/src/app/pages/herregistratie/herregistratie.page.ts deleted file mode 100644 index a43d17a..0000000 --- a/src/app/pages/herregistratie/herregistratie.page.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Component } from '@angular/core'; -import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; -import { AlertComponent } from '../../atoms/alert/alert.component'; -import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component'; - -/** A whole new page built from existing building blocks — no new components. - This is the atomic-design payoff: a new flow is just composition. */ -@Component({ - selector: 'app-herregistratie-page', - imports: [PageShellComponent, AlertComponent, HerregistratieWizardComponent], - template: ` - - - Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan. - -
- -
-
- `, -}) -export class HerregistratiePage {} diff --git a/src/app/pages/login/login.page.ts b/src/app/pages/login/login.page.ts deleted file mode 100644 index f8959fd..0000000 --- a/src/app/pages/login/login.page.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { Router } from '@angular/router'; -import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; -import { LoginFormComponent } from '../../organisms/login-form/login-form.component'; - -@Component({ - selector: 'app-login-page', - imports: [PageShellComponent, LoginFormComponent], - template: ` - - - - `, -}) -export class LoginPage { - private router = inject(Router); - login() { this.router.navigate(['/dashboard']); } -} diff --git a/src/app/registratie/application/big-profile.store.ts b/src/app/registratie/application/big-profile.store.ts new file mode 100644 index 0000000..0ebeda3 --- /dev/null +++ b/src/app/registratie/application/big-profile.store.ts @@ -0,0 +1,60 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { RemoteData, fromResource, map2 } from '@shared/application/remote-data'; +import { Aantekening } from '../domain/registration'; +import { BigProfile } from '../domain/big-profile'; +import { BigRegisterAdapter } from '../infrastructure/big-register.adapter'; +import { BrpAdapter } from '../infrastructure/brp.adapter'; + +type Err = Error | undefined; + +/** + * The single source of truth for the logged-in professional's profile, shared + * across pages (providedIn:'root' = one instance). It owns the httpResources + * (created here, in the required injection context) and exposes them as + * RemoteData signals. + * + * The headline trick: `profile` combines TWO independent services — the + * BIG-register and the BRP — into ONE RemoteData via map2. A page renders a + * single state (loading / error / loaded), never juggling three. + */ +@Injectable({ providedIn: 'root' }) +export class BigProfileStore { + private big = inject(BigRegisterAdapter); + private brp = inject(BrpAdapter); + + private registrationRes = this.big.registrationResource(); + private aantekeningenRes = this.big.aantekeningenResource(); + private personRes = this.brp.personResource(); + + /** BIG-register + BRP folded into one state. */ + readonly profile = computed>(() => + map2( + fromResource(this.registrationRes), + fromResource(this.personRes), + // httpResource types value as T | undefined; in the Success branch it is + // always present, so narrowing here is safe. + (registration, person) => ({ registration: registration!, person: person! }), + ), + ); + + /** Specialisms/notes stay a separate stream (they have their own empty state). */ + readonly aantekeningen = computed>(() => + fromResource(this.aantekeningenRes, (v) => v.length === 0), + ); + + // --- Optimistic herregistratie state, shared with the dashboard ----------- + private pending = signal(false); + /** True while a herregistratie submission is in flight or just submitted. */ + readonly pendingHerregistratie = this.pending.asReadonly(); + + beginHerregistratie() { + this.pending.set(true); // optimistic: show it immediately on the dashboard + } + confirmHerregistratie() { + this.pending.set(false); + this.registrationRes.reload(); // invalidate: re-fetch the now-updated registration + } + rollbackHerregistratie() { + this.pending.set(false); // submission failed — undo the optimistic flag + } +} diff --git a/src/app/registratie/domain/big-profile.ts b/src/app/registratie/domain/big-profile.ts new file mode 100644 index 0000000..0db3e7e --- /dev/null +++ b/src/app/registratie/domain/big-profile.ts @@ -0,0 +1,12 @@ +import { Registration } from './registration'; +import { Person } from './person'; + +/** + * The view the dashboard/detail render: a registration (from the BIG-register) + * enriched with person data (from the BRP). It only exists when BOTH sources + * have loaded — see BigProfileStore, which builds it with map2. + */ +export interface BigProfile { + registration: Registration; + person: Person; +} diff --git a/src/app/registratie/domain/person.ts b/src/app/registratie/domain/person.ts new file mode 100644 index 0000000..9bd93b5 --- /dev/null +++ b/src/app/registratie/domain/person.ts @@ -0,0 +1,12 @@ +/** Person identity as supplied by the BRP (Basisregistratie Personen). */ +export interface Adres { + straat: string; + postcode: string; + woonplaats: string; +} + +export interface Person { + naam: string; + geboortedatum: string; // ISO date + adres: Adres; +} diff --git a/src/app/registratie/domain/registration.policy.spec.ts b/src/app/registratie/domain/registration.policy.spec.ts new file mode 100644 index 0000000..0ac84de --- /dev/null +++ b/src/app/registratie/domain/registration.policy.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { Registration } from './registration'; +import { isHerregistratieEligible, statusColor } from './registration.policy'; + +const reg = (status: Registration['status']): Registration => ({ + bigNummer: '19012345601', naam: 'Test', beroep: 'Arts', + registratiedatum: '2012-09-01', geboortedatum: '1985-03-14', status, +}); + +describe('registration.policy', () => { + it('only an active registration within the window is eligible', () => { + const active = reg({ tag: 'Geregistreerd', herregistratieDatum: '2027-01-01' }); + expect(isHerregistratieEligible(active, new Date('2026-06-01'))).toBe(true); // within 12 months + expect(isHerregistratieEligible(active, new Date('2020-01-01'))).toBe(false); // too early + }); + + it('struck-off / suspended registrations are never eligible', () => { + expect(isHerregistratieEligible(reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }), new Date('2027-01-01'))).toBe(false); + expect(isHerregistratieEligible(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }), new Date('2027-01-01'))).toBe(false); + }); + + it('statusColor is total over the union', () => { + expect(statusColor('Geregistreerd')).toContain('groen'); + expect(statusColor('Doorgehaald')).toContain('rood'); + expect(statusColor('Geschorst')).toContain('oranje'); + }); +}); diff --git a/src/app/registratie/domain/registration.policy.ts b/src/app/registratie/domain/registration.policy.ts new file mode 100644 index 0000000..91a29cb --- /dev/null +++ b/src/app/registratie/domain/registration.policy.ts @@ -0,0 +1,51 @@ +import { assertNever } from '@shared/kernel/fp'; +import { Registration, RegistrationStatus, StatusTag } from './registration'; + +/** + * Domain logic for a registration — pure functions, NO Angular. This is where + * "what the business rules say" lives, separate from "how it looks" (UI) and + * "where the data comes from" (infrastructure). Keeping it framework-free means + * it is trivial to read and unit-test. + */ + +/** Human-readable label for a status. */ +export function statusLabel(tag: StatusTag): string { + return tag; // the tag already reads as Dutch; kept as a function so labels can diverge later +} + +/** Brand colour token for a status. assertNever forces a colour for every new + status variant at compile time. */ +export function statusColor(tag: StatusTag): string { + switch (tag) { + case 'Geregistreerd': + return 'var(--rhc-color-groen-500)'; + case 'Doorgehaald': + return 'var(--rhc-color-rood-500)'; + case 'Geschorst': + return 'var(--rhc-color-oranje-500)'; + default: + return assertNever(tag); + } +} + +/** The herregistratie deadline, if the status has one (only the active state does). */ +export function herregistratieDeadline(reg: Registration): Date | null { + return reg.status.tag === 'Geregistreerd' ? new Date(reg.status.herregistratieDatum) : null; +} + +/** A registration may apply for herregistratie only while active and within the + window before its deadline. A struck-off or suspended registration may not. */ +export function isHerregistratieEligible(reg: Registration, today: Date, windowMonths = 12): boolean { + const deadline = herregistratieDeadline(reg); + if (!deadline) return false; + const windowStart = new Date(deadline); + windowStart.setMonth(windowStart.getMonth() - windowMonths); + return today >= windowStart; +} + +/** Invariant check used in tests/demos: a non-active status must not carry a + herregistratie date. The union already enforces this structurally; this is + the runtime statement of the same rule. */ +export function isStatusConsistent(status: RegistrationStatus): boolean { + return status.tag === 'Geregistreerd' ? typeof status.herregistratieDatum === 'string' : true; +} diff --git a/src/app/core/models.ts b/src/app/registratie/domain/registration.ts similarity index 100% rename from src/app/core/models.ts rename to src/app/registratie/domain/registration.ts diff --git a/src/app/registratie/domain/value-objects/big-nummer.ts b/src/app/registratie/domain/value-objects/big-nummer.ts new file mode 100644 index 0000000..d60fd93 --- /dev/null +++ b/src/app/registratie/domain/value-objects/big-nummer.ts @@ -0,0 +1,9 @@ +import { Brand, Result, ok, err } from '@shared/kernel/fp'; + +/** Value object: a BIG registration number — 11 digits. */ +export type BigNummer = Brand; + +export function parseBigNummer(raw: string): Result { + const t = raw.trim(); + return /^\d{11}$/.test(t) ? ok(t as BigNummer) : err('Een BIG-nummer bestaat uit 11 cijfers.'); +} diff --git a/src/app/registratie/domain/value-objects/postcode.ts b/src/app/registratie/domain/value-objects/postcode.ts new file mode 100644 index 0000000..2339a45 --- /dev/null +++ b/src/app/registratie/domain/value-objects/postcode.ts @@ -0,0 +1,17 @@ +import { Brand, Result, ok, err } from '@shared/kernel/fp'; + +/** + * Value object: a Dutch postcode. "Parse, don't validate" — a Postcode is a + * distinct type from a raw string, mintable only via parsePostcode, so holding + * one is proof it is well-formed. + */ +export type Postcode = Brand; + +export function parsePostcode(raw: string): Result { + const t = raw.trim().toUpperCase(); + if (!/^[1-9]\d{3}\s?[A-Z]{2}$/.test(t)) { + return err('Voer een geldige postcode in, bijv. 1234 AB.'); + } + // Normalise to "1234 AB" — the parser also cleans up. + return ok(t.replace(/^(\d{4})\s?([A-Z]{2})$/, '$1 $2') as Postcode); +} diff --git a/src/app/registratie/domain/value-objects/uren.ts b/src/app/registratie/domain/value-objects/uren.ts new file mode 100644 index 0000000..8e8563c --- /dev/null +++ b/src/app/registratie/domain/value-objects/uren.ts @@ -0,0 +1,14 @@ +import { Brand, Result, ok, err } from '@shared/kernel/fp'; + +/** Value object: a non-negative whole number of hours. */ +export type Uren = Brand; + +export function parseUren(raw: string): Result { + const t = raw.trim(); + const n = Number(t); + // Number('') is 0 — guard the empty string explicitly. + if (t === '' || !Number.isInteger(n) || n < 0) { + return err('Vul een geheel aantal in (0 of meer).'); + } + return ok(n as Uren); +} diff --git a/src/app/registratie/infrastructure/big-register.adapter.ts b/src/app/registratie/infrastructure/big-register.adapter.ts new file mode 100644 index 0000000..24fa549 --- /dev/null +++ b/src/app/registratie/infrastructure/big-register.adapter.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { httpResource } from '@angular/common/http'; +import { Registration, Aantekening } from '../domain/registration'; + +/** + * Infrastructure adapter for the BIG-register source. Exposes signal-based + * resources (Angular's httpResource); each returns a Resource with + * status()/value()/error()/reload(). Call from an injection context + * (a field initializer in the store). + */ +@Injectable({ providedIn: 'root' }) +export class BigRegisterAdapter { + registrationResource() { + return httpResource(() => 'mock/registration.json'); + } + + aantekeningenResource() { + return httpResource(() => 'mock/notes.json', { defaultValue: [] }); + } +} diff --git a/src/app/registratie/infrastructure/brp.adapter.ts b/src/app/registratie/infrastructure/brp.adapter.ts new file mode 100644 index 0000000..dec04a3 --- /dev/null +++ b/src/app/registratie/infrastructure/brp.adapter.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@angular/core'; +import { httpResource } from '@angular/common/http'; +import { Person } from '../domain/person'; + +/** Infrastructure adapter for the BRP (Basisregistratie Personen) source. */ +@Injectable({ providedIn: 'root' }) +export class BrpAdapter { + personResource() { + return httpResource(() => 'mock/brp.json'); + } +} diff --git a/src/app/organisms/change-request-form/change-request-form.component.ts b/src/app/registratie/ui/change-request-form/change-request-form.component.ts similarity index 84% rename from src/app/organisms/change-request-form/change-request-form.component.ts rename to src/app/registratie/ui/change-request-form/change-request-form.component.ts index fb923de..c43839f 100644 --- a/src/app/organisms/change-request-form/change-request-form.component.ts +++ b/src/app/registratie/ui/change-request-form/change-request-form.component.ts @@ -1,10 +1,10 @@ import { Component, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { FormFieldComponent } from '../../molecules/form-field/form-field.component'; -import { TextInputComponent } from '../../atoms/text-input/text-input.component'; -import { ButtonComponent } from '../../atoms/button/button.component'; -import { HeadingComponent } from '../../atoms/heading/heading.component'; -import { Postcode, parsePostcode } from '../../core/parse'; +import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; +import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode'; /** A submitted change request carries a *parsed* postcode (branded Postcode), not a raw string — downstream code can't receive an unvalidated one. */ diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts new file mode 100644 index 0000000..f0725a4 --- /dev/null +++ b/src/app/registratie/ui/dashboard.page.ts @@ -0,0 +1,72 @@ +import { Component, inject } from '@angular/core'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { LinkComponent } from '@shared/ui/link/link.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; +import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; +import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; + +@Component({ + selector: 'app-dashboard-page', + imports: [ + PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent, + DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent, + ], + template: ` + + @if (store.pendingHerregistratie()) { + Uw herregistratie-aanvraag is in behandeling. + } + + + + + +
+ Persoonsgegevens (BRP) +
+ + + +
+
+
+ + + +
+ +
+ Specialismen en aantekeningen + + + + + + + + +

U heeft nog geen specialismen of aantekeningen.

+
+
+
+ +

+ Gegevens bekijken of een wijziging doorgeven → +

+

+ Herregistratie aanvragen → +

+

+ Functionele patronen (impossible states) → +

+
+ `, +}) +export class DashboardPage { + protected store = inject(BigProfileStore); +} diff --git a/src/app/pages/registration-detail/registration-detail.page.ts b/src/app/registratie/ui/registration-detail.page.ts similarity index 55% rename from src/app/pages/registration-detail/registration-detail.page.ts rename to src/app/registratie/ui/registration-detail.page.ts index a91fb4b..4db9db5 100644 --- a/src/app/pages/registration-detail/registration-detail.page.ts +++ b/src/app/registratie/ui/registration-detail.page.ts @@ -1,11 +1,11 @@ import { Component, inject, signal } from '@angular/core'; -import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; -import { AlertComponent } from '../../atoms/alert/alert.component'; -import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component'; -import { ASYNC } from '../../molecules/async/async.component'; -import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component'; -import { ChangeRequestFormComponent } from '../../organisms/change-request-form/change-request-form.component'; -import { RegistrationService } from '../../core/registration.service'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; +import { ChangeRequestFormComponent } from '@registratie/ui/change-request-form/change-request-form.component'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; @Component({ selector: 'app-registration-detail-page', @@ -15,9 +15,9 @@ import { RegistrationService } from '../../core/registration.service'; ], template: ` - - - + + + @@ -35,8 +35,6 @@ import { RegistrationService } from '../../core/registration.service'; `, }) export class RegistrationDetailPage { - private svc = inject(RegistrationService); - reg = this.svc.registrationResource(); - regEmpty = (v: unknown) => !v || Object.keys(v).length === 0; + protected store = inject(BigProfileStore); submitted = signal(false); } diff --git a/src/app/organisms/registration-summary/registration-summary.component.ts b/src/app/registratie/ui/registration-summary/registration-summary.component.ts similarity index 76% rename from src/app/organisms/registration-summary/registration-summary.component.ts rename to src/app/registratie/ui/registration-summary/registration-summary.component.ts index bf6802c..4c7ee64 100644 --- a/src/app/organisms/registration-summary/registration-summary.component.ts +++ b/src/app/registratie/ui/registration-summary/registration-summary.component.ts @@ -1,8 +1,9 @@ import { Component, input } from '@angular/core'; import { DatePipe } from '@angular/common'; -import { Registration } from '../../core/models'; -import { DataRowComponent } from '../../molecules/data-row/data-row.component'; -import { StatusBadgeComponent } from '../../atoms/status-badge/status-badge.component'; +import { Registration } from '@registratie/domain/registration'; +import { statusColor, statusLabel } from '@registratie/domain/registration.policy'; +import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; +import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; /** Organism: registration summary card. Composes data-row molecules + status-badge atom. */ @Component({ @@ -15,7 +16,7 @@ import { StatusBadgeComponent } from '../../atoms/status-badge/status-badge.comp - + @@ -38,4 +39,6 @@ import { StatusBadgeComponent } from '../../atoms/status-badge/status-badge.comp }) export class RegistrationSummaryComponent { reg = input.required(); + protected label = () => statusLabel(this.reg().status.tag); + protected color = () => statusColor(this.reg().status.tag); } diff --git a/src/app/organisms/registration-summary/registration-summary.stories.ts b/src/app/registratie/ui/registration-summary/registration-summary.stories.ts similarity index 94% rename from src/app/organisms/registration-summary/registration-summary.stories.ts rename to src/app/registratie/ui/registration-summary/registration-summary.stories.ts index 43fdd0b..f0bf6b9 100644 --- a/src/app/organisms/registration-summary/registration-summary.stories.ts +++ b/src/app/registratie/ui/registration-summary/registration-summary.stories.ts @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { RegistrationSummaryComponent } from './registration-summary.component'; -import { Registration } from '../../core/models'; +import { Registration } from '@registratie/domain/registration'; const base = { bigNummer: '19012345601', diff --git a/src/app/organisms/registration-table/registration-table.component.ts b/src/app/registratie/ui/registration-table/registration-table.component.ts similarity index 94% rename from src/app/organisms/registration-table/registration-table.component.ts rename to src/app/registratie/ui/registration-table/registration-table.component.ts index bd189df..f6309ef 100644 --- a/src/app/organisms/registration-table/registration-table.component.ts +++ b/src/app/registratie/ui/registration-table/registration-table.component.ts @@ -1,6 +1,6 @@ import { Component, input } from '@angular/core'; import { DatePipe } from '@angular/common'; -import { Aantekening } from '../../core/models'; +import { Aantekening } from '@registratie/domain/registration'; /** Organism: table of specialismen/aantekeningen. */ @Component({ diff --git a/src/app/organisms/registration-table/registration-table.stories.ts b/src/app/registratie/ui/registration-table/registration-table.stories.ts similarity index 100% rename from src/app/organisms/registration-table/registration-table.stories.ts rename to src/app/registratie/ui/registration-table/registration-table.stories.ts diff --git a/src/app/shared/application/remote-data.spec.ts b/src/app/shared/application/remote-data.spec.ts new file mode 100644 index 0000000..30a1489 --- /dev/null +++ b/src/app/shared/application/remote-data.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { RemoteData, map2, map } from './remote-data'; + +const loading: RemoteData = { tag: 'Loading' }; +const failure: RemoteData = { tag: 'Failure', error: 'x' }; +const ok = (n: number): RemoteData => ({ tag: 'Success', value: n }); + +describe('RemoteData combinators', () => { + it('map only touches Success', () => { + const times10 = (n: number) => n * 10; + expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 }); + expect(map(loading, times10)).toEqual(loading); + }); + + it('map2 precedence: Failure > Loading > Success', () => { + const add = (a: number, b: number) => a + b; + expect(map2(failure, ok(1), add)).toEqual(failure); // a failed + expect(map2(ok(1), failure, add)).toEqual(failure); // b failed + expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' }); + expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 }); + }); +}); diff --git a/src/app/shared/application/remote-data.ts b/src/app/shared/application/remote-data.ts new file mode 100644 index 0000000..eeb51ee --- /dev/null +++ b/src/app/shared/application/remote-data.ts @@ -0,0 +1,90 @@ +import type { Resource } from '@angular/core'; +import { assertNever } from '@shared/kernel/fp'; + +/** + * The four mutually-exclusive states of an async fetch, as a tagged union. + * Crucially the data lives ON the state: only `Failure` has an `error`, only + * `Success` has a `value`. "Loaded but no value" or "error with stale value" + * are unrepresentable — Richard Feldman's RemoteData. + */ +export type RemoteData = + | { tag: 'Loading' } + | { tag: 'Empty' } + | { tag: 'Failure'; error: E } + | { tag: 'Success'; value: T }; + +/** Project Angular's loosely-typed Resource into a RemoteData value. */ +export function fromResource( + r: Resource, + isEmpty: (v: T) => boolean = () => false, +): RemoteData { + if (r.status() === 'error') return { tag: 'Failure', error: r.error() }; + if (r.status() === 'loading') return { tag: 'Loading' }; + if (r.hasValue()) { + const v = r.value(); + return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v }; + } + return { tag: 'Loading' }; +} + +/** Exhaustive fold: you must handle every case, checked at compile time. */ +export function foldRemote( + rd: RemoteData, + h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R }, +): R { + switch (rd.tag) { + case 'Loading': + return h.loading(); + case 'Empty': + return h.empty(); + case 'Failure': + return h.failure(rd.error); + case 'Success': + return h.success(rd.value); + default: + return assertNever(rd); + } +} + +// --- Combinators ----------------------------------------------------------- +// Let several independent async sources be treated as one. When you combine +// two streams the result is: a failure if EITHER failed, still loading if +// either is loading, empty if either is empty, and only Success when BOTH +// succeeded. Precedence: Failure > Loading > Empty > Success. + +/** Transform the value inside a Success; pass other states through unchanged. */ +export function map(rd: RemoteData, f: (a: A) => B): RemoteData { + return rd.tag === 'Success' ? { tag: 'Success', value: f(rd.value) } : rd; +} + +/** Combine two sources into one. Use this to merge e.g. a BIG-register call + and a BRP call into a single state the page can render. */ +export function map2( + a: RemoteData, + b: RemoteData, + f: (a: A, b: B) => R, +): RemoteData { + if (a.tag === 'Failure') return a; + if (b.tag === 'Failure') return b; + if (a.tag === 'Loading' || b.tag === 'Loading') return { tag: 'Loading' }; + if (a.tag === 'Empty' || b.tag === 'Empty') return { tag: 'Empty' }; + return { tag: 'Success', value: f(a.value, b.value) }; +} + +/** Combine three sources (built on map2). */ +export function map3( + a: RemoteData, + b: RemoteData, + c: RemoteData, + f: (a: A, b: B, c: C) => R, +): RemoteData { + return map2(map2(a, b, (x, y) => [x, y] as const), c, ([x, y], z) => f(x, y, z)); +} + +/** Chain a second source that depends on the first one's value. */ +export function andThen( + rd: RemoteData, + f: (a: A) => RemoteData, +): RemoteData { + return rd.tag === 'Success' ? f(rd.value) : rd; +} diff --git a/src/app/shared/application/store.ts b/src/app/shared/application/store.ts new file mode 100644 index 0000000..86782bf --- /dev/null +++ b/src/app/shared/application/store.ts @@ -0,0 +1,29 @@ +import { Signal, signal } from '@angular/core'; + +/** + * A tiny "Elm-style" store. The whole idea: all state lives in ONE value + * (the Model). The only way to change it is to send a message (Msg) to a PURE + * function `update(model, msg)` that returns the next Model. Nothing else + * mutates state, so to understand the app you only read the update function. + * + * Side effects (HTTP, timers) do NOT go in `update` — that stays pure and easy + * to test. Instead, effectful "command" functions call the network and then + * `dispatch` a message describing what happened (e.g. Loaded / Failed). + */ +export interface Store { + /** The current state, as a read-only Angular signal. */ + readonly model: Signal; + /** Send a message; the model becomes update(model, msg). */ + dispatch(msg: Msg): void; +} + +export function createStore( + init: Model, + update: (model: Model, msg: Msg) => Model, +): Store { + const model = signal(init); + return { + model: model.asReadonly(), + dispatch: (msg) => model.set(update(model(), msg)), + }; +} diff --git a/src/app/core/scenario.interceptor.ts b/src/app/shared/infrastructure/scenario.interceptor.ts similarity index 100% rename from src/app/core/scenario.interceptor.ts rename to src/app/shared/infrastructure/scenario.interceptor.ts diff --git a/src/app/core/scenario.ts b/src/app/shared/infrastructure/scenario.ts similarity index 100% rename from src/app/core/scenario.ts rename to src/app/shared/infrastructure/scenario.ts diff --git a/src/app/core/fp.ts b/src/app/shared/kernel/fp.ts similarity index 100% rename from src/app/core/fp.ts rename to src/app/shared/kernel/fp.ts diff --git a/src/app/templates/page-shell/page-shell.component.ts b/src/app/shared/layout/page-shell/page-shell.component.ts similarity index 88% rename from src/app/templates/page-shell/page-shell.component.ts rename to src/app/shared/layout/page-shell/page-shell.component.ts index 8c09dae..465c217 100644 --- a/src/app/templates/page-shell/page-shell.component.ts +++ b/src/app/shared/layout/page-shell/page-shell.component.ts @@ -1,6 +1,6 @@ import { Component, input } from '@angular/core'; -import { HeadingComponent } from '../../atoms/heading/heading.component'; -import { LinkComponent } from '../../atoms/link/link.component'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { LinkComponent } from '@shared/ui/link/link.component'; /** Template: standard page body — optional back-link, a heading, optional intro, and projected content. Rendered inside the persistent ShellComponent via the diff --git a/src/app/templates/page-shell/page-shell.stories.ts b/src/app/shared/layout/page-shell/page-shell.stories.ts similarity index 94% rename from src/app/templates/page-shell/page-shell.stories.ts rename to src/app/shared/layout/page-shell/page-shell.stories.ts index 1d23e69..a67a4cf 100644 --- a/src/app/templates/page-shell/page-shell.stories.ts +++ b/src/app/shared/layout/page-shell/page-shell.stories.ts @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { applicationConfig, moduleMetadata } from '@storybook/angular'; import { provideRouter } from '@angular/router'; import { PageShellComponent } from './page-shell.component'; -import { ButtonComponent } from '../../atoms/button/button.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; const meta: Meta = { title: 'Templates/PageShell', diff --git a/src/app/templates/shell/shell.component.ts b/src/app/shared/layout/shell/shell.component.ts similarity index 85% rename from src/app/templates/shell/shell.component.ts rename to src/app/shared/layout/shell/shell.component.ts index d721c1f..8548049 100644 --- a/src/app/templates/shell/shell.component.ts +++ b/src/app/shared/layout/shell/shell.component.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { SiteHeaderComponent } from '../../organisms/site-header/site-header.component'; -import { SiteFooterComponent } from '../../organisms/site-footer/site-footer.component'; +import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component'; +import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component'; /** Template: persistent app chrome. Header + footer mount once; only the routed content inside changes (and cross-fades — see styles.scss). */ diff --git a/src/app/organisms/site-footer/site-footer.component.ts b/src/app/shared/layout/site-footer/site-footer.component.ts similarity index 100% rename from src/app/organisms/site-footer/site-footer.component.ts rename to src/app/shared/layout/site-footer/site-footer.component.ts diff --git a/src/app/organisms/site-header/site-header.component.ts b/src/app/shared/layout/site-header/site-header.component.ts similarity index 100% rename from src/app/organisms/site-header/site-header.component.ts rename to src/app/shared/layout/site-header/site-header.component.ts diff --git a/src/app/atoms/alert/alert.component.ts b/src/app/shared/ui/alert/alert.component.ts similarity index 100% rename from src/app/atoms/alert/alert.component.ts rename to src/app/shared/ui/alert/alert.component.ts diff --git a/src/app/atoms/alert/alert.stories.ts b/src/app/shared/ui/alert/alert.stories.ts similarity index 100% rename from src/app/atoms/alert/alert.stories.ts rename to src/app/shared/ui/alert/alert.stories.ts diff --git a/src/app/molecules/async/async.component.ts b/src/app/shared/ui/async/async.component.ts similarity index 78% rename from src/app/molecules/async/async.component.ts rename to src/app/shared/ui/async/async.component.ts index b92fe38..ab997ea 100644 --- a/src/app/molecules/async/async.component.ts +++ b/src/app/shared/ui/async/async.component.ts @@ -1,10 +1,10 @@ import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import type { Resource } from '@angular/core'; -import { SpinnerComponent } from '../../atoms/spinner/spinner.component'; -import { AlertComponent } from '../../atoms/alert/alert.component'; -import { ButtonComponent } from '../../atoms/button/button.component'; -import { fromResource, foldRemote } from '../../core/remote-data'; +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 { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data'; /* Slot markers. Put on children of . */ @Directive({ selector: '[appAsyncLoaded]' }) @@ -63,7 +63,11 @@ export class AsyncErrorDirective { `, }) export class AsyncComponent { - resource = input.required>(); + // Two ways to feed this component: + // [resource] — a raw httpResource (the common case), or + // [data] — an already-combined RemoteData (e.g. from a store via map2). + resource = input>(); + data = input>(); isEmpty = input<(v: T) => boolean>(() => false); loadedTpl = contentChild.required(AsyncLoadedDirective); @@ -71,8 +75,13 @@ export class AsyncComponent { emptyTpl = contentChild(AsyncEmptyDirective); errorTpl = contentChild(AsyncErrorDirective); - // Single source of truth: the resource projected into a RemoteData union. - protected rd = computed(() => fromResource(this.resource(), this.isEmpty())); + // Single source of truth: the supplied RemoteData, or the resource projected into one. + protected rd = computed>(() => { + const data = this.data(); + if (data) return data; + const r = this.resource(); + return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' }; + }); // value/error are pulled out via the exhaustive fold — only Success carries a // value, only Failure carries an error, so these can't lie. @@ -85,7 +94,7 @@ export class AsyncComponent { retry = () => { const r = this.resource(); - if ('reload' in r && typeof (r as { reload?: unknown }).reload === 'function') { + if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') { (r as { reload: () => void }).reload(); } }; diff --git a/src/app/molecules/async/async.stories.ts b/src/app/shared/ui/async/async.stories.ts similarity index 96% rename from src/app/molecules/async/async.stories.ts rename to src/app/shared/ui/async/async.stories.ts index 0932e56..b458191 100644 --- a/src/app/molecules/async/async.stories.ts +++ b/src/app/shared/ui/async/async.stories.ts @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular'; import type { Resource } from '@angular/core'; import { ASYNC } from './async.component'; -import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; /** Minimal fake of a signal Resource so the wrapper can be driven through every state in isolation (no HTTP). */ diff --git a/src/app/atoms/button/button.component.ts b/src/app/shared/ui/button/button.component.ts similarity index 100% rename from src/app/atoms/button/button.component.ts rename to src/app/shared/ui/button/button.component.ts diff --git a/src/app/atoms/button/button.stories.ts b/src/app/shared/ui/button/button.stories.ts similarity index 100% rename from src/app/atoms/button/button.stories.ts rename to src/app/shared/ui/button/button.stories.ts diff --git a/src/app/molecules/data-row/data-row.component.ts b/src/app/shared/ui/data-row/data-row.component.ts similarity index 100% rename from src/app/molecules/data-row/data-row.component.ts rename to src/app/shared/ui/data-row/data-row.component.ts diff --git a/src/app/molecules/form-field/form-field.component.ts b/src/app/shared/ui/form-field/form-field.component.ts similarity index 100% rename from src/app/molecules/form-field/form-field.component.ts rename to src/app/shared/ui/form-field/form-field.component.ts diff --git a/src/app/molecules/form-field/form-field.stories.ts b/src/app/shared/ui/form-field/form-field.stories.ts similarity index 91% rename from src/app/molecules/form-field/form-field.stories.ts rename to src/app/shared/ui/form-field/form-field.stories.ts index a34daec..ac56ec5 100644 --- a/src/app/molecules/form-field/form-field.stories.ts +++ b/src/app/shared/ui/form-field/form-field.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular'; import { FormFieldComponent } from './form-field.component'; -import { TextInputComponent } from '../../atoms/text-input/text-input.component'; +import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; const meta: Meta = { title: 'Molecules/Form Field', diff --git a/src/app/atoms/heading/heading.component.ts b/src/app/shared/ui/heading/heading.component.ts similarity index 100% rename from src/app/atoms/heading/heading.component.ts rename to src/app/shared/ui/heading/heading.component.ts diff --git a/src/app/atoms/heading/heading.stories.ts b/src/app/shared/ui/heading/heading.stories.ts similarity index 100% rename from src/app/atoms/heading/heading.stories.ts rename to src/app/shared/ui/heading/heading.stories.ts diff --git a/src/app/atoms/link/link.component.ts b/src/app/shared/ui/link/link.component.ts similarity index 100% rename from src/app/atoms/link/link.component.ts rename to src/app/shared/ui/link/link.component.ts diff --git a/src/app/atoms/skeleton/skeleton.component.ts b/src/app/shared/ui/skeleton/skeleton.component.ts similarity index 100% rename from src/app/atoms/skeleton/skeleton.component.ts rename to src/app/shared/ui/skeleton/skeleton.component.ts diff --git a/src/app/atoms/skeleton/skeleton.stories.ts b/src/app/shared/ui/skeleton/skeleton.stories.ts similarity index 100% rename from src/app/atoms/skeleton/skeleton.stories.ts rename to src/app/shared/ui/skeleton/skeleton.stories.ts diff --git a/src/app/atoms/spinner/spinner.component.ts b/src/app/shared/ui/spinner/spinner.component.ts similarity index 100% rename from src/app/atoms/spinner/spinner.component.ts rename to src/app/shared/ui/spinner/spinner.component.ts diff --git a/src/app/atoms/spinner/spinner.stories.ts b/src/app/shared/ui/spinner/spinner.stories.ts similarity index 100% rename from src/app/atoms/spinner/spinner.stories.ts rename to src/app/shared/ui/spinner/spinner.stories.ts diff --git a/src/app/shared/ui/status-badge/status-badge.component.ts b/src/app/shared/ui/status-badge/status-badge.component.ts new file mode 100644 index 0000000..f04010a --- /dev/null +++ b/src/app/shared/ui/status-badge/status-badge.component.ts @@ -0,0 +1,18 @@ +import { Component, input } from '@angular/core'; + +/** Atom: a coloured dot + label. Purely presentational and domain-free — the + caller decides what colour and label mean (e.g. via registration.policy). + This keeps the shared UI kernel free of any domain knowledge. */ +@Component({ + selector: 'app-status-badge', + template: ` + + + {{ label() }} + + `, +}) +export class StatusBadgeComponent { + label = input.required(); + color = input.required(); +} diff --git a/src/app/shared/ui/status-badge/status-badge.stories.ts b/src/app/shared/ui/status-badge/status-badge.stories.ts new file mode 100644 index 0000000..0fb9305 --- /dev/null +++ b/src/app/shared/ui/status-badge/status-badge.stories.ts @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { StatusBadgeComponent } from './status-badge.component'; + +const meta: Meta = { + title: 'Shared UI/Status Badge', + component: StatusBadgeComponent, +}; +export default meta; +type Story = StoryObj; + +export const Geregistreerd: Story = { args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' } }; +export const Geschorst: Story = { args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' } }; +export const Doorgehaald: Story = { args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' } }; diff --git a/src/app/atoms/text-input/text-input.component.ts b/src/app/shared/ui/text-input/text-input.component.ts similarity index 100% rename from src/app/atoms/text-input/text-input.component.ts rename to src/app/shared/ui/text-input/text-input.component.ts diff --git a/src/app/pages/concepts/concepts.page.ts b/src/app/showcase/concepts.page.ts similarity index 89% rename from src/app/pages/concepts/concepts.page.ts rename to src/app/showcase/concepts.page.ts index d4faecf..1fd8e93 100644 --- a/src/app/pages/concepts/concepts.page.ts +++ b/src/app/showcase/concepts.page.ts @@ -1,16 +1,16 @@ import { Component, computed, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import type { Resource } from '@angular/core'; -import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; -import { HeadingComponent } from '../../atoms/heading/heading.component'; -import { AlertComponent } from '../../atoms/alert/alert.component'; -import { TextInputComponent } from '../../atoms/text-input/text-input.component'; -import { ASYNC } from '../../molecules/async/async.component'; -import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component'; -import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component'; -import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component'; -import { Registration } from '../../core/models'; -import { parsePostcode } from '../../core/parse'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; +import { ASYNC } from '@shared/ui/async/async.component'; +import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; +import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; +import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; +import { Registration } from '@registratie/domain/registration'; +import { parsePostcode } from '@registratie/domain/value-objects/postcode'; /** Minimal fake Resource so can be driven through every state without HTTP. */ function fakeResource(status: string, value?: T, error?: Error): Resource { diff --git a/tsconfig.json b/tsconfig.json index d2fbb9c..e8ea8aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,15 @@ "experimentalDecorators": true, "importHelpers": true, "target": "ES2022", - "module": "preserve" + "module": "preserve", + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@shared/*": ["src/app/shared/*"], + "@auth/*": ["src/app/auth/*"], + "@registratie/*": ["src/app/registratie/*"], + "@herregistratie/*": ["src/app/herregistratie/*"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false,