Add ASP.NET Core backend hosting business rules; FE consumes via typed client
Move the authoritative business rules off the frontend into a real backend,
realising the BFF-lite + decision-DTO design (ADR-0001) that until now lived
only in static mock JSON.
Backend (backend/):
- ASP.NET Core (.NET 10) minimal API, contract-first, Swagger UI at /swagger.
- DDD Domain/ rules layer: profession derivation + applicable policy questions
(DiplomaRules), herregistratie eligibility + reason (HerregistratieRule),
scholing threshold (IntakePolicy), submit rejections + reference generation
(SubmissionRules). In-memory seeded data, ProblemDetails (RFC 7807) errors.
- 27 xUnit tests: rule units + endpoint integration incl. BRP no-address and
DUO not-found fallbacks and 422 submit paths.
Frontend (only infrastructure/ + contracts/ change, as the architecture promised):
- NSwag-generated typed client (api-client.ts), routed through Angular HttpClient
via a small fetch adapter so the ?scenario= interceptor still applies.
- GET adapters use resource({ loader: client.x }); submit commands call the client
and map ProblemDetails -> err. The hardcoded uren==0 / manual-diploma rules are
deleted (now server-side). Domain, stores, UI and format validators unchanged.
- Deleted the now-dead public/mock/*.json.
Tooling/docs:
- npm start proxies /api -> backend; npm run gen:api regenerates the client;
docker compose up runs both (bind mounts use :z for SELinux/Fedora).
- backend/README.md walkthrough: adding a policy question is a one-file backend
change, no FE change, no client regen. Updated CLAUDE.md + ARCHITECTURE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,15 +8,20 @@ update this file.
|
|||||||
|
|
||||||
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
||||||
view their registration, apply for re-registration). Angular 22, standalone,
|
view their registration, apply for re-registration). Angular 22, standalone,
|
||||||
signals. No real backend/auth — static mock JSON + fake timers.
|
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
|
||||||
|
Core backend** (`backend/`, see its README — in-memory seeded, no DB) and consumed
|
||||||
|
through an NSwag-generated typed client. The FE renders the backend's decisions.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # ng serve → http://localhost:4200
|
npm start # ng serve (proxies /api → backend) → http://localhost:4200
|
||||||
npm test # vitest
|
npm test # vitest
|
||||||
npm run build # ng build (must stay green)
|
npm run build # ng build (must stay green)
|
||||||
npm run storybook # component library by atomic layer
|
npm run storybook # component library by atomic layer
|
||||||
|
npm run gen:api # regenerate the typed client from the backend OpenAPI doc
|
||||||
|
docker compose up # run FE + backend together (Swagger at :5000/swagger)
|
||||||
|
cd backend && dotnet test # backend rule + endpoint tests
|
||||||
```
|
```
|
||||||
|
|
||||||
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
|
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
|
||||||
|
|||||||
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
8
backend/BigRegister.slnx
Normal file
8
backend/BigRegister.slnx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/BigRegister.Api/BigRegister.Api.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/BigRegister.Tests/BigRegister.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
102
backend/README.md
Normal file
102
backend/README.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# BIG-register BFF (ASP.NET Core)
|
||||||
|
|
||||||
|
The backend that hosts the **business rules** for the BIG-register portal. The
|
||||||
|
frontend renders the decisions this service computes; it does not recompute them
|
||||||
|
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`).
|
||||||
|
|
||||||
|
No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`),
|
||||||
|
but the endpoints, DTOs, status codes and error envelope are production-shaped.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
### Everything (docker-compose, from repo root)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
- App: <http://localhost:4200>
|
||||||
|
- Swagger UI: <http://localhost:5000/swagger>
|
||||||
|
|
||||||
|
### Backend only (local)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
dotnet run --project src/BigRegister.Api
|
||||||
|
# → http://localhost:5000/swagger
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend against a local backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start # ng serve, proxies /api → http://localhost:5000 (proxy.conf.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && dotnet test # rule unit tests + endpoint integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| Method | Route | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/dashboard-view` | registration + person + computed herregistratie decision |
|
||||||
|
| GET | `/api/notes` | specialisms / aantekeningen |
|
||||||
|
| GET | `/api/brp/address` | BRP address lookup (`gevonden:false` = no address) |
|
||||||
|
| GET | `/api/duo/diplomas` | diplomas with derived profession + applicable policy questions, + manual fallback |
|
||||||
|
| GET | `/api/intake/policy` | scholing threshold (config value) |
|
||||||
|
| POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) |
|
||||||
|
| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) |
|
||||||
|
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) |
|
||||||
|
|
||||||
|
Rejections use **ProblemDetails (RFC 7807)** with status **422**.
|
||||||
|
|
||||||
|
## Where the rules live (`src/BigRegister.Api/Domain/`)
|
||||||
|
|
||||||
|
- `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply.
|
||||||
|
- `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant.
|
||||||
|
- `Intake/IntakePolicy.cs` — scholing threshold.
|
||||||
|
- `Submissions/SubmissionRules.cs` — submit rejections + reference generation.
|
||||||
|
|
||||||
|
## Typed client (NSwag)
|
||||||
|
|
||||||
|
The frontend calls this API through a generated TypeScript client. Regenerate it
|
||||||
|
from the contract after a **shape** change:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run gen:api # builds backend → swagger.json → src/app/shared/infrastructure/api-client.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Maintainability: changing a policy is one backend change
|
||||||
|
|
||||||
|
**Goal:** require every *Verpleegkundige* diploma to confirm a Dutch skills
|
||||||
|
assessment. This is a new policy question on a diploma type.
|
||||||
|
|
||||||
|
Edit **one file** — `Domain/Diplomas/DiplomaRules.cs`:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
|
||||||
|
{
|
||||||
|
var questions = new List<PolicyQuestion>();
|
||||||
|
if (d.Engelstalig)
|
||||||
|
questions.Add(NlTaalEngelstalig);
|
||||||
|
+ if (d.Opleiding == "verpleegkunde")
|
||||||
|
+ questions.Add(new PolicyQuestion(
|
||||||
|
+ "bekwaamheid",
|
||||||
|
+ "Heeft u in de afgelopen vijf jaar een bekwaamheidstoets afgelegd?",
|
||||||
|
+ QuestionType.JaNee));
|
||||||
|
return questions;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuild the backend (`docker compose up` or `dotnet run`). The new question now
|
||||||
|
appears in the registration wizard for HBO-Verpleegkunde.
|
||||||
|
|
||||||
|
- **No frontend change.** The FE renders whatever questions the API returns.
|
||||||
|
- **No client regeneration.** The wire shape (`PolicyQuestionDto`) is unchanged —
|
||||||
|
only the data behind it. `npm run gen:api` is only needed when a DTO *shape* changes.
|
||||||
|
|
||||||
|
Add a unit test for the new rule in `tests/BigRegister.Tests/RuleTests.cs` and
|
||||||
|
you're done.
|
||||||
13
backend/dotnet-tools.json
Normal file
13
backend/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"swashbuckle.aspnetcore.cli": {
|
||||||
|
"version": "10.2.3",
|
||||||
|
"commands": [
|
||||||
|
"swagger"
|
||||||
|
],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/BigRegister.Api/BigRegister.Api.csproj
Normal file
13
backend/src/BigRegister.Api/BigRegister.Api.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
55
backend/src/BigRegister.Api/Contracts/Dtos.cs
Normal file
55
backend/src/BigRegister.Api/Contracts/Dtos.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
namespace BigRegister.Api.Contracts;
|
||||||
|
|
||||||
|
// Wire contracts (the FE⇄BE seam). Field names + shapes mirror the frontend's
|
||||||
|
// contracts/*.dto.ts 1:1; the NSwag-generated TS client is produced from these.
|
||||||
|
|
||||||
|
public sealed record AdresDto(string Straat, string Postcode, string Woonplaats);
|
||||||
|
|
||||||
|
public sealed record RegistrationStatusDto(
|
||||||
|
string Tag,
|
||||||
|
string? HerregistratieDatum = null,
|
||||||
|
string? GeschorstTot = null,
|
||||||
|
string? Reden = null,
|
||||||
|
string? DoorgehaaldOp = null);
|
||||||
|
|
||||||
|
public sealed record RegistrationDto(
|
||||||
|
string BigNummer,
|
||||||
|
string Naam,
|
||||||
|
string Beroep,
|
||||||
|
string Registratiedatum,
|
||||||
|
string Geboortedatum,
|
||||||
|
RegistrationStatusDto Status);
|
||||||
|
|
||||||
|
public sealed record PersonDto(string Naam, string Geboortedatum, AdresDto Adres);
|
||||||
|
|
||||||
|
public sealed record HerregistratieDecisionsDto(bool EligibleForHerregistratie, string? HerregistratieReason);
|
||||||
|
|
||||||
|
public sealed record DashboardViewDto(RegistrationDto Registration, PersonDto Person, HerregistratieDecisionsDto Decisions);
|
||||||
|
|
||||||
|
public sealed record AantekeningDto(string Type, string Omschrijving, string Datum);
|
||||||
|
|
||||||
|
public sealed record BrpAddressDto(bool Gevonden, AdresDto? Adres);
|
||||||
|
|
||||||
|
public sealed record PolicyQuestionDto(string Id, string Vraag, string Type);
|
||||||
|
|
||||||
|
public sealed record DuoDiplomaDto(
|
||||||
|
string Id,
|
||||||
|
string Naam,
|
||||||
|
string Instelling,
|
||||||
|
int Jaar,
|
||||||
|
string Beroep,
|
||||||
|
IReadOnlyList<PolicyQuestionDto> PolicyQuestions);
|
||||||
|
|
||||||
|
public sealed record ManualDiplomaPolicyDto(IReadOnlyList<string> Beroepen, IReadOnlyList<PolicyQuestionDto> PolicyQuestions);
|
||||||
|
|
||||||
|
public sealed record DuoLookupDto(IReadOnlyList<DuoDiplomaDto> Diplomas, ManualDiplomaPolicyDto Handmatig);
|
||||||
|
|
||||||
|
public sealed record IntakePolicyDto(int ScholingThreshold);
|
||||||
|
|
||||||
|
// Submit requests carry only the fields the server re-validates (UX-only fields
|
||||||
|
// stay on the client). ponytail: a real submit would carry the full application.
|
||||||
|
public sealed record RegistratieRequest(string DiplomaHerkomst);
|
||||||
|
public sealed record IntakeRequest(int Uren);
|
||||||
|
public sealed record HerregistratieRequest(int Uren);
|
||||||
|
|
||||||
|
public sealed record ReferentieResponse(string Referentie);
|
||||||
33
backend/src/BigRegister.Api/Contracts/Mappers.cs
Normal file
33
backend/src/BigRegister.Api/Contracts/Mappers.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using BigRegister.Domain.Diplomas;
|
||||||
|
using BigRegister.Domain.People;
|
||||||
|
using BigRegister.Domain.Registrations;
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Contracts;
|
||||||
|
|
||||||
|
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
|
||||||
|
public static class Mappers
|
||||||
|
{
|
||||||
|
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
|
||||||
|
Tag: s.Tag.ToString(),
|
||||||
|
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
|
||||||
|
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
|
||||||
|
Reden: s.Reden,
|
||||||
|
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
|
||||||
|
|
||||||
|
public static RegistrationDto ToDto(this Registration r) => new(
|
||||||
|
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
|
||||||
|
|
||||||
|
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
|
||||||
|
|
||||||
|
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
|
||||||
|
|
||||||
|
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
|
||||||
|
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
|
||||||
|
|
||||||
|
public static DuoDiplomaDto ToDto(this Diploma d) => new(
|
||||||
|
d.Id, d.Naam, d.Instelling, d.Jaar,
|
||||||
|
DiplomaRules.ProfessionFor(d),
|
||||||
|
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
|
||||||
|
}
|
||||||
42
backend/src/BigRegister.Api/Data/SeedData.cs
Normal file
42
backend/src/BigRegister.Api/Data/SeedData.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using BigRegister.Domain.Diplomas;
|
||||||
|
using BigRegister.Domain.People;
|
||||||
|
using BigRegister.Domain.Registrations;
|
||||||
|
|
||||||
|
namespace BigRegister.Api.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory seeded synthetic data — no DB, no real BRP/DUO. Stands in for the
|
||||||
|
/// upstream systems so the API behaves like production for a demo.
|
||||||
|
/// </summary>
|
||||||
|
public static class SeedData
|
||||||
|
{
|
||||||
|
public static readonly Registration Registration = new(
|
||||||
|
BigNummer: "19012345601",
|
||||||
|
Naam: "Dr. A. (Anna) de Vries",
|
||||||
|
Beroep: "Arts",
|
||||||
|
Registratiedatum: new DateOnly(2012, 9, 1),
|
||||||
|
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||||
|
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
|
||||||
|
|
||||||
|
public static readonly Person Person = new(
|
||||||
|
Naam: "Dr. A. (Anna) de Vries",
|
||||||
|
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||||
|
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
|
||||||
|
|
||||||
|
/// <summary>The address BRP returns for the seeded citizen.</summary>
|
||||||
|
public static readonly Adres BrpAddress = Person.Adres;
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
|
||||||
|
{
|
||||||
|
new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
|
||||||
|
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
|
||||||
|
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
|
||||||
|
{
|
||||||
|
("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
|
||||||
|
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
|
||||||
|
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),
|
||||||
|
};
|
||||||
|
}
|
||||||
22
backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs
Normal file
22
backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
namespace BigRegister.Domain.Diplomas;
|
||||||
|
|
||||||
|
public enum QuestionType
|
||||||
|
{
|
||||||
|
JaNee,
|
||||||
|
Tekst,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A diploma as DUO knows it. The profession and the applicable policy questions
|
||||||
|
/// are NOT stored on the diploma — they are DERIVED by the rules below from its
|
||||||
|
/// attributes (<see cref="Opleiding"/>, <see cref="Engelstalig"/>).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record Diploma(
|
||||||
|
string Id,
|
||||||
|
string Naam,
|
||||||
|
string Instelling,
|
||||||
|
int Jaar,
|
||||||
|
string Opleiding,
|
||||||
|
bool Engelstalig);
|
||||||
68
backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs
Normal file
68
backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
namespace BigRegister.Domain.Diplomas;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SERVER-OWNED business rules for diplomas. This is the single place a policy
|
||||||
|
/// changes: which profession a study program maps to, and which policy questions
|
||||||
|
/// (geldigheidsvragen) apply to a diploma. The frontend renders these; it never
|
||||||
|
/// derives them.
|
||||||
|
/// </summary>
|
||||||
|
public static class DiplomaRules
|
||||||
|
{
|
||||||
|
// RULE: study program → BIG profession.
|
||||||
|
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["geneeskunde"] = "Arts",
|
||||||
|
["verpleegkunde"] = "Verpleegkundige",
|
||||||
|
["fysiotherapie"] = "Fysiotherapeut",
|
||||||
|
["farmacie"] = "Apotheker",
|
||||||
|
["tandheelkunde"] = "Tandarts",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string ProfessionFor(Diploma d) =>
|
||||||
|
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
||||||
|
|
||||||
|
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
|
||||||
|
public static IReadOnlyList<string> ManualProfessions() =>
|
||||||
|
ProfessionByProgram.Values.Distinct().ToList();
|
||||||
|
|
||||||
|
// --- Policy questions (geldigheidsvragen) ---
|
||||||
|
|
||||||
|
private static readonly PolicyQuestion NlTaalEngelstalig = new(
|
||||||
|
"nl-taalvaardigheid",
|
||||||
|
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||||
|
QuestionType.JaNee);
|
||||||
|
|
||||||
|
private static readonly PolicyQuestion NlTaalManual = new(
|
||||||
|
"nl-taalvaardigheid",
|
||||||
|
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||||
|
QuestionType.JaNee);
|
||||||
|
|
||||||
|
private static readonly PolicyQuestion DiplomaErkend = new(
|
||||||
|
"diploma-erkend",
|
||||||
|
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
|
||||||
|
QuestionType.JaNee);
|
||||||
|
|
||||||
|
private static readonly PolicyQuestion Toelichting = new(
|
||||||
|
"toelichting",
|
||||||
|
"Geef een korte toelichting op uw diploma en opleiding.",
|
||||||
|
QuestionType.Tekst);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
|
||||||
|
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
|
||||||
|
/// change, no frontend change.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
|
||||||
|
{
|
||||||
|
var questions = new List<PolicyQuestion>();
|
||||||
|
if (d.Engelstalig)
|
||||||
|
questions.Add(NlTaalEngelstalig);
|
||||||
|
return questions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
|
||||||
|
new[] { NlTaalManual, DiplomaErkend, Toelichting };
|
||||||
|
}
|
||||||
11
backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs
Normal file
11
backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace BigRegister.Domain.Intake;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SERVER-OWNED config value. Below this many NL work-hours the scholing question
|
||||||
|
/// is required. The frontend receives this value and applies it for instant UX
|
||||||
|
/// feedback, but the backend re-validates on submit as the authority.
|
||||||
|
/// </summary>
|
||||||
|
public static class IntakePolicy
|
||||||
|
{
|
||||||
|
public const int ScholingThreshold = 1000;
|
||||||
|
}
|
||||||
5
backend/src/BigRegister.Api/Domain/People/Person.cs
Normal file
5
backend/src/BigRegister.Api/Domain/People/Person.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
namespace BigRegister.Domain.People;
|
||||||
|
|
||||||
|
public sealed record Adres(string Straat, string Postcode, string Woonplaats);
|
||||||
|
|
||||||
|
public sealed record Person(string Naam, DateOnly Geboortedatum, Adres Adres);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace BigRegister.Domain.Registrations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SERVER-OWNED business rule (ported from the frontend reference impl
|
||||||
|
/// registration.policy.ts:isHerregistratieEligible). A registration may apply for
|
||||||
|
/// herregistratie only while active and within the window before its deadline.
|
||||||
|
/// The endpoint ships the result as a decision flag + reason; the frontend renders it.
|
||||||
|
/// </summary>
|
||||||
|
public static class HerregistratieRule
|
||||||
|
{
|
||||||
|
public const int WindowMonths = 12;
|
||||||
|
|
||||||
|
public static DateOnly? Deadline(Registration reg) =>
|
||||||
|
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
|
||||||
|
|
||||||
|
public static (bool Eligible, string? Reason) Evaluate(
|
||||||
|
Registration reg, DateOnly today, int windowMonths = WindowMonths)
|
||||||
|
{
|
||||||
|
var deadline = Deadline(reg);
|
||||||
|
if (deadline is null)
|
||||||
|
return (false, "Geen actieve registratie.");
|
||||||
|
|
||||||
|
var windowStart = deadline.Value.AddMonths(-windowMonths);
|
||||||
|
return today >= windowStart
|
||||||
|
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
|
||||||
|
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
|
||||||
|
public static bool IsStatusConsistent(RegistrationStatus s) =>
|
||||||
|
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace BigRegister.Domain.Registrations;
|
||||||
|
|
||||||
|
/// <summary>The three states a BIG registration can be in.</summary>
|
||||||
|
public enum StatusTag
|
||||||
|
{
|
||||||
|
Geregistreerd,
|
||||||
|
Geschorst,
|
||||||
|
Doorgehaald,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Status as a flat record: only <see cref="StatusTag.Geregistreerd"/> carries a
|
||||||
|
/// herregistratie deadline. The frontend mirrors this as a discriminated union.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RegistrationStatus(
|
||||||
|
StatusTag Tag,
|
||||||
|
DateOnly? HerregistratieDatum = null,
|
||||||
|
DateOnly? GeschorstTot = null,
|
||||||
|
string? Reden = null,
|
||||||
|
DateOnly? DoorgehaaldOp = null);
|
||||||
|
|
||||||
|
public sealed record Registration(
|
||||||
|
string BigNummer,
|
||||||
|
string Naam,
|
||||||
|
string Beroep,
|
||||||
|
DateOnly Registratiedatum,
|
||||||
|
DateOnly Geboortedatum,
|
||||||
|
RegistrationStatus Status);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace BigRegister.Domain.Submissions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SERVER-OWNED submit rules (ported from the frontend submit-*.ts commands, where
|
||||||
|
/// they were hardcoded). Each method returns a rejection reason, or null when the
|
||||||
|
/// submission is accepted. The reference is generated server-side on acceptance.
|
||||||
|
/// </summary>
|
||||||
|
public static class SubmissionRules
|
||||||
|
{
|
||||||
|
// RULE: a manually entered diploma cannot be auto-verified.
|
||||||
|
public static string? RejectRegistratie(string diplomaHerkomst) =>
|
||||||
|
diplomaHerkomst == "handmatig"
|
||||||
|
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// RULE: an application reporting zero worked hours is rejected.
|
||||||
|
public static string? RejectZeroUren(int uren) =>
|
||||||
|
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
|
||||||
|
|
||||||
|
public static string NewReference() =>
|
||||||
|
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
|
||||||
|
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
|
||||||
|
}
|
||||||
94
backend/src/BigRegister.Api/Program.cs
Normal file
94
backend/src/BigRegister.Api/Program.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Diplomas;
|
||||||
|
using BigRegister.Domain.Intake;
|
||||||
|
using BigRegister.Domain.Registrations;
|
||||||
|
using BigRegister.Domain.Submissions;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen(c =>
|
||||||
|
c.SwaggerDoc("v1", new() { Title = "BIG-register BFF", Version = "v1" }));
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(o =>
|
||||||
|
{
|
||||||
|
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||||
|
});
|
||||||
|
|
||||||
|
const string SpaCors = "spa";
|
||||||
|
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||||
|
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
app.UseCors(SpaCors);
|
||||||
|
|
||||||
|
var api = app.MapGroup("/api");
|
||||||
|
|
||||||
|
// --- GET: screen-shaped reads. Decisions are computed here, never on the client. ---
|
||||||
|
|
||||||
|
api.MapGet("/dashboard-view", () =>
|
||||||
|
{
|
||||||
|
var reg = SeedData.Registration;
|
||||||
|
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
|
||||||
|
new HerregistratieDecisionsDto(eligible, reason));
|
||||||
|
});
|
||||||
|
|
||||||
|
api.MapGet("/notes", () =>
|
||||||
|
SeedData.Notes.Select(n => new AantekeningDto(n.Type, n.Omschrijving, n.Datum)).ToList());
|
||||||
|
|
||||||
|
// BRP "no address" fallback would be `new BrpAddressDto(false, null)` — the seeded
|
||||||
|
// citizen has one.
|
||||||
|
api.MapGet("/brp/address", () => new BrpAddressDto(true, SeedData.BrpAddress.ToDto()));
|
||||||
|
|
||||||
|
api.MapGet("/duo/diplomas", () => new DuoLookupDto(
|
||||||
|
SeedData.Diplomas.Select(d => d.ToDto()).ToList(),
|
||||||
|
new ManualDiplomaPolicyDto(
|
||||||
|
DiplomaRules.ManualProfessions(),
|
||||||
|
DiplomaRules.ManualQuestions().Select(q => q.ToDto()).ToList())));
|
||||||
|
|
||||||
|
api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold));
|
||||||
|
|
||||||
|
// --- POST: submits. The server is the authority; it re-validates and decides. ---
|
||||||
|
|
||||||
|
api.MapPost("/registrations", (RegistratieRequest req) =>
|
||||||
|
{
|
||||||
|
var reject = SubmissionRules.RejectRegistratie(req.DiplomaHerkomst);
|
||||||
|
return reject is null
|
||||||
|
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||||
|
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||||
|
})
|
||||||
|
.Produces<ReferentieResponse>()
|
||||||
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||||
|
|
||||||
|
api.MapPost("/herregistraties", (HerregistratieRequest req) =>
|
||||||
|
{
|
||||||
|
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||||
|
return reject is null
|
||||||
|
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||||
|
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||||
|
})
|
||||||
|
.Produces<ReferentieResponse>()
|
||||||
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||||
|
|
||||||
|
api.MapPost("/intakes", (IntakeRequest req) =>
|
||||||
|
{
|
||||||
|
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||||
|
return reject is null
|
||||||
|
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||||
|
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||||
|
})
|
||||||
|
.Produces<ReferentieResponse>()
|
||||||
|
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||||
|
public partial class Program { }
|
||||||
15
backend/src/BigRegister.Api/Properties/launchSettings.json
Normal file
15
backend/src/BigRegister.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"applicationUrl": "http://localhost:5000",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
backend/src/BigRegister.Api/appsettings.Development.json
Normal file
8
backend/src/BigRegister.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/BigRegister.Api/appsettings.json
Normal file
9
backend/src/BigRegister.Api/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
545
backend/swagger.json
Normal file
545
backend/swagger.json
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.0.4",
|
||||||
|
"info": {
|
||||||
|
"title": "BIG-register BFF",
|
||||||
|
"version": "v1"
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/api/dashboard-view": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DashboardViewDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/notes": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AantekeningDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/brp/address": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BrpAddressDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/duo/diplomas": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DuoLookupDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/intake/policy": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/IntakePolicyDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/registrations": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RegistratieRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReferentieResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Unprocessable Content",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/herregistraties": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HerregistratieRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReferentieResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Unprocessable Content",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/intakes": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/IntakeRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReferentieResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Unprocessable Content",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ProblemDetails"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"AantekeningDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"omschrijving": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"datum": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"AdresDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"straat": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"postcode": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"woonplaats": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"BrpAddressDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"gevonden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"adres": {
|
||||||
|
"$ref": "#/components/schemas/AdresDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"DashboardViewDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"registration": {
|
||||||
|
"$ref": "#/components/schemas/RegistrationDto"
|
||||||
|
},
|
||||||
|
"person": {
|
||||||
|
"$ref": "#/components/schemas/PersonDto"
|
||||||
|
},
|
||||||
|
"decisions": {
|
||||||
|
"$ref": "#/components/schemas/HerregistratieDecisionsDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"DuoDiplomaDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"naam": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"instelling": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"jaar": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
},
|
||||||
|
"beroep": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"policyQuestions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PolicyQuestionDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"DuoLookupDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"diplomas": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/DuoDiplomaDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"handmatig": {
|
||||||
|
"$ref": "#/components/schemas/ManualDiplomaPolicyDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"HerregistratieDecisionsDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"eligibleForHerregistratie": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"herregistratieReason": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"HerregistratieRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uren": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"IntakePolicyDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scholingThreshold": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"IntakeRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"uren": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ManualDiplomaPolicyDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"beroepen": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"policyQuestions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PolicyQuestionDto"
|
||||||
|
},
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"PersonDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"naam": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"geboortedatum": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"adres": {
|
||||||
|
"$ref": "#/components/schemas/AdresDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"PolicyQuestionDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"vraag": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"ProblemDetails": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"instance": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": { }
|
||||||
|
},
|
||||||
|
"ReferentieResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"referentie": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"RegistratieRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"diplomaHerkomst": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"RegistrationDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bigNummer": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"naam": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"beroep": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"registratiedatum": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"geboortedatum": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/components/schemas/RegistrationStatusDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"RegistrationStatusDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tag": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"herregistratieDatum": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"geschorstTot": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"reden": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"doorgehaaldOp": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
Normal file
26
backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
100
backend/tests/BigRegister.Tests/EndpointTests.cs
Normal file
100
backend/tests/BigRegister.Tests/EndpointTests.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using BigRegister.Api.Contracts;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client = factory.CreateClient();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DashboardView_computes_eligibility_decision()
|
||||||
|
{
|
||||||
|
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/dashboard-view");
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
Assert.Equal("19012345601", dto.Registration.BigNummer);
|
||||||
|
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
|
||||||
|
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
|
||||||
|
Assert.True(dto.Decisions.EligibleForHerregistratie);
|
||||||
|
Assert.NotNull(dto.Decisions.HerregistratieReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Notes_returns_seeded_aantekeningen()
|
||||||
|
{
|
||||||
|
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/notes");
|
||||||
|
Assert.Equal(3, notes!.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Brp_returns_address()
|
||||||
|
{
|
||||||
|
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/brp/address");
|
||||||
|
Assert.True(dto!.Gevonden);
|
||||||
|
Assert.Equal("2514 EA", dto.Adres!.Postcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
|
||||||
|
{
|
||||||
|
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/duo/diplomas");
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
|
||||||
|
var english = dto.Diplomas.Single(d => d.Id == "d2");
|
||||||
|
Assert.Equal("Arts", english.Beroep);
|
||||||
|
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
|
||||||
|
|
||||||
|
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
|
||||||
|
Assert.Empty(dutch.PolicyQuestions);
|
||||||
|
|
||||||
|
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
|
||||||
|
// which the same lookup provides (maximal question set + declarable professions).
|
||||||
|
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
|
||||||
|
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
|
||||||
|
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IntakePolicy_returns_scholing_threshold()
|
||||||
|
{
|
||||||
|
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/intake/policy");
|
||||||
|
Assert.Equal(1000, dto!.ScholingThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Registration_with_duo_diploma_succeeds()
|
||||||
|
{
|
||||||
|
var res = await _client.PostAsJsonAsync("/api/registrations", new RegistratieRequest("duo"));
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||||
|
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
|
||||||
|
{
|
||||||
|
var res = await _client.PostAsJsonAsync("/api/registrations", new RegistratieRequest("handmatig"));
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||||
|
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/intakes")]
|
||||||
|
[InlineData("/api/herregistraties")]
|
||||||
|
public async Task Zero_hours_submission_is_rejected(string route)
|
||||||
|
{
|
||||||
|
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/api/intakes")]
|
||||||
|
[InlineData("/api/herregistraties")]
|
||||||
|
public async Task Worked_hours_submission_succeeds(string route)
|
||||||
|
{
|
||||||
|
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
|
||||||
|
res.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
117
backend/tests/BigRegister.Tests/RuleTests.cs
Normal file
117
backend/tests/BigRegister.Tests/RuleTests.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using BigRegister.Domain.Diplomas;
|
||||||
|
using BigRegister.Domain.Registrations;
|
||||||
|
using BigRegister.Domain.Submissions;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
public class HerregistratieRuleTests
|
||||||
|
{
|
||||||
|
private static Registration Active(DateOnly deadline) => new(
|
||||||
|
"19012345601", "Test", "Arts",
|
||||||
|
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
|
||||||
|
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Eligible_within_window()
|
||||||
|
{
|
||||||
|
var (eligible, reason) = HerregistratieRule.Evaluate(
|
||||||
|
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
|
||||||
|
Assert.True(eligible);
|
||||||
|
Assert.Contains("12 maanden", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Not_eligible_before_window()
|
||||||
|
{
|
||||||
|
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||||
|
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
|
||||||
|
Assert.False(eligible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Eligible_on_window_boundary()
|
||||||
|
{
|
||||||
|
// window opens exactly 12 months before the deadline
|
||||||
|
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||||
|
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
|
||||||
|
Assert.True(eligible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Suspended_is_not_eligible()
|
||||||
|
{
|
||||||
|
var reg = Active(new DateOnly(2027, 3, 1)) with
|
||||||
|
{
|
||||||
|
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
|
||||||
|
};
|
||||||
|
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
|
||||||
|
Assert.False(eligible);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Status_consistency_invariant()
|
||||||
|
{
|
||||||
|
Assert.True(HerregistratieRule.IsStatusConsistent(
|
||||||
|
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
|
||||||
|
Assert.False(HerregistratieRule.IsStatusConsistent(
|
||||||
|
new RegistrationStatus(StatusTag.Geregistreerd)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DiplomaRuleTests
|
||||||
|
{
|
||||||
|
private static Diploma Diploma(string opleiding, bool engelstalig) =>
|
||||||
|
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("geneeskunde", "Arts")]
|
||||||
|
[InlineData("verpleegkunde", "Verpleegkundige")]
|
||||||
|
[InlineData("onbekend-programma", "Onbekend")]
|
||||||
|
public void Profession_is_derived_from_program(string opleiding, string expected) =>
|
||||||
|
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void English_diploma_requires_dutch_proficiency()
|
||||||
|
{
|
||||||
|
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
|
||||||
|
Assert.Single(questions);
|
||||||
|
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dutch_diploma_has_no_policy_questions() =>
|
||||||
|
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Manual_diploma_gets_maximal_set()
|
||||||
|
{
|
||||||
|
var questions = DiplomaRules.ManualQuestions();
|
||||||
|
Assert.Equal(3, questions.Count);
|
||||||
|
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
|
||||||
|
questions.Select(q => q.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Manual_professions_match_known_programs() =>
|
||||||
|
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
|
||||||
|
DiplomaRules.ManualProfessions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubmissionRuleTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Manual_diploma_is_rejected() =>
|
||||||
|
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duo_diploma_is_accepted() =>
|
||||||
|
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Zero_hours_is_rejected() =>
|
||||||
|
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Worked_hours_are_accepted() =>
|
||||||
|
Assert.Null(SubmissionRules.RejectZeroUren(40));
|
||||||
|
}
|
||||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# ponytail: dev-server images (not multi-stage prod builds) — this is a demo.
|
||||||
|
# `docker compose up` → app at http://localhost:4200, Swagger at http://localhost:5000/swagger
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||||
|
working_dir: /src
|
||||||
|
command: dotnet run --project src/BigRegister.Api --urls http://+:5000
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Development
|
||||||
|
volumes:
|
||||||
|
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
|
||||||
|
- ./backend:/src:z
|
||||||
|
- api-bin:/src/src/BigRegister.Api/bin
|
||||||
|
- api-obj:/src/src/BigRegister.Api/obj
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: node:24
|
||||||
|
working_dir: /app
|
||||||
|
# Uses the committed generated client (no codegen at startup); proxies /api → api container.
|
||||||
|
command: sh -c "npm ci && npx ng serve --host 0.0.0.0 --proxy-config proxy.conf.docker.json"
|
||||||
|
volumes:
|
||||||
|
- ./:/app:z
|
||||||
|
- web-modules:/app/node_modules
|
||||||
|
ports:
|
||||||
|
- "4200:4200"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
api-bin:
|
||||||
|
api-obj:
|
||||||
|
web-modules:
|
||||||
@@ -415,9 +415,14 @@ update as you type.
|
|||||||
|
|
||||||
## 6. Connecting to a .NET backend
|
## 6. Connecting to a .NET backend
|
||||||
|
|
||||||
Today the adapters read static JSON (`mock/*.json`). Because `infrastructure/` is the only
|
> **Implemented.** No longer hypothetical: a minimal ASP.NET Core backend now hosts
|
||||||
|
> the business rules and serves the endpoints; the FE consumes it through an
|
||||||
|
> NSwag-generated typed client. See `backend/README.md`. The text below remains as
|
||||||
|
> the rationale for _why_ only `infrastructure/` + `contracts/` had to change.
|
||||||
|
|
||||||
|
The adapters used to read static JSON (`mock/*.json`). Because `infrastructure/` is the only
|
||||||
layer that touches the network — the **anti-corruption boundary** — pointing the app at a
|
layer that touches the network — the **anti-corruption boundary** — pointing the app at a
|
||||||
real ASP.NET API touches _only these files_. Domain, application and UI don't change.
|
real ASP.NET API touched _only these files_. Domain, application and UI don't change.
|
||||||
|
|
||||||
The one concrete change per adapter: a **DTO** type matching the .NET response, a
|
The one concrete change per adapter: a **DTO** type matching the .NET response, a
|
||||||
`toDomain` mapper, and a real URL.
|
`toDomain` mapper, and a real URL.
|
||||||
|
|||||||
29
nswag.json
Normal file
29
nswag.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"runtime": "Net80",
|
||||||
|
"documentGenerator": {
|
||||||
|
"fromDocument": {
|
||||||
|
"url": "backend/swagger.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"codeGenerators": {
|
||||||
|
"openApiToTypeScriptClient": {
|
||||||
|
"className": "ApiClient",
|
||||||
|
"moduleName": "",
|
||||||
|
"namespace": "",
|
||||||
|
"template": "Fetch",
|
||||||
|
"promiseType": "Promise",
|
||||||
|
"httpClass": "HttpClient",
|
||||||
|
"useGetBaseUrlMethod": false,
|
||||||
|
"baseUrlTokenName": "API_BASE_URL",
|
||||||
|
"generateClientClasses": true,
|
||||||
|
"generateOptionalParameters": true,
|
||||||
|
"typeScriptVersion": 5.0,
|
||||||
|
"dateTimeType": "String",
|
||||||
|
"nullValue": "Undefined",
|
||||||
|
"generateDtoTypes": true,
|
||||||
|
"typeStyle": "Interface",
|
||||||
|
"markOptionalProperties": true,
|
||||||
|
"output": "src/app/shared/infrastructure/api-client.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -33,6 +33,7 @@
|
|||||||
"@storybook/addon-onboarding": "^10.4.6",
|
"@storybook/addon-onboarding": "^10.4.6",
|
||||||
"@storybook/angular": "^10.4.6",
|
"@storybook/angular": "^10.4.6",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
|
"nswag": "^14.7.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"storybook": "^10.4.6",
|
"storybook": "^10.4.6",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
@@ -15276,6 +15277,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nswag": {
|
||||||
|
"version": "14.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nswag/-/nswag-14.7.1.tgz",
|
||||||
|
"integrity": "sha512-V6LiNhRLY4EfaEWAvExAPSPHGUaVIuneA+a67zuhu00fcwR+7xxiBBTpyw82vuI4xMUh1Eq8Nwnc6iLqp/74+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nswag": "bin/nswag.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=3.10.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nth-check": {
|
"node_modules/nth-check": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
|||||||
28
package.json
28
package.json
@@ -3,13 +3,14 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
"gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"storybook": "ng run atomic-design-poc:storybook",
|
"storybook": "ng run atomic-design-poc:storybook",
|
||||||
"build-storybook": "ng run atomic-design-poc:build-storybook",
|
"build-storybook": "ng run atomic-design-poc:build-storybook",
|
||||||
"check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi"
|
"check:tokens": "if grep -rnE '#[0-9a-fA-F]{3,6}' src/app/registratie/ui src/app/shared/ui src/app/shared/layout --include='*.component.ts' --exclude='debug-state.component.ts'; then echo 'FAIL: hardcoded hex colors found (use design tokens)'; exit 1; else echo 'OK: no hardcoded hex colors in wizard/atoms/chrome'; fi"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
@@ -26,23 +27,24 @@
|
|||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@angular-devkit/architect": "^0.2200.0",
|
||||||
|
"@angular-devkit/build-angular": "^22.0.0",
|
||||||
|
"@angular-devkit/core": "^22.0.0",
|
||||||
"@angular/build": "^22.0.4",
|
"@angular/build": "^22.0.4",
|
||||||
"@angular/cli": "^22.0.4",
|
"@angular/cli": "^22.0.4",
|
||||||
"@angular/compiler-cli": "^22.0.0",
|
"@angular/compiler-cli": "^22.0.0",
|
||||||
"jsdom": "^29.0.0",
|
"@angular/platform-browser-dynamic": "^22.0.0",
|
||||||
"prettier": "^3.8.1",
|
"@compodoc/compodoc": "^1.2.1",
|
||||||
"typescript": "~6.0.2",
|
|
||||||
"vitest": "^4.0.8",
|
|
||||||
"storybook": "^10.4.6",
|
|
||||||
"@storybook/angular": "^10.4.6",
|
|
||||||
"@storybook/addon-a11y": "^10.4.6",
|
"@storybook/addon-a11y": "^10.4.6",
|
||||||
"@storybook/addon-docs": "^10.4.6",
|
"@storybook/addon-docs": "^10.4.6",
|
||||||
"@storybook/addon-onboarding": "^10.4.6",
|
"@storybook/addon-onboarding": "^10.4.6",
|
||||||
"@angular-devkit/build-angular": "^22.0.0",
|
"@storybook/angular": "^10.4.6",
|
||||||
"@angular-devkit/architect": "^0.2200.0",
|
"jsdom": "^29.0.0",
|
||||||
"@angular-devkit/core": "^22.0.0",
|
"nswag": "^14.7.1",
|
||||||
"@angular/platform-browser-dynamic": "^22.0.0",
|
"prettier": "^3.8.1",
|
||||||
"@compodoc/compodoc": "^1.2.1"
|
"storybook": "^10.4.6",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vitest": "^4.0.8"
|
||||||
},
|
},
|
||||||
"comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.",
|
"comment-overrides": "Pin patched versions of vulnerable DEV/BUILD-only transitive deps (Storybook + build chain). The shipped app already audits clean; this clears the dev-tooling advisories without downgrading Angular 22.",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
7
proxy.conf.docker.json
Normal file
7
proxy.conf.docker.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://api:5000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
7
proxy.conf.json
Normal file
7
proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:5000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"gevonden": true,
|
|
||||||
"adres": {
|
|
||||||
"straat": "Lange Voorhout 9",
|
|
||||||
"postcode": "2514 EA",
|
|
||||||
"woonplaats": "Den Haag"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"registration": {
|
|
||||||
"bigNummer": "19012345601",
|
|
||||||
"naam": "Dr. A. (Anna) de Vries",
|
|
||||||
"beroep": "Arts",
|
|
||||||
"registratiedatum": "2012-09-01",
|
|
||||||
"geboortedatum": "1985-03-14",
|
|
||||||
"status": { "tag": "Geregistreerd", "herregistratieDatum": "2027-03-01" }
|
|
||||||
},
|
|
||||||
"person": {
|
|
||||||
"naam": "Dr. A. (Anna) de Vries",
|
|
||||||
"geboortedatum": "1985-03-14",
|
|
||||||
"adres": {
|
|
||||||
"straat": "Lange Voorhout 9",
|
|
||||||
"postcode": "2514 EA",
|
|
||||||
"woonplaats": "Den Haag"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"decisions": {
|
|
||||||
"eligibleForHerregistratie": true,
|
|
||||||
"herregistratieReason": "Registratie verloopt binnen 12 maanden (2027-03-01)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"diplomas": [
|
|
||||||
{
|
|
||||||
"id": "d1",
|
|
||||||
"naam": "Geneeskunde",
|
|
||||||
"instelling": "Universiteit Leiden",
|
|
||||||
"jaar": 2011,
|
|
||||||
"beroep": "Arts",
|
|
||||||
"policyQuestions": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "d2",
|
|
||||||
"naam": "Medicine (MBChB)",
|
|
||||||
"instelling": "University of Edinburgh",
|
|
||||||
"jaar": 2013,
|
|
||||||
"beroep": "Arts",
|
|
||||||
"policyQuestions": [
|
|
||||||
{ "id": "nl-taalvaardigheid", "vraag": "Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "type": "ja-nee" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "d3",
|
|
||||||
"naam": "HBO-Verpleegkunde",
|
|
||||||
"instelling": "Hogeschool Utrecht",
|
|
||||||
"jaar": 2016,
|
|
||||||
"beroep": "Verpleegkundige",
|
|
||||||
"policyQuestions": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"handmatig": {
|
|
||||||
"beroepen": ["Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts"],
|
|
||||||
"policyQuestions": [
|
|
||||||
{ "id": "nl-taalvaardigheid", "vraag": "Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "type": "ja-nee" },
|
|
||||||
{ "id": "diploma-erkend", "vraag": "Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?", "type": "ja-nee" },
|
|
||||||
{ "id": "toelichting", "vraag": "Geef een korte toelichting op uw diploma en opleiding.", "type": "tekst" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{ "scholingThreshold": 1000 }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
[
|
|
||||||
{ "type": "Specialisme", "omschrijving": "Huisartsgeneeskunde", "datum": "2016-04-12" },
|
|
||||||
{ "type": "Aantekening", "omschrijving": "Erkend opleider huisartsgeneeskunde", "datum": "2019-01-08" },
|
|
||||||
{ "type": "Specialisme", "omschrijving": "Spoedeisende hulp (kaderopleiding)", "datum": "2021-06-30" }
|
|
||||||
]
|
|
||||||
@@ -6,6 +6,7 @@ import localeNl from '@angular/common/locales/nl';
|
|||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||||
|
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||||
|
|
||||||
registerLocaleData(localeNl);
|
registerLocaleData(localeNl);
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes, withViewTransitions()),
|
provideRouter(routes, withViewTransitions()),
|
||||||
provideHttpClient(withInterceptors([scenarioInterceptor])),
|
provideHttpClient(withInterceptors([scenarioInterceptor])),
|
||||||
|
provideApiClient(),
|
||||||
{ provide: LOCALE_ID, useValue: 'nl' },
|
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { Valid } from '../domain/herregistratie.machine';
|
import { Valid } from '../domain/herregistratie.machine';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The mutation/command: send a herregistratie application to the backend.
|
* Command: POST a herregistratie application to the backend (`/api/herregistraties`).
|
||||||
* Returns a Result so the caller can branch on success/failure without
|
* The "uren must be > 0" rule now lives server-side; the backend returns a 422
|
||||||
* try/catch. ponytail: faked with a timer; swap for a real POST when there's
|
* ProblemDetails on rejection, which we surface as the error.
|
||||||
* an API. The "uren must be > 0" rule lets the demo show a failure path.
|
|
||||||
*/
|
*/
|
||||||
export async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
|
export async function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
try {
|
||||||
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
|
await client.herregistraties({ uren: data.uren });
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/app/herregistratie/application/submit-intake.spec.ts
Normal file
26
src/app/herregistratie/application/submit-intake.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { submitIntake } from './submit-intake';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
import { ValidIntake } from '../domain/intake.machine';
|
||||||
|
|
||||||
|
// submit-herregistratie shares this exact shape (POST uren → Result); covered here.
|
||||||
|
const data = { uren: 40 } as unknown as ValidIntake;
|
||||||
|
|
||||||
|
describe('submitIntake', () => {
|
||||||
|
it('returns ok on success', async () => {
|
||||||
|
const client = { intakes: async () => ({ referentie: 'BIG-2026-1' }) } as unknown as ApiClient;
|
||||||
|
const r = await submitIntake(client, data);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns err with the ProblemDetails detail when the server rejects', async () => {
|
||||||
|
const client = {
|
||||||
|
intakes: async () => {
|
||||||
|
throw { detail: 'Aanvraag afgewezen: geen gewerkte uren geregistreerd.', status: 422 };
|
||||||
|
},
|
||||||
|
} as unknown as ApiClient;
|
||||||
|
const r = await submitIntake(client, data);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r).toMatchObject({ error: expect.stringContaining('geen gewerkte uren') });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { ValidIntake } from '../domain/intake.machine';
|
import { ValidIntake } from '../domain/intake.machine';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command: send the intake questionnaire to the backend. Returns a Result so the
|
* Command: POST the intake questionnaire to the backend (`/api/intakes`). The
|
||||||
* caller branches on success/failure without try/catch. ponytail: faked with a
|
* "uren must be > 0" rule now lives server-side; the backend returns a 422
|
||||||
* timer; swap for a real POST when there's an API. The "uren must be > 0" rule
|
* ProblemDetails on rejection, which we surface as the error.
|
||||||
* lets the demo show the failure path.
|
|
||||||
*/
|
*/
|
||||||
export async function submitIntake(data: ValidIntake): Promise<Result<string, void>> {
|
export async function submitIntake(client: ApiClient, data: ValidIntake): Promise<Result<string, void>> {
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
try {
|
||||||
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
|
await client.intakes({ uren: data.uren });
|
||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* WIRE CONTRACT for intake policy values owned by the backend.
|
|
||||||
*
|
|
||||||
* This is the "config value" shape of moving policy off the client: instead of
|
|
||||||
* hardcoding the scholing threshold in the frontend, the backend ships the value
|
|
||||||
* and the UI applies it for instant feedback. The backend remains the AUTHORITY
|
|
||||||
* — it re-validates on submit. See docs/architecture/0001-bff-lite-decision-dtos.md.
|
|
||||||
*/
|
|
||||||
export interface IntakePolicyDto {
|
|
||||||
/** Below this many NL-hours the scholing question is required. */
|
|
||||||
scholingThreshold: number;
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,13 @@ import { Component, computed, inject, input } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.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 { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
||||||
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
|
||||||
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||||
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
||||||
@@ -18,12 +18,22 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
|
|||||||
the dashboard shows "in behandeling" immediately. */
|
the dashboard shows "in behandeling" immediately. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-herregistratie-wizard',
|
selector: 'app-herregistratie-wizard',
|
||||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent],
|
||||||
template: `
|
template: `
|
||||||
@switch (state().tag) {
|
<app-wizard-shell
|
||||||
@case ('Editing') {
|
[steps]="stepLabels"
|
||||||
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ step() }} van 2</p>
|
[current]="step() - 1"
|
||||||
<form (ngSubmit)="onPrimary()" style="max-width:28rem">
|
[stepTitle]="stepTitle()"
|
||||||
|
[status]="shellStatus()"
|
||||||
|
[primaryLabel]="primaryLabel()"
|
||||||
|
[canGoBack]="step() === 2"
|
||||||
|
[errors]="errorList()"
|
||||||
|
[errorMessage]="errorMessage()"
|
||||||
|
(primary)="onPrimary()"
|
||||||
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
|
(cancel)="restart()"
|
||||||
|
(retry)="onRetry()">
|
||||||
|
|
||||||
@if (step() === 1) {
|
@if (step() === 1) {
|
||||||
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
|
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
|
||||||
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||||
@@ -33,40 +43,22 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
|
|||||||
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||||
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
|
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<div style="display:flex;gap:0.5rem">
|
|
||||||
<app-button type="submit" variant="primary">Volgende</app-button>
|
|
||||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
} @else {
|
||||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
||||||
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
||||||
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
|
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<div style="display:flex;gap:0.5rem">
|
|
||||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
|
||||||
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
|
|
||||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</form>
|
|
||||||
}
|
<div wizardSuccess>
|
||||||
@case ('Submitting') {
|
|
||||||
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
|
|
||||||
}
|
|
||||||
@case ('Submitted') {
|
|
||||||
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
||||||
}
|
|
||||||
@case ('Failed') {
|
|
||||||
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
|
|
||||||
<div style="margin-top:1rem">
|
|
||||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</app-wizard-shell>
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class HerregistratieWizardComponent {
|
export class HerregistratieWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
|
private apiClient = inject(ApiClient);
|
||||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
@@ -75,6 +67,10 @@ export class HerregistratieWizardComponent {
|
|||||||
readonly state = this.store.model; // public so the showcase can highlight the live state
|
readonly state = this.store.model; // public so the showcase can highlight the live state
|
||||||
protected dispatch = this.store.dispatch;
|
protected dispatch = this.store.dispatch;
|
||||||
|
|
||||||
|
// Stepper labels + per-step heading titles (presentational only).
|
||||||
|
readonly stepLabels = ['Werkervaring', 'Nascholing'];
|
||||||
|
private stepTitles = ['Werkervaring (afgelopen 5 jaar)', 'Nascholing'];
|
||||||
|
|
||||||
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
|
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
|
||||||
protected step = computed(() => this.editing()?.step ?? 1);
|
protected step = computed(() => this.editing()?.step ?? 1);
|
||||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
||||||
@@ -83,6 +79,26 @@ export class HerregistratieWizardComponent {
|
|||||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
|
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
|
||||||
|
|
||||||
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
|
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
||||||
|
protected primaryLabel = computed(() => (this.step() === 1 ? 'Volgende' : 'Herregistratie aanvragen'));
|
||||||
|
protected errorMessage = computed(() => `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() {
|
constructor() {
|
||||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
||||||
}
|
}
|
||||||
@@ -110,7 +126,7 @@ export class HerregistratieWizardComponent {
|
|||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Submitting') return;
|
if (s.tag !== 'Submitting') return;
|
||||||
this.profile.beginHerregistratie();
|
this.profile.beginHerregistratie();
|
||||||
const r = await submitHerregistratie(s.data);
|
const r = await submitHerregistratie(this.apiClient, s.data);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
this.profile.confirmHerregistratie();
|
this.profile.confirmHerregistratie();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie
|
|||||||
<app-alert type="info">
|
<app-alert type="info">
|
||||||
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
|
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
|
||||||
</app-alert>
|
</app-alert>
|
||||||
<div style="margin-top:1.5rem">
|
<div class="app-section">
|
||||||
<app-herregistratie-wizard />
|
<app-herregistratie-wizard />
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
import { Component, computed, effect, inject, input, resource, untracked } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||||
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
|
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
|
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||||
import {
|
import {
|
||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
lageUren,
|
lageUren,
|
||||||
SCHOLING_THRESHOLD_DEFAULT,
|
SCHOLING_THRESHOLD_DEFAULT,
|
||||||
} from '@herregistratie/domain/intake.machine';
|
} from '@herregistratie/domain/intake.machine';
|
||||||
import { IntakePolicyDto } from '@herregistratie/contracts/intake-policy.dto';
|
|
||||||
import { submitIntake } from '@herregistratie/application/submit-intake';
|
import { submitIntake } from '@herregistratie/application/submit-intake';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
|
||||||
const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration.
|
const STORAGE_KEY = 'intake-v3'; // ponytail: bump the suffix if the persisted state shape changes; no migration.
|
||||||
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
||||||
@@ -33,16 +33,26 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
|||||||
localStorage so a page reload keeps the user's progress. */
|
localStorage so a page reload keeps the user's progress. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-intake-wizard',
|
selector: 'app-intake-wizard',
|
||||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent],
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, DataRowComponent, WizardShellComponent],
|
||||||
template: `
|
template: `
|
||||||
@switch (state().tag) {
|
<app-wizard-shell
|
||||||
@case ('Answering') {
|
[steps]="stepLabels"
|
||||||
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps.length }}</p>
|
[current]="cursor()"
|
||||||
<form (ngSubmit)="onPrimary()" style="max-width:30rem">
|
[stepTitle]="stepTitle()"
|
||||||
|
[status]="shellStatus()"
|
||||||
|
[primaryLabel]="primaryLabel()"
|
||||||
|
[canGoBack]="cursor() > 0"
|
||||||
|
[errors]="errorList()"
|
||||||
|
[errorMessage]="errorMessage()"
|
||||||
|
(primary)="onPrimary()"
|
||||||
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
|
(cancel)="restart()"
|
||||||
|
(retry)="onRetry()">
|
||||||
|
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@case ('buitenland') {
|
@case ('buitenland') {
|
||||||
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenland" [error]="err('buitenlandGewerkt')">
|
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" [error]="err('buitenlandGewerkt')">
|
||||||
<app-radio-group name="buitenland" [options]="jaNee"
|
<app-radio-group name="buitenlandGewerkt" [options]="jaNee"
|
||||||
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
|
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
@if (answers().buitenlandGewerkt === 'ja') {
|
@if (answers().buitenlandGewerkt === 'ja') {
|
||||||
@@ -59,8 +69,8 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
|||||||
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
|
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
@if (scholingZichtbaar()) {
|
@if (scholingZichtbaar()) {
|
||||||
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholingGevolgd')">
|
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" [error]="err('scholingGevolgd')">
|
||||||
<app-radio-group name="scholing" [options]="jaNee"
|
<app-radio-group name="scholingGevolgd" [options]="jaNee"
|
||||||
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
}
|
}
|
||||||
@@ -72,58 +82,41 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
|||||||
}
|
}
|
||||||
@case ('review') {
|
@case ('review') {
|
||||||
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
|
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
|
||||||
<dl class="rhc-data-summary rhc-data-summary--row" style="margin:1rem 0">
|
<dl class="rhc-data-summary rhc-data-summary--row app-section">
|
||||||
<div><dt>Buiten NL gewerkt</dt><dd>{{ answers().buitenlandGewerkt ?? '—' }}</dd></div>
|
<app-data-row key="Buiten NL gewerkt" [value]="answers().buitenlandGewerkt ?? '—'" />
|
||||||
@if (answers().buitenlandGewerkt === 'ja') {
|
@if (answers().buitenlandGewerkt === 'ja') {
|
||||||
<div><dt>Land</dt><dd>{{ answers().land }}</dd></div>
|
<app-data-row key="Land" [value]="answers().land ?? ''" />
|
||||||
<div><dt>Buitenlandse uren</dt><dd>{{ answers().buitenlandseUren }}</dd></div>
|
<app-data-row key="Buitenlandse uren" [value]="answers().buitenlandseUren ?? ''" />
|
||||||
}
|
}
|
||||||
<div><dt>Uren NL</dt><dd>{{ answers().uren }}</dd></div>
|
<app-data-row key="Uren NL" [value]="answers().uren ?? ''" />
|
||||||
@if (scholingZichtbaar()) {
|
@if (scholingZichtbaar()) {
|
||||||
<div><dt>Aanvullende scholing</dt><dd>{{ answers().scholingGevolgd }}</dd></div>
|
<app-data-row key="Aanvullende scholing" [value]="answers().scholingGevolgd ?? ''" />
|
||||||
}
|
}
|
||||||
@if (answers().scholingGevolgd === 'ja') {
|
@if (answers().scholingGevolgd === 'ja') {
|
||||||
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
|
<app-data-row key="Nascholingspunten" [value]="answers().punten ?? ''" />
|
||||||
}
|
}
|
||||||
</dl>
|
</dl>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<div style="display:flex;gap:0.5rem;margin-top:1rem">
|
|
||||||
@if (cursor() > 0) {
|
<div wizardSuccess>
|
||||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
|
||||||
}
|
|
||||||
<app-button type="submit" variant="primary">{{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}</app-button>
|
|
||||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
@case ('Submitting') {
|
|
||||||
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
|
|
||||||
}
|
|
||||||
@case ('Submitted') {
|
|
||||||
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
||||||
<div style="margin-top:1rem">
|
<div class="app-section">
|
||||||
<app-button variant="secondary" (click)="restart()">Opnieuw beginnen</app-button>
|
<app-button variant="secondary" (click)="restart()">Opnieuw beginnen</app-button>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
@case ('Failed') {
|
|
||||||
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
|
|
||||||
<div style="margin-top:1rem">
|
|
||||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</app-wizard-shell>
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class IntakeWizardComponent {
|
export class IntakeWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
|
private apiClient = inject(ApiClient);
|
||||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
||||||
|
|
||||||
// Server-owned policy: the scholing threshold is fetched, not hardcoded. The
|
// Server-owned policy: the scholing threshold is fetched from the backend
|
||||||
// backend stays the authority and re-validates on submit.
|
// (`GET /api/intake/policy`), not hardcoded. The backend stays the authority
|
||||||
private policyRes = httpResource<IntakePolicyDto>(() => 'mock/intake-policy.json', {
|
// and re-validates on submit.
|
||||||
defaultValue: { scholingThreshold: SCHOLING_THRESHOLD_DEFAULT },
|
private policyRes = resource({ loader: () => this.apiClient.policy() });
|
||||||
});
|
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<IntakeState>(initial);
|
seed = input<IntakeState>(initial);
|
||||||
@@ -144,6 +137,33 @@ export class IntakeWizardComponent {
|
|||||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : ''));
|
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).error : ''));
|
||||||
|
|
||||||
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
|
readonly stepLabels = ['Buitenland', 'Werk', 'Controle'];
|
||||||
|
private stepTitles: Record<StepId, string> = {
|
||||||
|
buitenland: 'Werken in het buitenland',
|
||||||
|
werk: 'Werkervaring in Nederland',
|
||||||
|
review: 'Controleren en indienen',
|
||||||
|
};
|
||||||
|
protected stepTitle = computed(() => this.stepTitles[this.step()]);
|
||||||
|
protected primaryLabel = computed(() => (this.step() === 'review' ? 'Aanvraag indienen' : 'Volgende'));
|
||||||
|
protected errorMessage = computed(() => `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 err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
||||||
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
|
protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value });
|
||||||
|
|
||||||
@@ -161,7 +181,7 @@ export class IntakeWizardComponent {
|
|||||||
// only the policy value; untrack the dispatch (it reads the state signal
|
// only the policy value; untrack the dispatch (it reads the state signal
|
||||||
// internally, which would otherwise make this effect loop on its own write).
|
// internally, which would otherwise make this effect loop on its own write).
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const scholingThreshold = this.policyRes.value().scholingThreshold;
|
const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT;
|
||||||
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -198,7 +218,7 @@ export class IntakeWizardComponent {
|
|||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Submitting') return;
|
if (s.tag !== 'Submitting') return;
|
||||||
this.profile.beginHerregistratie();
|
this.profile.beginHerregistratie();
|
||||||
const r = await submitIntake(s.data);
|
const r = await submitIntake(this.apiClient, s.data);
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
this.profile.confirmHerregistratie();
|
this.profile.confirmHerregistratie();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-w
|
|||||||
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u
|
antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u
|
||||||
de pagina herlaadt.
|
de pagina herlaadt.
|
||||||
</app-alert>
|
</app-alert>
|
||||||
<div style="margin-top:1.5rem">
|
<div class="app-section">
|
||||||
<app-intake-wizard />
|
<app-intake-wizard />
|
||||||
</div>
|
</div>
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ export class BigProfileStore {
|
|||||||
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
|
readonly decisions = computed<RemoteData<Err, HerregistratieDecisions>>(() => map(this.view(), (v) => v.decisions));
|
||||||
|
|
||||||
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
/** Specialisms/notes stay a separate stream (they have their own empty state). */
|
||||||
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() =>
|
readonly aantekeningen = computed<RemoteData<Err, Aantekening[]>>(() => {
|
||||||
fromResource(this.aantekeningenRes, (v) => v.length === 0),
|
const rd = fromResource(this.aantekeningenRes, (v) => !v || v.length === 0);
|
||||||
);
|
return rd.tag === 'Success' ? { tag: 'Success', value: rd.value ?? [] } : rd;
|
||||||
|
});
|
||||||
|
|
||||||
// --- Optimistic herregistratie state, shared with the dashboard -----------
|
// --- Optimistic herregistratie state, shared with the dashboard -----------
|
||||||
private pending = signal(false);
|
private pending = signal(false);
|
||||||
|
|||||||
27
src/app/registratie/application/submit-registratie.spec.ts
Normal file
27
src/app/registratie/application/submit-registratie.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { submitRegistratie } from './submit-registratie';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
|
||||||
|
// Mocked at the client boundary: the rule itself lives server-side now, so these
|
||||||
|
// tests only assert that the command maps the client's response onto a Result.
|
||||||
|
const data = { diplomaHerkomst: 'duo' } as unknown as ValidRegistratie;
|
||||||
|
|
||||||
|
describe('submitRegistratie', () => {
|
||||||
|
it('returns ok with the server reference on success', async () => {
|
||||||
|
const client = { registrations: async () => ({ referentie: 'BIG-2026-123456' }) } as unknown as ApiClient;
|
||||||
|
const r = await submitRegistratie(client, data);
|
||||||
|
expect(r).toEqual({ ok: true, value: 'BIG-2026-123456' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns err with the ProblemDetails detail when the server rejects (422)', async () => {
|
||||||
|
const client = {
|
||||||
|
registrations: async () => {
|
||||||
|
throw { detail: 'Handmatig diploma kan niet automatisch worden geverifieerd.', status: 422 };
|
||||||
|
},
|
||||||
|
} as unknown as ApiClient;
|
||||||
|
const r = await submitRegistratie(client, data);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r).toMatchObject({ error: expect.stringContaining('Handmatig diploma') });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
import { ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command: send the completed registration to the backend, which invokes the
|
* Command: POST the completed registration to the backend (`/api/registrations`),
|
||||||
* domain command to create/finalize the registration aggregate. Returns a Result
|
* which re-validates and decides. The business rule that a manually entered
|
||||||
* so the caller branches on success/failure without try/catch; on success it
|
* diploma cannot be auto-verified now lives server-side; the backend returns it as
|
||||||
* yields the confirmation reference (PRD §9). ponytail: faked with a timer; swap
|
* a 422 ProblemDetails, which we surface as the error. On success it yields the
|
||||||
* for a real POST when there's an API. The "manually entered diploma" rule lets
|
* server-generated confirmation reference (PRD §9).
|
||||||
* the demo show the failure path.
|
|
||||||
*/
|
*/
|
||||||
export async function submitRegistratie(data: ValidRegistratie): Promise<Result<string, string>> {
|
export async function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
|
||||||
await new Promise((r) => setTimeout(r, 800));
|
try {
|
||||||
if (data.diplomaHerkomst === 'handmatig') {
|
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
|
||||||
return err('Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling.');
|
return ok(res.referentie ?? '');
|
||||||
|
} catch (e) {
|
||||||
|
return err(problemDetail(e, 'Het indienen is niet gelukt. Probeer het later opnieuw.'));
|
||||||
}
|
}
|
||||||
const referentie = 'BIG-2026-' + Math.floor(100000 + Math.random() * 900000);
|
|
||||||
return ok(referentie);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject, resource } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
import { Aantekening, AantekeningType } from '../domain/registration';
|
||||||
import { Aantekening } from '../domain/registration';
|
import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for the BIG-register source. Exposes signal-based
|
* Infrastructure adapter for the BIG-register source. Exposes signal-based
|
||||||
* resources (Angular's httpResource); each returns a Resource with
|
* resources (Angular's `resource` over the generated typed client); each returns
|
||||||
* status()/value()/error()/reload(). Call from an injection context
|
* a Resource with status()/value()/error()/reload(). Call from an injection
|
||||||
* (a field initializer in the store).
|
* context (a field initializer in the store).
|
||||||
*
|
*
|
||||||
* Note: registration + person are now served via the aggregated dashboard-view
|
* Note: registration + person are now served via the aggregated dashboard-view
|
||||||
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
|
* endpoint (see DashboardViewAdapter). Only the notes stream remains separate.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BigRegisterAdapter {
|
export class BigRegisterAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
aantekeningenResource() {
|
aantekeningenResource() {
|
||||||
return httpResource<Aantekening[]>(() => 'mock/notes.json', { defaultValue: [] });
|
return resource({ loader: () => this.client.notes().then((ns) => ns.map(toAantekening)) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Map the wire DTO (all fields optional) onto our domain type. */
|
||||||
|
function toAantekening(n: AantekeningDto): Aantekening {
|
||||||
|
return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject, resource } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
|
import { BrpAddressDto } from '@registratie/contracts/brp-address.dto';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for the BRP address lookup, reached only through our own
|
* Infrastructure adapter for the BRP address lookup, reached only through our own
|
||||||
* ("BFF-lite") endpoint — the anti-corruption boundary. In this POC the endpoint
|
* ("BFF-lite") endpoint — the anti-corruption boundary. Data comes from the .NET
|
||||||
* is a static mock; pointing at a real backend touches only this file + the DTO.
|
* backend (`GET /api/brp/address`) via the generated typed client.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BrpAdapter {
|
export class BrpAdapter {
|
||||||
// Typed as the DTO for ergonomics, but the value is untrusted JSON until
|
private client = inject(ApiClient);
|
||||||
// parseBrpAddress validates it.
|
|
||||||
|
// The value is untrusted JSON until parseBrpAddress validates it.
|
||||||
adresResource() {
|
adresResource() {
|
||||||
return httpResource<BrpAddressDto>(() => 'mock/brp-address.json');
|
return resource({ loader: () => this.client.address() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject, resource } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
|
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
|
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
|
||||||
* ONE call returns registration + person + server-computed decisions.
|
* ONE call returns registration + person + server-computed decisions. The data
|
||||||
* (In this POC the endpoint is a static mock; the decisions are precomputed to
|
* comes from the .NET backend (`GET /api/dashboard-view`) via the generated typed
|
||||||
* stand in for what the backend would compute.)
|
* client; the decisions (e.g. herregistratie eligibility) are computed there.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DashboardViewAdapter {
|
export class DashboardViewAdapter {
|
||||||
// Typed as the DTO for ergonomics, but the value is still untrusted JSON —
|
private client = inject(ApiClient);
|
||||||
// parseDashboardView validates it at the boundary before the app uses it.
|
|
||||||
|
// The value is still untrusted JSON — parseDashboardView validates it at the
|
||||||
|
// boundary and maps DTO → domain before the app uses it.
|
||||||
dashboardViewResource() {
|
dashboardViewResource() {
|
||||||
return httpResource<DashboardViewDto>(() => 'mock/dashboard-view.json');
|
return resource({ loader: () => this.client.dashboardView() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject, resource } from '@angular/core';
|
||||||
import { httpResource } from '@angular/common/http';
|
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
|
import { DuoLookupDto, DuoDiplomaDto, PolicyQuestionDto, ManualDiplomaPolicyDto } from '@registratie/contracts/duo-diplomas.dto';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
const EMPTY: DuoLookupDto = { diplomas: [], handmatig: { beroepen: [], policyQuestions: [] } };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
|
* Infrastructure adapter for the DUO diploma lookup, reached only through our own
|
||||||
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
|
* ("BFF-lite") endpoint — the anti-corruption boundary. The response carries the
|
||||||
* user's diplomas (each with its server-computed beroep + policy questions) and
|
* user's diplomas (each with its server-computed beroep + policy questions) and
|
||||||
* the manual-entry fallback policy. The frontend renders; it does not derive. In
|
* the manual-entry fallback policy. The frontend renders; it does not derive.
|
||||||
* this POC the endpoint is a static mock.
|
* Data comes from the .NET backend (`GET /api/duo/diplomas`) via the typed client.
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class DuoAdapter {
|
export class DuoAdapter {
|
||||||
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
diplomasResource() {
|
diplomasResource() {
|
||||||
return httpResource<DuoLookupDto>(() => 'mock/duo-diplomas.json', { defaultValue: EMPTY });
|
return resource({ loader: () => this.client.diplomas() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-page-shell heading="Mijn BIG-registratie">
|
<app-page-shell heading="Mijn BIG-registratie">
|
||||||
|
<div class="app-stack">
|
||||||
@if (store.pendingHerregistratie()) {
|
@if (store.pendingHerregistratie()) {
|
||||||
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
|
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
|
||||||
}
|
}
|
||||||
@@ -26,7 +27,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|||||||
<app-async [data]="store.profile()">
|
<app-async [data]="store.profile()">
|
||||||
<ng-template appAsyncLoaded let-p>
|
<ng-template appAsyncLoaded let-p>
|
||||||
<app-registration-summary [reg]="$any(p).registration" />
|
<app-registration-summary [reg]="$any(p).registration" />
|
||||||
<div class="rhc-card rhc-card--default" style="margin-top:1rem">
|
<div class="rhc-card rhc-card--default app-section">
|
||||||
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
|
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
|
||||||
<dl class="rhc-data-summary rhc-data-summary--row">
|
<dl class="rhc-data-summary rhc-data-summary--row">
|
||||||
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
|
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
|
||||||
@@ -40,7 +41,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</app-async>
|
</app-async>
|
||||||
|
|
||||||
<div style="margin-top:2rem">
|
<section>
|
||||||
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
|
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
|
||||||
<app-async [data]="store.aantekeningen()">
|
<app-async [data]="store.aantekeningen()">
|
||||||
<ng-template appAsyncLoaded let-r>
|
<ng-template appAsyncLoaded let-r>
|
||||||
@@ -53,26 +54,33 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|||||||
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
|
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-async>
|
</app-async>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<p style="margin-top:2rem">
|
<section>
|
||||||
<app-link to="/registreren">Inschrijven in het BIG-register (registratiewizard) →</app-link>
|
<app-heading [level]="2">Wat wilt u doen?</app-heading>
|
||||||
</p>
|
<ul class="app-card-grid app-section">
|
||||||
<p>
|
@for (a of acties; track a.to) {
|
||||||
<app-link to="/registratie">Gegevens bekijken of een wijziging doorgeven →</app-link>
|
<li class="rhc-card rhc-card--default">
|
||||||
</p>
|
<app-heading [level]="3">{{ a.titel }}</app-heading>
|
||||||
<p>
|
<p class="rhc-paragraph">{{ a.tekst }}</p>
|
||||||
<app-link to="/herregistratie">Herregistratie aanvragen →</app-link>
|
<app-link [to]="a.to">{{ a.actie }} →</app-link>
|
||||||
</p>
|
</li>
|
||||||
<p>
|
}
|
||||||
<app-link to="/intake">Herregistratie-intake (vragenlijst met vertakkingen) →</app-link>
|
</ul>
|
||||||
</p>
|
</section>
|
||||||
<p>
|
</div>
|
||||||
<app-link to="/concepts">Functionele patronen (impossible states) →</app-link>
|
|
||||||
</p>
|
|
||||||
</app-page-shell>
|
</app-page-shell>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class DashboardPage {
|
export class DashboardPage {
|
||||||
protected store = inject(BigProfileStore);
|
protected store = inject(BigProfileStore);
|
||||||
|
|
||||||
|
/** Primary actions, rendered as an accessible card grid. */
|
||||||
|
protected readonly acties = [
|
||||||
|
{ to: '/registreren', titel: 'Inschrijven', tekst: 'Schrijf u in in het BIG-register via de registratiewizard.', actie: 'Start inschrijving' },
|
||||||
|
{ to: '/registratie', titel: 'Mijn gegevens', tekst: 'Bekijk uw gegevens of geef een wijziging door.', actie: 'Bekijk gegevens' },
|
||||||
|
{ to: '/herregistratie', titel: 'Herregistratie', tekst: 'Vraag uw herregistratie aan.', actie: 'Vraag aan' },
|
||||||
|
{ to: '/intake', titel: 'Herregistratie-intake', tekst: 'Vragenlijst met vertakkingen.', actie: 'Start intake' },
|
||||||
|
{ to: '/concepts', titel: 'Functionele patronen', tekst: 'Onmogelijke toestanden onmogelijk maken.', actie: 'Bekijk patronen' },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { Component, ElementRef, computed, effect, inject, input, untracked, viewChild } from '@angular/core';
|
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||||
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
|
import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
|
||||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
import { WizardShellComponent, WizardError, WizardStatus } from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
@@ -28,6 +27,7 @@ import {
|
|||||||
STEPS,
|
STEPS,
|
||||||
} from '@registratie/domain/registratie-wizard.machine';
|
} from '@registratie/domain/registratie-wizard.machine';
|
||||||
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
||||||
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
|
|
||||||
const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.
|
const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.
|
||||||
const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];
|
const KANALEN = [{ value: 'email', label: 'E-mail' }, { value: 'post', label: 'Post' }];
|
||||||
@@ -44,15 +44,25 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
selector: 'app-registratie-wizard',
|
selector: 'app-registratie-wizard',
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
|
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
|
||||||
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent,
|
AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
|
||||||
AddressFieldsComponent, ...ASYNC,
|
AddressFieldsComponent, ...ASYNC,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
@switch (state().tag) {
|
<app-wizard-shell
|
||||||
@case ('Invullen') {
|
[steps]="stepLabels"
|
||||||
<app-stepper class="app-section" [steps]="stepLabels" [current]="cursor()" />
|
[current]="cursor()"
|
||||||
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
|
[stepTitle]="stepTitle()"
|
||||||
<form (ngSubmit)="onPrimary()" class="app-form">
|
[status]="shellStatus()"
|
||||||
|
[primaryLabel]="primaryLabel()"
|
||||||
|
[canGoBack]="cursor() > 0"
|
||||||
|
[errors]="errorList()"
|
||||||
|
[errorMessage]="errorMessage()"
|
||||||
|
submittingLabel="Uw registratie wordt verwerkt…"
|
||||||
|
(primary)="onPrimary()"
|
||||||
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
|
(cancel)="restart()"
|
||||||
|
(retry)="onRetry()">
|
||||||
|
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@case ('adres') {
|
@case ('adres') {
|
||||||
@if (adresStatus() === 'laden') {
|
@if (adresStatus() === 'laden') {
|
||||||
@@ -142,36 +152,20 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<div class="app-button-row app-button-row--spaced">
|
|
||||||
@if (cursor() > 0) {
|
<div wizardSuccess>
|
||||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
|
||||||
}
|
|
||||||
<app-button type="submit" variant="primary">{{ step() === 'controle' ? 'Registratie indienen' : 'Volgende' }}</app-button>
|
|
||||||
<app-button type="button" variant="subtle" (click)="restart()">Annuleren</app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
@case ('Indienen') {
|
|
||||||
<app-spinner /> <span>Uw registratie wordt verwerkt…</span>
|
|
||||||
}
|
|
||||||
@case ('Ingediend') {
|
|
||||||
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
|
<app-alert type="ok">Uw registratie is ontvangen. Uw referentienummer is {{ referentie() }}. Bewaar dit nummer voor uw administratie.</app-alert>
|
||||||
<div class="app-section">
|
<div class="app-section">
|
||||||
<app-button variant="secondary" (click)="restart()">Nieuwe registratie starten</app-button>
|
<app-button variant="secondary" (click)="restart()">Nieuwe registratie starten</app-button>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
@case ('Mislukt') {
|
|
||||||
<app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert>
|
|
||||||
<div class="app-section">
|
|
||||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</app-wizard-shell>
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class RegistratieWizardComponent {
|
export class RegistratieWizardComponent {
|
||||||
private brp = inject(BrpAdapter);
|
private brp = inject(BrpAdapter);
|
||||||
private duo = inject(DuoAdapter);
|
private duo = inject(DuoAdapter);
|
||||||
|
private apiClient = inject(ApiClient);
|
||||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
||||||
|
|
||||||
protected adresRes = this.brp.adresResource();
|
protected adresRes = this.brp.adresResource();
|
||||||
@@ -186,9 +180,6 @@ export class RegistratieWizardComponent {
|
|||||||
readonly state = this.store.model;
|
readonly state = this.store.model;
|
||||||
readonly dispatch = this.store.dispatch;
|
readonly dispatch = this.store.dispatch;
|
||||||
|
|
||||||
/** Focus target: the step heading receives focus on step change (a11y). */
|
|
||||||
private stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
|
|
||||||
|
|
||||||
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
|
private invullen = computed(() => (this.state().tag === 'Invullen' ? (this.state() as Extract<RegistratieState, { tag: 'Invullen' }>) : null));
|
||||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||||
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
||||||
@@ -196,6 +187,30 @@ export class RegistratieWizardComponent {
|
|||||||
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
||||||
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
|
protected referentie = computed(() => (this.state().tag === 'Ingediend' ? (this.state() as Extract<RegistratieState, { tag: 'Ingediend' }>).referentie : ''));
|
||||||
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
|
protected failedError = computed(() => (this.state().tag === 'Mislukt' ? (this.state() as Extract<RegistratieState, { tag: 'Mislukt' }>).error : ''));
|
||||||
|
|
||||||
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
|
protected primaryLabel = computed(() => (this.step() === 'controle' ? 'Registratie indienen' : 'Volgende'));
|
||||||
|
protected errorMessage = computed(() => `Het indienen is niet gelukt: ${this.failedError()}`);
|
||||||
|
protected shellStatus = computed<WizardStatus>(() => {
|
||||||
|
switch (this.state().tag) {
|
||||||
|
case 'Invullen': return 'editing';
|
||||||
|
case 'Indienen': return 'submitting';
|
||||||
|
case 'Ingediend': return 'submitted';
|
||||||
|
case 'Mislukt': return 'failed';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
/** Current step's errors (incl. per-question), flattened for the error summary. */
|
||||||
|
protected errorList = computed<WizardError[]>(() => {
|
||||||
|
const e = this.invullen()?.errors ?? {};
|
||||||
|
const out: WizardError[] = [];
|
||||||
|
for (const [k, v] of Object.entries(e)) {
|
||||||
|
if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v });
|
||||||
|
}
|
||||||
|
for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) {
|
||||||
|
if (msg) out.push({ id: 'vraag-' + qid, message: msg });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
protected adresSamenvatting = computed(() => {
|
protected adresSamenvatting = computed(() => {
|
||||||
const d = this.draft();
|
const d = this.draft();
|
||||||
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
return [d.straat, [d.postcode, d.woonplaats].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||||||
@@ -302,18 +317,8 @@ export class RegistratieWizardComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// A11y: move focus to the step heading when the STEP changes (depends only on
|
// A11y: focus management (step heading on step change, error summary on a
|
||||||
// cursor(), which is value-stable across keystrokes — so typing never steals
|
// failed submit) now lives in the shared WizardShellComponent.
|
||||||
// focus). Skip the first run so we don't grab focus on initial load.
|
|
||||||
let firstStep = true;
|
|
||||||
effect(() => {
|
|
||||||
this.cursor();
|
|
||||||
if (firstStep) { firstStep = false; return; }
|
|
||||||
untracked(() => {
|
|
||||||
if (this.state().tag !== 'Invullen') return;
|
|
||||||
queueMicrotask(() => this.stepHeading()?.nativeElement.focus());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private restore(): RegistratieState | null {
|
private restore(): RegistratieState | null {
|
||||||
@@ -350,7 +355,7 @@ export class RegistratieWizardComponent {
|
|||||||
private async runIfIndienen() {
|
private async runIfIndienen() {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Indienen') return;
|
if (s.tag !== 'Indienen') return;
|
||||||
const r = await submitRegistratie(s.data);
|
const r = await submitRegistratie(this.apiClient, s.data);
|
||||||
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
|
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
|
||||||
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/app/shared/infrastructure/api-client.provider.ts
Normal file
49
src/app/shared/infrastructure/api-client.provider.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Provider } from '@angular/core';
|
||||||
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import { ApiClient, ProblemDetails } from './api-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
|
||||||
|
* client expects, so every API call flows through HttpClient interceptors — the
|
||||||
|
* `?scenario=` toggle (scenario.interceptor.ts) and any future auth header. The
|
||||||
|
* generated client is the only place HTTP shapes are known; this is the only
|
||||||
|
* place it meets Angular's HTTP stack.
|
||||||
|
*/
|
||||||
|
function httpClientFetch(http: HttpClient) {
|
||||||
|
return {
|
||||||
|
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||||
|
const method = (init?.method ?? 'GET').toUpperCase();
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
try {
|
||||||
|
const res = await firstValueFrom(
|
||||||
|
http.request(method, url as string, {
|
||||||
|
body: init?.body as string | undefined,
|
||||||
|
headers,
|
||||||
|
observe: 'response',
|
||||||
|
responseType: 'text',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return new Response(res.body ?? '', { status: res.status || 200 });
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as HttpErrorResponse;
|
||||||
|
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
|
||||||
|
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
|
||||||
|
// request reports status 0, which `new Response` rejects).
|
||||||
|
const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
|
||||||
|
return new Response(body, { status });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provide a root ApiClient that talks through HttpClient (relative `/api` base URL). */
|
||||||
|
export function provideApiClient(): Provider {
|
||||||
|
return {
|
||||||
|
provide: ApiClient,
|
||||||
|
useFactory: (http: HttpClient) => new ApiClient('', httpClientFetch(http)),
|
||||||
|
deps: [HttpClient],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ProblemDetails };
|
||||||
474
src/app/shared/infrastructure/api-client.ts
Normal file
474
src/app/shared/infrastructure/api-client.ts
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
//----------------------
|
||||||
|
// <auto-generated>
|
||||||
|
// Generated using the NSwag toolchain v14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org)
|
||||||
|
// </auto-generated>
|
||||||
|
//----------------------
|
||||||
|
|
||||||
|
/* eslint-disable */
|
||||||
|
// ReSharper disable InconsistentNaming
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
|
||||||
|
private baseUrl: string;
|
||||||
|
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
|
||||||
|
|
||||||
|
constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }) {
|
||||||
|
this.http = http ? http : window as any;
|
||||||
|
this.baseUrl = baseUrl ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
dashboardView(): Promise<DashboardViewDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/dashboard-view";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processDashboardView(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processDashboardView(response: Response): Promise<DashboardViewDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as DashboardViewDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<DashboardViewDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
notes(): Promise<AantekeningDto[]> {
|
||||||
|
let url_ = this.baseUrl + "/api/notes";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processNotes(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processNotes(response: Response): Promise<AantekeningDto[]> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AantekeningDto[];
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<AantekeningDto[]>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
address(): Promise<BrpAddressDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/brp/address";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processAddress(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processAddress(response: Response): Promise<BrpAddressDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BrpAddressDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<BrpAddressDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
diplomas(): Promise<DuoLookupDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/duo/diplomas";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processDiplomas(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processDiplomas(response: Response): Promise<DuoLookupDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as DuoLookupDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<DuoLookupDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
policy(): Promise<IntakePolicyDto> {
|
||||||
|
let url_ = this.baseUrl + "/api/intake/policy";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processPolicy(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processPolicy(response: Response): Promise<IntakePolicyDto> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as IntakePolicyDto;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<IntakePolicyDto>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
registrations(body: RegistratieRequest): Promise<ReferentieResponse> {
|
||||||
|
let url_ = this.baseUrl + "/api/registrations";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
const content_ = JSON.stringify(body);
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
body: content_,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processRegistrations(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processRegistrations(response: Response): Promise<ReferentieResponse> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 422) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result422: any = null;
|
||||||
|
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<ReferentieResponse>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
herregistraties(body: HerregistratieRequest): Promise<ReferentieResponse> {
|
||||||
|
let url_ = this.baseUrl + "/api/herregistraties";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
const content_ = JSON.stringify(body);
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
body: content_,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processHerregistraties(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processHerregistraties(response: Response): Promise<ReferentieResponse> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 422) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result422: any = null;
|
||||||
|
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<ReferentieResponse>(null as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return OK
|
||||||
|
*/
|
||||||
|
intakes(body: IntakeRequest): Promise<ReferentieResponse> {
|
||||||
|
let url_ = this.baseUrl + "/api/intakes";
|
||||||
|
url_ = url_.replace(/[?&]$/, "");
|
||||||
|
|
||||||
|
const content_ = JSON.stringify(body);
|
||||||
|
|
||||||
|
let options_: RequestInit = {
|
||||||
|
body: content_,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||||
|
return this.processIntakes(_response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processIntakes(response: Response): Promise<ReferentieResponse> {
|
||||||
|
const status = response.status;
|
||||||
|
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||||
|
if (status === 200) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result200: any = null;
|
||||||
|
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse;
|
||||||
|
return result200;
|
||||||
|
});
|
||||||
|
} else if (status === 422) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
let result422: any = null;
|
||||||
|
result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||||
|
return throwException("Unprocessable Content", status, _responseText, _headers, result422);
|
||||||
|
});
|
||||||
|
} else if (status !== 200 && status !== 204) {
|
||||||
|
return response.text().then((_responseText) => {
|
||||||
|
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve<ReferentieResponse>(null as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AantekeningDto {
|
||||||
|
type?: string | undefined;
|
||||||
|
omschrijving?: string | undefined;
|
||||||
|
datum?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdresDto {
|
||||||
|
straat?: string | undefined;
|
||||||
|
postcode?: string | undefined;
|
||||||
|
woonplaats?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrpAddressDto {
|
||||||
|
gevonden?: boolean;
|
||||||
|
adres?: AdresDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardViewDto {
|
||||||
|
registration?: RegistrationDto;
|
||||||
|
person?: PersonDto;
|
||||||
|
decisions?: HerregistratieDecisionsDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuoDiplomaDto {
|
||||||
|
id?: string | undefined;
|
||||||
|
naam?: string | undefined;
|
||||||
|
instelling?: string | undefined;
|
||||||
|
jaar?: number;
|
||||||
|
beroep?: string | undefined;
|
||||||
|
policyQuestions?: PolicyQuestionDto[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuoLookupDto {
|
||||||
|
diplomas?: DuoDiplomaDto[] | undefined;
|
||||||
|
handmatig?: ManualDiplomaPolicyDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HerregistratieDecisionsDto {
|
||||||
|
eligibleForHerregistratie?: boolean;
|
||||||
|
herregistratieReason?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HerregistratieRequest {
|
||||||
|
uren?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntakePolicyDto {
|
||||||
|
scholingThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntakeRequest {
|
||||||
|
uren?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManualDiplomaPolicyDto {
|
||||||
|
beroepen?: string[] | undefined;
|
||||||
|
policyQuestions?: PolicyQuestionDto[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonDto {
|
||||||
|
naam?: string | undefined;
|
||||||
|
geboortedatum?: string | undefined;
|
||||||
|
adres?: AdresDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyQuestionDto {
|
||||||
|
id?: string | undefined;
|
||||||
|
vraag?: string | undefined;
|
||||||
|
type?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemDetails {
|
||||||
|
type?: string | undefined;
|
||||||
|
title?: string | undefined;
|
||||||
|
status?: number | undefined;
|
||||||
|
detail?: string | undefined;
|
||||||
|
instance?: string | undefined;
|
||||||
|
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferentieResponse {
|
||||||
|
referentie?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistratieRequest {
|
||||||
|
diplomaHerkomst?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationDto {
|
||||||
|
bigNummer?: string | undefined;
|
||||||
|
naam?: string | undefined;
|
||||||
|
beroep?: string | undefined;
|
||||||
|
registratiedatum?: string | undefined;
|
||||||
|
geboortedatum?: string | undefined;
|
||||||
|
status?: RegistrationStatusDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrationStatusDto {
|
||||||
|
tag?: string | undefined;
|
||||||
|
herregistratieDatum?: string | undefined;
|
||||||
|
geschorstTot?: string | undefined;
|
||||||
|
reden?: string | undefined;
|
||||||
|
doorgehaaldOp?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SwaggerException extends Error {
|
||||||
|
override message: string;
|
||||||
|
status: number;
|
||||||
|
response: string;
|
||||||
|
headers: { [key: string]: any; };
|
||||||
|
result: any;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.message = message;
|
||||||
|
this.status = status;
|
||||||
|
this.response = response;
|
||||||
|
this.headers = headers;
|
||||||
|
this.result = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isSwaggerException = true;
|
||||||
|
|
||||||
|
static isSwaggerException(obj: any): obj is SwaggerException {
|
||||||
|
return obj.isSwaggerException === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): any {
|
||||||
|
if (result !== null && result !== undefined)
|
||||||
|
throw result;
|
||||||
|
else
|
||||||
|
throw new SwaggerException(message, status, response, headers, null);
|
||||||
|
}
|
||||||
14
src/app/shared/infrastructure/api-error.spec.ts
Normal file
14
src/app/shared/infrastructure/api-error.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { problemDetail } from './api-error';
|
||||||
|
|
||||||
|
describe('problemDetail', () => {
|
||||||
|
it('extracts the detail from an RFC-7807 ProblemDetails', () => {
|
||||||
|
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe('Afgewezen: 0 uren.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back when there is no detail', () => {
|
||||||
|
expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback');
|
||||||
|
expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback');
|
||||||
|
expect(problemDetail(undefined, 'fallback')).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
14
src/app/shared/infrastructure/api-error.ts
Normal file
14
src/app/shared/infrastructure/api-error.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { ProblemDetails } from './api-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a human-readable message from a rejected API call. A 4xx/5xx with a
|
||||||
|
* ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed
|
||||||
|
* object; anything else falls back to the given message.
|
||||||
|
*/
|
||||||
|
export function problemDetail(e: unknown, fallback: string): string {
|
||||||
|
if (e && typeof e === 'object' && 'detail' in e) {
|
||||||
|
const detail = (e as ProblemDetails).detail;
|
||||||
|
if (typeof detail === 'string' && detail) return detail;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -4,12 +4,12 @@ import { delay } from 'rxjs/operators';
|
|||||||
import { currentScenario } from './scenario';
|
import { currentScenario } from './scenario';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Demo-only: rewrites the timing/outcome of mock data requests based on
|
* Demo-only: rewrites the timing/outcome of API data requests based on
|
||||||
* ?scenario= so loading / empty / error states can be shown on demand.
|
* ?scenario= so loading / empty / error states can be shown on demand.
|
||||||
* Real requests are untouched.
|
* Non-API requests are untouched.
|
||||||
*/
|
*/
|
||||||
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
if (!req.url.includes('mock/')) return next(req);
|
if (!req.url.includes('/api/')) return next(req);
|
||||||
|
|
||||||
switch (currentScenario()) {
|
switch (currentScenario()) {
|
||||||
case 'slow':
|
case 'slow':
|
||||||
@@ -17,7 +17,8 @@ export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
case 'loading':
|
case 'loading':
|
||||||
return next(req).pipe(delay(600_000)); // effectively never resolves
|
return next(req).pipe(delay(600_000)); // effectively never resolves
|
||||||
case 'empty':
|
case 'empty':
|
||||||
return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));
|
// '[]' so the typed client parses it to an empty array (notes → Empty state).
|
||||||
|
return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400));
|
||||||
case 'error':
|
case 'error':
|
||||||
return timer(400).pipe(
|
return timer(400).pipe(
|
||||||
switchMap(() => throwError(() =>
|
switchMap(() => throwError(() =>
|
||||||
|
|||||||
115
src/app/shared/layout/wizard-shell/wizard-shell.component.ts
Normal file
115
src/app/shared/layout/wizard-shell/wizard-shell.component.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
|
||||||
|
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 { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||||
|
|
||||||
|
/** A flat validation error pointing at a field: `id` matches the field's anchor. */
|
||||||
|
export interface WizardError {
|
||||||
|
readonly id: string;
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template: the canonical shell every wizard renders into, so they cannot drift.
|
||||||
|
* It owns the consistent outline — stepper + focusable step heading + error
|
||||||
|
* summary + the <form> + the Back/Next/Cancel action bar + the submitting/
|
||||||
|
* submitted/failed states — and the a11y focus management.
|
||||||
|
*
|
||||||
|
* Presentational and unidirectional: all state stays in the wizard container
|
||||||
|
* (the Elm-style store). Inputs flow down; the container reacts to the outputs
|
||||||
|
* and dispatches messages. The step's own fields are projected as the default
|
||||||
|
* slot; the success screen is projected via [wizardSuccess].
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-wizard-shell',
|
||||||
|
imports: [ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
|
||||||
|
styles: [`
|
||||||
|
.es-title{margin:0 0 var(--rhc-space-max-sm)}
|
||||||
|
.es-list{margin:0;padding-inline-start:var(--rhc-space-max-xl)}
|
||||||
|
`],
|
||||||
|
template: `
|
||||||
|
@switch (status()) {
|
||||||
|
@case ('editing') {
|
||||||
|
<app-stepper class="app-section" [steps]="steps()" [current]="current()" />
|
||||||
|
<h2 #stepHeading tabindex="-1" class="rhc-heading nl-heading--level-2 app-section">{{ stepTitle() }}</h2>
|
||||||
|
@if (errors().length) {
|
||||||
|
<div #errorSummary tabindex="-1" role="alert" aria-labelledby="wizard-error-title" class="app-section">
|
||||||
|
<app-alert type="error">
|
||||||
|
<h3 id="wizard-error-title" class="rhc-heading nl-heading--level-3 es-title">Er ging iets mis met uw invoer</h3>
|
||||||
|
<ul class="es-list">
|
||||||
|
@for (e of errors(); track e.id) {
|
||||||
|
<li><a [href]="'#' + e.id">{{ e.message }}</a></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</app-alert>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<form (ngSubmit)="primary.emit()" class="app-form">
|
||||||
|
<ng-content />
|
||||||
|
<div class="app-button-row app-button-row--spaced">
|
||||||
|
@if (canGoBack()) {
|
||||||
|
<app-button type="button" variant="secondary" (click)="back.emit()">Vorige</app-button>
|
||||||
|
}
|
||||||
|
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
|
||||||
|
<app-button type="button" variant="subtle" (click)="cancel.emit()">Annuleren</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
@case ('submitting') {
|
||||||
|
<app-spinner /> <span>{{ submittingLabel() }}</span>
|
||||||
|
}
|
||||||
|
@case ('submitted') {
|
||||||
|
<ng-content select="[wizardSuccess]" />
|
||||||
|
}
|
||||||
|
@case ('failed') {
|
||||||
|
<app-alert type="error">{{ errorMessage() }}</app-alert>
|
||||||
|
<div class="app-section">
|
||||||
|
<app-button variant="secondary" (click)="retry.emit()">Opnieuw proberen</app-button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class WizardShellComponent {
|
||||||
|
steps = input.required<string[]>();
|
||||||
|
current = input.required<number>();
|
||||||
|
stepTitle = input.required<string>();
|
||||||
|
status = input.required<WizardStatus>();
|
||||||
|
primaryLabel = input.required<string>();
|
||||||
|
canGoBack = input(false);
|
||||||
|
errors = input<readonly WizardError[]>([]);
|
||||||
|
errorMessage = input('');
|
||||||
|
submittingLabel = input('Aanvraag wordt verwerkt…');
|
||||||
|
|
||||||
|
primary = output<void>();
|
||||||
|
back = output<void>();
|
||||||
|
cancel = output<void>();
|
||||||
|
retry = output<void>();
|
||||||
|
|
||||||
|
private stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
|
||||||
|
private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// A11y: move focus to the step heading when the step changes (skip first run
|
||||||
|
// so we don't grab focus on initial load). Tracks current(), which is value-
|
||||||
|
// stable across keystrokes, so typing never steals focus.
|
||||||
|
let firstStep = true;
|
||||||
|
effect(() => {
|
||||||
|
this.current();
|
||||||
|
if (firstStep) { firstStep = false; return; }
|
||||||
|
untracked(() => queueMicrotask(() => this.stepHeading()?.nativeElement.focus()));
|
||||||
|
});
|
||||||
|
// A11y: when validation errors appear (after a failed submit), move focus to
|
||||||
|
// the error summary so it's announced. The reducer returns a fresh errors
|
||||||
|
// object per attempt, so resubmitting the same invalid step re-announces.
|
||||||
|
let firstErr = true;
|
||||||
|
effect(() => {
|
||||||
|
const has = this.errors().length > 0;
|
||||||
|
if (firstErr) { firstErr = false; return; }
|
||||||
|
if (has) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/shared/layout/wizard-shell/wizard-shell.stories.ts
Normal file
37
src/app/shared/layout/wizard-shell/wizard-shell.stories.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/angular';
|
||||||
|
import { WizardShellComponent } from './wizard-shell.component';
|
||||||
|
|
||||||
|
const meta: Meta<WizardShellComponent> = {
|
||||||
|
title: 'Templates/WizardShell',
|
||||||
|
component: WizardShellComponent,
|
||||||
|
render: (args) => ({
|
||||||
|
props: args,
|
||||||
|
template: `
|
||||||
|
<app-wizard-shell
|
||||||
|
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [status]="status"
|
||||||
|
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors" [errorMessage]="errorMessage">
|
||||||
|
<p class="rhc-paragraph">Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).</p>
|
||||||
|
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
|
||||||
|
</app-wizard-shell>`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<WizardShellComponent>;
|
||||||
|
|
||||||
|
const steps = ['Adres', 'Beroep', 'Controle'];
|
||||||
|
const base = { steps, current: 1, stepTitle: 'Beroep op basis van uw diploma', primaryLabel: 'Volgende', canGoBack: true, errors: [], errorMessage: '' };
|
||||||
|
|
||||||
|
export const Editing: Story = { args: { ...base, status: 'editing' } };
|
||||||
|
export const EditingMetFouten: Story = {
|
||||||
|
args: {
|
||||||
|
...base,
|
||||||
|
status: 'editing',
|
||||||
|
errors: [
|
||||||
|
{ id: 'uren', message: 'Vul het aantal gewerkte uren in.' },
|
||||||
|
{ id: 'diploma', message: 'Kies een diploma.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const Submitting: Story = { args: { ...base, status: 'submitting' } };
|
||||||
|
export const Submitted: Story = { args: { ...base, status: 'submitted' } };
|
||||||
|
export const Failed: Story = { args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' } };
|
||||||
@@ -31,6 +31,15 @@ html, body { margin: 0; min-height: 100%; }
|
|||||||
.app-stack > * + * { margin-block-start: var(--rhc-space-max-xl); }
|
.app-stack > * + * { margin-block-start: var(--rhc-space-max-xl); }
|
||||||
.app-section { margin-block-start: var(--rhc-space-max-2xl); }
|
.app-section { margin-block-start: var(--rhc-space-max-2xl); }
|
||||||
.app-text-subtle { color: var(--rhc-color-foreground-subtle); }
|
.app-text-subtle { color: var(--rhc-color-foreground-subtle); }
|
||||||
|
/* responsive card grid: cards wrap and stretch, gap from the spacing scale */
|
||||||
|
.app-card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
gap: var(--rhc-space-max-xl);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
|
/* Route transitions (withViewTransitions): cross-fade the routed CONTENT only.
|
||||||
The chrome gets its own stable view-transition-name so it's lifted out of the
|
The chrome gets its own stable view-transition-name so it's lifted out of the
|
||||||
|
|||||||
Reference in New Issue
Block a user