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
This commit is contained in:
Dotty Dotter 2026-04-01 14:58:10 +02:00
parent 7358aa1b61
commit c6291a285a
14 changed files with 385 additions and 139 deletions

View File

@ -38,26 +38,8 @@ def get_abstimmungen_stats(conn=Depends(_db)):
def _periode_filter(periode: str | None) -> tuple[str, list]: def _periode_filter(periode: str | None) -> tuple[str, list]:
"""Return (WHERE clause fragment, params) for Ratsperiode filtering on abstimmungen.sitzung_datum.""" """Return (WHERE clause fragment, params) for Ratsperiode filtering on abstimmungen.sitzung_datum."""
PERIODEN = { from tracker.core.perioden import periode_date_filter
"2025-2030": ("2025-11-01", "2030-10-31"), return periode_date_filter(periode, "a.sitzung_datum")
"2020-2025": ("2020-11-01", "2025-10-31"),
"2014-2020": ("2014-06-01", "2020-10-31"),
"2009-2014": ("2009-09-01", "2014-05-31"),
"2004-2009": ("2004-09-01", "2009-08-31"),
}
if not periode:
return "", []
# Support multi-select: "2020-2025,2025-2030"
selected = [p.strip() for p in periode.split(",") if p.strip() in PERIODEN]
if not selected:
return "", []
conditions = []
params = []
for p in selected:
start, end = PERIODEN[p]
conditions.append("(a.sitzung_datum >= ? AND a.sitzung_datum <= ?)")
params.extend([start, end])
return f"({' OR '.join(conditions)})", params
def _parteien_filter(parteien: str | None) -> tuple[str, list]: def _parteien_filter(parteien: str | None) -> tuple[str, list]:

View File

@ -53,9 +53,12 @@ def list_fraktionen(conn=Depends(_db)):
def fraktion_dashboard( def fraktion_dashboard(
kuerzel: str, kuerzel: str,
jahr: Optional[int] = None, jahr: Optional[int] = None,
periode: Optional[str] = None,
conn=Depends(_db), conn=Depends(_db),
): ):
"""Dashboard for a single Fraktion with Umsetzungsanalyse.""" """Dashboard for a single Fraktion with Umsetzungsanalyse."""
from tracker.core.perioden import periode_date_filter
# Find party # Find party
partei = conn.execute( partei = conn.execute(
"SELECT id, kuerzel, name, farbe FROM parteien WHERE kuerzel = ?", (kuerzel,) "SELECT id, kuerzel, name, farbe FROM parteien WHERE kuerzel = ?", (kuerzel,)
@ -70,6 +73,12 @@ def fraktion_dashboard(
jahr_filter = "AND strftime('%Y', v.datum_eingang) = ?" jahr_filter = "AND strftime('%Y', v.datum_eingang) = ?"
params.append(str(jahr)) params.append(str(jahr))
# Global filter: Ratsperiode
per_clause, per_params = periode_date_filter(periode, "v.datum_eingang")
if per_clause:
jahr_filter += f" AND {per_clause}"
params.extend(per_params)
# Total Anträge # Total Anträge
total = conn.execute(f""" total = conn.execute(f"""
SELECT COUNT(DISTINCT v.id) as c SELECT COUNT(DISTINCT v.id) as c

View File

@ -33,9 +33,13 @@ def list_ketten(
typ: str | None = None, typ: str | None = None,
suche: str | None = None, suche: str | None = None,
partei: str | None = None, partei: str | None = None,
periode: str | None = None,
parteien: str | None = None,
conn=Depends(_db), conn=Depends(_db),
): ):
"""List Ketten with optional filters.""" """List Ketten with optional filters."""
from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter
where_clauses = [] where_clauses = []
params: list = [] params: list = []
@ -58,6 +62,22 @@ def list_ketten(
where_clauses.append("k.thema LIKE ?") where_clauses.append("k.thema LIKE ?")
params.append(f"%{suche}%") params.append(f"%{suche}%")
# Global filter: Ratsperiode (filter on letzte_aktivitaet)
per_clause, per_params = periode_date_filter(periode, "k.letzte_aktivitaet")
if per_clause:
where_clauses.append(per_clause)
params.extend(per_params)
# Global filter: Parteien (multi-select on Ursprung-Antragsteller)
p_kuerzel = parteien_kuerzel_filter(parteien)
if p_kuerzel:
placeholders = ",".join("?" * len(p_kuerzel))
where_clauses.append(
f"k.ursprung_id IN (SELECT a.vorlage_id FROM antragsteller a "
f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({placeholders}))"
)
params.extend(p_kuerzel)
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
total = conn.execute( total = conn.execute(

View File

@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
"""API routes for Dashboard statistics.""" """API routes for Dashboard statistics."""
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter
from tracker.db.session import get_connection from tracker.db.session import get_connection
router = APIRouter(prefix="/stats", tags=["Statistics"]) router = APIRouter(prefix="/stats", tags=["Statistics"])
@ -112,30 +113,89 @@ def get_stats(conn=Depends(_db)):
@router.get("/dashboard") @router.get("/dashboard")
def get_dashboard_stats(conn=Depends(_db)): def get_dashboard_stats(
"""Dashboard-level aggregated stats for the new hub.""" periode: str | None = None,
vorlagen_total = conn.execute("SELECT COUNT(*) as c FROM vorlagen").fetchone()["c"] parteien: str | None = None,
ketten_total = conn.execute("SELECT COUNT(*) as c FROM ketten").fetchone()["c"] conn=Depends(_db),
abstimmungen_total = conn.execute("SELECT COUNT(*) as c FROM abstimmungen").fetchone()["c"] ):
"""Dashboard-level aggregated stats for the new hub, with optional global filters."""
# Build WHERE clauses for vorlagen
v_where_parts: list[str] = []
v_params: list = []
per_clause, per_params = periode_date_filter(periode, "v.datum_eingang")
if per_clause:
v_where_parts.append(per_clause)
v_params.extend(per_params)
p_kuerzel = parteien_kuerzel_filter(parteien)
if p_kuerzel:
ph = ",".join("?" * len(p_kuerzel))
v_where_parts.append(
f"v.id IN (SELECT a.vorlage_id FROM antragsteller a "
f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({ph}))"
)
v_params.extend(p_kuerzel)
v_where = ("WHERE " + " AND ".join(v_where_parts)) if v_where_parts else ""
vorlagen_nach_typ = conn.execute(""" # Build WHERE clauses for ketten
SELECT typ, COUNT(*) as c FROM vorlagen k_where_parts: list[str] = []
WHERE typ IS NOT NULL k_params: list = []
k_per_clause, k_per_params = periode_date_filter(periode, "k.letzte_aktivitaet")
if k_per_clause:
k_where_parts.append(k_per_clause)
k_params.extend(k_per_params)
if p_kuerzel:
ph = ",".join("?" * len(p_kuerzel))
k_where_parts.append(
f"k.ursprung_id IN (SELECT a.vorlage_id FROM antragsteller a "
f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({ph}))"
)
k_params.extend(p_kuerzel)
k_where = ("WHERE " + " AND ".join(k_where_parts)) if k_where_parts else ""
# Build WHERE for abstimmungen
a_where_parts: list[str] = []
a_params: list = []
a_per_clause, a_per_params = periode_date_filter(periode, "ab.sitzung_datum")
if a_per_clause:
a_where_parts.append(a_per_clause)
a_params.extend(a_per_params)
a_where = ("WHERE " + " AND ".join(a_where_parts)) if a_where_parts else ""
vorlagen_total = conn.execute(
f"SELECT COUNT(*) as c FROM vorlagen v {v_where}", v_params
).fetchone()["c"]
ketten_total = conn.execute(
f"SELECT COUNT(*) as c FROM ketten k {k_where}", k_params
).fetchone()["c"]
abstimmungen_total = conn.execute(
f"SELECT COUNT(*) as c FROM abstimmungen ab {a_where}", a_params
).fetchone()["c"]
vorlagen_nach_typ = conn.execute(f"""
SELECT typ, COUNT(*) as c FROM vorlagen v
{v_where + (' AND' if v_where else 'WHERE')} typ IS NOT NULL
GROUP BY typ ORDER BY c DESC GROUP BY typ ORDER BY c DESC
""").fetchall() """.replace("WHERE AND", "WHERE"), v_params).fetchall()
ketten_nach_status = conn.execute(""" ketten_nach_status = conn.execute(f"""
SELECT status, COUNT(*) as c FROM ketten SELECT status, COUNT(*) as c FROM ketten k
WHERE status IS NOT NULL {k_where + (' AND' if k_where else 'WHERE')} status IS NOT NULL
GROUP BY status ORDER BY c DESC GROUP BY status ORDER BY c DESC
""").fetchall() """.replace("WHERE AND", "WHERE"), k_params).fetchall()
# Umsetzungsquote: umgesetzt, teilweise_umgesetzt, versandet aus ketten # Umsetzungsquote
umgesetzt = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'umgesetzt'").fetchone()["c"] def _k_count(status_val: str) -> int:
teilweise = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'teilweise_umgesetzt'").fetchone()["c"] extra = f" AND k.status = ?" if k_where else "WHERE k.status = ?"
versandet = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'versandet'").fetchone()["c"] return conn.execute(
beschlossen = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'beschlossen'").fetchone()["c"] f"SELECT COUNT(*) as c FROM ketten k {k_where}{extra}",
abgelehnt = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'abgelehnt'").fetchone()["c"] k_params + [status_val],
).fetchone()["c"]
umgesetzt = _k_count("umgesetzt")
teilweise = _k_count("teilweise_umgesetzt")
versandet = _k_count("versandet")
beschlossen = _k_count("beschlossen")
abgelehnt = _k_count("abgelehnt")
total_bewertet = umgesetzt + teilweise + versandet + beschlossen + abgelehnt total_bewertet = umgesetzt + teilweise + versandet + beschlossen + abgelehnt
return { return {

View File

@ -95,9 +95,13 @@ def list_vorlagen(
typ: str | None = None, typ: str | None = None,
suche: str | None = None, suche: str | None = None,
partei: str | None = None, partei: str | None = None,
periode: str | None = None,
parteien: str | None = None,
conn=Depends(_db), conn=Depends(_db),
): ):
"""List Vorlagen with optional filters.""" """List Vorlagen with optional filters."""
from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter
where_clauses = [] where_clauses = []
params: list = [] params: list = []
@ -112,6 +116,22 @@ def list_vorlagen(
) )
params.append(partei) params.append(partei)
# Global filter: Ratsperiode
per_clause, per_params = periode_date_filter(periode, "v.datum_eingang")
if per_clause:
where_clauses.append(per_clause)
params.extend(per_params)
# Global filter: Parteien (multi-select)
p_kuerzel = parteien_kuerzel_filter(parteien)
if p_kuerzel:
placeholders = ",".join("?" * len(p_kuerzel))
where_clauses.append(
f"v.id IN (SELECT a.vorlage_id FROM antragsteller a "
f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({placeholders}))"
)
params.extend(p_kuerzel)
if suche: if suche:
# Try FTS5 first, fall back to LIKE # Try FTS5 first, fall back to LIKE
has_fts = conn.execute( has_fts = conn.execute(

View File

@ -0,0 +1,46 @@
"""Shared Perioden filter utilities for all routers."""
from __future__ import annotations
PERIODEN = {
"2025-2030": ("2025-11-01", "2030-10-31"),
"2020-2025": ("2020-11-01", "2025-10-31"),
"2014-2020": ("2014-06-01", "2020-10-31"),
"2009-2014": ("2009-09-01", "2014-05-31"),
"2004-2009": ("2004-09-01", "2009-08-31"),
}
def parse_perioden(periode: str | None) -> list[str]:
"""Parse comma-separated perioden string into valid period keys."""
if not periode:
return []
return [p.strip() for p in periode.split(",") if p.strip() in PERIODEN]
def periode_date_filter(periode: str | None, date_column: str) -> tuple[str, list]:
"""Return (WHERE clause fragment, params) for filtering a date column by Ratsperiode.
Args:
periode: Comma-separated perioden string (e.g. "2020-2025,2025-2030")
date_column: SQL column expression to filter on (e.g. "v.datum_eingang", "a.sitzung_datum")
Returns:
Tuple of (SQL fragment, list of params). Empty string if no filter.
"""
selected = parse_perioden(periode)
if not selected:
return "", []
conditions = []
params = []
for p in selected:
start, end = PERIODEN[p]
conditions.append(f"({date_column} >= ? AND {date_column} <= ?)")
params.extend([start, end])
return f"({' OR '.join(conditions)})", params
def parteien_kuerzel_filter(parteien: str | None) -> list[str]:
"""Parse comma-separated parteien string into kuerzel list."""
if not parteien:
return []
return [p.strip() for p in parteien.split(",") if p.strip()]

View File

@ -216,7 +216,10 @@ 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 const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => { export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
const params = jahr ? `?jahr=${jahr}` : ''; const p = new URLSearchParams();
return get<FraktionDashboard>(`/fraktionen/${kuerzel}/dashboard${params}`); if (jahr) p.set('jahr', jahr);
if (periode) p.set('periode', periode);
const qs = p.toString();
return get<FraktionDashboard>(`/fraktionen/${kuerzel}/dashboard${qs ? `?${qs}` : ''}`);
}; };

View File

@ -0,0 +1,84 @@
/**
* Shared global filter state for Ratsperiode + Parteien.
* Uses Svelte 5 Runes ($state).
*/
export const PERIODEN = ['2025-2030', '2020-2025', '2014-2020', '2009-2014', '2004-2009'];
export interface Fraktion {
kuerzel: string;
name: string;
farbe: string | null;
anzahl: number;
}
// Reactive filter state — module-level $state
export const filters = $state({
perioden: [] as string[],
parteien: [] as string[],
});
// Available fraktionen (loaded once from API)
export const fraktionenList = $state<{ items: Fraktion[] }>({ items: [] });
export function togglePeriode(p: string) {
if (filters.perioden.includes(p)) {
filters.perioden = filters.perioden.filter((x) => x !== p);
} else {
filters.perioden = [...filters.perioden, p];
}
}
export function togglePartei(p: string) {
if (filters.parteien.includes(p)) {
filters.parteien = filters.parteien.filter((x) => x !== p);
} else {
filters.parteien = [...filters.parteien, p];
}
}
export function clearFilters() {
filters.perioden = [];
filters.parteien = [];
}
export function hasActiveFilters(): boolean {
return filters.perioden.length > 0 || filters.parteien.length > 0;
}
/**
* Build URLSearchParams from active filters.
* Append to any existing params for API calls.
*/
export function filterParams(): URLSearchParams {
const params = new URLSearchParams();
if (filters.perioden.length > 0) {
params.set('periode', filters.perioden.join(','));
}
if (filters.parteien.length > 0) {
params.set('parteien', filters.parteien.join(','));
}
return params;
}
/**
* Merge filter params into an existing Record<string, string>.
*/
export function mergeFilterParams(existing: Record<string, string>): Record<string, string> {
const merged = { ...existing };
if (filters.perioden.length > 0) {
merged.periode = filters.perioden.join(',');
}
if (filters.parteien.length > 0) {
merged.parteien = filters.parteien.join(',');
}
return merged;
}
/**
* Returns a reactive "version" that changes when filters change.
* Use this in $effect() to trigger reloads.
*/
export function filterVersion(): string {
return `${filters.perioden.join(',')}_${filters.parteien.join(',')}`;
}

View File

@ -1,7 +1,29 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { onMount } from 'svelte';
import { filters, fraktionenList, PERIODEN, togglePeriode, togglePartei, clearFilters, hasActiveFilters, type Fraktion } from '$lib/filters.svelte';
let { children } = $props(); let { children } = $props();
let menuOpen = $state(false); let menuOpen = $state(false);
const API_BASE = typeof window !== 'undefined'
? (window.location.port === '5173'
? `http://${window.location.hostname}:8099/api`
: '/api')
: '/api';
onMount(async () => {
if (fraktionenList.items.length === 0) {
try {
const res = await fetch(`${API_BASE}/fraktionen`);
if (res.ok) {
fraktionenList.items = await res.json();
}
} catch (e) {
console.error('Fraktionen laden fehlgeschlagen:', e);
}
}
});
</script> </script>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
@ -59,6 +81,56 @@
{/if} {/if}
</nav> </nav>
<!-- Global Filter Bar -->
<div class="sticky top-0 z-40 bg-white/95 backdrop-blur-sm border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5">
<div class="flex flex-col sm:flex-row gap-2.5 sm:items-center">
<!-- Ratsperioden -->
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-medium text-gray-500 uppercase shrink-0">Periode</span>
<div class="flex flex-wrap gap-1">
{#each PERIODEN as p}
<button onclick={() => togglePeriode(p)}
class="px-2 py-1 rounded-md text-xs font-medium transition-all
{filters.perioden.includes(p)
? 'bg-green-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
{p}
</button>
{/each}
</div>
</div>
<!-- Separator -->
<div class="hidden sm:block w-px h-6 bg-gray-300"></div>
<!-- Parteien -->
<div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
<span class="text-xs font-medium text-gray-500 uppercase shrink-0">Partei</span>
<div class="flex flex-wrap gap-1">
{#each fraktionenList.items as f}
<button onclick={() => togglePartei(f.kuerzel)}
class="px-2 py-1 rounded-md text-xs font-medium transition-all border"
style={filters.parteien.includes(f.kuerzel)
? `background-color: ${f.farbe || '#6b7280'}; color: white; border-color: ${f.farbe || '#6b7280'};`
: `background-color: white; color: ${f.farbe || '#6b7280'}; border-color: ${f.farbe || '#6b7280'}40;`}>
{f.kuerzel}
</button>
{/each}
</div>
</div>
<!-- Reset -->
{#if hasActiveFilters()}
<button onclick={clearFilters}
class="text-xs text-gray-500 hover:text-gray-700 border border-gray-300 rounded-md px-2.5 py-1 hover:bg-gray-50 whitespace-nowrap shrink-0 transition-colors">
✕ Reset
</button>
{/if}
</div>
</div>
</div>
<!-- Content --> <!-- Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8"> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{@render children()} {@render children()}

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { filters, filterVersion } from '$lib/filters.svelte';
interface Vorlage { interface Vorlage {
id: number; id: number;
@ -95,9 +96,16 @@
async function loadData() { async function loadData() {
try { try {
const fp = new URLSearchParams();
if (filters.perioden.length > 0) fp.set('periode', filters.perioden.join(','));
if (filters.parteien.length > 0) fp.set('parteien', filters.parteien.join(','));
const fqs = fp.toString();
const dashSuffix = fqs ? `?${fqs}` : '';
const vorlagenSuffix = fqs ? `&${fqs}` : '';
const [dashRes, antraegeRes] = await Promise.all([ const [dashRes, antraegeRes] = await Promise.all([
fetch(`${API_BASE}/stats/dashboard`), fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10`), fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
]); ]);
if (dashRes.ok) { if (dashRes.ok) {
@ -117,7 +125,11 @@
} }
} }
loadData(); // Reload when global filters change (also handles initial load)
$effect(() => {
filterVersion(); // track reactivity
loadData();
});
</script> </script>
<svelte:head> <svelte:head>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { filters, filterVersion } from '$lib/filters.svelte';
interface FraktionStats { interface FraktionStats {
fraktion: string; fraktion: string;
@ -53,12 +53,6 @@
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
// Global filters
const PERIODEN = ['2025-2030', '2020-2025', '2014-2020', '2009-2014', '2004-2009'];
let selectedPerioden = $state<string[]>([]);
let selectedParteien = $state<string[]>([]);
let allParteien = $state<string[]>([]);
// Filter state for detail view // Filter state for detail view
let selectedFraktion = $state(''); let selectedFraktion = $state('');
let selectedStimme = $state(''); let selectedStimme = $state('');
@ -85,8 +79,8 @@
loading = true; loading = true;
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (selectedPerioden.length > 0) params.set('periode', selectedPerioden.join(',')); if (filters.perioden.length > 0) params.set('periode', filters.perioden.join(','));
if (selectedParteien.length > 0) params.set('parteien', selectedParteien.join(',')); if (filters.parteien.length > 0) params.set('parteien', filters.parteien.join(','));
const qs = params.toString(); const qs = params.toString();
const suffix = qs ? `?${qs}` : ''; const suffix = qs ? `?${qs}` : '';
@ -97,10 +91,6 @@
if (frakRes.ok) { if (frakRes.ok) {
fraktionen = await frakRes.json(); fraktionen = await frakRes.json();
// Build party list from unfiltered data for multi-select
if (allParteien.length === 0) {
allParteien = fraktionen.filter(f => f.ist_ratsfraktion).map(f => f.fraktion).sort();
}
} }
if (koalRes.ok) koalitionsmatrix = await koalRes.json(); if (koalRes.ok) koalitionsmatrix = await koalRes.json();
} catch (e) { } catch (e) {
@ -110,38 +100,12 @@
} }
} }
function togglePeriode(p: string) { // Reload when global filters change (also handles initial load)
if (selectedPerioden.includes(p)) { $effect(() => {
selectedPerioden = selectedPerioden.filter(x => x !== p); filterVersion(); // track reactivity
} else {
selectedPerioden = [...selectedPerioden, p];
}
clearDetail(); clearDetail();
clearVergleich(); clearVergleich();
loadAll(); loadAll();
}
function togglePartei(p: string) {
if (selectedParteien.includes(p)) {
selectedParteien = selectedParteien.filter(x => x !== p);
} else {
selectedParteien = [...selectedParteien, p];
}
clearDetail();
clearVergleich();
loadAll();
}
function clearFilters() {
selectedPerioden = [];
selectedParteien = [];
clearDetail();
clearVergleich();
loadAll();
}
onMount(() => {
loadAll();
}); });
async function loadDetails(fraktion: string, stimme: string, page: number = 1) { async function loadDetails(fraktion: string, stimme: string, page: number = 1) {
@ -242,53 +206,6 @@
<p class="text-gray-500 text-sm mt-1">Analyse des Stimmverhaltens der Ratsfraktionen</p> <p class="text-gray-500 text-sm mt-1">Analyse des Stimmverhaltens der Ratsfraktionen</p>
</div> </div>
<!-- Global Filters -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div class="flex flex-col sm:flex-row gap-4">
<!-- Ratsperioden -->
<div class="flex-1">
<span class="text-xs font-medium text-gray-500 uppercase mb-2 block">Ratsperiode</span>
<div class="flex flex-wrap gap-1.5">
{#each PERIODEN as p}
<button onclick={() => togglePeriode(p)}
class="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all
{selectedPerioden.includes(p)
? 'bg-green-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
{p}
</button>
{/each}
</div>
</div>
<!-- Parteien -->
<div class="flex-1">
<span class="text-xs font-medium text-gray-500 uppercase mb-2 block">Parteien</span>
<div class="flex flex-wrap gap-1.5">
{#each allParteien as p}
<button onclick={() => togglePartei(p)}
class="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all
{selectedParteien.includes(p)
? 'bg-green-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
{p}
</button>
{/each}
</div>
</div>
<!-- Reset -->
{#if selectedPerioden.length > 0 || selectedParteien.length > 0}
<div class="flex items-end">
<button onclick={clearFilters}
class="text-xs text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 whitespace-nowrap">
✕ Filter zurücksetzen
</button>
</div>
{/if}
</div>
</div>
{#if error} {#if error}
<div class="bg-red-50 text-red-700 p-4 rounded-lg mb-6">{error}</div> <div class="bg-red-50 text-red-700 p-4 rounded-lg mb-6">{error}</div>
{/if} {/if}

View File

@ -5,6 +5,7 @@
import { KATEGORIEN } from '$lib/umsetzung'; import { KATEGORIEN } from '$lib/umsetzung';
import { formatDate } from '$lib/status'; import { formatDate } from '$lib/status';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { filters, filterVersion } from '$lib/filters.svelte';
let data = $state<FraktionDashboard | null>(null); let data = $state<FraktionDashboard | null>(null);
let loading = $state(true); let loading = $state(true);
@ -17,7 +18,8 @@
loading = true; loading = true;
error = null; error = null;
try { try {
data = await fetchFraktionDashboard(kuerzel, selectedJahr || undefined); const periode = filters.perioden.length > 0 ? filters.perioden.join(',') : undefined;
data = await fetchFraktionDashboard(kuerzel, selectedJahr || undefined, periode);
} catch (e) { } catch (e) {
error = (e as Error).message; error = (e as Error).message;
} }
@ -26,8 +28,9 @@
onMount(loadData); onMount(loadData);
// Reload when filters change // Reload when filters change (including global filters)
$effect(() => { $effect(() => {
filterVersion(); // track global filter changes
if (kuerzel) loadData(); if (kuerzel) loadData();
}); });

View File

@ -5,6 +5,7 @@
import { fetchKetten, fetchFraktionen, type KetteKurz, type Paginated } from '$lib/api'; import { fetchKetten, fetchFraktionen, type KetteKurz, type Paginated } from '$lib/api';
import { formatDate } from '$lib/status'; import { formatDate } from '$lib/status';
import StatusBadge from '$lib/components/StatusBadge.svelte'; import StatusBadge from '$lib/components/StatusBadge.svelte';
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
let data: Paginated<KetteKurz> | null = $state(null); let data: Paginated<KetteKurz> | null = $state(null);
let error: string | null = $state(null); let error: string | null = $state(null);
@ -30,11 +31,12 @@
async function load() { async function load() {
loading = true; loading = true;
try { try {
const params: Record<string, string> = { page: String(currentPage), page_size: '30' }; let params: Record<string, string> = { page: String(currentPage), page_size: '30' };
if (filterStatus) params.status = filterStatus; if (filterStatus) params.status = filterStatus;
if (filterTyp) params.typ = filterTyp; if (filterTyp) params.typ = filterTyp;
if (filterSuche) params.suche = filterSuche; if (filterSuche) params.suche = filterSuche;
if (filterPartei) params.partei = filterPartei; if (filterPartei) params.partei = filterPartei;
params = mergeFilterParams(params);
data = await fetchKetten(params); data = await fetchKetten(params);
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Fehler'; error = e instanceof Error ? e.message : 'Fehler';
@ -68,6 +70,13 @@
syncFromUrl(); syncFromUrl();
load(); load();
}); });
// Reload when global filters change
$effect(() => {
filterVersion();
currentPage = 1;
load();
});
</script> </script>
<svelte:head> <svelte:head>

View File

@ -3,6 +3,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { fetchVorlagen, fetchFraktionen, fetchSuchvorschlaege, type VorlageKurz, type Paginated, type SuchVorschlag } from '$lib/api'; import { fetchVorlagen, fetchFraktionen, fetchSuchvorschlaege, type VorlageKurz, type Paginated, type SuchVorschlag } from '$lib/api';
import { formatDate } from '$lib/status'; import { formatDate } from '$lib/status';
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
let data: Paginated<VorlageKurz & { antragsteller?: { kuerzel: string; name: string; farbe: string | null }[] }> | null = $state(null); let data: Paginated<VorlageKurz & { antragsteller?: { kuerzel: string; name: string; farbe: string | null }[] }> | null = $state(null);
let parteien = $state<{ kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]); let parteien = $state<{ kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]);
@ -88,10 +89,11 @@
async function load() { async function load() {
loading = true; loading = true;
try { try {
const params: Record<string, string> = { page: String(currentPage), page_size: '50' }; let params: Record<string, string> = { page: String(currentPage), page_size: '50' };
if (filterTyp) params.typ = filterTyp; if (filterTyp) params.typ = filterTyp;
if (filterSuche) params.suche = filterSuche; if (filterSuche) params.suche = filterSuche;
if (filterPartei) params.partei = filterPartei; if (filterPartei) params.partei = filterPartei;
params = mergeFilterParams(params);
data = await fetchVorlagen(params); data = await fetchVorlagen(params);
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Fehler'; error = e instanceof Error ? e.message : 'Fehler';
@ -124,6 +126,13 @@
syncFromUrl(); syncFromUrl();
load(); load();
}); });
// Reload when global filters change
$effect(() => {
filterVersion();
currentPage = 1;
load();
});
</script> </script>
<svelte:head> <svelte:head>