feat(stellungnahme): Einwendungs-Assistent für Windvorranggebiete
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 <noreply@anthropic.com>
This commit is contained in:
@@ -448,3 +448,25 @@ export interface WindMapBlockData {
|
|||||||
areas?: (string | { _slug: string;[key: string]: unknown })[];
|
areas?: (string | { _slug: string;[key: string]: unknown })[];
|
||||||
layout?: BlockLayout;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
getPostBySlug,
|
getPostBySlug,
|
||||||
getTags,
|
getTags,
|
||||||
getTextFragmentBySlug,
|
getTextFragmentBySlug,
|
||||||
|
getTextFragmentsByTag,
|
||||||
|
getEntryBySlug,
|
||||||
getCalendarBySlug,
|
getCalendarBySlug,
|
||||||
getCalendarItemBySlug,
|
getCalendarItemBySlug,
|
||||||
getCalendarItems,
|
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<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
// 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<string, unknown>).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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<TextFragmentEntry[]> {
|
||||||
|
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). */
|
/** Fullwidth-Banner (z. B. für Top-Banner mit Bild). */
|
||||||
export type TopBannerEntry = components["schemas"]["top_banner"];
|
export type TopBannerEntry = components["schemas"]["top_banner"];
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
DeadlineBannerBlockData,
|
DeadlineBannerBlockData,
|
||||||
LiveStrommixBlockData,
|
LiveStrommixBlockData,
|
||||||
WindMapBlockData,
|
WindMapBlockData,
|
||||||
|
StellingnahmeGeneratorBlockData,
|
||||||
} from "$lib/block-types";
|
} from "$lib/block-types";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -104,6 +105,11 @@
|
|||||||
{/await}
|
{/await}
|
||||||
{:else if type === "wind_map"}
|
{:else if type === "wind_map"}
|
||||||
<WindkarteBlock block={block as WindMapBlockData} />
|
<WindkarteBlock block={block as WindMapBlockData} />
|
||||||
|
{:else if type === "stellungnahme_generator"}
|
||||||
|
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
|
||||||
|
{@const Comp = m.default}
|
||||||
|
<Comp block={block as StellingnahmeGeneratorBlockData} />
|
||||||
|
{/await}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded border border-erde-200 bg-erde-50 px-3 py-2 text-sm text-erde-800">
|
<div class="rounded border border-erde-200 bg-erde-50 px-3 py-2 text-sm text-erde-800">
|
||||||
Unbekannter Block: <code>{type}</code>
|
Unbekannter Block: <code>{type}</code>
|
||||||
|
|||||||
@@ -0,0 +1,640 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { marked } from "$lib/markdown-safe";
|
||||||
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
|
import type { StellingnahmeGeneratorBlockData } from "$lib/block-types";
|
||||||
|
import type { TextFragment, WindArea } from "$lib/windkarte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
block,
|
||||||
|
class: className = "",
|
||||||
|
}: {
|
||||||
|
block: StellingnahmeGeneratorBlockData;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
|
|
||||||
|
// Resolved data from server
|
||||||
|
const windArea = $derived(
|
||||||
|
typeof block.windArea === "object" && block.windArea !== null
|
||||||
|
? (block.windArea as WindArea & { stellungnahme_kriterien?: TextFragment[] })
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const kriterien = $derived(
|
||||||
|
(windArea?.stellungnahme_kriterien ?? []).filter(
|
||||||
|
(k): k is TextFragment => typeof k === "object" && k !== null && "_slug" in k,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const allgemeineArgumente = $derived(
|
||||||
|
(block.allgemeineArgumente ?? []) as TextFragment[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Step state ──────────────────────────────────────────────────────────────
|
||||||
|
type Step = 0 | 1 | 2 | 3 | 4;
|
||||||
|
let step = $state<Step>(0);
|
||||||
|
|
||||||
|
// Step 1: selected argument slugs
|
||||||
|
let selectedSlugs = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Step 2: mode
|
||||||
|
let mode = $state<"fertig" | "leitfaden">("fertig");
|
||||||
|
|
||||||
|
// Step 3: personal statement
|
||||||
|
let persoenlicheEingabe = $state("");
|
||||||
|
|
||||||
|
// Step 4: sender
|
||||||
|
let absenderName = $state("");
|
||||||
|
let absenderAnschrift = $state("");
|
||||||
|
let absenderOrt = $state("");
|
||||||
|
let showOutput = $state(false);
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function slugLabel(slug: string) {
|
||||||
|
return slug.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fragmentTags(f: TextFragment): string[] {
|
||||||
|
return (f.tags ?? []).map((t) =>
|
||||||
|
typeof t === "string" ? t : ((t as { name?: string })?.name ?? ""),
|
||||||
|
).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group kriterien by first tag (ortskonkret) vs. allgemeine
|
||||||
|
const ortskonkreteKriterien = $derived(kriterien);
|
||||||
|
const allgemeineArgList = $derived(allgemeineArgumente);
|
||||||
|
|
||||||
|
// Pre-select all ortskonkrete on mount
|
||||||
|
$effect(() => {
|
||||||
|
const initial = new Set<string>();
|
||||||
|
for (const k of kriterien) {
|
||||||
|
initial.add(k._slug);
|
||||||
|
}
|
||||||
|
selectedSlugs = initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSlug(slug: string) {
|
||||||
|
const next = new Set(selectedSlugs);
|
||||||
|
if (next.has(slug)) {
|
||||||
|
next.delete(slug);
|
||||||
|
} else {
|
||||||
|
next.add(slug);
|
||||||
|
}
|
||||||
|
selectedSlugs = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build selected fragments in order: ortskonkret first, then allgemeine
|
||||||
|
const selectedFragments = $derived([
|
||||||
|
...ortskonkreteKriterien.filter((k) => selectedSlugs.has(k._slug)),
|
||||||
|
...allgemeineArgList.filter((k) => selectedSlugs.has(k._slug)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Output generation ────────────────────────────────────────────────────────
|
||||||
|
const RECIPIENT = `Regionale Planungsgemeinschaft Südwestthüringen
|
||||||
|
Regionale Planungsstelle
|
||||||
|
Karl-Liebknecht-Straße 4
|
||||||
|
98527 Suhl`;
|
||||||
|
|
||||||
|
const heute = $derived(
|
||||||
|
new Date().toLocaleDateString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const gebietsBez = $derived(
|
||||||
|
windArea
|
||||||
|
? `${windArea.gebiets_nr}${windArea.bezeichnung ? " – " + windArea.bezeichnung : ""}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildText(): string {
|
||||||
|
const sender = [absenderName.trim(), absenderAnschrift.trim(), absenderOrt.trim()]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const betreff = `Einwendung zum Entwurf des Regionalplans Südwestthüringen (2. Entwurf)\nWindenergie-Vorranggebiet ${gebietsBez}`;
|
||||||
|
|
||||||
|
let body = "";
|
||||||
|
|
||||||
|
if (mode === "fertig") {
|
||||||
|
// Fließtext: Einleitung + Argumente ausformuliert
|
||||||
|
body += `ich erhebe Einwendung gegen die Ausweisung des Windenergie-Vorranggebiets ${gebietsBez} im 2. Entwurf des Regionalplans Südwestthüringen.\n\n`;
|
||||||
|
|
||||||
|
for (const f of selectedFragments) {
|
||||||
|
if (f.title) body += `**${f.title}**\n\n`;
|
||||||
|
if (f.text) body += `${f.text.trim()}\n\n`;
|
||||||
|
if (f.bulletPoints?.length) {
|
||||||
|
for (const bp of f.bulletPoints) {
|
||||||
|
body += `- ${bp}\n`;
|
||||||
|
}
|
||||||
|
body += "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persoenlicheEingabe.trim()) {
|
||||||
|
body += `**Persönliche Betroffenheit**\n\n${persoenlicheEingabe.trim()}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body +=
|
||||||
|
"Ich fordere die Regionalplanung auf, das Vorranggebiet aus dem Regionalplan zu streichen oder zumindest einer erneuten, umfassenden Überprüfung zu unterziehen.\n\n";
|
||||||
|
body += "Mit freundlichen Grüßen";
|
||||||
|
} else {
|
||||||
|
// Leitfaden: Stichpunkte als Gliederung
|
||||||
|
body += `Betreff: Einwendung gegen Windenergie-Vorranggebiet ${gebietsBez}\n\n`;
|
||||||
|
body += "Gliederung meiner Einwendung (bitte in eigenen Worten ausformulieren):\n\n";
|
||||||
|
|
||||||
|
let i = 1;
|
||||||
|
for (const f of selectedFragments) {
|
||||||
|
body += `${i}. ${f.title ?? slugLabel(f._slug)}\n`;
|
||||||
|
if (f.bulletPoints?.length) {
|
||||||
|
for (const bp of f.bulletPoints) {
|
||||||
|
body += ` - ${bp}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body += "\n";
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persoenlicheEingabe.trim()) {
|
||||||
|
body += `${i}. Persönliche Betroffenheit\n ${persoenlicheEingabe.trim()}\n\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
sender,
|
||||||
|
"",
|
||||||
|
heute,
|
||||||
|
"",
|
||||||
|
"An:",
|
||||||
|
RECIPIENT,
|
||||||
|
"",
|
||||||
|
"Betreff: " + betreff.split("\n")[0],
|
||||||
|
betreff.split("\n")[1] ?? "",
|
||||||
|
"",
|
||||||
|
"Sehr geehrte Damen und Herren,",
|
||||||
|
"",
|
||||||
|
body.trimEnd(),
|
||||||
|
];
|
||||||
|
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let outputText = $state("");
|
||||||
|
|
||||||
|
function generateOutput() {
|
||||||
|
outputText = buildText();
|
||||||
|
showOutput = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(outputText);
|
||||||
|
copyFeedback = "Kopiert!";
|
||||||
|
setTimeout(() => (copyFeedback = ""), 2000);
|
||||||
|
} catch {
|
||||||
|
copyFeedback = "Kopieren fehlgeschlagen";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let copyFeedback = $state("");
|
||||||
|
|
||||||
|
function printText() {
|
||||||
|
const w = window.open("", "_blank");
|
||||||
|
if (!w) return;
|
||||||
|
w.document.write(
|
||||||
|
`<html><head><title>Stellungnahme</title><style>
|
||||||
|
body { font-family: serif; font-size: 12pt; max-width: 700px; margin: 40px auto; white-space: pre-wrap; }
|
||||||
|
@media print { body { margin: 0; } }
|
||||||
|
|
||||||
|
</style></head><body><pre>${outputText.replace(/</g, "<").replace(/>/g, ">")}</pre></body></html>`,
|
||||||
|
);
|
||||||
|
w.document.close();
|
||||||
|
w.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Markdown helper ──────────────────────────────────────────────────────────
|
||||||
|
function md(text: string | undefined): string {
|
||||||
|
if (!text) return "";
|
||||||
|
return marked.parse(text) as string;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="{layoutClasses} {className}">
|
||||||
|
<!-- Privacy notice -->
|
||||||
|
<div class="mb-6 flex items-start gap-2 rounded-lg border border-himmel-200 bg-himmel-50 px-4 py-3 text-sm text-himmel-800">
|
||||||
|
<svg class="mt-0.5 h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span><strong>Ihre Daten bleiben auf Ihrem Gerät.</strong> Name, Anschrift und alle Eingaben werden nicht an Server übertragen.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Headline / Intro -->
|
||||||
|
{#if block.headline}
|
||||||
|
<h2 class="mb-2 text-2xl font-bold text-wald-900">{block.headline}</h2>
|
||||||
|
{/if}
|
||||||
|
{#if block.intro}
|
||||||
|
<div class="prose prose-wald mb-6 max-w-none text-stein-700">
|
||||||
|
{@html md(block.intro)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Step indicator -->
|
||||||
|
<div class="mb-8 flex items-center gap-1 text-xs font-medium">
|
||||||
|
{#each ([0, 1, 2, 3, 4] as const) as s}
|
||||||
|
<button
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-full border transition-colors
|
||||||
|
{s === step
|
||||||
|
? 'border-wald-700 bg-wald-700 text-white'
|
||||||
|
: s < step
|
||||||
|
? 'border-wald-400 bg-wald-100 text-wald-700'
|
||||||
|
: 'border-stein-300 bg-white text-stein-400'}"
|
||||||
|
onclick={() => { if (s <= step) step = s; }}
|
||||||
|
disabled={s > step}
|
||||||
|
aria-current={s === step ? "step" : undefined}
|
||||||
|
title={["Gebiet", "Argumente", "Modus", "Betroffenheit", "Ausgabe"][s]}
|
||||||
|
>{s + 1}</button>
|
||||||
|
{#if s < 4}
|
||||||
|
<div class="h-px flex-1 {s < step ? 'bg-wald-400' : 'bg-stein-200'}"></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 0: Gebietskontext ─────────────────────────────────────────── -->
|
||||||
|
{#if step === 0}
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-4 text-lg font-semibold text-wald-800">
|
||||||
|
Schritt 1 – Ihr Vorranggebiet
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if windArea}
|
||||||
|
<div class="mb-6 rounded-xl border border-wald-200 bg-wald-50 p-5">
|
||||||
|
<div class="mb-2 text-xs font-medium uppercase tracking-wide text-wald-600">
|
||||||
|
Vorranggebiet
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-bold text-wald-900">{windArea.gebiets_nr}</div>
|
||||||
|
{#if windArea.bezeichnung}
|
||||||
|
<div class="mt-0.5 text-base text-wald-700">{windArea.bezeichnung}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<dl class="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">
|
||||||
|
{#if windArea.gemeinden?.length}
|
||||||
|
<div class="col-span-full">
|
||||||
|
<dt class="text-stein-500">Gemeinden</dt>
|
||||||
|
<dd class="font-medium text-stein-800">{windArea.gemeinden.join(", ")}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if windArea.flaeche_ha}
|
||||||
|
<div>
|
||||||
|
<dt class="text-stein-500">Fläche</dt>
|
||||||
|
<dd class="font-medium text-stein-800">{windArea.flaeche_ha.toLocaleString("de-DE")} ha</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if windArea.anlagen_geplant}
|
||||||
|
<div>
|
||||||
|
<dt class="text-stein-500">Anlagen geplant</dt>
|
||||||
|
<dd class="font-medium text-stein-800">{windArea.anlagen_geplant}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if windArea.hoehe_max_m}
|
||||||
|
<div>
|
||||||
|
<dt class="text-stein-500">Max. Höhe</dt>
|
||||||
|
<dd class="font-medium text-stein-800">{windArea.hoehe_max_m} m</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if windArea.investor}
|
||||||
|
<div>
|
||||||
|
<dt class="text-stein-500">Investor</dt>
|
||||||
|
<dd class="font-medium text-stein-800">{windArea.investor}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if windArea.status}
|
||||||
|
<div>
|
||||||
|
<dt class="text-stein-500">Status</dt>
|
||||||
|
<dd class="font-medium text-stein-800 capitalize">{windArea.status.replace(/_/g, " ")}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if windArea.notizen}
|
||||||
|
<div class="mt-4 border-t border-wald-200 pt-3 text-sm text-stein-700">
|
||||||
|
{@html md(windArea.notizen)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mb-6 rounded-xl border border-erde-200 bg-erde-50 p-4 text-sm text-erde-700">
|
||||||
|
Kein Vorranggebiet konfiguriert. Bitte Seite im CMS einrichten.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="mb-6 text-sm text-stein-600">
|
||||||
|
Dieser Assistent hilft Ihnen, eine individuelle Einwendung für den
|
||||||
|
<strong>2. Entwurf des Regionalplans Südwestthüringen</strong> zu verfassen.
|
||||||
|
Im nächsten Schritt wählen Sie die Argumente, die auf Ihre Situation zutreffen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800 disabled:opacity-40"
|
||||||
|
disabled={!windArea}
|
||||||
|
onclick={() => (step = 1)}
|
||||||
|
>
|
||||||
|
Weiter zu den Argumenten →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 1: Argumente wählen ──────────────────────────────────────── -->
|
||||||
|
{:else if step === 1}
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1 text-lg font-semibold text-wald-800">
|
||||||
|
Schritt 2 – Argumente wählen
|
||||||
|
</h3>
|
||||||
|
<p class="mb-5 text-sm text-stein-600">
|
||||||
|
Wählen Sie alle Punkte, die auf Ihr Gebiet und Ihre Situation zutreffen.
|
||||||
|
Ortskonkrete Argumente sind vorausgewählt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if ortskonkreteKriterien.length > 0}
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-wald-700">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-wald-600"></span>
|
||||||
|
Ortskonkrete Argumente
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each ortskonkreteKriterien as k}
|
||||||
|
<label class="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors
|
||||||
|
{selectedSlugs.has(k._slug) ? 'border-wald-400 bg-wald-50' : 'border-stein-200 bg-white hover:border-wald-200'}">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-4 w-4 shrink-0 rounded accent-wald-700"
|
||||||
|
checked={selectedSlugs.has(k._slug)}
|
||||||
|
onchange={() => toggleSlug(k._slug)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-stein-800">{k.title ?? slugLabel(k._slug)}</div>
|
||||||
|
{#if k.bulletPoints?.length}
|
||||||
|
<ul class="mt-1 space-y-0.5 text-xs text-stein-500">
|
||||||
|
{#each k.bulletPoints.slice(0, 2) as bp}
|
||||||
|
<li>– {bp}</li>
|
||||||
|
{/each}
|
||||||
|
{#if k.bulletPoints.length > 2}
|
||||||
|
<li class="text-stein-400">+ {k.bulletPoints.length - 2} weitere …</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if allgemeineArgList.length > 0}
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-stein-500">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full bg-stein-400"></span>
|
||||||
|
Allgemeine Argumente
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each allgemeineArgList as k}
|
||||||
|
<label class="flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors
|
||||||
|
{selectedSlugs.has(k._slug) ? 'border-stein-400 bg-stein-50' : 'border-stein-200 bg-white hover:border-stein-300'}">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-4 w-4 shrink-0 rounded accent-stein-600"
|
||||||
|
checked={selectedSlugs.has(k._slug)}
|
||||||
|
onchange={() => toggleSlug(k._slug)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-stein-800">{k.title ?? slugLabel(k._slug)}</div>
|
||||||
|
{#if k.bulletPoints?.length}
|
||||||
|
<ul class="mt-1 space-y-0.5 text-xs text-stein-500">
|
||||||
|
{#each k.bulletPoints.slice(0, 2) as bp}
|
||||||
|
<li>– {bp}</li>
|
||||||
|
{/each}
|
||||||
|
{#if k.bulletPoints.length > 2}
|
||||||
|
<li class="text-stein-400">+ {k.bulletPoints.length - 2} weitere …</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if ortskonkreteKriterien.length === 0 && allgemeineArgList.length === 0}
|
||||||
|
<div class="mb-6 rounded-xl border border-erde-200 bg-erde-50 p-4 text-sm text-erde-700">
|
||||||
|
Noch keine Argumente im CMS hinterlegt.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-stein-300 px-4 py-2 text-sm text-stein-600 hover:bg-stein-50"
|
||||||
|
onclick={() => (step = 0)}
|
||||||
|
>← Zurück</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800 disabled:opacity-40"
|
||||||
|
disabled={selectedSlugs.size === 0}
|
||||||
|
onclick={() => (step = 2)}
|
||||||
|
>
|
||||||
|
Weiter ({selectedSlugs.size} Argument{selectedSlugs.size !== 1 ? "e" : ""}) →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 2: Modus ─────────────────────────────────────────────────── -->
|
||||||
|
{:else if step === 2}
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1 text-lg font-semibold text-wald-800">
|
||||||
|
Schritt 3 – Ausgabeform
|
||||||
|
</h3>
|
||||||
|
<p class="mb-5 text-sm text-stein-600">
|
||||||
|
Wie soll Ihre Einwendung aufgebaut sein?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-6 grid gap-3 sm:grid-cols-2">
|
||||||
|
<label class="flex cursor-pointer flex-col gap-2 rounded-xl border-2 p-4 transition-colors
|
||||||
|
{mode === 'fertig' ? 'border-wald-600 bg-wald-50' : 'border-stein-200 bg-white hover:border-wald-200'}">
|
||||||
|
<input type="radio" class="sr-only" name="mode" value="fertig" bind:group={mode} />
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold text-wald-900">Fertigtext</span>
|
||||||
|
{#if mode === "fertig"}
|
||||||
|
<span class="text-wald-600">✓</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-stein-600">
|
||||||
|
Ausformulierter Brieftext auf Basis der gewählten Argumente.
|
||||||
|
Ideal als Grundlage zum Anpassen und Ergänzen.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer flex-col gap-2 rounded-xl border-2 p-4 transition-colors
|
||||||
|
{mode === 'leitfaden' ? 'border-wald-600 bg-wald-50' : 'border-stein-200 bg-white hover:border-wald-200'}">
|
||||||
|
<input type="radio" class="sr-only" name="mode" value="leitfaden" bind:group={mode} />
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold text-wald-900">Leitfaden</span>
|
||||||
|
{#if mode === "leitfaden"}
|
||||||
|
<span class="text-wald-600">✓</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-stein-600">
|
||||||
|
Gegliederte Stichpunkte. Schreiben Sie Ihren Text komplett selbst –
|
||||||
|
empfohlen für individuelle Einwendungen.
|
||||||
|
</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-stein-300 px-4 py-2 text-sm text-stein-600 hover:bg-stein-50"
|
||||||
|
onclick={() => (step = 1)}
|
||||||
|
>← Zurück</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800"
|
||||||
|
onclick={() => (step = 3)}
|
||||||
|
>Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 3: Persönliche Betroffenheit ─────────────────────────────── -->
|
||||||
|
{:else if step === 3}
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1 text-lg font-semibold text-wald-800">
|
||||||
|
Schritt 4 – Persönliche Betroffenheit
|
||||||
|
</h3>
|
||||||
|
<p class="mb-2 text-sm text-stein-600">
|
||||||
|
Beschreiben Sie kurz, wie Sie persönlich betroffen sind.
|
||||||
|
<strong>Wichtig:</strong> Individuelle Betroffenheit stärkt Ihre Einwendung erheblich
|
||||||
|
und macht sie von anderen unterscheidbar.
|
||||||
|
</p>
|
||||||
|
<p class="mb-4 text-xs text-stein-400">
|
||||||
|
Beispiel: Ich wohne 800 m vom geplanten Standort entfernt und bin Eigentümer von …
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
class="w-full rounded-lg border border-stein-300 px-4 py-3 text-sm text-stein-800 focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
|
||||||
|
rows="6"
|
||||||
|
placeholder="Meine Betroffenheit: …"
|
||||||
|
bind:value={persoenlicheEingabe}
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-xs text-stein-400">Bleibt auf Ihrem Gerät. Wird nicht übertragen.</p>
|
||||||
|
|
||||||
|
<div class="mt-5 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-stein-300 px-4 py-2 text-sm text-stein-600 hover:bg-stein-50"
|
||||||
|
onclick={() => (step = 2)}
|
||||||
|
>← Zurück</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800"
|
||||||
|
onclick={() => (step = 4)}
|
||||||
|
>Weiter →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Step 4: Absender + Ausgabe ────────────────────────────────────── -->
|
||||||
|
{:else if step === 4}
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1 text-lg font-semibold text-wald-800">
|
||||||
|
Schritt 5 – Absender & Ausgabe
|
||||||
|
</h3>
|
||||||
|
<p class="mb-1 text-sm text-stein-600">
|
||||||
|
Ihre Daten werden nur lokal zur Texterstellung genutzt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-5 grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-stein-600" for="absender-name">
|
||||||
|
Vor- und Nachname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="absender-name"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-lg border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
|
||||||
|
placeholder="Max Mustermann"
|
||||||
|
bind:value={absenderName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium text-stein-600" for="absender-anschrift">
|
||||||
|
Straße und Hausnummer
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="absender-anschrift"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-lg border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
|
||||||
|
placeholder="Hauptstr. 1"
|
||||||
|
bind:value={absenderAnschrift}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="mb-1 block text-xs font-medium text-stein-600" for="absender-ort">
|
||||||
|
PLZ und Ort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="absender-ort"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-lg border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"
|
||||||
|
placeholder="98529 Suhl"
|
||||||
|
bind:value={absenderOrt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empfänger-Info -->
|
||||||
|
<div class="mb-5 rounded-lg border border-stein-200 bg-stein-50 p-3 text-xs text-stein-600">
|
||||||
|
<div class="mb-1 font-semibold text-stein-700">Einzureichen bei:</div>
|
||||||
|
<div class="whitespace-pre">{RECIPIENT}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6 flex gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-wald-700 px-6 py-2.5 font-semibold text-white transition hover:bg-wald-800 disabled:opacity-40"
|
||||||
|
disabled={!absenderName.trim()}
|
||||||
|
onclick={generateOutput}
|
||||||
|
>
|
||||||
|
Text erstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-stein-300 px-4 py-2 text-sm text-stein-600 hover:bg-stein-50"
|
||||||
|
onclick={() => (step = 3)}
|
||||||
|
>← Zurück</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showOutput}
|
||||||
|
<div class="rounded-xl border border-wald-300 bg-white shadow-sm">
|
||||||
|
<div class="flex items-center justify-between border-b border-wald-200 px-4 py-3">
|
||||||
|
<span class="text-sm font-semibold text-wald-800">Ihre Einwendung</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-md border border-stein-300 px-3 py-1.5 text-xs font-medium text-stein-700 hover:bg-stein-50"
|
||||||
|
onclick={copyText}
|
||||||
|
>
|
||||||
|
{copyFeedback || "Kopieren"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-md border border-stein-300 px-3 py-1.5 text-xs font-medium text-stein-700 hover:bg-stein-50"
|
||||||
|
onclick={printText}
|
||||||
|
>
|
||||||
|
Drucken / Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto whitespace-pre-wrap break-words px-5 py-4 text-sm leading-relaxed text-stein-800 font-sans">{outputText}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-lg border border-himmel-200 bg-himmel-50 px-4 py-3 text-sm text-himmel-800">
|
||||||
|
<strong>Hinweis:</strong> 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}.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -3,6 +3,7 @@ export type TextFragment = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
bulletPoints?: string[];
|
||||||
tags?: Array<{ _slug: string; name?: string } | string>;
|
tags?: Array<{ _slug: string; name?: string } | string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ export type WindArea = {
|
|||||||
investor?: string;
|
investor?: string;
|
||||||
gemeinden?: string[];
|
gemeinden?: string[];
|
||||||
stellungnahme?: { _slug: string; slug?: string } | string | null;
|
stellungnahme?: { _slug: string; slug?: string } | string | null;
|
||||||
stellungnahme_kriterien?: Array<{ _slug: string } | string> | null;
|
stellungnahme_kriterien?: Array<TextFragment | { _slug: string } | string> | null;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
geometry?: GeoJSON.Geometry;
|
geometry?: GeoJSON.Geometry;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
resolveSearchableTextBlocks,
|
resolveSearchableTextBlocks,
|
||||||
resolveCalendarBlocks,
|
resolveCalendarBlocks,
|
||||||
resolveDeadlineBannerBlocks,
|
resolveDeadlineBannerBlocks,
|
||||||
|
resolveStellingnahmeGeneratorBlocks,
|
||||||
} from '$lib/blog-utils';
|
} from '$lib/blog-utils';
|
||||||
import { PAGE_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM } from '$lib/constants';
|
import { PAGE_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM } from '$lib/constants';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
@@ -64,6 +65,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
|
|||||||
resolveSearchableTextBlocks(page, tagsMap),
|
resolveSearchableTextBlocks(page, tagsMap),
|
||||||
resolveCalendarBlocks(page),
|
resolveCalendarBlocks(page),
|
||||||
resolveDeadlineBannerBlocks(page),
|
resolveDeadlineBannerBlocks(page),
|
||||||
|
resolveStellingnahmeGeneratorBlocks(page),
|
||||||
]);
|
]);
|
||||||
sanitizeBlocks(page);
|
sanitizeBlocks(page);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user