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>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { Component, computed, inject, input } 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 { AlertComponent } from '@shared/ui/alert/alert.component';
|
|
import {
|
|
WizardShellComponent,
|
|
WizardError,
|
|
WizardStatus,
|
|
naarStapLabel,
|
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
|
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
|
import { createStore } from '@shared/application/store';
|
|
import { whenTag } from '@shared/kernel/fp';
|
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|
import {
|
|
WizardState,
|
|
WizardMsg,
|
|
Draft,
|
|
initial,
|
|
reduce,
|
|
hasProgress,
|
|
} from '@herregistratie/domain/herregistratie.machine';
|
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
|
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
|
import { createUploadController } from '@shared/upload/upload-controller';
|
|
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
|
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
|
|
|
/** 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,
|
|
AlertComponent,
|
|
ConfirmationComponent,
|
|
WizardShellComponent,
|
|
DocumentUploadComponent,
|
|
],
|
|
template: `
|
|
<app-wizard-shell
|
|
[steps]="stepLabels"
|
|
[current]="step() - 1"
|
|
[stepTitle]="stepTitle()"
|
|
i18n-processName="@@herregWizard.processName"
|
|
processName="Herregistratie aanvragen"
|
|
[status]="shellStatus()"
|
|
[primaryLabel]="primaryLabel()"
|
|
[canGoBack]="step() > 1"
|
|
[errors]="errorList()"
|
|
[errorMessage]="errorMessage()"
|
|
(primary)="onPrimary()"
|
|
(back)="dispatch({ tag: 'Back' })"
|
|
(cancel)="restart()"
|
|
(retry)="onRetry()"
|
|
(goToStep)="goToStep($event)"
|
|
>
|
|
@switch (step()) {
|
|
@case (1) {
|
|
<fieldset>
|
|
<app-form-field
|
|
i18n-label="@@herregWizard.urenLabel"
|
|
label="Gewerkte uren (afgelopen 5 jaar)"
|
|
fieldId="uren"
|
|
required
|
|
[error]="errUren()"
|
|
>
|
|
<app-text-input
|
|
inputId="uren"
|
|
[ngModel]="draft().uren"
|
|
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
|
name="uren"
|
|
[invalid]="!!errUren()"
|
|
i18n-placeholder="@@herregWizard.urenPlaceholder"
|
|
placeholder="bijv. 4160"
|
|
/>
|
|
</app-form-field>
|
|
<app-form-field
|
|
i18n-label="@@herregWizard.jarenLabel"
|
|
label="Aantal jaren werkzaam"
|
|
fieldId="jaren"
|
|
required
|
|
[error]="errJaren()"
|
|
>
|
|
<app-text-input
|
|
inputId="jaren"
|
|
[ngModel]="draft().jaren"
|
|
(ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
|
name="jaren"
|
|
[invalid]="!!errJaren()"
|
|
i18n-placeholder="@@herregWizard.jarenPlaceholder"
|
|
placeholder="bijv. 5"
|
|
/>
|
|
</app-form-field>
|
|
</fieldset>
|
|
}
|
|
@case (2) {
|
|
<fieldset>
|
|
<app-form-field
|
|
i18n-label="@@herregWizard.puntenLabel"
|
|
label="Behaalde nascholingspunten"
|
|
fieldId="punten"
|
|
required
|
|
[error]="errPunten()"
|
|
>
|
|
<app-text-input
|
|
inputId="punten"
|
|
[ngModel]="draft().punten"
|
|
(ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
|
name="punten"
|
|
[invalid]="!!errPunten()"
|
|
i18n-placeholder="@@herregWizard.puntenPlaceholder"
|
|
placeholder="bijv. 200"
|
|
/>
|
|
</app-form-field>
|
|
</fieldset>
|
|
}
|
|
@case (3) {
|
|
<app-document-upload
|
|
[state]="upload()"
|
|
[previewUrlFor]="previewUrlFor"
|
|
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
|
|
(removeUpload)="uploadCtl.onRemove($event)"
|
|
(retryUpload)="uploadCtl.onRetry($event)"
|
|
(deleteUpload)="uploadCtl.onDelete($event)"
|
|
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
|
|
/>
|
|
@if (errDocumenten()) {
|
|
<app-alert type="warning">{{ errDocumenten() }}</app-alert>
|
|
}
|
|
}
|
|
}
|
|
|
|
<div wizardSuccess>
|
|
<app-confirmation
|
|
i18n-title="@@herregWizard.success.title"
|
|
title="Uw aanvraag tot herregistratie is ontvangen"
|
|
/>
|
|
</div>
|
|
</app-wizard-shell>
|
|
`,
|
|
})
|
|
export class HerregistratieWizardComponent {
|
|
private profile = inject(BigProfileStore);
|
|
private uploadAdapter = inject(UploadAdapter);
|
|
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
|
|
|
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
|
|
no stored bytes, so they get no link. */
|
|
protected previewUrlFor = (documentId: string): string | undefined =>
|
|
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
|
|
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
|
seed = input<WizardState>(initial);
|
|
|
|
readonly state = this.store.model; // public so the showcase can highlight the live state
|
|
protected dispatch = this.store.dispatch;
|
|
|
|
// Backend draft-sync (new persistence for this wizard): create a Concept on first
|
|
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
|
|
private draftSync = createDraftSync({
|
|
type: 'herregistratie',
|
|
snapshot: () => {
|
|
const s = this.state();
|
|
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
|
|
const documentIds = deliveryRefs(s.upload)
|
|
.filter((r) => r.channel === 'digital' && r.documentId)
|
|
.map((r) => r.documentId!);
|
|
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
|
|
},
|
|
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
|
|
enabled: () => this.seed() === initial,
|
|
});
|
|
|
|
// Stepper labels + per-step heading titles (presentational only).
|
|
readonly stepLabels = [
|
|
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
|
|
$localize`:@@herregWizard.step.nascholing:Nascholing`,
|
|
$localize`:@@herregWizard.step.documenten:Documenten`,
|
|
];
|
|
private stepTitles = [
|
|
$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`,
|
|
$localize`:@@herregWizard.title.nascholing:Nascholing`,
|
|
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
|
|
];
|
|
|
|
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
|
protected step = computed(() => this.editing()?.step ?? 1);
|
|
protected draft = computed<Draft>(
|
|
() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' },
|
|
);
|
|
protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload);
|
|
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
|
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
|
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
|
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
|
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
|
protected uploadCtl = createUploadController({
|
|
wizardId: 'herregistratie',
|
|
getUpload: () => this.upload(),
|
|
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
|
});
|
|
|
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
|
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
|
protected primaryLabel = computed(() => {
|
|
const step = this.step();
|
|
return step < 3
|
|
? naarStapLabel(step + 1, this.stepLabels[step])
|
|
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
|
|
});
|
|
|
|
/** Stepper emits a 0-based index for an earlier (visited) step. */
|
|
protected goToStep(index: number) {
|
|
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
|
}
|
|
protected errorMessage = computed(
|
|
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
|
);
|
|
protected shellStatus = computed<WizardStatus>(() => {
|
|
switch (this.state().tag) {
|
|
case 'Editing':
|
|
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. */
|
|
protected errorList = computed<WizardError[]>(() => {
|
|
const e = this.editing()?.errors ?? {};
|
|
return (Object.keys(e) as (keyof typeof e)[])
|
|
.filter((k) => e[k])
|
|
.map((k) => ({ id: k, message: e[k]! }));
|
|
});
|
|
|
|
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(),
|
|
);
|
|
}
|
|
|
|
onPrimary() {
|
|
const s = this.state();
|
|
if (s.tag !== 'Editing') return;
|
|
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
|
|
this.runIfSubmitting();
|
|
}
|
|
|
|
onRetry() {
|
|
this.dispatch({ tag: 'Retry' });
|
|
this.runIfSubmitting();
|
|
}
|
|
|
|
/** Reset the wizard to a fresh, empty start. */
|
|
restart() {
|
|
this.draftSync.reset();
|
|
this.dispatch({ tag: 'Seed', state: initial });
|
|
}
|
|
|
|
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
|
|
flip the optimistic cross-page flag, then dispatch the result (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, documents: s.data.documents });
|
|
if (r.ok) {
|
|
this.dispatch({ tag: 'SubmitConfirmed' });
|
|
this.profile.confirmHerregistratie();
|
|
} else {
|
|
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
this.profile.rollbackHerregistratie();
|
|
}
|
|
}
|
|
}
|