2026-04-01 10:36:22 +02:00
|
|
|
|
<script lang="ts">
|
|
|
|
|
|
import { page } from '$app/stores';
|
|
|
|
|
|
import { fetchFraktionDashboard, type FraktionDashboard } from '$lib/api';
|
|
|
|
|
|
import UmsetzungBadge from '$lib/components/UmsetzungBadge.svelte';
|
|
|
|
|
|
import { KATEGORIEN } from '$lib/umsetzung';
|
|
|
|
|
|
import { formatDate } from '$lib/status';
|
|
|
|
|
|
import { onMount } from 'svelte';
|
2026-04-01 14:58:10 +02:00
|
|
|
|
import { filters, filterVersion } from '$lib/filters.svelte';
|
2026-04-01 10:36:22 +02:00
|
|
|
|
|
|
|
|
|
|
let data = $state<FraktionDashboard | null>(null);
|
|
|
|
|
|
let loading = $state(true);
|
|
|
|
|
|
let error = $state<string | null>(null);
|
|
|
|
|
|
let selectedJahr = $state<string>('');
|
|
|
|
|
|
let filterKategorie = $state<string>('');
|
|
|
|
|
|
let kuerzel = $derived($page.params.kuerzel);
|
|
|
|
|
|
|
|
|
|
|
|
async function loadData() {
|
|
|
|
|
|
loading = true;
|
|
|
|
|
|
error = null;
|
|
|
|
|
|
try {
|
2026-04-01 14:58:10 +02:00
|
|
|
|
const periode = filters.perioden.length > 0 ? filters.perioden.join(',') : undefined;
|
|
|
|
|
|
data = await fetchFraktionDashboard(kuerzel, selectedJahr || undefined, periode);
|
2026-04-01 10:36:22 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error = (e as Error).message;
|
|
|
|
|
|
}
|
|
|
|
|
|
loading = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMount(loadData);
|
|
|
|
|
|
|
2026-04-01 14:58:10 +02:00
|
|
|
|
// Reload when filters change (including global filters)
|
2026-04-01 10:36:22 +02:00
|
|
|
|
$effect(() => {
|
2026-04-01 14:58:10 +02:00
|
|
|
|
filterVersion(); // track global filter changes
|
2026-04-01 10:36:22 +02:00
|
|
|
|
if (kuerzel) loadData();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
let filteredAntraege = $derived(
|
|
|
|
|
|
data?.antraege.filter(a => !filterKategorie || a.umsetzung_bewertung === filterKategorie) ?? []
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate percentages for bar chart
|
|
|
|
|
|
let umsetzungPct = $derived(() => {
|
|
|
|
|
|
if (!data || data.bewertet === 0) return [];
|
|
|
|
|
|
return data.umsetzung.map(u => ({
|
|
|
|
|
|
...u,
|
|
|
|
|
|
pct: ((u.anzahl / data!.bewertet) * 100).toFixed(1),
|
|
|
|
|
|
}));
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<svelte:head>
|
|
|
|
|
|
<title>{data?.partei?.name ?? kuerzel} — Antragstracker Hagen</title>
|
|
|
|
|
|
</svelte:head>
|
|
|
|
|
|
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="max-w-6xl mx-auto">
|
2026-04-01 10:36:22 +02:00
|
|
|
|
{#if loading && !data}
|
|
|
|
|
|
<div class="text-gray-500">Laden...</div>
|
|
|
|
|
|
{:else if error}
|
|
|
|
|
|
<div class="text-red-600">Fehler: {error}</div>
|
|
|
|
|
|
{:else if data}
|
|
|
|
|
|
<!-- Header -->
|
|
|
|
|
|
<div class="flex items-center gap-4 mb-6">
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="w-3 h-12 rounded shrink-0" style="background-color: {data.partei.farbe || '#6b7280'}"></div>
|
|
|
|
|
|
<div class="min-w-0">
|
2026-04-01 10:36:22 +02:00
|
|
|
|
<h1 class="text-2xl font-bold">{data.partei.name}</h1>
|
|
|
|
|
|
<span class="text-sm text-gray-500">{data.partei.kuerzel}</span>
|
|
|
|
|
|
</div>
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<a href="/fraktionen" class="ml-auto text-sm text-blue-600 hover:underline shrink-0">← Alle Fraktionen</a>
|
2026-04-01 10:36:22 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- KPIs -->
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-8">
|
2026-04-01 10:36:22 +02:00
|
|
|
|
<div class="bg-white rounded-lg border p-4">
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="text-2xl sm:text-3xl font-bold">{data.total_antraege}</div>
|
|
|
|
|
|
<div class="text-xs sm:text-sm text-gray-500">Anträge gesamt</div>
|
2026-04-01 10:36:22 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="bg-white rounded-lg border p-4">
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="text-2xl sm:text-3xl font-bold">{data.bewertet}</div>
|
|
|
|
|
|
<div class="text-xs sm:text-sm text-gray-500">Mit Bewertung</div>
|
2026-04-01 10:36:22 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
{#each data.umsetzung.filter(u => u.bewertung === 'erfuellt') as u}
|
|
|
|
|
|
<div class="bg-green-50 rounded-lg border border-green-200 p-4">
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="text-2xl sm:text-3xl font-bold text-green-700">{u.anzahl}</div>
|
|
|
|
|
|
<div class="text-xs sm:text-sm text-green-600">Erfüllt</div>
|
2026-04-01 10:36:22 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
{#each data.umsetzung.filter(u => u.bewertung === 'nebelkerze') as u}
|
|
|
|
|
|
<div class="bg-red-50 rounded-lg border border-red-200 p-4">
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="text-2xl sm:text-3xl font-bold text-red-700">{u.anzahl}</div>
|
|
|
|
|
|
<div class="text-xs sm:text-sm text-red-600">Nebelkerzen</div>
|
2026-04-01 10:36:22 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Umsetzungs-Übersicht (Horizontal Bar) -->
|
|
|
|
|
|
{#if data.bewertet > 0}
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="bg-white rounded-lg border p-4 sm:p-6 mb-8">
|
2026-04-01 10:36:22 +02:00
|
|
|
|
<h2 class="font-bold mb-4">Umsetzungsquote</h2>
|
|
|
|
|
|
<div class="flex rounded-full overflow-hidden h-8 mb-4">
|
|
|
|
|
|
{#each data.umsetzung as u}
|
|
|
|
|
|
{@const info = KATEGORIEN[u.bewertung as keyof typeof KATEGORIEN]}
|
|
|
|
|
|
{@const pct = (u.anzahl / data.bewertet) * 100}
|
|
|
|
|
|
{#if info && pct > 0}
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="flex items-center justify-center text-xs font-medium text-white transition-all"
|
|
|
|
|
|
style="width: {pct}%; background-color: {info.farbe};"
|
|
|
|
|
|
title="{info.label}: {u.anzahl} ({pct.toFixed(1)}%)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if pct > 8}{info.label} {pct.toFixed(0)}%{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Legend -->
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="flex flex-wrap gap-3 sm:gap-4 text-sm">
|
2026-04-01 10:36:22 +02:00
|
|
|
|
{#each data.umsetzung as u}
|
|
|
|
|
|
{@const info = KATEGORIEN[u.bewertung as keyof typeof KATEGORIEN]}
|
|
|
|
|
|
{#if info}
|
|
|
|
|
|
<button
|
2026-04-01 14:21:06 +02:00
|
|
|
|
class="flex items-center gap-1.5 hover:opacity-70 transition-opacity p-1"
|
2026-04-01 10:36:22 +02:00
|
|
|
|
class:opacity-40={filterKategorie && filterKategorie !== u.bewertung}
|
|
|
|
|
|
onclick={() => filterKategorie = filterKategorie === u.bewertung ? '' : u.bewertung}
|
|
|
|
|
|
>
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<span class="w-3 h-3 rounded-full inline-block shrink-0" style="background-color: {info.farbe}"></span>
|
2026-04-01 10:36:22 +02:00
|
|
|
|
{info.label}: {u.anzahl}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Filters -->
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<div class="flex flex-wrap gap-3 sm:gap-4 mb-4 items-center">
|
|
|
|
|
|
<select bind:value={selectedJahr} onchange={loadData} class="border rounded px-3 py-2 text-sm">
|
2026-04-01 10:36:22 +02:00
|
|
|
|
<option value="">Alle Jahre</option>
|
|
|
|
|
|
{#each data.jahre as j}
|
|
|
|
|
|
<option value={j}>{j}</option>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
{#if filterKategorie}
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<button onclick={() => filterKategorie = ''} class="text-sm text-blue-600 hover:underline p-1">
|
2026-04-01 10:36:22 +02:00
|
|
|
|
Filter zurücksetzen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<span class="text-sm text-gray-500 sm:ml-auto">{filteredAntraege.length} Anträge</span>
|
2026-04-01 10:36:22 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-01 14:21:06 +02:00
|
|
|
|
<!-- Desktop Table -->
|
|
|
|
|
|
<div class="hidden md:block bg-white rounded-lg border overflow-hidden">
|
2026-04-01 10:36:22 +02:00
|
|
|
|
<table class="w-full text-sm">
|
|
|
|
|
|
<thead class="bg-gray-50 text-left">
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th class="px-4 py-3 font-medium">Aktenzeichen</th>
|
|
|
|
|
|
<th class="px-4 py-3 font-medium">Betreff</th>
|
|
|
|
|
|
<th class="px-4 py-3 font-medium">Datum</th>
|
|
|
|
|
|
<th class="px-4 py-3 font-medium">Umsetzung</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody class="divide-y">
|
|
|
|
|
|
{#each filteredAntraege as a}
|
|
|
|
|
|
<tr class="hover:bg-gray-50">
|
|
|
|
|
|
<td class="px-4 py-3">
|
|
|
|
|
|
<a href="/vorlagen/{a.id}" class="text-blue-600 hover:underline font-mono text-xs">
|
|
|
|
|
|
{a.aktenzeichen}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td class="px-4 py-3 max-w-md truncate">{a.betreff}</td>
|
|
|
|
|
|
<td class="px-4 py-3 text-gray-500 whitespace-nowrap">{formatDate(a.datum_eingang)}</td>
|
|
|
|
|
|
<td class="px-4 py-3">
|
|
|
|
|
|
{#if a.umsetzung_bewertung}
|
|
|
|
|
|
<UmsetzungBadge kategorie={a.umsetzung_bewertung} score={a.umsetzung_score} />
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<span class="text-gray-400 text-xs">–</span>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
2026-04-01 14:21:06 +02:00
|
|
|
|
|
|
|
|
|
|
<!-- Mobile Cards -->
|
|
|
|
|
|
<div class="md:hidden space-y-3">
|
|
|
|
|
|
{#each filteredAntraege as a}
|
|
|
|
|
|
<a href="/vorlagen/{a.id}" class="block bg-white rounded-lg border p-4 hover:shadow-md transition-shadow">
|
|
|
|
|
|
<div class="flex items-start justify-between gap-2 mb-2">
|
|
|
|
|
|
<span class="font-mono text-xs text-blue-600">{a.aktenzeichen}</span>
|
|
|
|
|
|
<span class="text-xs text-gray-500 shrink-0">{formatDate(a.datum_eingang)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="text-sm text-gray-700 line-clamp-2 mb-2">{a.betreff}</div>
|
|
|
|
|
|
{#if a.umsetzung_bewertung}
|
|
|
|
|
|
<UmsetzungBadge kategorie={a.umsetzung_bewertung} score={a.umsetzung_score} />
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</div>
|
2026-04-01 10:36:22 +02:00
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|