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:
@@ -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.`;
|
||||
Reference in New Issue
Block a user