antragstracker/frontend/src/routes/fraktionen/[kuerzel]/+page.svelte
Dotty Dotter c6291a285a feat: Globale Filter (Ratsperiode + Parteien) seitenübergreifend (#15)
Layout:
- Sticky Filter-Bar unter Navigation auf ALLEN Seiten
- Ratsperioden als Multi-Select Toggle-Buttons
- Parteien-Buttons mit Parteifarben aus DB
- Reset-Button bei aktiven Filtern

Backend:
- Shared utility tracker/core/perioden.py (Perioden-Mapping + Filter-Helper)
- GET /api/vorlagen: periode + parteien Parameter
- GET /api/ketten: periode + parteien Parameter
- GET /api/stats/dashboard: periode + parteien Parameter
- GET /api/fraktionen/{kuerzel}/dashboard: periode Parameter

Frontend:
- Shared reactive state (filters.svelte.ts) mit Svelte 5 Runes
- $effect() in allen Seiten reagiert auf Filteränderungen
- Dashboard, Vorlagen, Ketten, Abstimmungen, Fraktionen nutzen globale Filter
- Filter-Bar aus Abstimmungen entfernt (jetzt im Layout)

Closes #15
2026-04-01 14:58:10 +02:00

200 lines
7.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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';
import { filters, filterVersion } from '$lib/filters.svelte';
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 {
const periode = filters.perioden.length > 0 ? filters.perioden.join(',') : undefined;
data = await fetchFraktionDashboard(kuerzel, selectedJahr || undefined, periode);
} catch (e) {
error = (e as Error).message;
}
loading = false;
}
onMount(loadData);
// Reload when filters change (including global filters)
$effect(() => {
filterVersion(); // track global filter changes
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>
<div class="max-w-6xl mx-auto">
{#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">
<div class="w-3 h-12 rounded shrink-0" style="background-color: {data.partei.farbe || '#6b7280'}"></div>
<div class="min-w-0">
<h1 class="text-2xl font-bold">{data.partei.name}</h1>
<span class="text-sm text-gray-500">{data.partei.kuerzel}</span>
</div>
<a href="/fraktionen" class="ml-auto text-sm text-blue-600 hover:underline shrink-0">← Alle Fraktionen</a>
</div>
<!-- KPIs -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4 mb-8">
<div class="bg-white rounded-lg border p-4">
<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>
</div>
<div class="bg-white rounded-lg border p-4">
<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>
</div>
{#each data.umsetzung.filter(u => u.bewertung === 'erfuellt') as u}
<div class="bg-green-50 rounded-lg border border-green-200 p-4">
<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>
</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">
<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>
</div>
{/each}
</div>
<!-- Umsetzungs-Übersicht (Horizontal Bar) -->
{#if data.bewertet > 0}
<div class="bg-white rounded-lg border p-4 sm:p-6 mb-8">
<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 -->
<div class="flex flex-wrap gap-3 sm:gap-4 text-sm">
{#each data.umsetzung as u}
{@const info = KATEGORIEN[u.bewertung as keyof typeof KATEGORIEN]}
{#if info}
<button
class="flex items-center gap-1.5 hover:opacity-70 transition-opacity p-1"
class:opacity-40={filterKategorie && filterKategorie !== u.bewertung}
onclick={() => filterKategorie = filterKategorie === u.bewertung ? '' : u.bewertung}
>
<span class="w-3 h-3 rounded-full inline-block shrink-0" style="background-color: {info.farbe}"></span>
{info.label}: {u.anzahl}
</button>
{/if}
{/each}
</div>
</div>
{/if}
<!-- Filters -->
<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">
<option value="">Alle Jahre</option>
{#each data.jahre as j}
<option value={j}>{j}</option>
{/each}
</select>
{#if filterKategorie}
<button onclick={() => filterKategorie = ''} class="text-sm text-blue-600 hover:underline p-1">
Filter zurücksetzen
</button>
{/if}
<span class="text-sm text-gray-500 sm:ml-auto">{filteredAntraege.length} Anträge</span>
</div>
<!-- Desktop Table -->
<div class="hidden md:block bg-white rounded-lg border overflow-hidden">
<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>
<!-- 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>
{/if}
</div>