feat(WP-67): merge behandelportal into this repo as a monorepo

Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
+161
View File
@@ -0,0 +1,161 @@
import {
Component,
Directive,
TemplateRef,
computed,
contentChild,
input,
output,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import type { Resource } from '@angular/core';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
/* Slot markers. Put on <ng-template> children of <app-async>. Generic so the
$implicit context is typed as the resource's T instead of unknown — see
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
host's own T through the query result type. */
@Directive({ selector: '[appAsyncLoaded]' })
export class AsyncLoadedDirective<T = unknown> {
constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
static ngTemplateContextGuard<T>(
_dir: AsyncLoadedDirective<T>,
_ctx: unknown,
): _ctx is { $implicit: T } {
return true;
}
}
@Directive({ selector: '[appAsyncLoading]' })
export class AsyncLoadingDirective {
constructor(public tpl: TemplateRef<unknown>) {}
}
@Directive({ selector: '[appAsyncEmpty]' })
export class AsyncEmptyDirective {
constructor(public tpl: TemplateRef<unknown>) {}
}
@Directive({ selector: '[appAsyncError]' })
export class AsyncErrorDirective {
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
}
/**
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
* resource (e.g. httpResource). Built on a RemoteData tagged union (see
* core/remote-data.ts), so the states are mutually exclusive by construction —
* the UI can never show two at once ("impossible states"). Unprovided slots
* fall back to sensible defaults.
*/
@Component({
selector: 'app-async',
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
template: `
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
@switch (rd().tag) {
@case ('Loading') {
@if (loadingTpl()) {
<ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" />
} @else {
<app-spinner />
}
}
@case ('Failure') {
@if (errorTpl()) {
<ng-container
[ngTemplateOutlet]="errorTpl()!.tpl"
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }"
/>
} @else {
<app-alert type="error">{{ errorText() }}</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
</div>
}
}
@case ('Empty') {
@if (emptyTpl()) {
<ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" />
} @else {
<p>{{ emptyText() }}</p>
}
}
@case ('Success') {
<ng-container
[ngTemplateOutlet]="loadedTpl().tpl"
[ngTemplateOutletContext]="{ $implicit: value() }"
/>
}
}
</div>
`,
})
export class AsyncComponent<T> {
// Two ways to feed this component:
// [resource] — a raw httpResource (the common case), or
// [data] — an already-combined RemoteData (e.g. from a store via map2).
resource = input<Resource<T>>();
data = input<RemoteData<Error | undefined, T>>();
isEmpty = input<(v: T) => boolean>(() => false);
// Shared/English component: copy lives behind language-agnostic inputs. Defaults are
// localizable via $localize (source = default locale, currently nl); callers may override.
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
retryText = input($localize`:@@async.retry:Opnieuw proberen`);
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
loadedTpl = contentChild.required<AsyncLoadedDirective<T>>(AsyncLoadedDirective);
loadingTpl = contentChild(AsyncLoadingDirective);
emptyTpl = contentChild(AsyncEmptyDirective);
errorTpl = contentChild(AsyncErrorDirective);
// Single source of truth: the supplied RemoteData, or the resource projected into one.
protected rd = computed<RemoteData<Error | undefined, T>>(() => {
const data = this.data();
if (data) return data;
const r = this.resource();
return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };
});
// value/error are pulled out via the exhaustive fold — only Success carries a
// value, only Failure carries an error, so these can't lie.
protected value = computed(() =>
foldRemote(this.rd(), {
loading: () => undefined,
empty: () => undefined,
failure: () => undefined,
success: (v) => v,
}),
);
protected error = computed(() =>
foldRemote(this.rd(), {
loading: () => undefined,
empty: () => undefined,
failure: (e) => e,
success: () => undefined,
}),
);
// [resource]-fed callers get reload() for free. [data]-fed callers (a store's
// combined RemoteData — the component doesn't own that resource) must reload
// it themselves; retryClicked is how they find out a retry was requested.
retryClicked = output<void>();
retry = () => {
const r = this.resource();
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
(r as { reload: () => void }).reload();
}
this.retryClicked.emit();
};
}
/** Convenience: import this array to get the wrapper + all slot directives. */
export const ASYNC = [
AsyncComponent,
AsyncLoadedDirective,
AsyncLoadingDirective,
AsyncEmptyDirective,
AsyncErrorDirective,
] as const;
+47
View File
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import type { Resource } from '@angular/core';
import { ASYNC } from './async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
/** Minimal fake of a signal Resource so the wrapper can be driven through every
state in isolation (no HTTP). */
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
return {
value: () => value as T,
status: () => status,
error: () => error,
hasValue: () => value !== undefined,
reload: () => {},
} as unknown as Resource<T>;
}
const meta: Meta = {
title: 'Design System/Molecules/Async States',
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
render: (args) => ({
// isEmpty is a function — Storybook strips function args, so set it here.
props: { ...args, isEmpty: (v: string[]) => !v || v.length === 0 },
template: `
<app-async [resource]="resource" [isEmpty]="isEmpty">
<ng-template appAsyncLoaded let-items>
<ul class="rhc-unordered-list">
@for (i of items; track i) { <li>{{ i }}</li> }
</ul>
</ng-template>
<ng-template appAsyncLoading><app-skeleton [count]="3" height="1.5rem" [delay]="0" /></ng-template>
<ng-template appAsyncEmpty><p class="rhc-paragraph">Geen items gevonden.</p></ng-template>
</app-async>`,
}),
};
export default meta;
type Story = StoryObj;
export const Loaded: Story = {
args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) },
};
export const Loading: Story = { args: { resource: fakeResource('loading') } };
export const Empty: Story = { args: { resource: fakeResource('resolved', [] as string[]) } };
export const ErrorState: Story = {
args: { resource: fakeResource('error', undefined, new Error('Demo')) },
};