feat(frontend): 3-Panel Explorer + Ampel-Komponente (#16)
- Ampel.svelte: horizontal/vertikal/compact Darstellung mit Farb-Mapping, Abzweigungen und Kontrollfrage - Explorer-Route (/explorer): 3-Panel-Layout mit Ketten-Liste, Ketten-Detail (vertikale Ampel + Timeline) und Vorlagen-Detail - Mobile: Tab-basierte Navigation (Liste/Kette/Detail) - Dashboard: Ampel-Legende mit Strang-Definitionen - Navigation: Explorer-Link hinzugefügt - API-Types: AmpelData, AmpelKompakt, AmpelDefinition
This commit is contained in:
parent
3758079038
commit
d0f838a50a
@ -68,6 +68,34 @@ export interface VorlageDetail extends VorlageKurz {
|
|||||||
kette_id: number | null;
|
kette_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AmpelSchritt {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
aktiv: boolean;
|
||||||
|
erreicht: boolean;
|
||||||
|
farbe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmpelAbzweigung {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
farbe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmpelData {
|
||||||
|
strang: string;
|
||||||
|
strang_label: string;
|
||||||
|
kontrollfrage: string | null;
|
||||||
|
schritte: AmpelSchritt[];
|
||||||
|
abzweigung: AmpelAbzweigung | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AmpelKompakt {
|
||||||
|
schritt: string;
|
||||||
|
farbe: string;
|
||||||
|
ist_abzweigung: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KetteKurz {
|
export interface KetteKurz {
|
||||||
id: number;
|
id: number;
|
||||||
ursprung: VorlageKurz | null;
|
ursprung: VorlageKurz | null;
|
||||||
@ -78,6 +106,8 @@ export interface KetteKurz {
|
|||||||
letzte_aktivitaet: string | null;
|
letzte_aktivitaet: string | null;
|
||||||
vertagungen_count: number;
|
vertagungen_count: number;
|
||||||
glieder_count: number;
|
glieder_count: number;
|
||||||
|
strang: string | null;
|
||||||
|
ampel: AmpelKompakt | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KettenGliedOut {
|
export interface KettenGliedOut {
|
||||||
@ -102,6 +132,8 @@ export interface KetteDetail {
|
|||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
} | null;
|
} | null;
|
||||||
|
strang: string | null;
|
||||||
|
ampel: AmpelData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
@ -216,6 +248,17 @@ export const fetchSuchvorschlaege = (q: string) =>
|
|||||||
get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`);
|
get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`);
|
||||||
|
|
||||||
export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen');
|
export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen');
|
||||||
|
export interface AmpelDefinition {
|
||||||
|
straenge: Record<string, {
|
||||||
|
label: string;
|
||||||
|
kontrollfrage: string | null;
|
||||||
|
schritte: { id: string; label: string; endfarbe: string | null }[];
|
||||||
|
}>;
|
||||||
|
abzweigungen: Record<string, { label: string; farbe: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchAmpelDefinition = () => get<AmpelDefinition>('/ampel/definition');
|
||||||
|
|
||||||
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
|
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
if (jahr) p.set('jahr', jahr);
|
if (jahr) p.set('jahr', jahr);
|
||||||
|
|||||||
177
frontend/src/lib/components/Ampel.svelte
Normal file
177
frontend/src/lib/components/Ampel.svelte
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Ampel-Visualisierung: Zeigt den Fortschritt einer Kette als Schritt-Indikator.
|
||||||
|
* Horizontal (default) oder vertikal, compact oder normal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface AmpelSchritt {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
aktiv: boolean;
|
||||||
|
erreicht: boolean;
|
||||||
|
farbe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AmpelAbzweigung {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
farbe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AmpelData {
|
||||||
|
strang: string;
|
||||||
|
strang_label: string;
|
||||||
|
kontrollfrage: string | null;
|
||||||
|
schritte: AmpelSchritt[];
|
||||||
|
abzweigung: AmpelAbzweigung | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ampel: AmpelData | null;
|
||||||
|
compact?: boolean;
|
||||||
|
vertical?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { ampel, compact = false, vertical = false }: Props = $props();
|
||||||
|
|
||||||
|
const FARB_MAP: Record<string, string> = {
|
||||||
|
gruen: '#22c55e',
|
||||||
|
gelb: '#eab308',
|
||||||
|
rot: '#ef4444',
|
||||||
|
amber: '#f59e0b',
|
||||||
|
grau: '#d1d5db',
|
||||||
|
blau: '#3b82f6',
|
||||||
|
};
|
||||||
|
|
||||||
|
function farbeHex(farbe: string): string {
|
||||||
|
return FARB_MAP[farbe] || FARB_MAP.grau;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the step where the branch-off originates (last reached step)
|
||||||
|
function abzweigungIndex(): number {
|
||||||
|
if (!ampel?.schritte) return -1;
|
||||||
|
let lastReached = -1;
|
||||||
|
for (let i = 0; i < ampel.schritte.length; i++) {
|
||||||
|
if (ampel.schritte[i].erreicht) lastReached = i;
|
||||||
|
}
|
||||||
|
return lastReached;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if ampel}
|
||||||
|
{#if compact}
|
||||||
|
<!-- Compact: Just colored dots inline -->
|
||||||
|
<div class="flex items-center gap-0.5" title="{ampel.strang_label}">
|
||||||
|
{#each ampel.schritte as schritt, i}
|
||||||
|
{#if i > 0}
|
||||||
|
<div class="w-1 h-0.5 rounded-full" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="rounded-full shrink-0"
|
||||||
|
style="width: {schritt.aktiv ? '10px' : '8px'}; height: {schritt.aktiv ? '10px' : '8px'}; {schritt.aktiv
|
||||||
|
? `background-color: ${farbeHex(schritt.farbe)};`
|
||||||
|
: schritt.erreicht
|
||||||
|
? `background-color: ${farbeHex('grau')};`
|
||||||
|
: `border: 1.5px solid #d1d5db; background: white;`}"
|
||||||
|
title="{schritt.label}{schritt.aktiv ? ' (aktuell)' : ''}"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
{#if ampel.abzweigung}
|
||||||
|
<div class="w-1 h-0.5 rounded-full" style="background-color: #d1d5db; border-top: 1px dashed #9ca3af;"></div>
|
||||||
|
<div
|
||||||
|
class="rounded-full shrink-0"
|
||||||
|
style="width: 10px; height: 10px; background-color: {farbeHex(ampel.abzweigung.farbe)};"
|
||||||
|
title="{ampel.abzweigung.label}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if vertical}
|
||||||
|
<!-- Vertical layout for Panel 2 -->
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
{#each ampel.schritte as schritt, i}
|
||||||
|
{#if i > 0}
|
||||||
|
<div class="w-0.5 h-4" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="rounded-full shrink-0 transition-all"
|
||||||
|
style="width: 20px; height: 20px; {schritt.aktiv
|
||||||
|
? `background-color: ${farbeHex(schritt.farbe)}; box-shadow: 0 0 0 3px ${farbeHex(schritt.farbe)}30;`
|
||||||
|
: schritt.erreicht
|
||||||
|
? `background-color: ${farbeHex('grau')};`
|
||||||
|
: `border: 2px solid #d1d5db; background: white;`}"
|
||||||
|
></div>
|
||||||
|
<span class="text-xs {schritt.aktiv ? 'font-semibold text-gray-900' : 'text-gray-500'} whitespace-nowrap">
|
||||||
|
{schritt.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Abzweigung branch off this step -->
|
||||||
|
{#if ampel.abzweigung && i === abzweigungIndex()}
|
||||||
|
<div class="flex items-start ml-2.5">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="w-0.5 h-3 border-l-2 border-dashed" style="border-color: {farbeHex(ampel.abzweigung.farbe)};"></div>
|
||||||
|
<div
|
||||||
|
class="rounded-full shrink-0"
|
||||||
|
style="width: 20px; height: 20px; background-color: {farbeHex(ampel.abzweigung.farbe)}; box-shadow: 0 0 0 3px {farbeHex(ampel.abzweigung.farbe)}30;"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-semibold ml-2 mt-3" style="color: {farbeHex(ampel.abzweigung.farbe)}">
|
||||||
|
{ampel.abzweigung.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if ampel.kontrollfrage}
|
||||||
|
<p class="text-xs italic text-gray-400 mt-3 text-center">{ampel.kontrollfrage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<!-- Horizontal (default) -->
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<div class="flex items-center">
|
||||||
|
{#each ampel.schritte as schritt, i}
|
||||||
|
{#if i > 0}
|
||||||
|
<div class="h-0.5 w-4 sm:w-6" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
class="rounded-full shrink-0 transition-all"
|
||||||
|
style="width: 24px; height: 24px; {schritt.aktiv
|
||||||
|
? `background-color: ${farbeHex(schritt.farbe)}; box-shadow: 0 0 0 3px ${farbeHex(schritt.farbe)}30;`
|
||||||
|
: schritt.erreicht
|
||||||
|
? `background-color: ${farbeHex('grau')};`
|
||||||
|
: `border: 2px solid #d1d5db; background: white;`}"
|
||||||
|
></div>
|
||||||
|
<span class="text-[10px] mt-1 {schritt.aktiv ? 'font-semibold text-gray-900' : 'text-gray-400'} text-center max-w-[60px] leading-tight">
|
||||||
|
{schritt.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<!-- Abzweigung -->
|
||||||
|
{#if ampel.abzweigung}
|
||||||
|
{@const idx = abzweigungIndex()}
|
||||||
|
{#if idx >= 0}
|
||||||
|
<!-- Position branch under the correct step -->
|
||||||
|
<div class="flex items-start" style="margin-left: {idx * (24 + 24)}px;">
|
||||||
|
<div class="flex flex-col items-center ml-3">
|
||||||
|
<div class="h-3 border-l-2 border-dashed" style="border-color: {farbeHex(ampel.abzweigung.farbe)};"></div>
|
||||||
|
<div
|
||||||
|
class="rounded-full shrink-0"
|
||||||
|
style="width: 24px; height: 24px; background-color: {farbeHex(ampel.abzweigung.farbe)}; box-shadow: 0 0 0 3px {farbeHex(ampel.abzweigung.farbe)}30;"
|
||||||
|
></div>
|
||||||
|
<span class="text-[10px] mt-1 font-semibold text-center max-w-[70px] leading-tight" style="color: {farbeHex(ampel.abzweigung.farbe)}">
|
||||||
|
{ampel.abzweigung.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if ampel.kontrollfrage}
|
||||||
|
<p class="text-xs italic text-gray-400 mt-2">{ampel.kontrollfrage}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@ -37,6 +37,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="hidden sm:flex sm:ml-8 space-x-4">
|
<div class="hidden sm:flex sm:ml-8 space-x-4">
|
||||||
<a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Dashboard</a>
|
<a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Dashboard</a>
|
||||||
|
<a href="/explorer" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Explorer</a>
|
||||||
<a href="/ketten" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Ketten</a>
|
<a href="/ketten" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Ketten</a>
|
||||||
<a href="/vorlagen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Vorlagen</a>
|
<a href="/vorlagen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Vorlagen</a>
|
||||||
<a href="/abstimmungen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Abstimmungen</a>
|
<a href="/abstimmungen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Abstimmungen</a>
|
||||||
@ -71,6 +72,7 @@
|
|||||||
<div class="sm:hidden border-t border-gray-200 bg-white">
|
<div class="sm:hidden border-t border-gray-200 bg-white">
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||||
<a href="/" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Dashboard</a>
|
<a href="/" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Dashboard</a>
|
||||||
|
<a href="/explorer" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Explorer</a>
|
||||||
<a href="/ketten" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Ketten</a>
|
<a href="/ketten" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Ketten</a>
|
||||||
<a href="/vorlagen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Vorlagen</a>
|
<a href="/vorlagen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Vorlagen</a>
|
||||||
<a href="/abstimmungen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Abstimmungen</a>
|
<a href="/abstimmungen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Abstimmungen</a>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { filters, filterVersion } from '$lib/filters.svelte';
|
import { filters, filterVersion } from '$lib/filters.svelte';
|
||||||
|
import { fetchAmpelDefinition, type AmpelDefinition } from '$lib/api';
|
||||||
|
import Ampel from '$lib/components/Ampel.svelte';
|
||||||
|
|
||||||
interface Vorlage {
|
interface Vorlage {
|
||||||
id: number;
|
id: number;
|
||||||
@ -30,6 +32,7 @@
|
|||||||
|
|
||||||
let stats = $state<DashboardStats | null>(null);
|
let stats = $state<DashboardStats | null>(null);
|
||||||
let antraege = $state<Vorlage[]>([]);
|
let antraege = $state<Vorlage[]>([]);
|
||||||
|
let ampelDef = $state<AmpelDefinition | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@ -103,6 +106,11 @@
|
|||||||
const dashSuffix = fqs ? `?${fqs}` : '';
|
const dashSuffix = fqs ? `?${fqs}` : '';
|
||||||
const vorlagenSuffix = fqs ? `&${fqs}` : '';
|
const vorlagenSuffix = fqs ? `&${fqs}` : '';
|
||||||
|
|
||||||
|
// Load ampel definition once
|
||||||
|
if (!ampelDef) {
|
||||||
|
try { ampelDef = await fetchAmpelDefinition(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const [dashRes, antraegeRes] = await Promise.all([
|
const [dashRes, antraegeRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
|
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
|
||||||
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
|
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
|
||||||
@ -310,4 +318,53 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Ampel-Legende -->
|
||||||
|
{#if ampelDef}
|
||||||
|
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mt-8">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">🚦 Ampel-Legende</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">So liest sich die Fortschrittsanzeige im <a href="/explorer" class="text-green-600 hover:underline">Explorer</a>.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{#each Object.entries(ampelDef.straenge) as [key, strang]}
|
||||||
|
<div class="border border-gray-100 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800 mb-2">{strang.label}</h3>
|
||||||
|
{#if strang.kontrollfrage}
|
||||||
|
<p class="text-xs italic text-gray-500 mb-3">{strang.kontrollfrage}</p>
|
||||||
|
{/if}
|
||||||
|
<!-- Example ampel: show all steps as reached, last one active -->
|
||||||
|
<Ampel ampel={{
|
||||||
|
strang: key,
|
||||||
|
strang_label: strang.label,
|
||||||
|
kontrollfrage: null,
|
||||||
|
schritte: strang.schritte.map((s, i) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
aktiv: i === strang.schritte.length - 1,
|
||||||
|
erreicht: true,
|
||||||
|
farbe: i === strang.schritte.length - 1 ? (s.endfarbe || 'blau') : 'grau',
|
||||||
|
})),
|
||||||
|
abzweigung: null,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Abzweigungen -->
|
||||||
|
{#if Object.keys(ampelDef.abzweigungen).length > 0}
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800 mb-2">Abzweigungen</h3>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{#each Object.entries(ampelDef.abzweigungen) as [key, abzw]}
|
||||||
|
{@const farbMap = { rot: '#ef4444', amber: '#f59e0b', gelb: '#eab308', grau: '#d1d5db' }}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-3 h-3 rounded-full" style="background-color: {farbMap[abzw.farbe] || '#d1d5db'}"></span>
|
||||||
|
<span class="text-xs text-gray-600">{abzw.label}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
533
frontend/src/routes/explorer/+page.svelte
Normal file
533
frontend/src/routes/explorer/+page.svelte
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fetchKetten, fetchKette, fetchVorlage, type KetteKurz, type KetteDetail, type VorlageDetail, type Paginated } from '$lib/api';
|
||||||
|
import { formatDate, typLabel } from '$lib/status';
|
||||||
|
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
|
||||||
|
import Ampel from '$lib/components/Ampel.svelte';
|
||||||
|
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||||
|
|
||||||
|
// State
|
||||||
|
let ketten: KetteKurz[] = $state([]);
|
||||||
|
let kettenTotal = $state(0);
|
||||||
|
let selectedKette: KetteDetail | null = $state(null);
|
||||||
|
let selectedVorlage: VorlageDetail | null = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let ketteLoading = $state(false);
|
||||||
|
let vorlageLoading = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
let suche = $state('');
|
||||||
|
let strangFilter = $state('');
|
||||||
|
let currentPage = $state(1);
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
// Active IDs
|
||||||
|
let activeKetteId = $state<number | null>(null);
|
||||||
|
let activeVorlageId = $state<number | null>(null);
|
||||||
|
|
||||||
|
// Mobile tab
|
||||||
|
let mobileTab = $state<'liste' | 'kette' | 'detail'>('liste');
|
||||||
|
let showVolltext = $state(false);
|
||||||
|
|
||||||
|
const STRANG_TABS = [
|
||||||
|
{ value: '', label: 'Alle' },
|
||||||
|
{ value: 'antrag', label: 'Anträge' },
|
||||||
|
{ value: 'anfrage', label: 'Anfragen' },
|
||||||
|
{ value: 'beschlussvorlage', label: 'Beschlussvorlagen' },
|
||||||
|
{ value: 'mitteilung', label: 'Mitteilungen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FARB_MAP: Record<string, string> = {
|
||||||
|
gruen: '#22c55e',
|
||||||
|
gelb: '#eab308',
|
||||||
|
rot: '#ef4444',
|
||||||
|
amber: '#f59e0b',
|
||||||
|
grau: '#d1d5db',
|
||||||
|
blau: '#3b82f6',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadKetten() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
let params: Record<string, string> = {
|
||||||
|
page: String(currentPage),
|
||||||
|
page_size: String(PAGE_SIZE),
|
||||||
|
};
|
||||||
|
if (suche) params.suche = suche;
|
||||||
|
if (strangFilter) params.typ = strangFilter;
|
||||||
|
params = mergeFilterParams(params);
|
||||||
|
const data = await fetchKetten(params);
|
||||||
|
ketten = data.items;
|
||||||
|
kettenTotal = data.total;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectKette(id: number) {
|
||||||
|
if (activeKetteId === id) return;
|
||||||
|
activeKetteId = id;
|
||||||
|
activeVorlageId = null;
|
||||||
|
selectedVorlage = null;
|
||||||
|
ketteLoading = true;
|
||||||
|
mobileTab = 'kette';
|
||||||
|
try {
|
||||||
|
selectedKette = await fetchKette(id);
|
||||||
|
// Auto-select the first glied (most recent = last position)
|
||||||
|
if (selectedKette.glieder.length > 0) {
|
||||||
|
const sorted = [...selectedKette.glieder].sort((a, b) => b.position - a.position);
|
||||||
|
selectVorlage(sorted[0].vorlage.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
|
} finally {
|
||||||
|
ketteLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectVorlage(id: number) {
|
||||||
|
if (activeVorlageId === id) return;
|
||||||
|
activeVorlageId = id;
|
||||||
|
vorlageLoading = true;
|
||||||
|
showVolltext = false;
|
||||||
|
mobileTab = 'detail';
|
||||||
|
try {
|
||||||
|
selectedVorlage = await fetchVorlage(id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
|
} finally {
|
||||||
|
vorlageLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
currentPage = 1;
|
||||||
|
loadKetten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeStrang(value: string) {
|
||||||
|
strangFilter = value;
|
||||||
|
currentPage = 1;
|
||||||
|
loadKetten();
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(p: number) {
|
||||||
|
currentPage = p;
|
||||||
|
loadKetten();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadKetten();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload on global filter change
|
||||||
|
$effect(() => {
|
||||||
|
filterVersion();
|
||||||
|
currentPage = 1;
|
||||||
|
loadKetten();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sorted glieder for timeline (newest first)
|
||||||
|
let sortedGlieder = $derived(
|
||||||
|
selectedKette ? [...selectedKette.glieder].sort((a, b) => b.position - a.position) : []
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalPages = $derived(Math.ceil(kettenTotal / PAGE_SIZE));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Explorer - Antragstracker Hagen</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Mobile Tabs -->
|
||||||
|
<div class="lg:hidden flex border-b border-gray-200 mb-4 bg-white rounded-t-lg">
|
||||||
|
<button
|
||||||
|
onclick={() => mobileTab = 'liste'}
|
||||||
|
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
|
||||||
|
{mobileTab === 'liste' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}">
|
||||||
|
Liste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => mobileTab = 'kette'}
|
||||||
|
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
|
||||||
|
{mobileTab === 'kette' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
||||||
|
disabled={!selectedKette}>
|
||||||
|
Kette
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => mobileTab = 'detail'}
|
||||||
|
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
|
||||||
|
{mobileTab === 'detail' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
||||||
|
disabled={!selectedVorlage}>
|
||||||
|
Detail
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3-Panel Layout -->
|
||||||
|
<div class="flex gap-0 lg:gap-0 h-[calc(100vh-12rem)] lg:h-[calc(100vh-11rem)]">
|
||||||
|
|
||||||
|
<!-- Panel 1: Ketten-Liste -->
|
||||||
|
<div class="w-full lg:w-[280px] lg:shrink-0 lg:border-r border-gray-200 flex flex-col bg-white rounded-l-lg lg:rounded-l-xl overflow-hidden
|
||||||
|
{mobileTab !== 'liste' ? 'hidden lg:flex' : 'flex'}">
|
||||||
|
|
||||||
|
<!-- Search & Filters -->
|
||||||
|
<div class="p-3 border-b border-gray-100 space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={suche}
|
||||||
|
placeholder="Suche..."
|
||||||
|
onkeydown={handleSearch}
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each STRANG_TABS as tab}
|
||||||
|
<button
|
||||||
|
onclick={() => changeStrang(tab.value)}
|
||||||
|
class="px-2 py-1 rounded text-xs font-medium transition-all
|
||||||
|
{strangFilter === tab.value
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Count -->
|
||||||
|
<div class="px-3 py-1.5 text-xs text-gray-400 border-b border-gray-50">
|
||||||
|
{kettenTotal} Ketten
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if loading && ketten.length === 0}
|
||||||
|
<div class="flex justify-center py-10">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||||
|
</div>
|
||||||
|
{:else if ketten.length === 0}
|
||||||
|
<div class="p-4 text-sm text-gray-500 text-center">Keine Ketten gefunden</div>
|
||||||
|
{:else}
|
||||||
|
{#each ketten as kette}
|
||||||
|
<button
|
||||||
|
onclick={() => selectKette(kette.id)}
|
||||||
|
class="w-full text-left px-3 py-2.5 border-b border-gray-50 hover:bg-gray-50 transition-colors
|
||||||
|
{activeKetteId === kette.id ? 'bg-green-50 border-l-2 border-l-green-600' : 'border-l-2 border-l-transparent'}">
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-0.5">
|
||||||
|
<span class="font-mono text-xs font-medium text-green-700 truncate">
|
||||||
|
{kette.ursprung?.aktenzeichen || `#${kette.id}`}
|
||||||
|
</span>
|
||||||
|
{#if kette.ampel}
|
||||||
|
<span
|
||||||
|
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style="background-color: {FARB_MAP[kette.ampel.farbe] || FARB_MAP.grau}"
|
||||||
|
title="{kette.ampel.schritt}"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-600 line-clamp-2 leading-snug">
|
||||||
|
{kette.thema || kette.ursprung?.betreff || '-'}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="flex items-center justify-center gap-2 p-3 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onclick={() => goPage(currentPage - 1)}
|
||||||
|
class="px-2 py-1 rounded text-xs border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-500">{currentPage}/{totalPages}</span>
|
||||||
|
<button
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onclick={() => goPage(currentPage + 1)}
|
||||||
|
class="px-2 py-1 rounded text-xs border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel 2: Kette Detail -->
|
||||||
|
<div class="w-full lg:w-[220px] lg:shrink-0 lg:border-r border-gray-200 flex flex-col bg-white overflow-hidden
|
||||||
|
{mobileTab !== 'kette' ? 'hidden lg:flex' : 'flex'}">
|
||||||
|
|
||||||
|
{#if ketteLoading}
|
||||||
|
<div class="flex justify-center py-10">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||||
|
</div>
|
||||||
|
{:else if selectedKette}
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<!-- Ampel -->
|
||||||
|
<div class="p-4 border-b border-gray-100">
|
||||||
|
<div class="text-xs font-medium text-gray-500 uppercase mb-3">
|
||||||
|
{selectedKette.ampel?.strang_label || selectedKette.typ || 'Status'}
|
||||||
|
</div>
|
||||||
|
{#if selectedKette.ampel}
|
||||||
|
<Ampel ampel={selectedKette.ampel} vertical />
|
||||||
|
{:else}
|
||||||
|
<StatusBadge status={selectedKette.status} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="text-xs font-medium text-gray-500 uppercase mb-3">Ketten-Glieder</div>
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Vertical line -->
|
||||||
|
<div class="absolute left-3 top-2 bottom-2 w-0.5 bg-gray-200"></div>
|
||||||
|
|
||||||
|
{#each sortedGlieder as glied, i}
|
||||||
|
<button
|
||||||
|
onclick={() => selectVorlage(glied.vorlage.id)}
|
||||||
|
class="relative w-full text-left pl-8 pr-2 py-2 rounded-lg hover:bg-gray-50 transition-colors mb-1
|
||||||
|
{activeVorlageId === glied.vorlage.id ? 'bg-green-50' : ''}">
|
||||||
|
<!-- Dot -->
|
||||||
|
<div class="absolute left-1.5 top-3.5 w-3 h-3 rounded-full border-2 transition-colors
|
||||||
|
{activeVorlageId === glied.vorlage.id
|
||||||
|
? 'bg-green-600 border-green-600'
|
||||||
|
: 'bg-white border-gray-300'}">
|
||||||
|
</div>
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<span class="font-mono text-[11px] font-medium text-green-700 truncate">
|
||||||
|
{glied.vorlage.aktenzeichen || `#${glied.vorlage.id}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if glied.rolle}
|
||||||
|
<span class="inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 mb-0.5">
|
||||||
|
{glied.rolle}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if glied.vorlage.datum_eingang}
|
||||||
|
<div class="text-[10px] text-gray-400">
|
||||||
|
{formatDate(glied.vorlage.datum_eingang)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center h-full text-sm text-gray-400 p-4 text-center">
|
||||||
|
← Kette auswählen
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel 3: Vorlage Detail -->
|
||||||
|
<div class="w-full lg:flex-1 lg:min-w-0 flex flex-col bg-white rounded-r-lg lg:rounded-r-xl overflow-hidden
|
||||||
|
{mobileTab !== 'detail' ? 'hidden lg:flex' : 'flex'}">
|
||||||
|
|
||||||
|
{#if vorlageLoading}
|
||||||
|
<div class="flex justify-center py-10">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||||
|
</div>
|
||||||
|
{:else if selectedVorlage}
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 space-y-5">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||||
|
{#if selectedVorlage.aktenzeichen}
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 font-mono">{selectedVorlage.aktenzeichen}</h2>
|
||||||
|
{/if}
|
||||||
|
{#if selectedVorlage.typ}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">{typLabel(selectedVorlage.typ)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if selectedVorlage.ist_verwaltungsvorlage}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-700">Verwaltungsvorlage</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if selectedVorlage.betreff}
|
||||||
|
<p class="text-gray-700">{selectedVorlage.betreff}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mt-2 text-sm text-gray-500">
|
||||||
|
{#if selectedVorlage.datum_eingang}
|
||||||
|
<span>Eingegangen: <strong>{formatDate(selectedVorlage.datum_eingang)}</strong></span>
|
||||||
|
{/if}
|
||||||
|
{#if selectedVorlage.web_url}
|
||||||
|
<a href={selectedVorlage.web_url} target="_blank" rel="noopener" class="text-green-600 hover:underline">ALLRIS ↗</a>
|
||||||
|
{/if}
|
||||||
|
{#if selectedVorlage.pdf_url}
|
||||||
|
<a href={selectedVorlage.pdf_url} target="_blank" rel="noopener" class="text-green-600 hover:underline">PDF ↗</a>
|
||||||
|
{/if}
|
||||||
|
<a href="/vorlagen/{selectedVorlage.id}" class="text-green-600 hover:underline">Vollansicht →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Antragsteller -->
|
||||||
|
{#if selectedVorlage.antragsteller?.length > 0}
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">Antragsteller:</span>
|
||||||
|
{#each selectedVorlage.antragsteller as p}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background-color: {p.farbe || '#e5e7eb'}20; color: {p.farbe || '#4b5563'}; border: 1px solid {p.farbe || '#d1d5db'}">
|
||||||
|
{p.kuerzel}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KI-Zusammenfassung -->
|
||||||
|
{#if selectedVorlage.ki_zusammenfassung}
|
||||||
|
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl border border-green-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-green-800 mb-2 flex items-center gap-1.5">
|
||||||
|
<span>🤖</span> KI-Zusammenfassung
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-700 mb-3">{selectedVorlage.ki_zusammenfassung.zusammenfassung}</p>
|
||||||
|
|
||||||
|
{#if selectedVorlage.ki_zusammenfassung.kernforderung}
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="text-xs font-medium text-green-700 uppercase">Kernforderung:</span>
|
||||||
|
<p class="text-sm text-gray-800 font-medium">{selectedVorlage.ki_zusammenfassung.kernforderung}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedVorlage.ki_zusammenfassung.begruendung}
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="text-xs font-medium text-green-700 uppercase">Begründung:</span>
|
||||||
|
<p class="text-xs text-gray-600">{selectedVorlage.ki_zusammenfassung.begruendung}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5 mt-3">
|
||||||
|
{#if selectedVorlage.ki_zusammenfassung.thema}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-800">📂 {selectedVorlage.ki_zusammenfassung.thema}</span>
|
||||||
|
{/if}
|
||||||
|
{#if selectedVorlage.ki_zusammenfassung.partei}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-800">🏛️ {selectedVorlage.ki_zusammenfassung.partei}</span>
|
||||||
|
{/if}
|
||||||
|
{#each selectedVorlage.ki_zusammenfassung.betroffene_orte || [] as ort}
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">📍 {ort}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Umsetzungsbewertung -->
|
||||||
|
{#if selectedVorlage.umsetzungsbewertungen?.length}
|
||||||
|
<div class="rounded-xl border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-3">📊 Umsetzungsbewertung</h3>
|
||||||
|
{#each selectedVorlage.umsetzungsbewertungen as ub}
|
||||||
|
<div class="p-3 rounded-lg border {ub.score >= 0.7 ? 'border-green-200 bg-green-50' : ub.score >= 0.4 ? 'border-amber-200 bg-amber-50' : 'border-red-200 bg-red-50'}">
|
||||||
|
<div class="flex items-center gap-3 mb-1.5">
|
||||||
|
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold
|
||||||
|
{ub.score >= 0.7 ? 'bg-green-200 text-green-800' : ub.score >= 0.4 ? 'bg-amber-200 text-amber-800' : 'bg-red-200 text-red-800'}">
|
||||||
|
{Math.round((ub.score || 0) * 100)}%
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium {ub.score >= 0.7 ? 'text-green-800' : ub.score >= 0.4 ? 'text-amber-800' : 'text-red-800'}">
|
||||||
|
{ub.score >= 0.7 ? 'Weitgehend umgesetzt' : ub.score >= 0.4 ? 'Teilweise umgesetzt' : 'Kaum umgesetzt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if ub.begruendung}
|
||||||
|
<p class="text-xs text-gray-700">{ub.begruendung}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Volltext -->
|
||||||
|
{#if selectedVorlage.volltext_clean}
|
||||||
|
<div class="rounded-xl border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900">Volltext</h3>
|
||||||
|
<button onclick={() => showVolltext = !showVolltext} class="text-xs text-green-600 hover:underline">
|
||||||
|
{showVolltext ? 'Einklappen' : 'Aufklappen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if showVolltext}
|
||||||
|
<div class="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap text-xs">{selectedVorlage.volltext_clean}</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-gray-500 line-clamp-4">{selectedVorlage.volltext_clean}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Beratungen -->
|
||||||
|
{#if selectedVorlage.beratungen?.length > 0}
|
||||||
|
<div class="rounded-xl border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-3">Beratungsfolge</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each selectedVorlage.beratungen as b}
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between p-2.5 rounded-lg border border-gray-100 gap-1.5">
|
||||||
|
<div>
|
||||||
|
{#if b.gremium}
|
||||||
|
<span class="text-sm font-medium text-gray-900">{b.gremium.name}</span>
|
||||||
|
{/if}
|
||||||
|
{#if b.rolle}
|
||||||
|
<span class="text-xs ml-1.5 text-gray-500">({b.rolle})</span>
|
||||||
|
{/if}
|
||||||
|
{#if b.ergebnis}
|
||||||
|
<div class="mt-0.5">
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded
|
||||||
|
{b.ergebnis.includes('angenommen') || b.ergebnis.includes('empfohlen') ? 'bg-green-100 text-green-700' :
|
||||||
|
b.ergebnis.includes('abgelehnt') ? 'bg-red-100 text-red-700' :
|
||||||
|
b.ergebnis.includes('vertagt') ? 'bg-amber-100 text-amber-700' :
|
||||||
|
'bg-gray-100 text-gray-700'}">
|
||||||
|
{b.ergebnis}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 shrink-0">{formatDate(b.sitzung_datum)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Referenzen -->
|
||||||
|
{#if selectedVorlage.referenzen_ausgehend?.length > 0 || selectedVorlage.referenzen_eingehend?.length > 0}
|
||||||
|
<div class="rounded-xl border border-gray-200 p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-3">Referenzen</h3>
|
||||||
|
{#if selectedVorlage.referenzen_ausgehend?.length > 0}
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="text-xs font-medium text-gray-500 uppercase">Verweist auf</span>
|
||||||
|
<div class="space-y-1 mt-1">
|
||||||
|
{#each selectedVorlage.referenzen_ausgehend as ref}
|
||||||
|
<a href="/vorlagen/{ref.vorlage_id}" class="block p-2 rounded border border-gray-100 hover:bg-gray-50 text-xs">
|
||||||
|
<span class="font-mono font-medium text-green-700">{ref.aktenzeichen || `#${ref.vorlage_id}`}</span>
|
||||||
|
{#if ref.betreff}
|
||||||
|
<span class="text-gray-600 ml-1 truncate">{ref.betreff}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if selectedVorlage.referenzen_eingehend?.length > 0}
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-medium text-gray-500 uppercase">Referenziert von</span>
|
||||||
|
<div class="space-y-1 mt-1">
|
||||||
|
{#each selectedVorlage.referenzen_eingehend as ref}
|
||||||
|
<a href="/vorlagen/{ref.vorlage_id}" class="block p-2 rounded border border-gray-100 hover:bg-gray-50 text-xs">
|
||||||
|
<span class="font-mono font-medium text-green-700">{ref.aktenzeichen || `#${ref.vorlage_id}`}</span>
|
||||||
|
{#if ref.betreff}
|
||||||
|
<span class="text-gray-600 ml-1 truncate">{ref.betreff}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center h-full text-sm text-gray-400 p-4 text-center">
|
||||||
|
← Vorlage auswählen
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Reference in New Issue
Block a user