libs/shared/src/upload/ held a network adapter, an Elm machine, and two application-layer coordinators outside the folder-per-layer convention every other context follows. The dependency-cruiser rule carved an exception around the misplaced adapter instead of the violation being fixed. Move all five files to the layer each belongs to (git mv), update every import across 24 consumer files, then delete the carve-out clause from .dependency-cruiser.base.js. No export renamed, no file split, no spec content changed. Deleting the carve-out exposed a second, pre-existing rule violation: ui-not-infrastructure had never fired against upload.adapter.ts because its old path did not match /infrastructure/. Three UI components injected UploadAdapter directly for its one-line contentUrl() wrapper. Route each through the existing pure uploadContentUrl() function via the application layer (upload-controller's new previewUrlFor, OrgTemplateStore's new previewUrlFor) instead — the same idiom brief.store.ts already used. npm run ci passes; dep:check is clean for both apps with the carve-out gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
11 KiB
TypeScript
289 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/application/upload-controller';
|
|
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/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 store = createStore<WizardState, WizardMsg>(initial, reduce);
|
|
|
|
/** Preview/download link for a completed upload; delegates to the upload
|
|
controller (application layer), which knows the dev-simulation `demo-*` ids
|
|
have no stored bytes and returns no link for them. */
|
|
protected previewUrlFor = (documentId: string): string | undefined =>
|
|
this.uploadCtl.previewUrlFor(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();
|
|
}
|
|
}
|
|
}
|