From 6d0861bb0a645ea39b2ab3d3ddb8a61ab4e90182 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Wed, 27 May 2026 14:41:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(stellungnahme):=20Einwendungs-Assistent=20?= =?UTF-8?q?f=C3=BCr=20Windvorranggebiete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5-Schritt-Wizard: Gebietskontext → Argumente wählen → Ausgabeform → persönliche Betroffenheit → Absender + Ausgabe. Alle Eingaben bleiben lokal im Browser (kein Server-Transfer). Fertigtext- und Leitfaden-Modus. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/block-types.ts | 22 + src/lib/blog-utils.ts | 73 ++ src/lib/cms.ts | 26 + src/lib/components/BlockRenderer.svelte | 6 + .../blocks/StellingnahmeGeneratorBlock.svelte | 640 ++++++++++++++++++ src/lib/windkarte.ts | 3 +- src/routes/[...slug]/+page.server.ts | 2 + 7 files changed, 771 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index 752ff5e..0d81891 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -448,3 +448,25 @@ export interface WindMapBlockData { areas?: (string | { _slug: string;[key: string]: unknown })[]; layout?: BlockLayout; } + +import type { TextFragment, WindArea } from "./windkarte"; + +/** Stellungnahme-Generator (_type: "stellungnahme_generator"). + * Platzierbare Instanz, rendert Einwendungs-Assistenten für ein Vorranggebiet. */ +export interface StellingnahmeGeneratorBlockData { + _type?: "stellungnahme_generator"; + _slug?: string; + id?: string; + /** Referenz auf wind_area; nach Resolve vollständiges WindArea-Objekt inkl. + * aufgelöster stellungnahme_kriterien (TextFragment[]). */ + windArea?: string | (WindArea & { + stellungnahme_kriterien?: TextFragment[]; + }); + headline?: string; + intro?: string; + /** Referenz auf tag; nach Resolve: { _slug, name }. */ + allgemeineArgumenteTag?: string | { _slug: string; name?: string }; + /** Vom Resolver befüllt: allgemeine (gebietsunabhängige) Fragmente via Tag. */ + allgemeineArgumente?: TextFragment[]; + layout?: BlockLayout; +} diff --git a/src/lib/blog-utils.ts b/src/lib/blog-utils.ts index d34bd83..d2b7762 100644 --- a/src/lib/blog-utils.ts +++ b/src/lib/blog-utils.ts @@ -4,6 +4,8 @@ import { getPostBySlug, getTags, getTextFragmentBySlug, + getTextFragmentsByTag, + getEntryBySlug, getCalendarBySlug, getCalendarItemBySlug, getCalendarItems, @@ -760,3 +762,74 @@ export async function resolveSearchableTextBlocks( } } } + +function isStellingnahmeGeneratorBlock(item: unknown): boolean { + return (item as { _type?: string })?._type === "stellungnahme_generator"; +} + +/** Löst stellungnahme_generator-Blöcke auf: + * - windArea → vollständiges wind_area-Objekt mit aufgelösten stellungnahme_kriterien + * - allgemeineArgumenteTag → allgemeineArgumente[] via Tag-Filter + */ +export async function resolveStellingnahmeGeneratorBlocks( + layout: RowContentLayout | null | undefined, +): Promise { + if (!layout) return; + const rows = [ + layout.row1Content ?? [], + layout.row2Content ?? [], + layout.row3Content ?? [], + ]; + for (const content of rows) { + if (!Array.isArray(content)) continue; + for (const item of content) { + if (!isStellingnahmeGeneratorBlock(item)) continue; + const block = item as Record; + + // Resolve windArea + const windAreaRef = block.windArea; + const windAreaSlug = + typeof windAreaRef === "string" + ? windAreaRef + : (windAreaRef as { _slug?: string })?._slug ?? ""; + if (windAreaSlug) { + const windArea = await getEntryBySlug("wind_area", windAreaSlug, { + locale: "de", + resolve: ["stellungnahme_kriterien"], + depth: 2, + }); + if (windArea) { + const kriterienRaw = + (windArea as { stellungnahme_kriterien?: unknown[] }) + .stellungnahme_kriterien ?? []; + // Fragmente einzeln laden um bulletPoints zu bekommen (nicht im Basis-Resolve) + const kriterien = await Promise.all( + kriterienRaw.map(async (k) => { + const slug = + typeof k === "string" + ? k + : (k as { _slug?: string })?._slug ?? ""; + if (!slug) return null; + return getTextFragmentBySlug(slug, { locale: "de" }); + }), + ); + (windArea as Record).stellungnahme_kriterien = + kriterien.filter(Boolean); + block.windArea = windArea; + } + } + + // Resolve allgemeineArgumente via Tag + const tagRef = block.allgemeineArgumenteTag; + const tagSlug = + typeof tagRef === "string" + ? tagRef + : (tagRef as { _slug?: string })?._slug ?? ""; + if (tagSlug) { + block.allgemeineArgumente = await getTextFragmentsByTag(tagSlug, { + locale: "de", + }); + } + } + } +} diff --git a/src/lib/cms.ts b/src/lib/cms.ts index 88abb40..846955a 100644 --- a/src/lib/cms.ts +++ b/src/lib/cms.ts @@ -749,6 +749,32 @@ export async function getTextFragmentBySlug( }); } +/** Alle Text-Fragmente, die einen bestimmten Tag (Slug) tragen. */ +export async function getTextFragmentsByTag( + tagSlug: string, + options?: { locale?: string }, +): Promise { + const key = `text_fragment:tag:${tagSlug}:${options?.locale ?? ""}`; + return cached("text_fragment", key, async () => { + const base = getBaseUrl(); + const url = new URL(`${base}/api/content/text_fragment`); + url.searchParams.set("_per_page", "500"); + url.searchParams.set("_resolve", "tags"); + if (options?.locale) url.searchParams.set("_locale", options.locale); + const res = await cmsFetch(url.toString()); + if (!res.ok) return []; + const data = (await res.json()) as { items?: TextFragmentEntry[] }; + const items = data.items ?? []; + return items.filter((f) => { + const tags = (f as unknown as { tags?: unknown[] }).tags ?? []; + return tags.some((t) => { + if (typeof t === "string") return t === tagSlug; + return (t as { _slug?: string })?._slug === tagSlug; + }); + }); + }); +} + /** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */ export type TopBannerEntry = components["schemas"]["top_banner"]; diff --git a/src/lib/components/BlockRenderer.svelte b/src/lib/components/BlockRenderer.svelte index 4c05a3a..bd3db3d 100644 --- a/src/lib/components/BlockRenderer.svelte +++ b/src/lib/components/BlockRenderer.svelte @@ -44,6 +44,7 @@ DeadlineBannerBlockData, LiveStrommixBlockData, WindMapBlockData, + StellingnahmeGeneratorBlockData, } from "$lib/block-types"; let { @@ -104,6 +105,11 @@ {/await} {:else if type === "wind_map"} +{:else if type === "stellungnahme_generator"} + {#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m} + {@const Comp = m.default} + + {/await} {:else}
Unbekannter Block: {type} diff --git a/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte b/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte new file mode 100644 index 0000000..5b199d8 --- /dev/null +++ b/src/lib/components/blocks/StellingnahmeGeneratorBlock.svelte @@ -0,0 +1,640 @@ + + +
+ +
+ + + + Ihre Daten bleiben auf Ihrem Gerät. Name, Anschrift und alle Eingaben werden nicht an Server übertragen. +
+ + + {#if block.headline} +

{block.headline}

+ {/if} + {#if block.intro} +
+ {@html md(block.intro)} +
+ {/if} + + +
+ {#each ([0, 1, 2, 3, 4] as const) as s} + + {#if s < 4} +
+ {/if} + {/each} +
+ + + {#if step === 0} +
+

+ Schritt 1 – Ihr Vorranggebiet +

+ + {#if windArea} +
+
+ Vorranggebiet +
+
{windArea.gebiets_nr}
+ {#if windArea.bezeichnung} +
{windArea.bezeichnung}
+ {/if} + +
+ {#if windArea.gemeinden?.length} +
+
Gemeinden
+
{windArea.gemeinden.join(", ")}
+
+ {/if} + {#if windArea.flaeche_ha} +
+
Fläche
+
{windArea.flaeche_ha.toLocaleString("de-DE")} ha
+
+ {/if} + {#if windArea.anlagen_geplant} +
+
Anlagen geplant
+
{windArea.anlagen_geplant}
+
+ {/if} + {#if windArea.hoehe_max_m} +
+
Max. Höhe
+
{windArea.hoehe_max_m} m
+
+ {/if} + {#if windArea.investor} +
+
Investor
+
{windArea.investor}
+
+ {/if} + {#if windArea.status} +
+
Status
+
{windArea.status.replace(/_/g, " ")}
+
+ {/if} +
+ + {#if windArea.notizen} +
+ {@html md(windArea.notizen)} +
+ {/if} +
+ {:else} +
+ Kein Vorranggebiet konfiguriert. Bitte Seite im CMS einrichten. +
+ {/if} + +

+ Dieser Assistent hilft Ihnen, eine individuelle Einwendung für den + 2. Entwurf des Regionalplans Südwestthüringen zu verfassen. + Im nächsten Schritt wählen Sie die Argumente, die auf Ihre Situation zutreffen. +

+ + +
+ + + {:else if step === 1} +
+

+ Schritt 2 – Argumente wählen +

+

+ Wählen Sie alle Punkte, die auf Ihr Gebiet und Ihre Situation zutreffen. + Ortskonkrete Argumente sind vorausgewählt. +

+ + {#if ortskonkreteKriterien.length > 0} +
+
+ + Ortskonkrete Argumente +
+
+ {#each ortskonkreteKriterien as k} + + {/each} +
+
+ {/if} + + {#if allgemeineArgList.length > 0} +
+
+ + Allgemeine Argumente +
+
+ {#each allgemeineArgList as k} + + {/each} +
+
+ {/if} + + {#if ortskonkreteKriterien.length === 0 && allgemeineArgList.length === 0} +
+ Noch keine Argumente im CMS hinterlegt. +
+ {/if} + +
+ + +
+
+ + + {:else if step === 2} +
+

+ Schritt 3 – Ausgabeform +

+

+ Wie soll Ihre Einwendung aufgebaut sein? +

+ +
+ + + +
+ +
+ + +
+
+ + + {:else if step === 3} +
+

+ Schritt 4 – Persönliche Betroffenheit +

+

+ Beschreiben Sie kurz, wie Sie persönlich betroffen sind. + Wichtig: Individuelle Betroffenheit stärkt Ihre Einwendung erheblich + und macht sie von anderen unterscheidbar. +

+

+ Beispiel: Ich wohne 800 m vom geplanten Standort entfernt und bin Eigentümer von … +

+ + +

Bleibt auf Ihrem Gerät. Wird nicht übertragen.

+ +
+ + +
+
+ + + {:else if step === 4} +
+

+ Schritt 5 – Absender & Ausgabe +

+

+ Ihre Daten werden nur lokal zur Texterstellung genutzt. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
Einzureichen bei:
+
{RECIPIENT}
+
+ +
+ + +
+ + {#if showOutput} +
+
+ Ihre Einwendung +
+ + +
+
+
{outputText}
+
+ +
+ Hinweis: Bitte passen Sie den Text vor dem Einreichen individuell an. + Einwendungen mit persönlichen Formulierungen sind wirksamer als identische Kopien. + Einreichfrist: {#if windArea?.stellungnahme && typeof windArea.stellungnahme === "object" && "slug" in windArea.stellungnahme} + siehe Verfahrensbekanntmachung + {:else} + bitte bei der Planungsstelle erfragen + {/if}. +
+ {/if} +
+ {/if} +
diff --git a/src/lib/windkarte.ts b/src/lib/windkarte.ts index a73108b..e30cc28 100644 --- a/src/lib/windkarte.ts +++ b/src/lib/windkarte.ts @@ -3,6 +3,7 @@ export type TextFragment = { id?: string; title?: string; text?: string; + bulletPoints?: string[]; tags?: Array<{ _slug: string; name?: string } | string>; }; @@ -17,7 +18,7 @@ export type WindArea = { investor?: string; gemeinden?: string[]; stellungnahme?: { _slug: string; slug?: string } | string | null; - stellungnahme_kriterien?: Array<{ _slug: string } | string> | null; + stellungnahme_kriterien?: Array | null; notizen?: string; geometry?: GeoJSON.Geometry; }; diff --git a/src/routes/[...slug]/+page.server.ts b/src/routes/[...slug]/+page.server.ts index 966d9da..ce6c22b 100644 --- a/src/routes/[...slug]/+page.server.ts +++ b/src/routes/[...slug]/+page.server.ts @@ -12,6 +12,7 @@ import { resolveSearchableTextBlocks, resolveCalendarBlocks, resolveDeadlineBannerBlocks, + resolveStellingnahmeGeneratorBlocks, } from '$lib/blog-utils'; import { PAGE_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM } from '$lib/constants'; import { env } from '$env/dynamic/public'; @@ -64,6 +65,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead resolveSearchableTextBlocks(page, tagsMap), resolveCalendarBlocks(page), resolveDeadlineBannerBlocks(page), + resolveStellingnahmeGeneratorBlocks(page), ]); sanitizeBlocks(page);