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
@@ -0,0 +1,150 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { createHistory } from '@shared/application/history';
import {
ChangeCounts,
StamRow,
StamTable,
changeCounts,
isValid,
rowErrors,
toJson,
} from '@beheer/domain/stamdata';
import {
StamdataEditorMsg,
StamdataEditorState,
initial,
reduce,
} from '@beheer/domain/stamdata-editor.machine';
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
type LoadedState = Extract<StamdataEditorState, { tag: 'loaded' }>;
/**
* Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the
* draft rows; commands here load the catalog + a selected table and produce the download.
* There is deliberately NO save command — the reducer stays pure and the edit leaves as a
* downloaded JSON file that the admin drops into the repo (the CI build is the authority).
*/
@Injectable({ providedIn: 'root' })
export class StamdataStore {
private adapter = inject(StamdataAdapter);
private store = createStore<StamdataEditorState, StamdataEditorMsg>(initial, reduce);
readonly model = this.store.model;
readonly tables = signal<readonly StamTable[]>([]);
readonly selectedTableId = signal<string | null>(null);
/** Preview: show only rows valid on this date ('' = show all, editable). A local filter,
so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */
readonly previewDate = signal<string>('');
readonly remoteData = computed(() => machineRemoteData(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
});
readonly table = computed<StamTable | null>(() => this.loaded()?.table ?? null);
readonly rows = computed<readonly StamRow[]>(() => this.loaded()?.rows ?? []);
readonly errors = computed<readonly string[]>(() => {
const s = this.loaded();
return s ? rowErrors(s.table, s.rows) : [];
});
readonly counts = computed<ChangeCounts>(() => {
const s = this.loaded();
return s ? changeCounts(s.table, s.original, s.rows) : { added: 0, removed: 0, edited: 0 };
});
readonly dirty = computed(() => {
const c = this.counts();
return c.added + c.removed + c.edited > 0;
});
/** Download is blocked while previewing (the filtered view is not the full file) or while
any row has a format error (the CI gate would reject it anyway — fail fast here). */
readonly canDownload = computed(() => {
const s = this.loaded();
return this.previewDate() === '' && this.dirty() && s !== null && isValid(s.table, s.rows);
});
async load() {
this.store.dispatch({ tag: 'Loading' });
const list = await this.adapter.list();
if (!list.ok) {
this.store.dispatch({ tag: 'LoadFailed', reason: list.error });
return;
}
this.tables.set(list.value);
const first = list.value[0];
if (!first) {
this.store.dispatch({ tag: 'LoadFailed', reason: NO_TABLES });
return;
}
await this.selectTable(first.id);
}
async selectTable(tableId: string) {
this.selectedTableId.set(tableId);
this.previewDate.set('');
this.history.clear(); // undo history is per-table, not across tables
this.store.dispatch({ tag: 'Loading' });
const r = await this.adapter.load(tableId);
if (r.ok) this.store.dispatch({ tag: 'Loaded', table: r.value.table, rows: r.value.rows });
else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });
}
setPreviewDate(date: string) {
this.previewDate.set(date);
}
/** Undo/redo over the edited rows (WP-32): the document snapshot is `rows`; restore via
the existing `Seed` msg. Only real edits are recorded (a no-op reduce leaves no step). */
private history = createHistory<readonly StamRow[]>(50);
readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo;
private recordThenDispatch(msg: StamdataEditorMsg) {
const before = this.rows();
this.store.dispatch(msg);
if (this.loaded() && this.rows() !== before) this.history.record(before);
}
editCell(row: number, column: string, value: string) {
this.recordThenDispatch({ tag: 'CellEdited', row, column, value });
}
addRow() {
this.recordThenDispatch({ tag: 'RowAdded' });
}
removeRow(row: number) {
this.recordThenDispatch({ tag: 'RowRemoved', row });
}
undo() {
this.restore((rows) => this.history.undo(rows));
}
redo() {
this.restore((rows) => this.history.redo(rows));
}
private restore(step: (current: readonly StamRow[]) => readonly StamRow[] | undefined) {
const s = this.loaded();
if (!s) return;
const target = step(s.rows);
if (target === undefined) return;
// copy readonly history snapshot into the machine's mutable rows shape
this.store.dispatch({ tag: 'Seed', state: { ...s, rows: [...target] } });
}
/** Emit the edited data-file for the admin to drop into the repo (see domain `toJson`). */
download() {
const s = this.loaded();
if (!s || !this.canDownload()) return;
const blob = new Blob([toJson(s.table, s.rows)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${s.table.id}.json`;
a.click();
URL.revokeObjectURL(url);
}
}
const NO_TABLES = $localize`:@@beheer.noTables:Er is geen stamdata om te beheren.`;