diff --git a/documentation.json b/documentation.json index d44f551..405d360 100644 --- a/documentation.json +++ b/documentation.json @@ -2624,12 +2624,12 @@ }, { "name": "DraftSnapshot", - "id": "interface-DraftSnapshot-55123d51a2676aadb15e8ab015deab7e0aeadca2977e31ae5c4331659bbcf56dc907991fd0d7cfa37b9b0f227c0b360edc455991e250a23ff6655c05cf139a71", + "id": "interface-DraftSnapshot-dba1103783441ee36b7d1ee7cd9660186f9efc9ca56ceaf3602cb6c86eca02d1aea89b2f47858d7af6bba2cedf1da11015a952f30d8a27f7a4d0b0a2c39e972e", "file": "src/app/registratie/application/draft-sync.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { DestroyRef, effect, inject } from '@angular/core';\nimport { ActivatedRoute, Router } from '@angular/router';\nimport { Result } from '@shared/kernel/fp';\nimport { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';\nimport type {\n SubmitApplicationRequest,\n SubmitApplicationResponse,\n} from '@shared/infrastructure/api-client';\nimport { AanvraagType } from '@registratie/domain/aanvraag';\nimport {\n ApplicationsAdapter,\n parseApplications,\n} from '@registratie/infrastructure/applications.adapter';\n\n/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */\nexport interface DraftSnapshot {\n draft: unknown;\n stepIndex: number;\n stepCount: number;\n documentIds: string[];\n}\n\nexport interface DraftSyncDeps {\n type: AanvraagType;\n /** The machine snapshot while it's worth persisting; null when not (pristine/done). */\n snapshot: () => DraftSnapshot | null;\n /** Seed the machine from a resumed draft. Called at most once, on init, and ONLY\n with a real draft on a still-pristine machine — see `applyResume`. */\n onResume: (draft: unknown) => void;\n /** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */\n enabled: () => boolean;\n}\n\nconst DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels laggy/chatty.\n\n/**\n * The effectful glue that replaces per-wizard sessionStorage with a backend-owned\n * Concept (PRD 0001, phase D). Instantiated in a field initializer (like\n * `createStore`/`createUploadController`). Responsibilities:\n *\n * - resume: a `?aanvraag=` link wins; otherwise resume the ONE existing Concept of\n * this type (at most one per type), seeding the machine from its saved draft;\n * - create-on-first-progress: when no Concept exists, one is created lazily the first\n * time the wizard reports a non-null snapshot, and its id is stamped into the URL;\n * - debounced draft sync on every subsequent change.\n *\n * Inert without a Router (stories) or when `enabled()` is false — no network, no resume.\n */\nexport function createDraftSync(deps: DraftSyncDeps) {\n const adapter = inject(ApplicationsAdapter);\n const router = inject(Router, { optional: true });\n const route = inject(ActivatedRoute, { optional: true });\n const active = () => deps.enabled() && !!router && !!route;\n\n let id: string | undefined;\n let ensuring: Promise | undefined; // in-flight create, so we never create twice\n let timer: ReturnType | undefined;\n // Resolves once resume() has decided whether a Concept of this type already exists;\n // gates ensureId so a fast typist can't create a duplicate before that lookup lands.\n let resumeGate: Promise = Promise.resolve();\n\n const ensureId = async (): Promise => {\n await resumeGate;\n if (id) return id;\n ensuring ??= adapter.create(deps.type).then((newId) => {\n id = newId;\n // Stamp the id into the URL (no navigation) so a reload resumes this Concept.\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: newId },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n return newId;\n });\n return ensuring;\n };\n\n // Apply a resumed draft only when it's safe to: a late lookup must never clobber\n // progress the user already made while it was in flight, and \"start fresh\" needs no\n // dispatch (the machine already starts fresh). snapshot() is non-null once the user\n // has real progress.\n const applyResume = (draft: unknown | null) => {\n if (draft == null || deps.snapshot() != null) return;\n deps.onResume(draft);\n };\n\n const flush = async () => {\n const snap = deps.snapshot();\n if (!snap) return;\n const theId = await ensureId();\n await adapter.syncDraft(theId, {\n draft: snap.draft,\n stepIndex: snap.stepIndex,\n stepCount: snap.stepCount,\n documentIds: snap.documentIds,\n });\n };\n\n // One effect watches the snapshot; each change resets a debounce timer. The timer's\n // callback only does network I/O (never dispatch), so it can't livelock the store.\n effect(() => {\n if (!active()) return;\n const snap = deps.snapshot(); // tracked: fires on every machine change\n if (!snap) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void flush(), DEBOUNCE_MS);\n });\n\n inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));\n\n // Attach to a specific Concept id and seed the machine from its draft. A non-Concept\n // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.\n const load = (linked: string): Promise => {\n id = linked;\n return adapter\n .detail(linked)\n .then((dto) => {\n if (dto.status && dto.status.tag !== 'Concept') {\n id = undefined;\n applyResume(null);\n return;\n }\n applyResume(dto.draft ?? null);\n })\n .catch(() => {\n id = undefined;\n applyResume(null); // unknown/deleted id → start fresh\n });\n };\n\n // Find the user's existing Concept of this type (at most one), if any.\n const findConcept = async (): Promise => {\n try {\n const parsed = parseApplications(await adapter.list());\n return parsed.ok\n ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id\n : undefined;\n } catch {\n return undefined;\n }\n };\n\n return {\n /** Resolve the initial state: a `?aanvraag` link wins; else resume this type's\n existing Concept; else start fresh (a Concept is created on first progress). */\n async resume() {\n let release!: () => void;\n resumeGate = new Promise((r) => (release = r));\n try {\n if (!active()) {\n applyResume(null);\n return;\n }\n const linked = route!.snapshot.queryParamMap.get('aanvraag');\n if (linked) {\n await load(linked);\n return;\n }\n const existing = await findConcept();\n if (existing) {\n await load(existing);\n // Stamp the id into the URL so a reload resumes the same Concept.\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: existing },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n return;\n }\n applyResume(null);\n } finally {\n release();\n }\n },\n\n /** Submit through the aanvraag lifecycle: ensure the Concept exists, then\n `POST /applications/{id}/submit` (server sets autoApprovable + transitions).\n Folded into a Result like the old submit-* commands. */\n submit(body: SubmitApplicationRequest): Promise> {\n return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);\n },\n\n /** Restart: discard the current in-progress Concept (delete it) and detach, so a\n fresh one is created on next progress. Keeps the one-per-type invariant. A\n submitted id can't be deleted (409, caught) — that submission correctly remains,\n and detaching still lets the user start a new Concept. */\n reset() {\n if (id) {\n void adapter.cancel(id).catch(() => {}); // Concept → deleted; submitted → 409, kept\n id = undefined;\n ensuring = undefined;\n }\n if (active())\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: null },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n },\n };\n}\n", + "sourceCode": "import { DestroyRef, effect, inject } from '@angular/core';\nimport { ActivatedRoute, Router } from '@angular/router';\nimport { Result } from '@shared/kernel/fp';\nimport { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';\nimport { registerPendingSave } from '@shared/application/pending-saves';\nimport type {\n SubmitApplicationRequest,\n SubmitApplicationResponse,\n} from '@shared/infrastructure/api-client';\nimport { AanvraagType } from '@registratie/domain/aanvraag';\nimport {\n ApplicationsAdapter,\n parseApplications,\n} from '@registratie/infrastructure/applications.adapter';\n\n/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */\nexport interface DraftSnapshot {\n draft: unknown;\n stepIndex: number;\n stepCount: number;\n documentIds: string[];\n}\n\nexport interface DraftSyncDeps {\n type: AanvraagType;\n /** The machine snapshot while it's worth persisting; null when not (pristine/done). */\n snapshot: () => DraftSnapshot | null;\n /** Seed the machine from a resumed draft. Called at most once, on init, and ONLY\n with a real draft on a still-pristine machine — see `applyResume`. */\n onResume: (draft: unknown) => void;\n /** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */\n enabled: () => boolean;\n}\n\nconst DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels laggy/chatty.\n\n/**\n * The effectful glue that replaces per-wizard sessionStorage with a backend-owned\n * Concept (PRD 0001, phase D). Instantiated in a field initializer (like\n * `createStore`/`createUploadController`). Responsibilities:\n *\n * - resume: a `?aanvraag=` link wins; otherwise resume the ONE existing Concept of\n * this type (at most one per type), seeding the machine from its saved draft;\n * - create-on-first-progress: when no Concept exists, one is created lazily the first\n * time the wizard reports a non-null snapshot, and its id is stamped into the URL;\n * - debounced draft sync on every subsequent change.\n *\n * Inert without a Router (stories) or when `enabled()` is false — no network, no resume.\n */\nexport function createDraftSync(deps: DraftSyncDeps) {\n const adapter = inject(ApplicationsAdapter);\n const router = inject(Router, { optional: true });\n const route = inject(ActivatedRoute, { optional: true });\n const active = () => deps.enabled() && !!router && !!route;\n\n let id: string | undefined;\n let ensuring: Promise | undefined; // in-flight create, so we never create twice\n let timer: ReturnType | undefined;\n // Resolves once resume() has decided whether a Concept of this type already exists;\n // gates ensureId so a fast typist can't create a duplicate before that lookup lands.\n let resumeGate: Promise = Promise.resolve();\n\n const ensureId = async (): Promise => {\n await resumeGate;\n if (id) return id;\n ensuring ??= adapter.create(deps.type).then((newId) => {\n id = newId;\n // Stamp the id into the URL (no navigation) so a reload resumes this Concept.\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: newId },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n return newId;\n });\n return ensuring;\n };\n\n // Apply a resumed draft only when it's safe to: a late lookup must never clobber\n // progress the user already made while it was in flight, and \"start fresh\" needs no\n // dispatch (the machine already starts fresh). snapshot() is non-null once the user\n // has real progress.\n const applyResume = (draft: unknown | null) => {\n if (draft == null || deps.snapshot() != null) return;\n deps.onResume(draft);\n };\n\n const flush = async () => {\n const snap = deps.snapshot();\n if (!snap) return;\n const theId = await ensureId();\n await adapter.syncDraft(theId, {\n draft: snap.draft,\n stepIndex: snap.stepIndex,\n stepCount: snap.stepCount,\n documentIds: snap.documentIds,\n });\n };\n\n // One effect watches the snapshot; each change resets a debounce timer. The timer's\n // callback only does network I/O (never dispatch), so it can't livelock the store.\n effect(() => {\n if (!active()) return;\n const snap = deps.snapshot(); // tracked: fires on every machine change\n if (!snap) return;\n if (timer) clearTimeout(timer);\n // Null the handle when it fires so `hasPendingSave()` reflects \"a write is still owed\".\n timer = setTimeout(() => {\n timer = undefined;\n void flush();\n }, DEBOUNCE_MS);\n });\n\n inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));\n\n // Flush a pending debounced draft write before an in-app route change / unload (see\n // pending-saves.ts). onDestroy above only cancels the timer — this actually persists it.\n const hasPendingSave = () => timer !== undefined;\n const flushPending = async () => {\n if (timer === undefined) return;\n clearTimeout(timer);\n timer = undefined;\n await flush();\n };\n registerPendingSave({ hasPendingSave, flushPending });\n\n // Attach to a specific Concept id and seed the machine from its draft. A non-Concept\n // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.\n const load = (linked: string): Promise => {\n id = linked;\n return adapter\n .detail(linked)\n .then((dto) => {\n if (dto.status && dto.status.tag !== 'Concept') {\n id = undefined;\n applyResume(null);\n return;\n }\n applyResume(dto.draft ?? null);\n })\n .catch(() => {\n id = undefined;\n applyResume(null); // unknown/deleted id → start fresh\n });\n };\n\n // Find the user's existing Concept of this type (at most one), if any.\n const findConcept = async (): Promise => {\n try {\n const parsed = parseApplications(await adapter.list());\n return parsed.ok\n ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id\n : undefined;\n } catch {\n return undefined;\n }\n };\n\n return {\n /** True while a debounced draft write is still pending (PendingSave). */\n hasPendingSave,\n /** Flush the pending draft write now and await it; no-op when nothing is pending. */\n flushPending,\n\n /** Resolve the initial state: a `?aanvraag` link wins; else resume this type's\n existing Concept; else start fresh (a Concept is created on first progress). */\n async resume() {\n let release!: () => void;\n resumeGate = new Promise((r) => (release = r));\n try {\n if (!active()) {\n applyResume(null);\n return;\n }\n const linked = route!.snapshot.queryParamMap.get('aanvraag');\n if (linked) {\n await load(linked);\n return;\n }\n const existing = await findConcept();\n if (existing) {\n await load(existing);\n // Stamp the id into the URL so a reload resumes the same Concept.\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: existing },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n return;\n }\n applyResume(null);\n } finally {\n release();\n }\n },\n\n /** Submit through the aanvraag lifecycle: ensure the Concept exists, then\n `POST /applications/{id}/submit` (server sets autoApprovable + transitions).\n Folded into a Result like the old submit-* commands. */\n submit(body: SubmitApplicationRequest): Promise> {\n return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);\n },\n\n /** Restart: discard the current in-progress Concept (delete it) and detach, so a\n fresh one is created on next progress. Keeps the one-per-type invariant. A\n submitted id can't be deleted (409, caught) — that submission correctly remains,\n and detaching still lets the user start a new Concept. */\n reset() {\n if (id) {\n void adapter.cancel(id).catch(() => {}); // Concept → deleted; submitted → 409, kept\n id = undefined;\n ensuring = undefined;\n }\n if (active())\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: null },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n },\n };\n}\n", "properties": [ { "name": "documentIds", @@ -2639,7 +2639,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 20 + "line": 21 }, { "name": "draft", @@ -2649,7 +2649,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 17 + "line": 18 }, { "name": "stepCount", @@ -2659,7 +2659,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 19 + "line": 20 }, { "name": "stepIndex", @@ -2669,7 +2669,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 18 + "line": 19 } ], "indexSignatures": [], @@ -2681,12 +2681,12 @@ }, { "name": "DraftSyncDeps", - "id": "interface-DraftSyncDeps-55123d51a2676aadb15e8ab015deab7e0aeadca2977e31ae5c4331659bbcf56dc907991fd0d7cfa37b9b0f227c0b360edc455991e250a23ff6655c05cf139a71", + "id": "interface-DraftSyncDeps-dba1103783441ee36b7d1ee7cd9660186f9efc9ca56ceaf3602cb6c86eca02d1aea89b2f47858d7af6bba2cedf1da11015a952f30d8a27f7a4d0b0a2c39e972e", "file": "src/app/registratie/application/draft-sync.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { DestroyRef, effect, inject } from '@angular/core';\nimport { ActivatedRoute, Router } from '@angular/router';\nimport { Result } from '@shared/kernel/fp';\nimport { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';\nimport type {\n SubmitApplicationRequest,\n SubmitApplicationResponse,\n} from '@shared/infrastructure/api-client';\nimport { AanvraagType } from '@registratie/domain/aanvraag';\nimport {\n ApplicationsAdapter,\n parseApplications,\n} from '@registratie/infrastructure/applications.adapter';\n\n/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */\nexport interface DraftSnapshot {\n draft: unknown;\n stepIndex: number;\n stepCount: number;\n documentIds: string[];\n}\n\nexport interface DraftSyncDeps {\n type: AanvraagType;\n /** The machine snapshot while it's worth persisting; null when not (pristine/done). */\n snapshot: () => DraftSnapshot | null;\n /** Seed the machine from a resumed draft. Called at most once, on init, and ONLY\n with a real draft on a still-pristine machine — see `applyResume`. */\n onResume: (draft: unknown) => void;\n /** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */\n enabled: () => boolean;\n}\n\nconst DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels laggy/chatty.\n\n/**\n * The effectful glue that replaces per-wizard sessionStorage with a backend-owned\n * Concept (PRD 0001, phase D). Instantiated in a field initializer (like\n * `createStore`/`createUploadController`). Responsibilities:\n *\n * - resume: a `?aanvraag=` link wins; otherwise resume the ONE existing Concept of\n * this type (at most one per type), seeding the machine from its saved draft;\n * - create-on-first-progress: when no Concept exists, one is created lazily the first\n * time the wizard reports a non-null snapshot, and its id is stamped into the URL;\n * - debounced draft sync on every subsequent change.\n *\n * Inert without a Router (stories) or when `enabled()` is false — no network, no resume.\n */\nexport function createDraftSync(deps: DraftSyncDeps) {\n const adapter = inject(ApplicationsAdapter);\n const router = inject(Router, { optional: true });\n const route = inject(ActivatedRoute, { optional: true });\n const active = () => deps.enabled() && !!router && !!route;\n\n let id: string | undefined;\n let ensuring: Promise | undefined; // in-flight create, so we never create twice\n let timer: ReturnType | undefined;\n // Resolves once resume() has decided whether a Concept of this type already exists;\n // gates ensureId so a fast typist can't create a duplicate before that lookup lands.\n let resumeGate: Promise = Promise.resolve();\n\n const ensureId = async (): Promise => {\n await resumeGate;\n if (id) return id;\n ensuring ??= adapter.create(deps.type).then((newId) => {\n id = newId;\n // Stamp the id into the URL (no navigation) so a reload resumes this Concept.\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: newId },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n return newId;\n });\n return ensuring;\n };\n\n // Apply a resumed draft only when it's safe to: a late lookup must never clobber\n // progress the user already made while it was in flight, and \"start fresh\" needs no\n // dispatch (the machine already starts fresh). snapshot() is non-null once the user\n // has real progress.\n const applyResume = (draft: unknown | null) => {\n if (draft == null || deps.snapshot() != null) return;\n deps.onResume(draft);\n };\n\n const flush = async () => {\n const snap = deps.snapshot();\n if (!snap) return;\n const theId = await ensureId();\n await adapter.syncDraft(theId, {\n draft: snap.draft,\n stepIndex: snap.stepIndex,\n stepCount: snap.stepCount,\n documentIds: snap.documentIds,\n });\n };\n\n // One effect watches the snapshot; each change resets a debounce timer. The timer's\n // callback only does network I/O (never dispatch), so it can't livelock the store.\n effect(() => {\n if (!active()) return;\n const snap = deps.snapshot(); // tracked: fires on every machine change\n if (!snap) return;\n if (timer) clearTimeout(timer);\n timer = setTimeout(() => void flush(), DEBOUNCE_MS);\n });\n\n inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));\n\n // Attach to a specific Concept id and seed the machine from its draft. A non-Concept\n // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.\n const load = (linked: string): Promise => {\n id = linked;\n return adapter\n .detail(linked)\n .then((dto) => {\n if (dto.status && dto.status.tag !== 'Concept') {\n id = undefined;\n applyResume(null);\n return;\n }\n applyResume(dto.draft ?? null);\n })\n .catch(() => {\n id = undefined;\n applyResume(null); // unknown/deleted id → start fresh\n });\n };\n\n // Find the user's existing Concept of this type (at most one), if any.\n const findConcept = async (): Promise => {\n try {\n const parsed = parseApplications(await adapter.list());\n return parsed.ok\n ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id\n : undefined;\n } catch {\n return undefined;\n }\n };\n\n return {\n /** Resolve the initial state: a `?aanvraag` link wins; else resume this type's\n existing Concept; else start fresh (a Concept is created on first progress). */\n async resume() {\n let release!: () => void;\n resumeGate = new Promise((r) => (release = r));\n try {\n if (!active()) {\n applyResume(null);\n return;\n }\n const linked = route!.snapshot.queryParamMap.get('aanvraag');\n if (linked) {\n await load(linked);\n return;\n }\n const existing = await findConcept();\n if (existing) {\n await load(existing);\n // Stamp the id into the URL so a reload resumes the same Concept.\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: existing },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n return;\n }\n applyResume(null);\n } finally {\n release();\n }\n },\n\n /** Submit through the aanvraag lifecycle: ensure the Concept exists, then\n `POST /applications/{id}/submit` (server sets autoApprovable + transitions).\n Folded into a Result like the old submit-* commands. */\n submit(body: SubmitApplicationRequest): Promise> {\n return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);\n },\n\n /** Restart: discard the current in-progress Concept (delete it) and detach, so a\n fresh one is created on next progress. Keeps the one-per-type invariant. A\n submitted id can't be deleted (409, caught) — that submission correctly remains,\n and detaching still lets the user start a new Concept. */\n reset() {\n if (id) {\n void adapter.cancel(id).catch(() => {}); // Concept → deleted; submitted → 409, kept\n id = undefined;\n ensuring = undefined;\n }\n if (active())\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: null },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n },\n };\n}\n", + "sourceCode": "import { DestroyRef, effect, inject } from '@angular/core';\nimport { ActivatedRoute, Router } from '@angular/router';\nimport { Result } from '@shared/kernel/fp';\nimport { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';\nimport { registerPendingSave } from '@shared/application/pending-saves';\nimport type {\n SubmitApplicationRequest,\n SubmitApplicationResponse,\n} from '@shared/infrastructure/api-client';\nimport { AanvraagType } from '@registratie/domain/aanvraag';\nimport {\n ApplicationsAdapter,\n parseApplications,\n} from '@registratie/infrastructure/applications.adapter';\n\n/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */\nexport interface DraftSnapshot {\n draft: unknown;\n stepIndex: number;\n stepCount: number;\n documentIds: string[];\n}\n\nexport interface DraftSyncDeps {\n type: AanvraagType;\n /** The machine snapshot while it's worth persisting; null when not (pristine/done). */\n snapshot: () => DraftSnapshot | null;\n /** Seed the machine from a resumed draft. Called at most once, on init, and ONLY\n with a real draft on a still-pristine machine — see `applyResume`. */\n onResume: (draft: unknown) => void;\n /** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */\n enabled: () => boolean;\n}\n\nconst DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels laggy/chatty.\n\n/**\n * The effectful glue that replaces per-wizard sessionStorage with a backend-owned\n * Concept (PRD 0001, phase D). Instantiated in a field initializer (like\n * `createStore`/`createUploadController`). Responsibilities:\n *\n * - resume: a `?aanvraag=` link wins; otherwise resume the ONE existing Concept of\n * this type (at most one per type), seeding the machine from its saved draft;\n * - create-on-first-progress: when no Concept exists, one is created lazily the first\n * time the wizard reports a non-null snapshot, and its id is stamped into the URL;\n * - debounced draft sync on every subsequent change.\n *\n * Inert without a Router (stories) or when `enabled()` is false — no network, no resume.\n */\nexport function createDraftSync(deps: DraftSyncDeps) {\n const adapter = inject(ApplicationsAdapter);\n const router = inject(Router, { optional: true });\n const route = inject(ActivatedRoute, { optional: true });\n const active = () => deps.enabled() && !!router && !!route;\n\n let id: string | undefined;\n let ensuring: Promise | undefined; // in-flight create, so we never create twice\n let timer: ReturnType | undefined;\n // Resolves once resume() has decided whether a Concept of this type already exists;\n // gates ensureId so a fast typist can't create a duplicate before that lookup lands.\n let resumeGate: Promise = Promise.resolve();\n\n const ensureId = async (): Promise => {\n await resumeGate;\n if (id) return id;\n ensuring ??= adapter.create(deps.type).then((newId) => {\n id = newId;\n // Stamp the id into the URL (no navigation) so a reload resumes this Concept.\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: newId },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n return newId;\n });\n return ensuring;\n };\n\n // Apply a resumed draft only when it's safe to: a late lookup must never clobber\n // progress the user already made while it was in flight, and \"start fresh\" needs no\n // dispatch (the machine already starts fresh). snapshot() is non-null once the user\n // has real progress.\n const applyResume = (draft: unknown | null) => {\n if (draft == null || deps.snapshot() != null) return;\n deps.onResume(draft);\n };\n\n const flush = async () => {\n const snap = deps.snapshot();\n if (!snap) return;\n const theId = await ensureId();\n await adapter.syncDraft(theId, {\n draft: snap.draft,\n stepIndex: snap.stepIndex,\n stepCount: snap.stepCount,\n documentIds: snap.documentIds,\n });\n };\n\n // One effect watches the snapshot; each change resets a debounce timer. The timer's\n // callback only does network I/O (never dispatch), so it can't livelock the store.\n effect(() => {\n if (!active()) return;\n const snap = deps.snapshot(); // tracked: fires on every machine change\n if (!snap) return;\n if (timer) clearTimeout(timer);\n // Null the handle when it fires so `hasPendingSave()` reflects \"a write is still owed\".\n timer = setTimeout(() => {\n timer = undefined;\n void flush();\n }, DEBOUNCE_MS);\n });\n\n inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));\n\n // Flush a pending debounced draft write before an in-app route change / unload (see\n // pending-saves.ts). onDestroy above only cancels the timer — this actually persists it.\n const hasPendingSave = () => timer !== undefined;\n const flushPending = async () => {\n if (timer === undefined) return;\n clearTimeout(timer);\n timer = undefined;\n await flush();\n };\n registerPendingSave({ hasPendingSave, flushPending });\n\n // Attach to a specific Concept id and seed the machine from its draft. A non-Concept\n // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.\n const load = (linked: string): Promise => {\n id = linked;\n return adapter\n .detail(linked)\n .then((dto) => {\n if (dto.status && dto.status.tag !== 'Concept') {\n id = undefined;\n applyResume(null);\n return;\n }\n applyResume(dto.draft ?? null);\n })\n .catch(() => {\n id = undefined;\n applyResume(null); // unknown/deleted id → start fresh\n });\n };\n\n // Find the user's existing Concept of this type (at most one), if any.\n const findConcept = async (): Promise => {\n try {\n const parsed = parseApplications(await adapter.list());\n return parsed.ok\n ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id\n : undefined;\n } catch {\n return undefined;\n }\n };\n\n return {\n /** True while a debounced draft write is still pending (PendingSave). */\n hasPendingSave,\n /** Flush the pending draft write now and await it; no-op when nothing is pending. */\n flushPending,\n\n /** Resolve the initial state: a `?aanvraag` link wins; else resume this type's\n existing Concept; else start fresh (a Concept is created on first progress). */\n async resume() {\n let release!: () => void;\n resumeGate = new Promise((r) => (release = r));\n try {\n if (!active()) {\n applyResume(null);\n return;\n }\n const linked = route!.snapshot.queryParamMap.get('aanvraag');\n if (linked) {\n await load(linked);\n return;\n }\n const existing = await findConcept();\n if (existing) {\n await load(existing);\n // Stamp the id into the URL so a reload resumes the same Concept.\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: existing },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n return;\n }\n applyResume(null);\n } finally {\n release();\n }\n },\n\n /** Submit through the aanvraag lifecycle: ensure the Concept exists, then\n `POST /applications/{id}/submit` (server sets autoApprovable + transitions).\n Folded into a Result like the old submit-* commands. */\n submit(body: SubmitApplicationRequest): Promise> {\n return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);\n },\n\n /** Restart: discard the current in-progress Concept (delete it) and detach, so a\n fresh one is created on next progress. Keeps the one-per-type invariant. A\n submitted id can't be deleted (409, caught) — that submission correctly remains,\n and detaching still lets the user start a new Concept. */\n reset() {\n if (id) {\n void adapter.cancel(id).catch(() => {}); // Concept → deleted; submitted → 409, kept\n id = undefined;\n ensuring = undefined;\n }\n if (active())\n void router!.navigate([], {\n relativeTo: route!,\n queryParams: { aanvraag: null },\n queryParamsHandling: 'merge',\n replaceUrl: true,\n });\n },\n };\n}\n", "properties": [ { "name": "enabled", @@ -2696,7 +2696,7 @@ "indexKey": "", "optional": false, "description": "

Draft-sync only runs in the real app — false in Storybook/tests (explicit seed).

\n", - "line": 31, + "line": 32, "rawdescription": "\nDraft-sync only runs in the real app — false in Storybook/tests (explicit seed)." }, { @@ -2707,7 +2707,7 @@ "indexKey": "", "optional": false, "description": "

Seed the machine from a resumed draft. Called at most once, on init, and ONLY\nwith a real draft on a still-pristine machine — see applyResume.

\n", - "line": 29, + "line": 30, "rawdescription": "\nSeed the machine from a resumed draft. Called at most once, on init, and ONLY\nwith a real draft on a still-pristine machine — see `applyResume`." }, { @@ -2718,7 +2718,7 @@ "indexKey": "", "optional": false, "description": "

The machine snapshot while it's worth persisting; null when not (pristine/done).

\n", - "line": 26, + "line": 27, "rawdescription": "\nThe machine snapshot while it's worth persisting; null when not (pristine/done)." }, { @@ -2729,7 +2729,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 24 + "line": 25 } ], "indexSignatures": [], @@ -4706,6 +4706,47 @@ "methods": [], "extends": [] }, + { + "name": "PendingSave", + "id": "interface-PendingSave-2727407341bf27a69b5552e48492a92b5bb54df722ffb29bbcc0c427a84e69504b1a2f84c22487134702040754c185bd1ee35db43abfec753fa42630eeec81e8", + "file": "src/app/shared/application/pending-saves.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "interface", + "sourceCode": "import {\n DestroyRef,\n ENVIRONMENT_INITIALIZER,\n Injectable,\n inject,\n} from '@angular/core';\nimport { CanDeactivateFn } from '@angular/router';\n\n/**\n * A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in\n * this app have different lifetimes — root singleton stores (`BriefStore`,\n * `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child\n * organisms — so both register here instead of the guard/unload handler needing to know\n * which page or store owns the pending write.\n */\nexport interface PendingSave {\n /** True while a debounced edit hasn't been written to the backend yet. */\n hasPendingSave(): boolean;\n /** Flush that pending write now and await it. No-op when nothing is pending. */\n flushPending(): Promise;\n}\n\n/** Registry of every active autosave owner. The `CanDeactivate` guard and the\n `beforeunload` handler flush through this — one seam, both callers. */\n@Injectable({ providedIn: 'root' })\nexport class PendingSaves {\n private readonly owners = new Set();\n\n /** Register an owner; returns an unregister function. */\n register(owner: PendingSave): () => void {\n this.owners.add(owner);\n return () => this.owners.delete(owner);\n }\n\n hasPending(): boolean {\n return [...this.owners].some((o) => o.hasPendingSave());\n }\n\n /** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected\n flush is swallowed (a failed autosave surfaces its own error state; navigation must\n not be blocked by it). */\n async flushAll(): Promise {\n await Promise.allSettled(\n [...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),\n );\n }\n}\n\n/** Register the current injection context's owner for the life of its `DestroyRef`.\n Call from a constructor or field initializer (root store, or `createDraftSync`). */\nexport function registerPendingSave(owner: PendingSave): void {\n const unregister = inject(PendingSaves).register(owner);\n inject(DestroyRef).onDestroy(unregister);\n}\n\n/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,\n then allow navigation. Awaitable, so the write lands before the page tears down (which\n would otherwise drop a sub-debounce edit). We never block leaving — the flush is a\n guarantee of effort, not a gate. */\nexport const flushPendingGuard: CanDeactivateFn = () => {\n const pending = inject(PendingSaves);\n return pending.hasPending() ? pending.flushAll().then(() => true) : true;\n};\n\n/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.\n ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an\n async flush can't be guaranteed to finish as the page tears down — we fire it best-effort\n AND trigger the browser's native \"unsaved changes\" prompt, which lets the ~600ms debounce\n land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever\n needs to be guaranteed. */\nexport function provideUnloadFlush() {\n return {\n provide: ENVIRONMENT_INITIALIZER,\n multi: true,\n useValue: () => {\n const pending = inject(PendingSaves);\n window.addEventListener('beforeunload', (e) => {\n if (!pending.hasPending()) return;\n void pending.flushAll();\n e.preventDefault();\n e.returnValue = '';\n });\n },\n };\n}\n", + "properties": [], + "indexSignatures": [], + "kind": 174, + "description": "

A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in\nthis app have different lifetimes — root singleton stores (BriefStore,\nOrgTemplateStore) and per-wizard createDraftSync controllers living inside child\norganisms — so both register here instead of the guard/unload handler needing to know\nwhich page or store owns the pending write.

\n", + "rawdescription": "\n\nA source of debounced, not-yet-flushed writes (autosave). The two autosave owners in\nthis app have different lifetimes — root singleton stores (`BriefStore`,\n`OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child\norganisms — so both register here instead of the guard/unload handler needing to know\nwhich page or store owns the pending write.\n", + "methods": [ + { + "name": "flushPending", + "args": [], + "optional": false, + "returnType": "Promise", + "typeParameters": [], + "line": 20, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nFlush that pending write now and await it. No-op when nothing is pending.", + "description": "

Flush that pending write now and await it. No-op when nothing is pending.

\n" + }, + { + "name": "hasPendingSave", + "args": [], + "optional": false, + "returnType": "boolean", + "typeParameters": [], + "line": 18, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nTrue while a debounced edit hasn't been written to the backend yet.", + "description": "

True while a debounced edit hasn't been written to the backend yet.

\n" + } + ], + "extends": [] + }, { "name": "Person", "id": "interface-Person-07cec46d80a41919e40d0bcb002981627d1a30edb363d64a0a140a8af2c38ad85be5603d88030d0704870800694c5bed9c709ae6891fe83e646e90c1a67cf548", @@ -8620,7 +8661,7 @@ }, { "name": "BriefStore", - "id": "injectable-BriefStore-9da94deff6aceabc96670203b721d0828ba28ca220cbf5e875a610cb8187ef0062ce844156156fe290b821262dee437400648892e4f916db5d8d29eca0d4b086", + "id": "injectable-BriefStore-16e7ee051c2b3ad5c6544d54e37fe9fb5fd45103f760066b24a5e178a725ff6d8434c3ba13a5c7458bff940751e6ff7c5e2a92307f4a828ebe54d59875c8fe4c", "file": "src/app/brief/application/brief.store.ts", "properties": [ { @@ -8632,7 +8673,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 49, + "line": 50, "modifierKind": [ 123 ] @@ -8646,7 +8687,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 42, + "line": 43, "modifierKind": [ 123 ] @@ -8660,7 +8701,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 235 + "line": 257 }, { "name": "blockDiffs", @@ -8671,7 +8712,7 @@ "indexKey": "", "optional": false, "description": "

Changed/added/removed blocks since rejection — a pure fold over two snapshots.

\n", - "line": 76, + "line": 77, "rawdescription": "\nChanged/added/removed blocks since rejection — a pure fold over two snapshots.", "modifierKind": [ 148 @@ -8686,7 +8727,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 118, + "line": 119, "modifierKind": [ 123 ] @@ -8700,7 +8741,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 50, + "line": 51, "modifierKind": [ 148 ] @@ -8714,7 +8755,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 124, + "line": 125, "modifierKind": [ 148 ] @@ -8728,7 +8769,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 123, + "line": 124, "modifierKind": [ 148 ] @@ -8742,7 +8783,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 69, + "line": 70, "modifierKind": [ 148 ] @@ -8756,7 +8797,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 125, + "line": 126, "modifierKind": [ 148 ] @@ -8770,7 +8811,7 @@ "indexKey": "", "optional": false, "description": "

Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates.

\n", - "line": 128, + "line": 129, "rawdescription": "\nField-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates.", "modifierKind": [ 148 @@ -8785,7 +8826,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 126, + "line": 127, "modifierKind": [ 148 ] @@ -8799,7 +8840,7 @@ "indexKey": "", "optional": false, "description": "

Submit is allowed only when required sections are filled AND no blocking errors.

\n", - "line": 137, + "line": 138, "rawdescription": "\nSubmit is allowed only when required sections are filled AND no blocking errors.", "modifierKind": [ 148 @@ -8814,7 +8855,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 68, + "line": 69, "modifierKind": [ 148 ] @@ -8828,7 +8869,7 @@ "indexKey": "", "optional": false, "description": "

The case (zorgverlener + aanvraag) this letter concerns — server-joined context for\nthe behandel scherm header, not letter state. Set from every server view.

\n", - "line": 95, + "line": 96, "rawdescription": "\nThe case (zorgverlener + aanvraag) this letter concerns — server-joined context for\nthe behandel scherm header, not letter state. Set from every server view.", "modifierKind": [ 148 @@ -8843,7 +8884,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 130, + "line": 131, "modifierKind": [ 123 ] @@ -8857,7 +8898,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 134, + "line": 135, "modifierKind": [ 148 ] @@ -8871,11 +8912,23 @@ "indexKey": "", "optional": false, "description": "", - "line": 67, + "line": 68, "modifierKind": [ 123 ] }, + { + "name": "hasPendingSave", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

True while a debounced edit hasn't been written yet (PendingSave).

\n", + "line": 211, + "rawdescription": "\nTrue while a debounced edit hasn't been written yet (PendingSave)." + }, { "name": "hasRejectionDiff", "defaultValue": "computed(() => this.blockDiffs().size > 0)", @@ -8885,7 +8938,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 86, + "line": 87, "modifierKind": [ 148 ] @@ -8899,7 +8952,7 @@ "indexKey": "", "optional": false, "description": "

Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future\nBrief snapshots. Each is a deep-frozen immutable value, so sharing is safe.\nOnly CONTENT edits are recorded (they flow through edit()); status transitions\nnever enter history, or undo would replay workflow state. Capped so a long session\ncan't grow unbounded. Restore re-dispatches the existing Seed Msg — zero machine\nchanges.

\n", - "line": 65, + "line": 66, "rawdescription": "\nUndo/redo is SHELL state, not machine state (WP-27): a stack of past/future\n`Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.\nOnly CONTENT edits are recorded (they flow through `edit()`); status transitions\nnever enter history, or undo would replay workflow state. Capped so a long session\ncan't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine\nchanges.", "modifierKind": [ 123, @@ -8916,7 +8969,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 51, + "line": 52, "modifierKind": [ 148 ] @@ -8930,7 +8983,7 @@ "indexKey": "", "optional": false, "description": "

The org logo's content URL for the letterhead, or null when the template has none.

\n", - "line": 98, + "line": 99, "rawdescription": "\nThe org logo's content URL for the letterhead, or null when the template has none.", "modifierKind": [ 148 @@ -8945,7 +8998,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 47, + "line": 48, "modifierKind": [ 148 ] @@ -8959,7 +9012,7 @@ "indexKey": "", "optional": false, "description": "

The org template the letter renders with (WP-24). Server-owned appearance data,\nnot letter state — held beside the machine, never inside it (brief.machine.ts\nstays untouched by design). Set from every server view that carries it.

\n", - "line": 91, + "line": 92, "rawdescription": "\nThe org template the letter renders with (WP-24). Server-owned appearance data,\nnot letter state — held beside the machine, never inside it (`brief.machine.ts`\nstays untouched by design). Set from every server view that carries it.", "modifierKind": [ 148 @@ -8974,7 +9027,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 66, + "line": 67, "modifierKind": [ 123 ] @@ -8988,7 +9041,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 43, + "line": 44, "modifierKind": [ 123 ] @@ -9002,7 +9055,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 236 + "line": 258 }, { "name": "rejectionSnapshot", @@ -9013,7 +9066,7 @@ "indexKey": "", "optional": false, "description": "

The letter as it stood when it was REJECTED, captured shell-side (WP-27). The\napprover diffs it against the resubmitted letter. POC limit: in-memory only, so a\nfull page reload loses it — a real system would persist the rejected revision.

\n", - "line": 74, + "line": 75, "rawdescription": "\nThe letter as it stood when it was REJECTED, captured shell-side (WP-27). The\napprover diffs it against the resubmitted letter. POC limit: in-memory only, so a\nfull page reload loses it — a real system would persist the rejected revision.", "modifierKind": [ 123 @@ -9028,7 +9081,7 @@ "indexKey": "", "optional": false, "description": "

The load lifecycle as RemoteData, for <app-async> — the machine keeps\nowning the letter's own domain lifecycle (draft/submitted/approved/…); this is\npurely a projection of its loading/failed tags onto the shared async seam.

\n", - "line": 106, + "line": 107, "rawdescription": "\nThe load lifecycle as `RemoteData`, for `` — the machine keeps\nowning the letter's own domain lifecycle (draft/submitted/approved/…); this is\npurely a projection of its loading/failed tags onto the shared async seam.", "modifierKind": [ 148 @@ -9043,7 +9096,7 @@ "indexKey": "", "optional": false, "description": "

Count of blocks removed since rejection — badged as a summary, since a removed\nblock no longer renders inline.

\n", - "line": 83, + "line": 84, "rawdescription": "\nCount of blocks removed since rejection — badged as a summary, since a removed\nblock no longer renders inline.", "modifierKind": [ 148 @@ -9058,7 +9111,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 44, + "line": 45, "modifierKind": [ 123 ] @@ -9072,7 +9125,7 @@ "indexKey": "", "optional": false, "description": "

Surfaced autosave state for the indicator + aria-live region.

\n", - "line": 57, + "line": 58, "rawdescription": "\nSurfaced autosave state for the indicator + aria-live region.", "modifierKind": [ 148 @@ -9086,7 +9139,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 191, + "line": 198, "modifierKind": [ 123 ] @@ -9100,7 +9153,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 237 + "line": 259 }, { "name": "store", @@ -9111,7 +9164,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 45, + "line": 46, "modifierKind": [ 123 ] @@ -9125,7 +9178,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 234 + "line": 256 }, { "name": "unresolved", @@ -9136,7 +9189,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 135, + "line": 136, "modifierKind": [ 148 ] @@ -9158,7 +9211,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 281, + "line": 304, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9184,7 +9237,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 186, + "line": 187, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9206,7 +9259,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 157, + "line": 158, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nAn edit: apply it optimistically in the pure reducer, then debounce-save. Records\nan undo step only when the reducer actually changed the brief (a no-op edit — e.g.\na locked section — returns the same value and leaves no dead history step).", @@ -9225,13 +9278,28 @@ } ] }, + { + "name": "flushPending", + "args": [], + "optional": false, + "returnType": "any", + "typeParameters": [], + "line": 213, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nFlush a pending debounced save now and await it; no-op when nothing is pending.", + "description": "

Flush a pending debounced save now and await it; no-op when nothing is pending.

\n", + "modifierKind": [ + 134 + ] + }, { "name": "flushSave", "args": [], "optional": false, "returnType": "any", "typeParameters": [], - "line": 198, + "line": 219, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9245,7 +9313,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 142, + "line": 143, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9258,7 +9326,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 242, + "line": 264, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nExplicit action, never a live re-render (PRD §8): opens the server-composed\nletter in a new tab. ponytail: the blob URL is never revoked — it's cheap and\nthe tab outlives this call; not worth a teardown hook for a POC.", @@ -9273,7 +9341,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 173, + "line": 174, "deprecated": false, "deprecationMessage": "" }, @@ -9283,7 +9351,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 217, + "line": 238, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nDemo \"start over\": recreate the brief server-side and load the fresh view.", @@ -9298,7 +9366,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 212, + "line": 233, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nRetry a failed autosave — reuses the existing flush path, no new state (WP-27).", @@ -9310,7 +9378,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 257, + "line": 279, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nReveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability\n+ step-up and audits the attempt; on success we swap the masked value in the\nalready-loaded caseContext (a field update, not a reload). The step-up gesture\nitself is the UI's concern — this command just runs the audited server call.", @@ -9325,7 +9393,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 192, + "line": 199, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9355,7 +9423,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 176, + "line": 177, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9402,7 +9470,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 268, + "line": 290, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -9430,7 +9498,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 170, + "line": 171, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nUndo: restore the previous snapshot via the existing `Seed` Msg, push the current\nonto the redo stack, then autosave. Redo is the mirror image.", @@ -9441,7 +9509,15 @@ "deprecationMessage": "", "description": "

Root singleton for the letter: the Elm store (Model + dispatch), the derived\nread-model, and the commands (effects) that call the adapter and dispatch the\noutcome. Mirrors BigProfileStore. All of canEdit/canApprove/canReject/\ncanSend, diagnostics, unresolved, canSubmit are DERIVED here — never\nstored. The permission flags come from the server's decision DTO (PRD-0002 phase\nP1) via BriefState.loaded.decisions — this store never computes them itself.

\n", "rawdescription": "\n\nRoot singleton for the letter: the Elm store (Model + dispatch), the derived\nread-model, and the commands (effects) that call the adapter and dispatch the\noutcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n`canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\nstored. The permission flags come from the server's decision DTO (PRD-0002 phase\nP1) via `BriefState.loaded.decisions` — this store never computes them itself.\n", - "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { Result } from '@shared/kernel/fp';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { createStore } from '@shared/application/store';\nimport {\n Brief,\n CaseContext,\n allDiagnostics,\n canSubmit,\n hasBlockingErrors,\n unresolvedPlaceholders,\n} from '@brief/domain/brief';\nimport { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';\nimport { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';\nimport { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';\nimport { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';\nimport { uploadContentUrl } from '@shared/upload/upload.adapter';\n\n/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\n instead of a busy boolean + a nullable error sitting side by side. */\ntype ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };\n\n/** Debounced-autosave indicator, shown in a small status line near the toolbar —\n a separate concern from ActionState (a stale autosave error doesn't block\n submit/approve/reject), but tag-aligned with it for one consistent idiom. */\ntype SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };\n\ntype LoadedBriefState = Extract;\n\n/**\n * Root singleton for the letter: the Elm store (Model + dispatch), the derived\n * read-model, and the commands (effects) that call the adapter and dispatch the\n * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\n * stored. The permission flags come from the server's decision DTO (PRD-0002 phase\n * P1) via `BriefState.loaded.decisions` — this store never computes them itself.\n */\n@Injectable({ providedIn: 'root' })\nexport class BriefStore {\n private adapter = inject(BriefAdapter);\n private previewAdapter = inject(LetterPreviewAdapter);\n private revealAdapter = inject(RevealBigNummerAdapter);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n\n /** Surfaced autosave state for the indicator + aria-live region. */\n readonly saveState = signal({ tag: 'Idle' });\n\n /** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future\n `Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.\n Only CONTENT edits are recorded (they flow through `edit()`); status transitions\n never enter history, or undo would replay workflow state. Capped so a long session\n can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine\n changes. */\n private static readonly HISTORY_CAP = 50;\n private past = signal([]);\n private future = signal([]);\n readonly canUndo = computed(() => this.past().length > 0);\n readonly canRedo = computed(() => this.future().length > 0);\n\n /** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The\n approver diffs it against the resubmitted letter. POC limit: in-memory only, so a\n full page reload loses it — a real system would persist the rejected revision. */\n private rejectionSnapshot = signal(null);\n /** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */\n readonly blockDiffs = computed>(() => {\n const before = this.rejectionSnapshot();\n const after = this.brief();\n return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();\n });\n /** Count of blocks removed since rejection — badged as a summary, since a removed\n block no longer renders inline. */\n readonly removedSinceReject = computed(\n () => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,\n );\n readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);\n\n /** The org template the letter renders with (WP-24). Server-owned appearance data,\n not letter state — held beside the machine, never inside it (`brief.machine.ts`\n stays untouched by design). Set from every server view that carries it. */\n readonly orgTemplate = signal(null);\n\n /** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for\n the behandel scherm header, not letter state. Set from every server view. */\n readonly caseContext = signal(null);\n\n /** The org logo's content URL for the letterhead, or null when the template has none. */\n readonly logoUrl = computed(() => {\n const id = this.orgTemplate()?.logoDocumentId;\n return id ? uploadContentUrl(id) : null;\n });\n\n /** The load lifecycle as `RemoteData`, for `` — the machine keeps\n owning the letter's own domain lifecycle (draft/submitted/approved/…); this is\n purely a projection of its loading/failed tags onto the shared async seam. */\n readonly remoteData = computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n });\n\n private brief = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.brief : null;\n });\n\n readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);\n readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);\n readonly canReject = computed(() => this.decisions()?.canReject ?? false);\n readonly canSend = computed(() => this.decisions()?.canSend ?? false);\n /** Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates. */\n readonly canRevealBigNummer = computed(() => this.decisions()?.canRevealBigNummer ?? false);\n\n private decisions = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.decisions : null;\n });\n readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));\n readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));\n /** Submit is allowed only when required sections are filled AND no blocking errors. */\n readonly canSubmit = computed(() => {\n const b = this.brief();\n return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());\n });\n\n async load() {\n const r = await this.adapter.load();\n if (r.ok) {\n this.orgTemplate.set(r.value.orgTemplate);\n this.caseContext.set(r.value.caseContext);\n this.clearHistory();\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });\n }\n }\n\n /** An edit: apply it optimistically in the pure reducer, then debounce-save. Records\n an undo step only when the reducer actually changed the brief (a no-op edit — e.g.\n a locked section — returns the same value and leaves no dead history step). */\n edit(msg: BriefMsg) {\n const before = this.brief();\n this.store.dispatch(msg);\n const after = this.brief();\n if (before && after && after !== before) {\n this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));\n this.future.set([]);\n }\n this.scheduleSave();\n }\n\n /** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current\n onto the redo stack, then autosave. Redo is the mirror image. */\n undo() {\n this.step(this.past, this.future);\n }\n redo() {\n this.step(this.future, this.past);\n }\n private step(from: typeof this.past, to: typeof this.future) {\n const s = this.model();\n const target = from().at(-1);\n if (s.tag !== 'loaded' || !target) return;\n from.update((x) => x.slice(0, -1));\n to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));\n this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });\n this.scheduleSave();\n }\n\n private clearHistory() {\n this.past.set([]);\n this.future.set([]);\n }\n\n private saveTimer?: ReturnType;\n private scheduleSave() {\n if (!this.canEdit()) return;\n clearTimeout(this.saveTimer);\n // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.\n this.saveTimer = setTimeout(() => void this.flushSave(), 600);\n }\n private async flushSave() {\n const b = this.brief();\n if (!b) return;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(b.sections);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n this.saveState.set({ tag: 'Error' });\n }\n }\n\n /** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */\n retrySave() {\n void this.flushSave();\n }\n\n /** Demo \"start over\": recreate the brief server-side and load the fresh view. */\n async resetDemo() {\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n const r = await this.adapter.reset();\n this.saveState.set({ tag: 'Idle' });\n if (r.ok) {\n this.actionState.set({ tag: 'Idle' });\n this.orgTemplate.set(r.value.orgTemplate);\n this.caseContext.set(r.value.caseContext);\n this.clearHistory();\n this.rejectionSnapshot.set(null);\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n submit = () => this.transition(() => this.adapter.submit());\n approve = () => this.transition(() => this.adapter.approve());\n reject = (comments: string) => this.transition(() => this.adapter.reject(comments));\n send = () => this.transition(() => this.adapter.send());\n\n /** Explicit action, never a live re-render (PRD §8): opens the server-composed\n letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and\n the tab outlives this call; not worth a teardown hook for a POC. */\n async previewLetter() {\n this.actionState.set({ tag: 'Busy' });\n const r = await this.previewAdapter.preview();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n window.open(URL.createObjectURL(r.value), '_blank');\n }\n\n /** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability\n + step-up and audits the attempt; on success we swap the masked value in the\n already-loaded caseContext (a field update, not a reload). The step-up gesture\n itself is the UI's concern — this command just runs the audited server call. */\n async revealBigNummer() {\n const r = await this.revealAdapter.reveal();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));\n }\n\n // A transition: flush any pending save, call the server (authoritative), then mirror\n // the returned status through the pure reducer's guarded transition.\n private async transition(action: () => Promise>) {\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n await this.flushSave();\n const r = await action();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.applyServerStatus(r.value);\n }\n\n private applyServerStatus(view: BriefView) {\n // `send` pins the org-template version server-side — mirror whatever came back.\n this.orgTemplate.set(view.orgTemplate);\n this.caseContext.set(view.caseContext);\n const { brief, decisions } = view;\n const s = brief.status;\n switch (s.tag) {\n case 'submitted':\n this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });\n break;\n case 'approved':\n this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });\n break;\n case 'rejected':\n // Capture the letter as-rejected for the resubmission diff (WP-27). This is the\n // \"before\" snapshot the approver later compares against.\n this.rejectionSnapshot.set(brief);\n this.store.dispatch({\n tag: 'Rejected',\n by: s.rejectedBy,\n at: s.rejectedAt,\n comments: s.comments,\n decisions,\n });\n break;\n case 'sent':\n this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });\n break;\n case 'draft':\n // reopened by a save on a rejected letter — reducer already handled it locally.\n break;\n }\n }\n}\n", + "sourceCode": "import { Injectable, computed, inject, signal } from '@angular/core';\nimport { Result } from '@shared/kernel/fp';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { createStore } from '@shared/application/store';\nimport {\n Brief,\n CaseContext,\n allDiagnostics,\n canSubmit,\n hasBlockingErrors,\n unresolvedPlaceholders,\n} from '@brief/domain/brief';\nimport { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';\nimport { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';\nimport { OrgTemplate } from '@brief/domain/org-template';\nimport { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';\nimport { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';\nimport { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';\nimport { uploadContentUrl } from '@shared/upload/upload.adapter';\nimport { PendingSave, registerPendingSave } from '@shared/application/pending-saves';\n\n/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union\n instead of a busy boolean + a nullable error sitting side by side. */\ntype ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };\n\n/** Debounced-autosave indicator, shown in a small status line near the toolbar —\n a separate concern from ActionState (a stale autosave error doesn't block\n submit/approve/reject), but tag-aligned with it for one consistent idiom. */\ntype SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };\n\ntype LoadedBriefState = Extract;\n\n/**\n * Root singleton for the letter: the Elm store (Model + dispatch), the derived\n * read-model, and the commands (effects) that call the adapter and dispatch the\n * outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/\n * `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never\n * stored. The permission flags come from the server's decision DTO (PRD-0002 phase\n * P1) via `BriefState.loaded.decisions` — this store never computes them itself.\n */\n@Injectable({ providedIn: 'root' })\nexport class BriefStore implements PendingSave {\n private adapter = inject(BriefAdapter);\n private previewAdapter = inject(LetterPreviewAdapter);\n private revealAdapter = inject(RevealBigNummerAdapter);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n\n /** Surfaced autosave state for the indicator + aria-live region. */\n readonly saveState = signal({ tag: 'Idle' });\n\n /** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future\n `Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.\n Only CONTENT edits are recorded (they flow through `edit()`); status transitions\n never enter history, or undo would replay workflow state. Capped so a long session\n can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine\n changes. */\n private static readonly HISTORY_CAP = 50;\n private past = signal([]);\n private future = signal([]);\n readonly canUndo = computed(() => this.past().length > 0);\n readonly canRedo = computed(() => this.future().length > 0);\n\n /** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The\n approver diffs it against the resubmitted letter. POC limit: in-memory only, so a\n full page reload loses it — a real system would persist the rejected revision. */\n private rejectionSnapshot = signal(null);\n /** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */\n readonly blockDiffs = computed>(() => {\n const before = this.rejectionSnapshot();\n const after = this.brief();\n return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();\n });\n /** Count of blocks removed since rejection — badged as a summary, since a removed\n block no longer renders inline. */\n readonly removedSinceReject = computed(\n () => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,\n );\n readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);\n\n /** The org template the letter renders with (WP-24). Server-owned appearance data,\n not letter state — held beside the machine, never inside it (`brief.machine.ts`\n stays untouched by design). Set from every server view that carries it. */\n readonly orgTemplate = signal(null);\n\n /** The case (zorgverlener + aanvraag) this letter concerns — server-joined context for\n the behandel scherm header, not letter state. Set from every server view. */\n readonly caseContext = signal(null);\n\n /** The org logo's content URL for the letterhead, or null when the template has none. */\n readonly logoUrl = computed(() => {\n const id = this.orgTemplate()?.logoDocumentId;\n return id ? uploadContentUrl(id) : null;\n });\n\n /** The load lifecycle as `RemoteData`, for `` — the machine keeps\n owning the letter's own domain lifecycle (draft/submitted/approved/…); this is\n purely a projection of its loading/failed tags onto the shared async seam. */\n readonly remoteData = computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n });\n\n private brief = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.brief : null;\n });\n\n readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);\n readonly canApprove = computed(() => this.decisions()?.canApprove ?? false);\n readonly canReject = computed(() => this.decisions()?.canReject ?? false);\n readonly canSend = computed(() => this.decisions()?.canSend ?? false);\n /** Field-level PII reveal (PRD-0002 §5c), deny-by-default like the action gates. */\n readonly canRevealBigNummer = computed(() => this.decisions()?.canRevealBigNummer ?? false);\n\n private decisions = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s.decisions : null;\n });\n readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));\n readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));\n /** Submit is allowed only when required sections are filled AND no blocking errors. */\n readonly canSubmit = computed(() => {\n const b = this.brief();\n return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());\n });\n\n async load() {\n const r = await this.adapter.load();\n if (r.ok) {\n this.orgTemplate.set(r.value.orgTemplate);\n this.caseContext.set(r.value.caseContext);\n this.clearHistory();\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });\n }\n }\n\n /** An edit: apply it optimistically in the pure reducer, then debounce-save. Records\n an undo step only when the reducer actually changed the brief (a no-op edit — e.g.\n a locked section — returns the same value and leaves no dead history step). */\n edit(msg: BriefMsg) {\n const before = this.brief();\n this.store.dispatch(msg);\n const after = this.brief();\n if (before && after && after !== before) {\n this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));\n this.future.set([]);\n }\n this.scheduleSave();\n }\n\n /** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current\n onto the redo stack, then autosave. Redo is the mirror image. */\n undo() {\n this.step(this.past, this.future);\n }\n redo() {\n this.step(this.future, this.past);\n }\n private step(from: typeof this.past, to: typeof this.future) {\n const s = this.model();\n const target = from().at(-1);\n if (s.tag !== 'loaded' || !target) return;\n from.update((x) => x.slice(0, -1));\n to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));\n this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });\n this.scheduleSave();\n }\n\n private clearHistory() {\n this.past.set([]);\n this.future.set([]);\n }\n\n constructor() {\n // Register so the CanDeactivate guard / beforeunload handler can flush a pending\n // debounced edit before navigation or unload (see pending-saves.ts).\n registerPendingSave(this);\n }\n\n private saveTimer?: ReturnType;\n private scheduleSave() {\n if (!this.canEdit()) return;\n clearTimeout(this.saveTimer);\n // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.\n // Null the handle when it fires so `hasPendingSave()` reflects \"a write is still owed\".\n this.saveTimer = setTimeout(() => {\n this.saveTimer = undefined;\n void this.flushSave();\n }, 600);\n }\n\n /** True while a debounced edit hasn't been written yet (PendingSave). */\n hasPendingSave = () => this.saveTimer !== undefined;\n /** Flush a pending debounced save now and await it; no-op when nothing is pending. */\n async flushPending() {\n if (this.saveTimer === undefined) return;\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave();\n }\n private async flushSave() {\n const b = this.brief();\n if (!b) return;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(b.sections);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n this.saveState.set({ tag: 'Error' });\n }\n }\n\n /** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */\n retrySave() {\n void this.flushSave();\n }\n\n /** Demo \"start over\": recreate the brief server-side and load the fresh view. */\n async resetDemo() {\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n const r = await this.adapter.reset();\n this.saveState.set({ tag: 'Idle' });\n if (r.ok) {\n this.actionState.set({ tag: 'Idle' });\n this.orgTemplate.set(r.value.orgTemplate);\n this.caseContext.set(r.value.caseContext);\n this.clearHistory();\n this.rejectionSnapshot.set(null);\n this.store.dispatch({ tag: 'BriefLoaded', ...r.value });\n } else {\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n submit = () => this.transition(() => this.adapter.submit());\n approve = () => this.transition(() => this.adapter.approve());\n reject = (comments: string) => this.transition(() => this.adapter.reject(comments));\n send = () => this.transition(() => this.adapter.send());\n\n /** Explicit action, never a live re-render (PRD §8): opens the server-composed\n letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and\n the tab outlives this call; not worth a teardown hook for a POC. */\n async previewLetter() {\n this.actionState.set({ tag: 'Busy' });\n const r = await this.previewAdapter.preview();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n window.open(URL.createObjectURL(r.value), '_blank');\n }\n\n /** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability\n + step-up and audits the attempt; on success we swap the masked value in the\n already-loaded caseContext (a field update, not a reload). The step-up gesture\n itself is the UI's concern — this command just runs the audited server call. */\n async revealBigNummer() {\n const r = await this.revealAdapter.reveal();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));\n }\n\n // A transition: flush any pending save, call the server (authoritative), then mirror\n // the returned status through the pure reducer's guarded transition.\n private async transition(action: () => Promise>) {\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave();\n const r = await action();\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.applyServerStatus(r.value);\n }\n\n private applyServerStatus(view: BriefView) {\n // `send` pins the org-template version server-side — mirror whatever came back.\n this.orgTemplate.set(view.orgTemplate);\n this.caseContext.set(view.caseContext);\n const { brief, decisions } = view;\n const s = brief.status;\n switch (s.tag) {\n case 'submitted':\n this.store.dispatch({ tag: 'Submitted', by: s.submittedBy, at: s.submittedAt, decisions });\n break;\n case 'approved':\n this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });\n break;\n case 'rejected':\n // Capture the letter as-rejected for the resubmission diff (WP-27). This is the\n // \"before\" snapshot the approver later compares against.\n this.rejectionSnapshot.set(brief);\n this.store.dispatch({\n tag: 'Rejected',\n by: s.rejectedBy,\n at: s.rejectedAt,\n comments: s.comments,\n decisions,\n });\n break;\n case 'sent':\n this.store.dispatch({ tag: 'Sent', at: s.sentAt, decisions });\n break;\n case 'draft':\n // reopened by a save on a rejected letter — reducer already handled it locally.\n break;\n }\n }\n}\n", + "constructorObj": { + "name": "constructor", + "description": "", + "deprecated": false, + "deprecationMessage": "", + "args": [], + "line": 190 + }, "extends": [], "type": "injectable" }, @@ -10220,7 +10296,7 @@ }, { "name": "OrgTemplateStore", - "id": "injectable-OrgTemplateStore-a7e1e9096e5a90a446586184fb43eb70d87c4c6114fdc7c4551e369398ed1d7ea6bd37e2a2e9d2fc94106b4bcff2575ad3ef0a5b8604d6d33fffdcd2840e72e9", + "id": "injectable-OrgTemplateStore-1eecac2749af5b60f90c7b922e02f70be919d949a246db9375dd0e42b0cd9878e352ea1ca746c4bdcc3228aaba2c0a523fe277e2d8083369420c6aed689ab938", "file": "src/app/brief/application/org-template.store.ts", "properties": [ { @@ -10232,7 +10308,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 48, + "line": 49, "modifierKind": [ 123 ] @@ -10246,7 +10322,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 38, + "line": 39, "modifierKind": [ 123 ] @@ -10260,7 +10336,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 49, + "line": 50, "modifierKind": [ 148 ] @@ -10274,7 +10350,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 98, + "line": 99, "modifierKind": [ 123 ] @@ -10288,7 +10364,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 75, + "line": 76, "modifierKind": [ 148 ] @@ -10302,7 +10378,7 @@ "indexKey": "", "optional": false, "description": "

Client-side mirror of the server rules (OrgTemplateRules) for instant feedback;\nthe server re-validates and stays the authority — publish is gated on this.

\n", - "line": 87, + "line": 88, "rawdescription": "\nClient-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;\nthe server re-validates and stays the authority — publish is gated on this.", "modifierKind": [ 148 @@ -10317,11 +10393,23 @@ "indexKey": "", "optional": false, "description": "", - "line": 97, + "line": 98, "modifierKind": [ 123 ] }, + { + "name": "hasPendingSave", + "defaultValue": "() => {...}", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "

True while a debounced edit hasn't been written yet (PendingSave).

\n", + "line": 162, + "rawdescription": "\nTrue while a debounced edit hasn't been written yet (PendingSave)." + }, { "name": "history", "defaultValue": "computed(() => this.loaded()?.history ?? [])", @@ -10331,7 +10419,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 77, + "line": 78, "modifierKind": [ 148 ] @@ -10345,7 +10433,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 50, + "line": 51, "modifierKind": [ 148 ] @@ -10359,7 +10447,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 71, + "line": 72, "modifierKind": [ 123 ] @@ -10373,7 +10461,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 80, + "line": 81, "modifierKind": [ 148 ] @@ -10387,7 +10475,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 43, + "line": 44, "modifierKind": [ 148 ] @@ -10401,7 +10489,7 @@ "indexKey": "", "optional": false, "description": "

The publish impact-confirm gate (PRD §7h: show N affected letters before POST).

\n", - "line": 57, + "line": 58, "rawdescription": "\nThe publish impact-confirm gate (PRD §7h: show N affected letters before POST).", "modifierKind": [ 148 @@ -10416,7 +10504,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 78, + "line": 79, "modifierKind": [ 148 ] @@ -10430,7 +10518,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 59, + "line": 60, "modifierKind": [ 148 ] @@ -10444,7 +10532,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 54, + "line": 55, "modifierKind": [ 148 ] @@ -10457,7 +10545,7 @@ "indexKey": "", "optional": true, "description": "", - "line": 145, + "line": 149, "modifierKind": [ 123 ] @@ -10471,7 +10559,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 46, + "line": 47, "modifierKind": [ 148 ] @@ -10485,7 +10573,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 40, + "line": 41, "modifierKind": [ 123 ] @@ -10499,7 +10587,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 41, + "line": 42, "modifierKind": [ 123 ] @@ -10513,7 +10601,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 45, + "line": 46, "modifierKind": [ 148 ] @@ -10527,7 +10615,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 79, + "line": 80, "modifierKind": [ 148 ] @@ -10541,7 +10629,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 39, + "line": 40, "modifierKind": [ 123 ] @@ -10555,7 +10643,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 76, + "line": 77, "modifierKind": [ 148 ] @@ -10568,7 +10656,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 172, + "line": 190, "deprecated": false, "deprecationMessage": "" }, @@ -10578,7 +10666,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 175, + "line": 193, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10600,7 +10688,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 262, + "line": 283, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10635,7 +10723,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 140, + "line": 144, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nAn in-place canvas or margin edit: apply optimistically, then debounce-save.", @@ -10654,13 +10742,28 @@ } ] }, + { + "name": "flushPending", + "args": [], + "optional": false, + "returnType": "any", + "typeParameters": [], + "line": 164, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nFlush a pending debounced save now and await it; no-op when nothing is pending.", + "description": "

Flush a pending debounced save now and await it; no-op when nothing is pending.

\n", + "modifierKind": [ + 134 + ] + }, { "name": "flushSave", "args": [], "optional": false, "returnType": "any", "typeParameters": [], - "line": 152, + "line": 170, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10674,7 +10777,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 113, + "line": 116, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10696,7 +10799,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 246, + "line": 267, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10728,7 +10831,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 252, + "line": 273, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10760,7 +10863,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 222, + "line": 243, "deprecated": false, "deprecationMessage": "", "jsdoctags": [ @@ -10792,7 +10895,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 267, + "line": 288, "deprecated": false, "deprecationMessage": "", "rawdescription": "\nUpload effects arriving from the transport: a finished/removed logo edits the\ndraft (in the reducer) and needs persisting.", @@ -10820,7 +10923,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 205, + "line": 225, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10833,7 +10936,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 169, + "line": 187, "deprecated": false, "deprecationMessage": "" }, @@ -10852,7 +10955,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 191, + "line": 210, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10878,7 +10981,7 @@ "optional": false, "returnType": "void", "typeParameters": [], - "line": 146, + "line": 150, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10900,7 +11003,7 @@ "optional": false, "returnType": "any", "typeParameters": [], - "line": 129, + "line": 132, "deprecated": false, "deprecationMessage": "", "modifierKind": [ @@ -10925,18 +11028,108 @@ "deprecationMessage": "", "description": "

Root singleton for the admin org-template editor (WP-26). The Elm machine owns the\neditable draft; commands here do the debounced save, publish (impact-confirm),\nrollback and proefbrief, then dispatch the outcome — the reducer stays pure. The\nlogo upload reuses the shared upload transport; its completion mutates the draft\n(in the reducer) and triggers a save (here). Mirrors BriefStore.

\n", "rawdescription": "\n\nRoot singleton for the admin org-template editor (WP-26). The Elm machine owns the\neditable draft; commands here do the debounced save, publish (impact-confirm),\nrollback and proefbrief, then dispatch the outcome — the reducer stays pure. The\nlogo upload reuses the shared upload transport; its completion mutates the draft\n(in the reducer) and triggers a save (here). Mirrors `BriefStore`.\n", - "sourceCode": "import { Injectable, computed, effect, inject, signal } from '@angular/core';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { createStore } from '@shared/application/store';\nimport { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { UploadShellService } from '@shared/upload/upload-shell.service';\nimport { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';\nimport {\n MARGIN_MAX_MM,\n MARGIN_MIN_MM,\n OrgTemplate,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport {\n OrgTemplateMsg,\n OrgTemplateState,\n initial,\n reduce,\n} from '@brief/domain/org-template.machine';\nimport { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';\n\n/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */\ntype ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };\ntype SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };\ntype LoadedState = Extract;\n\nconst LOGO_CATEGORY = 'org-logo';\nconst NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;\n\n/**\n * Root singleton for the admin org-template editor (WP-26). The Elm machine owns the\n * editable draft; commands here do the debounced save, publish (impact-confirm),\n * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The\n * logo upload reuses the shared upload transport; its completion mutates the draft\n * (in the reducer) and triggers a save (here). Mirrors `BriefStore`.\n */\n@Injectable({ providedIn: 'root' })\nexport class OrgTemplateStore {\n private adapter = inject(OrgTemplateAdapter);\n private uploadAdapter = inject(UploadAdapter);\n private shell = inject(UploadShellService);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n readonly subOrgs = signal([]);\n readonly selectedSubOrgId = signal(null);\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n readonly saveState = signal({ tag: 'Idle' });\n\n /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */\n readonly pendingPublish = signal(false);\n\n readonly remoteData = computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n });\n\n private loaded = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s : null;\n });\n readonly draft = computed(() => this.loaded()?.draft ?? null);\n readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);\n readonly history = computed(() => this.loaded()?.history ?? []);\n readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);\n readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);\n readonly logoUrl = computed(() => {\n const id = this.draft()?.logoDocumentId;\n return id ? this.uploadAdapter.contentUrl(id) : null;\n });\n\n /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;\n the server re-validates and stays the authority — publish is gated on this. */\n readonly draftValid = computed(() => {\n const d = this.draft();\n if (!d) return false;\n const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(\n (v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,\n );\n return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;\n });\n\n // Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).\n private files = new Map();\n private categoriesRes = this.uploadAdapter.categoriesResource('org-template');\n\n constructor() {\n // Feed the logo category into the machine's upload sub-state once loaded. Tracks\n // `model()` so it re-fires after a sub-org switch reseeds an empty upload state;\n // the length guard makes it idempotent (no dispatch loop).\n effect(() => {\n const s = this.model();\n if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;\n const status = this.categoriesRes.status();\n if (status === 'resolved' || status === 'local')\n this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });\n });\n }\n\n async load() {\n this.store.dispatch({ tag: 'Loading' });\n const list = await this.adapter.list();\n if (!list.ok) {\n this.store.dispatch({ tag: 'LoadFailed', reason: list.error });\n return;\n }\n this.subOrgs.set(list.value);\n const first = list.value[0];\n if (!first) {\n this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });\n return;\n }\n await this.selectSubOrg(first.subOrgId);\n }\n\n async selectSubOrg(subOrgId: string) {\n this.selectedSubOrgId.set(subOrgId);\n this.saveState.set({ tag: 'Idle' });\n clearTimeout(this.saveTimer);\n this.store.dispatch({ tag: 'Loading' });\n const r = await this.adapter.load(subOrgId);\n if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });\n else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });\n }\n\n /** An in-place canvas or margin edit: apply optimistically, then debounce-save. */\n edit(msg: OrgTemplateMsg) {\n this.store.dispatch(msg);\n this.scheduleSave();\n }\n\n private saveTimer?: ReturnType;\n private scheduleSave() {\n if (this.loaded() === null) return;\n clearTimeout(this.saveTimer);\n // ponytail: 600ms debounce, same as BriefStore; the server is the store of record.\n this.saveTimer = setTimeout(() => void this.flushSave(), 600);\n }\n private async flushSave() {\n const s = this.loaded();\n if (!s || !s.dirty) return;\n const { subOrgId, draft } = s;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(subOrgId, draft);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });\n } else {\n this.saveState.set({ tag: 'Error' });\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n // --- publish (impact-confirm) / rollback / proefbrief ---\n\n requestPublish() {\n this.pendingPublish.set(true);\n }\n cancelPublish() {\n this.pendingPublish.set(false);\n }\n async confirmPublish() {\n const s = this.loaded();\n if (!s) return;\n this.pendingPublish.set(false);\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n await this.flushSave(); // publish the saved draft — flush any pending edit first\n const r = await this.adapter.publish(s.subOrgId);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0\n }\n\n async rollback(version: number) {\n const s = this.loaded();\n if (!s) return;\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n const r = await this.adapter.rollback(s.subOrgId, version);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft\n }\n\n async proefbrief() {\n const s = this.loaded();\n if (!s) return;\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n await this.flushSave(); // the proefbrief renders the server's draft\n const r = await this.adapter.proefbrief(s.subOrgId);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n window.open(URL.createObjectURL(r.value), '_blank');\n }\n\n // --- logo upload (reuses the shared upload transport; single `org-logo` file) ---\n\n onLogoSelected(files: File[]) {\n const s = this.loaded();\n const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);\n const file = files[0];\n if (!s || !cat || !file) return;\n const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });\n if (reason) {\n this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });\n return;\n }\n const localId = crypto.randomUUID();\n this.files.set(localId, file);\n this.dispatchUpload({\n type: 'FileSelected',\n categoryId: cat.categoryId,\n localId,\n fileName: file.name,\n fileSizeMb: file.size / 1e6,\n });\n this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>\n this.onUploadMsg(m),\n );\n }\n\n onLogoRemoved(localId: string) {\n this.shell.cancel([localId]);\n this.files.delete(localId);\n this.onUploadMsg({ type: 'UploadRemoved', localId });\n }\n\n onLogoRetry(localId: string) {\n const file = this.files.get(localId);\n const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);\n if (!file || !up) return;\n this.dispatchUpload({ type: 'UploadRetried', localId });\n this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>\n this.onUploadMsg(m),\n );\n }\n\n private dispatchUpload(msg: UploadMsg) {\n this.store.dispatch({ tag: 'Upload', msg });\n }\n /** Upload effects arriving from the transport: a finished/removed logo edits the\n draft (in the reducer) and needs persisting. */\n private onUploadMsg(msg: UploadMsg) {\n this.dispatchUpload(msg);\n if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();\n }\n}\n", + "sourceCode": "import { Injectable, computed, effect, inject, signal } from '@angular/core';\nimport { RemoteData } from '@shared/application/remote-data';\nimport { createStore } from '@shared/application/store';\nimport { UploadAdapter } from '@shared/upload/upload.adapter';\nimport { UploadShellService } from '@shared/upload/upload-shell.service';\nimport { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';\nimport {\n MARGIN_MAX_MM,\n MARGIN_MIN_MM,\n OrgTemplate,\n SubOrgSummary,\n} from '@brief/domain/org-template';\nimport {\n OrgTemplateMsg,\n OrgTemplateState,\n initial,\n reduce,\n} from '@brief/domain/org-template.machine';\nimport { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';\nimport { PendingSave, registerPendingSave } from '@shared/application/pending-saves';\n\n/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */\ntype ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };\ntype SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };\ntype LoadedState = Extract;\n\nconst LOGO_CATEGORY = 'org-logo';\nconst NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;\n\n/**\n * Root singleton for the admin org-template editor (WP-26). The Elm machine owns the\n * editable draft; commands here do the debounced save, publish (impact-confirm),\n * rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The\n * logo upload reuses the shared upload transport; its completion mutates the draft\n * (in the reducer) and triggers a save (here). Mirrors `BriefStore`.\n */\n@Injectable({ providedIn: 'root' })\nexport class OrgTemplateStore implements PendingSave {\n private adapter = inject(OrgTemplateAdapter);\n private uploadAdapter = inject(UploadAdapter);\n private shell = inject(UploadShellService);\n private store = createStore(initial, reduce);\n\n readonly model = this.store.model;\n\n readonly subOrgs = signal([]);\n readonly selectedSubOrgId = signal(null);\n\n private actionState = signal({ tag: 'Idle' });\n readonly busy = computed(() => this.actionState().tag === 'Busy');\n readonly lastError = computed(() => {\n const s = this.actionState();\n return s.tag === 'Failed' ? s.error : null;\n });\n readonly saveState = signal({ tag: 'Idle' });\n\n /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */\n readonly pendingPublish = signal(false);\n\n readonly remoteData = computed>(() => {\n const s = this.model();\n switch (s.tag) {\n case 'loading':\n return { tag: 'Loading' };\n case 'failed':\n return { tag: 'Failure', error: new Error(s.reason) };\n case 'loaded':\n return { tag: 'Success', value: s };\n }\n });\n\n private loaded = computed(() => {\n const s = this.model();\n return s.tag === 'loaded' ? s : null;\n });\n readonly draft = computed(() => this.loaded()?.draft ?? null);\n readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);\n readonly history = computed(() => this.loaded()?.history ?? []);\n readonly publishedVersion = computed(() => this.loaded()?.publishedVersion ?? 0);\n readonly unsentBriefs = computed(() => this.loaded()?.unsentBriefs ?? 0);\n readonly logoUrl = computed(() => {\n const id = this.draft()?.logoDocumentId;\n return id ? this.uploadAdapter.contentUrl(id) : null;\n });\n\n /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;\n the server re-validates and stays the authority — publish is gated on this. */\n readonly draftValid = computed(() => {\n const d = this.draft();\n if (!d) return false;\n const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(\n (v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,\n );\n return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;\n });\n\n // Live File blobs keyed by localId — needed to retry a failed upload (a reducer can't hold these).\n private files = new Map();\n private categoriesRes = this.uploadAdapter.categoriesResource('org-template');\n\n constructor() {\n // Feed the logo category into the machine's upload sub-state once loaded. Tracks\n // `model()` so it re-fires after a sub-org switch reseeds an empty upload state;\n // the length guard makes it idempotent (no dispatch loop).\n effect(() => {\n const s = this.model();\n if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;\n const status = this.categoriesRes.status();\n if (status === 'resolved' || status === 'local')\n this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });\n });\n // Flush a pending debounced edit before navigation/unload (see pending-saves.ts).\n registerPendingSave(this);\n }\n\n async load() {\n this.store.dispatch({ tag: 'Loading' });\n const list = await this.adapter.list();\n if (!list.ok) {\n this.store.dispatch({ tag: 'LoadFailed', reason: list.error });\n return;\n }\n this.subOrgs.set(list.value);\n const first = list.value[0];\n if (!first) {\n this.store.dispatch({ tag: 'LoadFailed', reason: NO_SUBORGS });\n return;\n }\n await this.selectSubOrg(first.subOrgId);\n }\n\n async selectSubOrg(subOrgId: string) {\n this.selectedSubOrgId.set(subOrgId);\n this.saveState.set({ tag: 'Idle' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n this.store.dispatch({ tag: 'Loading' });\n const r = await this.adapter.load(subOrgId);\n if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });\n else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });\n }\n\n /** An in-place canvas or margin edit: apply optimistically, then debounce-save. */\n edit(msg: OrgTemplateMsg) {\n this.store.dispatch(msg);\n this.scheduleSave();\n }\n\n private saveTimer?: ReturnType;\n private scheduleSave() {\n if (this.loaded() === null) return;\n clearTimeout(this.saveTimer);\n // ponytail: 600ms debounce, same as BriefStore; the server is the store of record.\n // Null the handle when it fires so `hasPendingSave()` reflects \"a write is still owed\".\n this.saveTimer = setTimeout(() => {\n this.saveTimer = undefined;\n void this.flushSave();\n }, 600);\n }\n\n /** True while a debounced edit hasn't been written yet (PendingSave). */\n hasPendingSave = () => this.saveTimer !== undefined;\n /** Flush a pending debounced save now and await it; no-op when nothing is pending. */\n async flushPending() {\n if (this.saveTimer === undefined) return;\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave();\n }\n private async flushSave() {\n const s = this.loaded();\n if (!s || !s.dirty) return;\n const { subOrgId, draft } = s;\n this.saveState.set({ tag: 'Saving' });\n const r = await this.adapter.save(subOrgId, draft);\n if (r.ok) {\n this.saveState.set({ tag: 'Saved' });\n this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });\n } else {\n this.saveState.set({ tag: 'Error' });\n this.actionState.set({ tag: 'Failed', error: r.error });\n }\n }\n\n // --- publish (impact-confirm) / rollback / proefbrief ---\n\n requestPublish() {\n this.pendingPublish.set(true);\n }\n cancelPublish() {\n this.pendingPublish.set(false);\n }\n async confirmPublish() {\n const s = this.loaded();\n if (!s) return;\n this.pendingPublish.set(false);\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave(); // publish the saved draft — flush any pending edit first\n const r = await this.adapter.publish(s.subOrgId);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0\n }\n\n async rollback(version: number) {\n const s = this.loaded();\n if (!s) return;\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n const r = await this.adapter.rollback(s.subOrgId, version);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft\n }\n\n async proefbrief() {\n const s = this.loaded();\n if (!s) return;\n this.actionState.set({ tag: 'Busy' });\n clearTimeout(this.saveTimer);\n this.saveTimer = undefined;\n await this.flushSave(); // the proefbrief renders the server's draft\n const r = await this.adapter.proefbrief(s.subOrgId);\n if (!r.ok) {\n this.actionState.set({ tag: 'Failed', error: r.error });\n return;\n }\n this.actionState.set({ tag: 'Idle' });\n window.open(URL.createObjectURL(r.value), '_blank');\n }\n\n // --- logo upload (reuses the shared upload transport; single `org-logo` file) ---\n\n onLogoSelected(files: File[]) {\n const s = this.loaded();\n const cat = s?.upload.categories.find((c) => c.categoryId === LOGO_CATEGORY);\n const file = files[0];\n if (!s || !cat || !file) return;\n const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });\n if (reason) {\n this.dispatchUpload({ type: 'FileRejected', categoryId: cat.categoryId, reason });\n return;\n }\n const localId = crypto.randomUUID();\n this.files.set(localId, file);\n this.dispatchUpload({\n type: 'FileSelected',\n categoryId: cat.categoryId,\n localId,\n fileName: file.name,\n fileSizeMb: file.size / 1e6,\n });\n this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>\n this.onUploadMsg(m),\n );\n }\n\n onLogoRemoved(localId: string) {\n this.shell.cancel([localId]);\n this.files.delete(localId);\n this.onUploadMsg({ type: 'UploadRemoved', localId });\n }\n\n onLogoRetry(localId: string) {\n const file = this.files.get(localId);\n const up = this.loaded()?.upload.uploads.find((u) => u.localId === localId);\n if (!file || !up) return;\n this.dispatchUpload({ type: 'UploadRetried', localId });\n this.shell.upload({ localId, categoryId: up.categoryId, wizardId: 'org-template', file }, (m) =>\n this.onUploadMsg(m),\n );\n }\n\n private dispatchUpload(msg: UploadMsg) {\n this.store.dispatch({ tag: 'Upload', msg });\n }\n /** Upload effects arriving from the transport: a finished/removed logo edits the\n draft (in the reducer) and needs persisting. */\n private onUploadMsg(msg: UploadMsg) {\n this.dispatchUpload(msg);\n if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();\n }\n}\n", "constructorObj": { "name": "constructor", "description": "", "deprecated": false, "deprecationMessage": "", "args": [], - "line": 98 + "line": 99 }, "extends": [], "type": "injectable" }, + { + "name": "PendingSaves", + "id": "injectable-PendingSaves-2727407341bf27a69b5552e48492a92b5bb54df722ffb29bbcc0c427a84e69504b1a2f84c22487134702040754c185bd1ee35db43abfec753fa42630eeec81e8", + "file": "src/app/shared/application/pending-saves.ts", + "properties": [ + { + "name": "owners", + "defaultValue": "new Set()", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "indexKey": "", + "optional": false, + "description": "", + "line": 27, + "modifierKind": [ + 123, + 148 + ] + } + ], + "methods": [ + { + "name": "flushAll", + "args": [], + "optional": false, + "returnType": "Promise", + "typeParameters": [], + "line": 42, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nFlush every owner that has a pending write, awaiting all. Best-effort: a rejected\nflush is swallowed (a failed autosave surfaces its own error state; navigation must\nnot be blocked by it).", + "description": "

Flush every owner that has a pending write, awaiting all. Best-effort: a rejected\nflush is swallowed (a failed autosave surfaces its own error state; navigation must\nnot be blocked by it).

\n", + "modifierKind": [ + 134 + ] + }, + { + "name": "hasPending", + "args": [], + "optional": false, + "returnType": "boolean", + "typeParameters": [], + "line": 35, + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "register", + "args": [ + { + "name": "owner", + "type": "PendingSave", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "" + } + ], + "optional": false, + "returnType": "void", + "typeParameters": [], + "line": 30, + "deprecated": false, + "deprecationMessage": "", + "rawdescription": "\nRegister an owner; returns an unregister function.", + "description": "

Register an owner; returns an unregister function.

\n", + "jsdoctags": [ + { + "name": "owner", + "type": "PendingSave", + "optional": false, + "dotDotDotToken": false, + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], + "deprecated": false, + "deprecationMessage": "", + "description": "

Registry of every active autosave owner. The CanDeactivate guard and the\nbeforeunload handler flush through this — one seam, both callers.

\n", + "rawdescription": "\nRegistry of every active autosave owner. The `CanDeactivate` guard and the\n`beforeunload` handler flush through this — one seam, both callers.", + "sourceCode": "import {\n DestroyRef,\n ENVIRONMENT_INITIALIZER,\n Injectable,\n inject,\n} from '@angular/core';\nimport { CanDeactivateFn } from '@angular/router';\n\n/**\n * A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in\n * this app have different lifetimes — root singleton stores (`BriefStore`,\n * `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child\n * organisms — so both register here instead of the guard/unload handler needing to know\n * which page or store owns the pending write.\n */\nexport interface PendingSave {\n /** True while a debounced edit hasn't been written to the backend yet. */\n hasPendingSave(): boolean;\n /** Flush that pending write now and await it. No-op when nothing is pending. */\n flushPending(): Promise;\n}\n\n/** Registry of every active autosave owner. The `CanDeactivate` guard and the\n `beforeunload` handler flush through this — one seam, both callers. */\n@Injectable({ providedIn: 'root' })\nexport class PendingSaves {\n private readonly owners = new Set();\n\n /** Register an owner; returns an unregister function. */\n register(owner: PendingSave): () => void {\n this.owners.add(owner);\n return () => this.owners.delete(owner);\n }\n\n hasPending(): boolean {\n return [...this.owners].some((o) => o.hasPendingSave());\n }\n\n /** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected\n flush is swallowed (a failed autosave surfaces its own error state; navigation must\n not be blocked by it). */\n async flushAll(): Promise {\n await Promise.allSettled(\n [...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),\n );\n }\n}\n\n/** Register the current injection context's owner for the life of its `DestroyRef`.\n Call from a constructor or field initializer (root store, or `createDraftSync`). */\nexport function registerPendingSave(owner: PendingSave): void {\n const unregister = inject(PendingSaves).register(owner);\n inject(DestroyRef).onDestroy(unregister);\n}\n\n/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,\n then allow navigation. Awaitable, so the write lands before the page tears down (which\n would otherwise drop a sub-debounce edit). We never block leaving — the flush is a\n guarantee of effort, not a gate. */\nexport const flushPendingGuard: CanDeactivateFn = () => {\n const pending = inject(PendingSaves);\n return pending.hasPending() ? pending.flushAll().then(() => true) : true;\n};\n\n/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.\n ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an\n async flush can't be guaranteed to finish as the page tears down — we fire it best-effort\n AND trigger the browser's native \"unsaved changes\" prompt, which lets the ~600ms debounce\n land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever\n needs to be guaranteed. */\nexport function provideUnloadFlush() {\n return {\n provide: ENVIRONMENT_INITIALIZER,\n multi: true,\n useValue: () => {\n const pending = inject(PendingSaves);\n window.addEventListener('beforeunload', (e) => {\n if (!pending.hasPending()) return;\n void pending.flushAll();\n e.preventDefault();\n e.returnValue = '';\n });\n },\n };\n}\n", + "extends": [], + "type": "injectable" + }, { "name": "RegistratieLookupStore", "id": "injectable-RegistratieLookupStore-3ef92fdc15ae45cb8436dc2681ef33f3b9328d9510bda8e6d25e37d653da1674bf3a938fefc9cd5a447d7c6c611a6ef646ecaab949d0cb116f7770ee98ca5523", @@ -30404,7 +30597,7 @@ "deprecated": false, "deprecationMessage": "", "type": "ApplicationConfig", - "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(\n routes,\n withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),\n // Cross-fade page-to-page navigations only. A silent same-route nav — e.g.\n // draft-sync stamping `?aanvraag=` into the URL mid-wizard — must NOT\n // animate: for the transition's duration Firefox's `::view-transition`\n // overlay swallows pointer events (Chrome sets pointer-events:none, so it\n // doesn't), which loses a click landing on it and makes the wizard's \"next\"\n // button need a second click. Skip the transition when the route is unchanged.\n withViewTransitions({\n onViewTransitionCreated: ({ transition, from, to }) => {\n // `from`/`to` are the ROOT snapshots (the shared shell), so descend to the\n // leaf before comparing — otherwise every navigation looks \"same route\".\n const leaf = (r: ActivatedRouteSnapshot) => {\n while (r.firstChild) r = r.firstChild;\n return r;\n };\n if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();\n },\n }),\n ),\n // Dev-only: the ?scenario= toggle must never reach a production build, where\n // a query param could otherwise force errors on the live app.\n provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),\n provideApiClient(),\n { provide: SESSION_PORT, useExisting: SessionStore },\n { provide: LOCALE_ID, useValue: 'nl' },\n provideRouteFocus(),\n ],\n}" + "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(\n routes,\n withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),\n // Cross-fade page-to-page navigations only. A silent same-route nav — e.g.\n // draft-sync stamping `?aanvraag=` into the URL mid-wizard — must NOT\n // animate: for the transition's duration Firefox's `::view-transition`\n // overlay swallows pointer events (Chrome sets pointer-events:none, so it\n // doesn't), which loses a click landing on it and makes the wizard's \"next\"\n // button need a second click. Skip the transition when the route is unchanged.\n withViewTransitions({\n onViewTransitionCreated: ({ transition, from, to }) => {\n // `from`/`to` are the ROOT snapshots (the shared shell), so descend to the\n // leaf before comparing — otherwise every navigation looks \"same route\".\n const leaf = (r: ActivatedRouteSnapshot) => {\n while (r.firstChild) r = r.firstChild;\n return r;\n };\n if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();\n },\n }),\n ),\n // Dev-only: the ?scenario= toggle must never reach a production build, where\n // a query param could otherwise force errors on the live app.\n provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),\n provideApiClient(),\n { provide: SESSION_PORT, useExisting: SessionStore },\n { provide: LOCALE_ID, useValue: 'nl' },\n provideRouteFocus(),\n provideUnloadFlush(),\n ],\n}" }, { "name": "ASYNC", @@ -30544,16 +30737,6 @@ "type": "unknown", "defaultValue": "(error: E): Result => ({ ok: false, error })" }, - { - "name": "FAILED", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/beheer/infrastructure/stamdata.adapter.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "unknown", - "defaultValue": "$localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`" - }, { "name": "FAILED", "ctype": "miscellaneous", @@ -30566,6 +30749,16 @@ "rawdescription": "The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\nrollback go through the generated client (X-Role added by `roleInterceptor`);\n`parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and\n`ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.", "description": "

The only place admin org-template HTTP lives (ADR-0001 boundary). CRUD/publish/\nrollback go through the generated client (X-Role added by roleInterceptor);\nparse* narrows the untrusted wire shape. The proefbrief is text/html and\nExcludeFromDescription'd — a hand-written fetch, same seam as letter-preview.adapter.

\n" }, + { + "name": "FAILED", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/beheer/infrastructure/stamdata.adapter.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "$localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`" + }, { "name": "find", "ctype": "miscellaneous", @@ -30576,6 +30769,18 @@ "type": "unknown", "defaultValue": "(s: UploadState, localId: string) => s.uploads.find((u) => u.localId === localId)" }, + { + "name": "flushPendingGuard", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/shared/application/pending-saves.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "CanDeactivateFn", + "defaultValue": "() => {\n const pending = inject(PendingSaves);\n return pending.hasPending() ? pending.flushAll().then(() => true) : true;\n}", + "rawdescription": "`CanDeactivate` guard: flush any pending debounced write before an in-app route change,\nthen allow navigation. Awaitable, so the write lands before the page tears down (which\nwould otherwise drop a sub-debounce edit). We never block leaving — the flush is a\nguarantee of effort, not a gate.", + "description": "

CanDeactivate guard: flush any pending debounced write before an in-app route change,\nthen allow navigation. Awaitable, so the write lands before the page tears down (which\nwould otherwise drop a sub-debounce edit). We never block leaving — the flush is a\nguarantee of effort, not a gate.

\n" + }, { "name": "GELDIG_TOT", "ctype": "miscellaneous", @@ -30638,16 +30843,6 @@ "rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.", "description": "

Used by the shell to find what to poll on return: still-in-flight uploads.

\n" }, - { - "name": "initial", - "ctype": "miscellaneous", - "subtype": "variable", - "file": "src/app/beheer/domain/stamdata-editor.machine.ts", - "deprecated": false, - "deprecationMessage": "", - "type": "StamdataEditorState", - "defaultValue": "{ tag: 'loading' }" - }, { "name": "initial", "ctype": "miscellaneous", @@ -30668,6 +30863,16 @@ "type": "OrgTemplateState", "defaultValue": "{ tag: 'loading' }" }, + { + "name": "initial", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/beheer/domain/stamdata-editor.machine.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "StamdataEditorState", + "defaultValue": "{ tag: 'loading' }" + }, { "name": "initial", "ctype": "miscellaneous", @@ -31051,7 +31256,7 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default\n // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces\n // via the OrgAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('orgtemplate:edit')\"],\n loadComponent: () =>\n \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'beheer/stamdata',\n // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default\n // unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the\n // StamdataAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('stamdata:edit')\"],\n loadComponent: () => \"import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" + "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n // Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts).\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default\n // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces\n // via the OrgAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('orgtemplate:edit')\"],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'beheer/stamdata',\n // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default\n // unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the\n // StamdataAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('stamdata:edit')\"],\n loadComponent: () => \"import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" }, { "name": "ROUTES", @@ -35932,6 +36137,16 @@ "description": "

Template-layer wiring (not a component): on every route change after the\ninitial load, moves focus to the new page's <h1> (page-shell always\nrenders one) so screen-reader/keyboard users land on the new content\ninstead of wherever focus happened to be. Falls back to #main (the\nshell's landmark) if a page has no heading. Deferred via afterNextRender\nso it doesn't race Angular's view-transition DOM swap.

\n", "args": [] }, + { + "name": "provideUnloadFlush", + "file": "src/app/shared/application/pending-saves.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Wire a beforeunload handler that guards the last-mile save on a hard tab-close/reload.\nponytail: the HTTP seam is Angular HttpClient (no keepalive/sendBeacon), so an\nasync flush can't be guaranteed to finish as the page tears down — we fire it best-effort\nAND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce\nland if the user stays. Upgrade path: a sendBeacon/keepalive last-mile if this ever\nneeds to be guaranteed.

\n", + "args": [] + }, { "name": "purposeLabel", "file": "src/app/registratie/domain/aanvraag-view.ts", @@ -36090,50 +36305,6 @@ } ] }, - { - "name": "reduce", - "file": "src/app/beheer/domain/stamdata-editor.machine.ts", - "ctype": "miscellaneous", - "subtype": "function", - "deprecated": false, - "deprecationMessage": "", - "description": "", - "args": [ - { - "name": "s", - "type": "StamdataEditorState", - "deprecated": false, - "deprecationMessage": "" - }, - { - "name": "m", - "type": "StamdataEditorMsg", - "deprecated": false, - "deprecationMessage": "" - } - ], - "returnType": "StamdataEditorState", - "jsdoctags": [ - { - "name": "s", - "type": "StamdataEditorState", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - }, - { - "name": "m", - "type": "StamdataEditorMsg", - "deprecated": false, - "deprecationMessage": "", - "tagName": { - "text": "param" - } - } - ] - }, { "name": "reduce", "file": "src/app/brief/domain/brief.machine.ts", @@ -36222,6 +36393,50 @@ } ] }, + { + "name": "reduce", + "file": "src/app/beheer/domain/stamdata-editor.machine.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "args": [ + { + "name": "s", + "type": "StamdataEditorState", + "deprecated": false, + "deprecationMessage": "" + }, + { + "name": "m", + "type": "StamdataEditorMsg", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "StamdataEditorState", + "jsdoctags": [ + { + "name": "s", + "type": "StamdataEditorState", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + }, + { + "name": "m", + "type": "StamdataEditorMsg", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "reduce", "file": "src/app/herregistratie/domain/herregistratie.machine.ts", @@ -36471,6 +36686,35 @@ } ] }, + { + "name": "registerPendingSave", + "file": "src/app/shared/application/pending-saves.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Register the current injection context's owner for the life of its DestroyRef.\nCall from a constructor or field initializer (root store, or createDraftSync).

\n", + "args": [ + { + "name": "owner", + "type": "PendingSave", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "void", + "jsdoctags": [ + { + "name": "owner", + "type": "PendingSave", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + }, { "name": "rejectReason", "file": "src/app/shared/upload/upload.machine.ts", @@ -39030,8 +39274,8 @@ "name": "LoadedState", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "Extract", - "file": "src/app/beheer/application/stamdata.store.ts", + "rawtype": "Extract", + "file": "src/app/brief/application/org-template.store.ts", "deprecated": false, "deprecationMessage": "", "description": "", @@ -39041,8 +39285,8 @@ "name": "LoadedState", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "Extract", - "file": "src/app/brief/application/org-template.store.ts", + "rawtype": "Extract", + "file": "src/app/beheer/application/stamdata.store.ts", "deprecated": false, "deprecationMessage": "", "description": "", @@ -39546,7 +39790,7 @@ "deprecated": false, "deprecationMessage": "", "type": "ApplicationConfig", - "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(\n routes,\n withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),\n // Cross-fade page-to-page navigations only. A silent same-route nav — e.g.\n // draft-sync stamping `?aanvraag=` into the URL mid-wizard — must NOT\n // animate: for the transition's duration Firefox's `::view-transition`\n // overlay swallows pointer events (Chrome sets pointer-events:none, so it\n // doesn't), which loses a click landing on it and makes the wizard's \"next\"\n // button need a second click. Skip the transition when the route is unchanged.\n withViewTransitions({\n onViewTransitionCreated: ({ transition, from, to }) => {\n // `from`/`to` are the ROOT snapshots (the shared shell), so descend to the\n // leaf before comparing — otherwise every navigation looks \"same route\".\n const leaf = (r: ActivatedRouteSnapshot) => {\n while (r.firstChild) r = r.firstChild;\n return r;\n };\n if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();\n },\n }),\n ),\n // Dev-only: the ?scenario= toggle must never reach a production build, where\n // a query param could otherwise force errors on the live app.\n provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),\n provideApiClient(),\n { provide: SESSION_PORT, useExisting: SessionStore },\n { provide: LOCALE_ID, useValue: 'nl' },\n provideRouteFocus(),\n ],\n}" + "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(\n routes,\n withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),\n // Cross-fade page-to-page navigations only. A silent same-route nav — e.g.\n // draft-sync stamping `?aanvraag=` into the URL mid-wizard — must NOT\n // animate: for the transition's duration Firefox's `::view-transition`\n // overlay swallows pointer events (Chrome sets pointer-events:none, so it\n // doesn't), which loses a click landing on it and makes the wizard's \"next\"\n // button need a second click. Skip the transition when the route is unchanged.\n withViewTransitions({\n onViewTransitionCreated: ({ transition, from, to }) => {\n // `from`/`to` are the ROOT snapshots (the shared shell), so descend to the\n // leaf before comparing — otherwise every navigation looks \"same route\".\n const leaf = (r: ActivatedRouteSnapshot) => {\n while (r.firstChild) r = r.firstChild;\n return r;\n };\n if (leaf(from).routeConfig === leaf(to).routeConfig) transition.skipTransition();\n },\n }),\n ),\n // Dev-only: the ?scenario= toggle must never reach a production build, where\n // a query param could otherwise force errors on the live app.\n provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),\n provideApiClient(),\n { provide: SESSION_PORT, useExisting: SessionStore },\n { provide: LOCALE_ID, useValue: 'nl' },\n provideRouteFocus(),\n provideUnloadFlush(),\n ],\n}" } ], "src/app/shared/ui/async/async.component.ts": [ @@ -39817,6 +40061,20 @@ "defaultValue": "$localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`" } ], + "src/app/shared/application/pending-saves.ts": [ + { + "name": "flushPendingGuard", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/shared/application/pending-saves.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "CanDeactivateFn", + "defaultValue": "() => {\n const pending = inject(PendingSaves);\n return pending.hasPending() ? pending.flushAll().then(() => true) : true;\n}", + "rawdescription": "`CanDeactivate` guard: flush any pending debounced write before an in-app route change,\nthen allow navigation. Awaitable, so the write lands before the page tears down (which\nwould otherwise drop a sub-debounce edit). We never block leaving — the flush is a\nguarantee of effort, not a gate.", + "description": "

CanDeactivate guard: flush any pending debounced write before an in-app route change,\nthen allow navigation. Awaitable, so the write lands before the page tears down (which\nwould otherwise drop a sub-debounce edit). We never block leaving — the flush is a\nguarantee of effort, not a gate.

\n" + } + ], "src/app/beheer/domain/stamdata.ts": [ { "name": "GELDIG_TOT", @@ -40301,7 +40559,7 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default\n // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces\n // via the OrgAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('orgtemplate:edit')\"],\n loadComponent: () =>\n \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'beheer/stamdata',\n // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default\n // unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the\n // StamdataAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('stamdata:edit')\"],\n loadComponent: () => \"import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" + "defaultValue": "[\n {\n path: '',\n component: ShellComponent, // persistent header/footer; only children swap\n children: [\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n {\n path: 'login',\n loadComponent: () => \"import('@auth/ui/login.page').then((m) => m.LoginPage)\",\n },\n {\n path: 'dashboard',\n canActivate: [authGuard],\n loadComponent: () => \"import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage)\",\n },\n {\n path: 'registratie',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registration-detail.page').then((m) => m.RegistrationDetailPage)\",\n },\n {\n path: 'aanvraag/:id',\n canActivate: [authGuard],\n loadComponent: () =>\n \"import('@registratie/ui/aanvraag-detail.page').then((m) => m.AanvraagDetailPage)\",\n },\n {\n path: 'registreren',\n canActivate: [authGuard],\n // Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts).\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage)\",\n },\n {\n path: 'herregistratie',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage)\",\n },\n {\n path: 'intake',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@herregistratie/ui/intake.page').then((m) => m.IntakePage)\",\n },\n {\n path: 'brief',\n canActivate: [authGuard],\n canDeactivate: [flushPendingGuard],\n loadComponent: () => \"import('@brief/ui/brief.page').then((m) => m.BriefPage)\",\n },\n {\n path: 'brief/huisstijl',\n // Admin-only org-template editor (WP-26): capabilityGuard denies-by-default\n // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces\n // via the OrgAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('orgtemplate:edit')\"],\n canDeactivate: [flushPendingGuard],\n loadComponent: () =>\n \"import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage)\",\n },\n {\n path: 'beheer/stamdata',\n // Admin-only stamdata maintenance editor (ADR-0004): capabilityGuard denies-by-default\n // unless GET /me resolved `stamdata:edit` (Admin role). Backend re-enforces via the\n // StamdataAdmin gate — the guard just avoids loading a page that would 403.\n canActivate: [\"capabilityGuard('stamdata:edit')\"],\n loadComponent: () => \"import('@beheer/ui/stamdata.page').then((m) => m.StamdataPage)\",\n },\n {\n path: 'concepts',\n loadComponent: () => \"import('./showcase/concepts.page').then((m) => m.ConceptsPage)\",\n },\n { path: '**', redirectTo: 'login' },\n ],\n },\n]" } ], "src/app/shared/layout/breadcrumb/breadcrumb-trail.ts": [ @@ -47411,6 +47669,47 @@ "args": [] } ], + "src/app/shared/application/pending-saves.ts": [ + { + "name": "provideUnloadFlush", + "file": "src/app/shared/application/pending-saves.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Wire a beforeunload handler that guards the last-mile save on a hard tab-close/reload.\nponytail: the HTTP seam is Angular HttpClient (no keepalive/sendBeacon), so an\nasync flush can't be guaranteed to finish as the page tears down — we fire it best-effort\nAND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce\nland if the user stays. Upgrade path: a sendBeacon/keepalive last-mile if this ever\nneeds to be guaranteed.

\n", + "args": [] + }, + { + "name": "registerPendingSave", + "file": "src/app/shared/application/pending-saves.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "

Register the current injection context's owner for the life of its DestroyRef.\nCall from a constructor or field initializer (root store, or createDraftSync).

\n", + "args": [ + { + "name": "owner", + "type": "PendingSave", + "deprecated": false, + "deprecationMessage": "" + } + ], + "returnType": "void", + "jsdoctags": [ + { + "name": "owner", + "type": "PendingSave", + "deprecated": false, + "deprecationMessage": "", + "tagName": { + "text": "param" + } + } + ] + } + ], "src/app/beheer/domain/stamdata-editor.machine.ts": [ { "name": "reduce", @@ -48878,7 +49177,7 @@ ] }, "coverage": { - "count": 35, + "count": 36, "status": "medium", "files": [ { @@ -49398,8 +49697,8 @@ "type": "injectable", "linktype": "injectable", "name": "BriefStore", - "coveragePercent": 34, - "coverageCount": "18/52", + "coveragePercent": 36, + "coverageCount": "20/55", "status": "medium" }, { @@ -49437,8 +49736,8 @@ "type": "injectable", "linktype": "injectable", "name": "OrgTemplateStore", - "coveragePercent": 12, - "coverageCount": "5/41", + "coveragePercent": 16, + "coverageCount": "7/43", "status": "low" }, { @@ -52398,6 +52697,54 @@ "coverageCount": "0/1", "status": "low" }, + { + "filePath": "src/app/shared/application/pending-saves.ts", + "type": "injectable", + "linktype": "injectable", + "name": "PendingSaves", + "coveragePercent": 60, + "coverageCount": "3/5", + "status": "good" + }, + { + "filePath": "src/app/shared/application/pending-saves.ts", + "type": "interface", + "linktype": "interface", + "name": "PendingSave", + "coveragePercent": 100, + "coverageCount": "3/3", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/pending-saves.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "provideUnloadFlush", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/pending-saves.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "registerPendingSave", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/shared/application/pending-saves.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "flushPendingGuard", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, { "filePath": "src/app/shared/application/remote-data.ts", "type": "function", diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 0d5839f..6d0e2c2 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -17,6 +17,7 @@ import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { SESSION_PORT } from '@shared/application/session.port'; import { SessionStore } from '@auth/application/session.store'; import { provideRouteFocus } from '@shared/layout/route-focus'; +import { provideUnloadFlush } from '@shared/application/pending-saves'; registerLocaleData(localeNl); @@ -51,5 +52,6 @@ export const appConfig: ApplicationConfig = { { provide: SESSION_PORT, useExisting: SessionStore }, { provide: LOCALE_ID, useValue: 'nl' }, provideRouteFocus(), + provideUnloadFlush(), ], }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 6256122..da42f9f 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { ShellComponent } from '@shared/layout/shell/shell.component'; import { authGuard, capabilityGuard } from '@auth/auth.guard'; +import { flushPendingGuard } from '@shared/application/pending-saves'; export const routes: Routes = [ { @@ -32,23 +33,28 @@ export const routes: Routes = [ { path: 'registreren', canActivate: [authGuard], + // Autosave wizard: flush the pending debounced draft before leaving (pending-saves.ts). + canDeactivate: [flushPendingGuard], loadComponent: () => import('@registratie/ui/registratie.page').then((m) => m.RegistratiePage), }, { path: 'herregistratie', canActivate: [authGuard], + canDeactivate: [flushPendingGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then((m) => m.HerregistratiePage), }, { path: 'intake', canActivate: [authGuard], + canDeactivate: [flushPendingGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then((m) => m.IntakePage), }, { path: 'brief', canActivate: [authGuard], + canDeactivate: [flushPendingGuard], loadComponent: () => import('@brief/ui/brief.page').then((m) => m.BriefPage), }, { @@ -57,6 +63,7 @@ export const routes: Routes = [ // unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces // via the OrgAdmin gate — the guard just avoids loading a page that would 403. canActivate: [capabilityGuard('orgtemplate:edit')], + canDeactivate: [flushPendingGuard], loadComponent: () => import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage), }, diff --git a/src/app/brief/application/brief.store.spec.ts b/src/app/brief/application/brief.store.spec.ts index c77c320..b7d774a 100644 --- a/src/app/brief/application/brief.store.spec.ts +++ b/src/app/brief/application/brief.store.spec.ts @@ -301,3 +301,28 @@ describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => { expect(store.lastError()).toBe('geweigerd'); }); }); + +describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => { + const okSave = () => + vi.fn(() => Promise.resolve({ ok: true, value: filledView } as Result)); + + it('flushes a pending debounced edit immediately and clears the pending flag', async () => { + const save = okSave(); + const store = await loadedStore({ save }); + expect(store.hasPendingSave()).toBe(false); + + store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' }); + expect(store.hasPendingSave()).toBe(true); // 600ms debounce armed, not yet fired + + await store.flushPending(); + expect(save).toHaveBeenCalledTimes(1); // no timer wait needed + expect(store.hasPendingSave()).toBe(false); // timer consumed + }); + + it('is a no-op when no edit is pending', async () => { + const save = okSave(); + const store = await loadedStore({ save }); + await store.flushPending(); + expect(save).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/brief/application/brief.store.ts b/src/app/brief/application/brief.store.ts index da7f84c..cde4ae4 100644 --- a/src/app/brief/application/brief.store.ts +++ b/src/app/brief/application/brief.store.ts @@ -17,6 +17,7 @@ import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; import { uploadContentUrl } from '@shared/upload/upload.adapter'; +import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; /** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union instead of a busy boolean + a nullable error sitting side by side. */ @@ -38,7 +39,7 @@ type LoadedBriefState = Extract; * P1) via `BriefState.loaded.decisions` — this store never computes them itself. */ @Injectable({ providedIn: 'root' }) -export class BriefStore { +export class BriefStore implements PendingSave { private adapter = inject(BriefAdapter); private previewAdapter = inject(LetterPreviewAdapter); private revealAdapter = inject(RevealBigNummerAdapter); @@ -188,12 +189,32 @@ export class BriefStore { this.future.set([]); } + constructor() { + // Register so the CanDeactivate guard / beforeunload handler can flush a pending + // debounced edit before navigation or unload (see pending-saves.ts). + registerPendingSave(this); + } + private saveTimer?: ReturnType; private scheduleSave() { if (!this.canEdit()) return; clearTimeout(this.saveTimer); // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record. - this.saveTimer = setTimeout(() => void this.flushSave(), 600); + // Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed". + this.saveTimer = setTimeout(() => { + this.saveTimer = undefined; + void this.flushSave(); + }, 600); + } + + /** True while a debounced edit hasn't been written yet (PendingSave). */ + hasPendingSave = () => this.saveTimer !== undefined; + /** Flush a pending debounced save now and await it; no-op when nothing is pending. */ + async flushPending() { + if (this.saveTimer === undefined) return; + clearTimeout(this.saveTimer); + this.saveTimer = undefined; + await this.flushSave(); } private async flushSave() { const b = this.brief(); @@ -217,6 +238,7 @@ export class BriefStore { async resetDemo() { this.actionState.set({ tag: 'Busy' }); clearTimeout(this.saveTimer); + this.saveTimer = undefined; const r = await this.adapter.reset(); this.saveState.set({ tag: 'Idle' }); if (r.ok) { @@ -268,6 +290,7 @@ export class BriefStore { private async transition(action: () => Promise>) { this.actionState.set({ tag: 'Busy' }); clearTimeout(this.saveTimer); + this.saveTimer = undefined; await this.flushSave(); const r = await action(); if (!r.ok) { diff --git a/src/app/brief/application/org-template.store.ts b/src/app/brief/application/org-template.store.ts index 9af7c49..e83b0e1 100644 --- a/src/app/brief/application/org-template.store.ts +++ b/src/app/brief/application/org-template.store.ts @@ -17,6 +17,7 @@ import { reduce, } from '@brief/domain/org-template.machine'; import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; +import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; /** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */ type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; @@ -34,7 +35,7 @@ const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesj * (in the reducer) and triggers a save (here). Mirrors `BriefStore`. */ @Injectable({ providedIn: 'root' }) -export class OrgTemplateStore { +export class OrgTemplateStore implements PendingSave { private adapter = inject(OrgTemplateAdapter); private uploadAdapter = inject(UploadAdapter); private shell = inject(UploadShellService); @@ -108,6 +109,8 @@ export class OrgTemplateStore { if (status === 'resolved' || status === 'local') this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] }); }); + // Flush a pending debounced edit before navigation/unload (see pending-saves.ts). + registerPendingSave(this); } async load() { @@ -130,6 +133,7 @@ export class OrgTemplateStore { this.selectedSubOrgId.set(subOrgId); this.saveState.set({ tag: 'Idle' }); clearTimeout(this.saveTimer); + this.saveTimer = undefined; this.store.dispatch({ tag: 'Loading' }); const r = await this.adapter.load(subOrgId); if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); @@ -147,7 +151,21 @@ export class OrgTemplateStore { if (this.loaded() === null) return; clearTimeout(this.saveTimer); // ponytail: 600ms debounce, same as BriefStore; the server is the store of record. - this.saveTimer = setTimeout(() => void this.flushSave(), 600); + // Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed". + this.saveTimer = setTimeout(() => { + this.saveTimer = undefined; + void this.flushSave(); + }, 600); + } + + /** True while a debounced edit hasn't been written yet (PendingSave). */ + hasPendingSave = () => this.saveTimer !== undefined; + /** Flush a pending debounced save now and await it; no-op when nothing is pending. */ + async flushPending() { + if (this.saveTimer === undefined) return; + clearTimeout(this.saveTimer); + this.saveTimer = undefined; + await this.flushSave(); } private async flushSave() { const s = this.loaded(); @@ -178,6 +196,7 @@ export class OrgTemplateStore { this.pendingPublish.set(false); this.actionState.set({ tag: 'Busy' }); clearTimeout(this.saveTimer); + this.saveTimer = undefined; await this.flushSave(); // publish the saved draft — flush any pending edit first const r = await this.adapter.publish(s.subOrgId); if (!r.ok) { @@ -193,6 +212,7 @@ export class OrgTemplateStore { if (!s) return; this.actionState.set({ tag: 'Busy' }); clearTimeout(this.saveTimer); + this.saveTimer = undefined; const r = await this.adapter.rollback(s.subOrgId, version); if (!r.ok) { this.actionState.set({ tag: 'Failed', error: r.error }); @@ -207,6 +227,7 @@ export class OrgTemplateStore { if (!s) return; this.actionState.set({ tag: 'Busy' }); clearTimeout(this.saveTimer); + this.saveTimer = undefined; await this.flushSave(); // the proefbrief renders the server's draft const r = await this.adapter.proefbrief(s.subOrgId); if (!r.ok) { diff --git a/src/app/registratie/application/draft-sync.spec.ts b/src/app/registratie/application/draft-sync.spec.ts index d1a6eff..616526c 100644 --- a/src/app/registratie/application/draft-sync.spec.ts +++ b/src/app/registratie/application/draft-sync.spec.ts @@ -96,4 +96,39 @@ describe('createDraftSync', () => { expect(r.ok).toBe(false); }); }); + + describe('flushPending (CanDeactivate guard / beforeunload)', () => { + it('hasPendingSave reflects an armed debounce timer', () => { + const { draftSync, snap } = setup({ + create: vi.fn().mockResolvedValue('a1'), + syncDraft: vi.fn().mockResolvedValue(undefined), + }); + expect(draftSync.hasPendingSave()).toBe(false); + + snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] }); + tick(); // the effect arms the 600ms timer + expect(draftSync.hasPendingSave()).toBe(true); + }); + + it('flushPending writes the pending draft immediately, before the debounce fires', async () => { + const create = vi.fn().mockResolvedValue('a1'); + const syncDraft = vi.fn().mockResolvedValue(undefined); + const { draftSync, snap } = setup({ create, syncDraft }); + + snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] }); + tick(); + await draftSync.flushPending(); + + expect(syncDraft).toHaveBeenCalledTimes(1); // no timer advance needed + expect(draftSync.hasPendingSave()).toBe(false); // timer consumed + }); + + it('flushPending is a no-op when nothing is pending', async () => { + const syncDraft = vi.fn().mockResolvedValue(undefined); + const { draftSync } = setup({ create: vi.fn().mockResolvedValue('a1'), syncDraft }); + + await draftSync.flushPending(); + expect(syncDraft).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/registratie/application/draft-sync.ts b/src/app/registratie/application/draft-sync.ts index aea2e47..6594a9e 100644 --- a/src/app/registratie/application/draft-sync.ts +++ b/src/app/registratie/application/draft-sync.ts @@ -2,6 +2,7 @@ import { DestroyRef, effect, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { Result } from '@shared/kernel/fp'; import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; +import { registerPendingSave } from '@shared/application/pending-saves'; import type { SubmitApplicationRequest, SubmitApplicationResponse, @@ -104,11 +105,26 @@ export function createDraftSync(deps: DraftSyncDeps) { const snap = deps.snapshot(); // tracked: fires on every machine change if (!snap) return; if (timer) clearTimeout(timer); - timer = setTimeout(() => void flush(), DEBOUNCE_MS); + // Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed". + timer = setTimeout(() => { + timer = undefined; + void flush(); + }, DEBOUNCE_MS); }); inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer)); + // Flush a pending debounced draft write before an in-app route change / unload (see + // pending-saves.ts). onDestroy above only cancels the timer — this actually persists it. + const hasPendingSave = () => timer !== undefined; + const flushPending = async () => { + if (timer === undefined) return; + clearTimeout(timer); + timer = undefined; + await flush(); + }; + registerPendingSave({ hasPendingSave, flushPending }); + // Attach to a specific Concept id and seed the machine from its draft. A non-Concept // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft. const load = (linked: string): Promise => { @@ -142,6 +158,11 @@ export function createDraftSync(deps: DraftSyncDeps) { }; return { + /** True while a debounced draft write is still pending (PendingSave). */ + hasPendingSave, + /** Flush the pending draft write now and await it; no-op when nothing is pending. */ + flushPending, + /** Resolve the initial state: a `?aanvraag` link wins; else resume this type's existing Concept; else start fresh (a Concept is created on first progress). */ async resume() { diff --git a/src/app/shared/application/pending-saves.spec.ts b/src/app/shared/application/pending-saves.spec.ts new file mode 100644 index 0000000..cd69603 --- /dev/null +++ b/src/app/shared/application/pending-saves.spec.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { PendingSave, PendingSaves, flushPendingGuard } from './pending-saves'; + +/** A fake autosave owner whose pending-ness and flush are controllable. */ +function fakeOwner(pending: boolean): PendingSave & { flushPending: ReturnType } { + return { + hasPendingSave: () => pending, + flushPending: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('PendingSaves registry', () => { + it('hasPending is true only while some registered owner has a pending write', () => { + const reg = new PendingSaves(); + const idle = fakeOwner(false); + reg.register(idle); + expect(reg.hasPending()).toBe(false); + + const dirty = fakeOwner(true); + reg.register(dirty); + expect(reg.hasPending()).toBe(true); + }); + + it('unregister removes an owner so it no longer counts', () => { + const reg = new PendingSaves(); + const dirty = fakeOwner(true); + const off = reg.register(dirty); + expect(reg.hasPending()).toBe(true); + off(); + expect(reg.hasPending()).toBe(false); + }); + + it('flushAll flushes only the pending owners', async () => { + const reg = new PendingSaves(); + const idle = fakeOwner(false); + const dirty = fakeOwner(true); + reg.register(idle); + reg.register(dirty); + + await reg.flushAll(); + + expect(dirty.flushPending).toHaveBeenCalledTimes(1); + expect(idle.flushPending).not.toHaveBeenCalled(); + }); + + it('flushAll awaits every owner and swallows a rejected flush', async () => { + const reg = new PendingSaves(); + const failing = fakeOwner(true); + failing.flushPending.mockRejectedValue(new Error('save failed')); + const ok = fakeOwner(true); + reg.register(failing); + reg.register(ok); + + await expect(reg.flushAll()).resolves.toBeUndefined(); // never rejects + expect(ok.flushPending).toHaveBeenCalledTimes(1); + }); +}); + +describe('flushPendingGuard', () => { + it('flushes then allows navigation when a write is pending', async () => { + const dirty = fakeOwner(true); + TestBed.configureTestingModule({}); + const reg = TestBed.inject(PendingSaves); + reg.register(dirty); + + const result = TestBed.runInInjectionContext(() => + // the guard ignores its route args + (flushPendingGuard as (...a: unknown[]) => boolean | Promise)(), + ); + + await expect(result).resolves.toBe(true); + expect(dirty.flushPending).toHaveBeenCalledTimes(1); + }); + + it('allows navigation immediately when nothing is pending', () => { + TestBed.configureTestingModule({}); + TestBed.inject(PendingSaves).register(fakeOwner(false)); + + const result = TestBed.runInInjectionContext(() => + (flushPendingGuard as (...a: unknown[]) => boolean | Promise)(), + ); + + expect(result).toBe(true); // synchronous, not a Promise + }); +}); diff --git a/src/app/shared/application/pending-saves.ts b/src/app/shared/application/pending-saves.ts new file mode 100644 index 0000000..58ed310 --- /dev/null +++ b/src/app/shared/application/pending-saves.ts @@ -0,0 +1,85 @@ +import { + DestroyRef, + ENVIRONMENT_INITIALIZER, + Injectable, + inject, +} from '@angular/core'; +import { CanDeactivateFn } from '@angular/router'; + +/** + * A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in + * this app have different lifetimes — root singleton stores (`BriefStore`, + * `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child + * organisms — so both register here instead of the guard/unload handler needing to know + * which page or store owns the pending write. + */ +export interface PendingSave { + /** True while a debounced edit hasn't been written to the backend yet. */ + hasPendingSave(): boolean; + /** Flush that pending write now and await it. No-op when nothing is pending. */ + flushPending(): Promise; +} + +/** Registry of every active autosave owner. The `CanDeactivate` guard and the + `beforeunload` handler flush through this — one seam, both callers. */ +@Injectable({ providedIn: 'root' }) +export class PendingSaves { + private readonly owners = new Set(); + + /** Register an owner; returns an unregister function. */ + register(owner: PendingSave): () => void { + this.owners.add(owner); + return () => this.owners.delete(owner); + } + + hasPending(): boolean { + return [...this.owners].some((o) => o.hasPendingSave()); + } + + /** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected + flush is swallowed (a failed autosave surfaces its own error state; navigation must + not be blocked by it). */ + async flushAll(): Promise { + await Promise.allSettled( + [...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()), + ); + } +} + +/** Register the current injection context's owner for the life of its `DestroyRef`. + Call from a constructor or field initializer (root store, or `createDraftSync`). */ +export function registerPendingSave(owner: PendingSave): void { + const unregister = inject(PendingSaves).register(owner); + inject(DestroyRef).onDestroy(unregister); +} + +/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change, + then allow navigation. Awaitable, so the write lands before the page tears down (which + would otherwise drop a sub-debounce edit). We never block leaving — the flush is a + guarantee of effort, not a gate. */ +export const flushPendingGuard: CanDeactivateFn = () => { + const pending = inject(PendingSaves); + return pending.hasPending() ? pending.flushAll().then(() => true) : true; +}; + +/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload. + ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an + async flush can't be guaranteed to finish as the page tears down — we fire it best-effort + AND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce + land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever + needs to be guaranteed. */ +export function provideUnloadFlush() { + return { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useValue: () => { + const pending = inject(PendingSaves); + window.addEventListener('beforeunload', (e) => { + if (!pending.hasPending()) return; + void pending.flushAll(); + e.preventDefault(); + e.returnValue = ''; + }); + }, + }; +}