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'; import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; type LoadedState = Extract; /** * 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 blobPresenter = inject(BLOB_PRESENTER); private store = createStore(initial, reduce); readonly model = this.store.model; readonly tables = signal([]); readonly selectedTableId = signal(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(''); readonly remoteData = computed(() => machineRemoteData(this.model())); private loaded = computed(() => { const s = this.model(); return s.tag === 'loaded' ? s : null; }); readonly table = computed(() => this.loaded()?.table ?? null); readonly rows = computed(() => this.loaded()?.rows ?? []); readonly errors = computed(() => { const s = this.loaded(); return s ? rowErrors(s.table, s.rows) : []; }); readonly counts = computed(() => { 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(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' }); this.blobPresenter.download(blob, `${s.table.id}.json`); } } const NO_TABLES = $localize`:@@beheer.noTables:Er is geen stamdata om te beheren.`;