feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import {
|
||||
IntakeState,
|
||||
IntakeMsg,
|
||||
Answers,
|
||||
StepId,
|
||||
initial,
|
||||
reduce,
|
||||
STEPS,
|
||||
lageUren,
|
||||
hasProgress,
|
||||
SCHOLING_THRESHOLD_DEFAULT,
|
||||
} from '@herregistratie/domain/intake.machine';
|
||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
|
||||
|
||||
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
||||
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
||||
from the answers via `visibleSteps`, never stored — so editing an earlier
|
||||
answer immediately changes the remaining steps. Answers are persisted to
|
||||
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
|
||||
@Component({
|
||||
selector: 'app-intake-wizard',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormFieldComponent,
|
||||
TextInputComponent,
|
||||
RadioGroupComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
DataRowComponent,
|
||||
ReviewSectionComponent,
|
||||
ConfirmationComponent,
|
||||
WizardShellComponent,
|
||||
],
|
||||
template: `
|
||||
<app-wizard-shell
|
||||
[steps]="stepLabels"
|
||||
[current]="cursor()"
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@intake.processName"
|
||||
processName="Herregistratie-intake"
|
||||
[status]="shellStatus()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="cursor() > 0"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="onPrimary()"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()"
|
||||
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||
>
|
||||
@switch (step()) {
|
||||
@case ('buitenland') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenland"
|
||||
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
||||
fieldId="buitenlandGewerkt"
|
||||
required
|
||||
[error]="err('buitenlandGewerkt')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="buitenlandGewerkt"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
||||
(ngModelChange)="set('buitenlandGewerkt', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.land"
|
||||
label="In welk land?"
|
||||
fieldId="land"
|
||||
required
|
||||
[error]="err('land')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="land"
|
||||
[ngModel]="answers().land ?? ''"
|
||||
(ngModelChange)="set('land', $event)"
|
||||
name="land"
|
||||
i18n-placeholder="@@intake.q.landPlaceholder"
|
||||
placeholder="bijv. België"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenlandseUren"
|
||||
label="Hoeveel uur heeft u daar gewerkt?"
|
||||
fieldId="buitenlandseUren"
|
||||
required
|
||||
[error]="err('buitenlandseUren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="buitenlandseUren"
|
||||
[ngModel]="answers().buitenlandseUren ?? ''"
|
||||
(ngModelChange)="set('buitenlandseUren', $event)"
|
||||
name="buitenlandseUren"
|
||||
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
||||
placeholder="bijv. 800"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
@case ('werk') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.urenNl"
|
||||
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
|
||||
fieldId="uren"
|
||||
required
|
||||
[error]="err('uren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="uren"
|
||||
[ngModel]="answers().uren ?? ''"
|
||||
(ngModelChange)="set('uren', $event)"
|
||||
name="uren"
|
||||
i18n-placeholder="@@intake.q.urenNlPlaceholder"
|
||||
placeholder="bijv. 4160"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
@if (scholingZichtbaar()) {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.scholing"
|
||||
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
|
||||
fieldId="scholingGevolgd"
|
||||
required
|
||||
[error]="err('scholingGevolgd')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="scholingGevolgd"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''"
|
||||
(ngModelChange)="set('scholingGevolgd', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.punten"
|
||||
label="Behaalde nascholingspunten"
|
||||
fieldId="punten"
|
||||
required
|
||||
[error]="err('punten')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="punten"
|
||||
[ngModel]="answers().punten ?? ''"
|
||||
(ngModelChange)="set('punten', $event)"
|
||||
name="punten"
|
||||
i18n-placeholder="@@intake.q.puntenPlaceholder"
|
||||
placeholder="bijv. 200"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
}
|
||||
@case ('review') {
|
||||
<app-alert type="info" i18n="@@intake.review.controleer"
|
||||
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
||||
>
|
||||
<app-review-section
|
||||
i18n-heading="@@intake.sectie.buitenland"
|
||||
heading="Buitenland"
|
||||
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
||||
editAriaLabel="Wijzigen buitenland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenNl"
|
||||
key="Buiten NL gewerkt"
|
||||
[value]="answers().buitenlandGewerkt ?? '—'"
|
||||
></div>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.land"
|
||||
key="Land"
|
||||
[value]="answers().land ?? ''"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenlandseUren"
|
||||
key="Buitenlandse uren"
|
||||
[value]="answers().buitenlandseUren ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
<app-review-section
|
||||
class="app-section"
|
||||
i18n-heading="@@intake.sectie.werk"
|
||||
heading="Werk in Nederland"
|
||||
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
||||
editAriaLabel="Wijzigen werk in Nederland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.urenNl"
|
||||
key="Uren NL"
|
||||
[value]="answers().uren ?? ''"
|
||||
></div>
|
||||
@if (scholingZichtbaar()) {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.scholing"
|
||||
key="Aanvullende scholing"
|
||||
[value]="answers().scholingGevolgd ?? ''"
|
||||
></div>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.punten"
|
||||
key="Nascholingspunten"
|
||||
[value]="answers().punten ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
}
|
||||
}
|
||||
|
||||
<div wizardSuccess>
|
||||
<app-confirmation
|
||||
i18n-title="@@intake.success.title"
|
||||
title="Uw aanvraag tot herregistratie is ontvangen"
|
||||
>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw"
|
||||
>Opnieuw beginnen</app-button
|
||||
>
|
||||
</div>
|
||||
</app-confirmation>
|
||||
</div>
|
||||
</app-wizard-shell>
|
||||
`,
|
||||
})
|
||||
export class IntakeWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
// Server-owned policy (scholing threshold): fetched from the backend via the
|
||||
// application facade, not hardcoded. The backend stays the authority on submit.
|
||||
private policyStore = inject(IntakePolicyStore);
|
||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
seed = input<IntakeState>(initial);
|
||||
|
||||
readonly jaNee = JA_NEE;
|
||||
readonly state = this.store.model;
|
||||
readonly dispatch = this.store.dispatch;
|
||||
|
||||
// Backend draft-sync (replaces sessionStorage); the intake has no uploads.
|
||||
private draftSync = createDraftSync({
|
||||
type: 'intake',
|
||||
snapshot: () => {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Answering' || !hasProgress(s)) return null;
|
||||
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] };
|
||||
},
|
||||
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }),
|
||||
enabled: () => this.seed() === initial,
|
||||
});
|
||||
|
||||
private answering = computed(() => whenTag(this.state(), 'Answering'));
|
||||
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
||||
readonly steps = STEPS;
|
||||
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
||||
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
|
||||
protected scholingThreshold = computed(
|
||||
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||
);
|
||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
readonly stepLabels = [
|
||||
$localize`:@@intake.step.buitenland:Buitenland`,
|
||||
$localize`:@@intake.step.werk:Werk`,
|
||||
$localize`:@@intake.step.controle:Controle`,
|
||||
];
|
||||
private stepTitles: Record<StepId, string> = {
|
||||
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
|
||||
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
|
||||
review: $localize`:@@intake.title.review:Controleren en indienen`,
|
||||
};
|
||||
protected stepTitle = computed(() => this.stepTitles[this.step()]);
|
||||
protected primaryLabel = computed(() => {
|
||||
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
|
||||
const next = this.cursor() + 1;
|
||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||
});
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
case 'Answering':
|
||||
return 'editing';
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
case 'Failed':
|
||||
return 'failed';
|
||||
}
|
||||
});
|
||||
/** Current step's field errors, flattened for the shell's error summary. The
|
||||
field ids match the answer keys, so the summary anchors jump to the field. */
|
||||
protected errorList = computed<WizardError[]>(() => {
|
||||
const e = this.answering()?.errors ?? {};
|
||||
return (Object.keys(e) as (keyof Answers)[])
|
||||
.filter((k) => e[k])
|
||||
.map((k) => ({ id: k, message: e[k]! }));
|
||||
});
|
||||
|
||||
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
||||
protected set = (key: keyof Answers, value: string) =>
|
||||
this.dispatch({ tag: 'SetAnswer', key, value });
|
||||
|
||||
constructor() {
|
||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
||||
const seeded = this.seed();
|
||||
queueMicrotask(() =>
|
||||
seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume(),
|
||||
);
|
||||
// Apply the server-owned threshold into machine state as it arrives. Track
|
||||
// only the policy value; untrack the dispatch (it reads the state signal
|
||||
// internally, which would otherwise make this effect loop on its own write).
|
||||
effect(() => {
|
||||
const scholingThreshold = this.policyStore.scholingThreshold();
|
||||
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
||||
});
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Answering') return;
|
||||
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
this.dispatch({ tag: 'Retry' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.draftSync.reset();
|
||||
this.dispatch({ tag: 'Seed', state: initial });
|
||||
}
|
||||
|
||||
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
|
||||
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
|
||||
private async runIfSubmitting() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie();
|
||||
const r = await this.draftSync.submit({ uren: s.data.uren });
|
||||
if (r.ok) {
|
||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
} else {
|
||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
this.profile.rollbackHerregistratie();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user