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:
parent
7358aa1b61
commit
c6291a285a
@ -38,26 +38,8 @@ def get_abstimmungen_stats(conn=Depends(_db)):
|
||||
|
||||
def _periode_filter(periode: str | None) -> tuple[str, list]:
|
||||
"""Return (WHERE clause fragment, params) for Ratsperiode filtering on abstimmungen.sitzung_datum."""
|
||||
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"),
|
||||
}
|
||||
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
|
||||
from tracker.core.perioden import periode_date_filter
|
||||
return periode_date_filter(periode, "a.sitzung_datum")
|
||||
|
||||
|
||||
def _parteien_filter(parteien: str | None) -> tuple[str, list]:
|
||||
|
||||
@ -53,9 +53,12 @@ def list_fraktionen(conn=Depends(_db)):
|
||||
def fraktion_dashboard(
|
||||
kuerzel: str,
|
||||
jahr: Optional[int] = None,
|
||||
periode: Optional[str] = None,
|
||||
conn=Depends(_db),
|
||||
):
|
||||
"""Dashboard for a single Fraktion with Umsetzungsanalyse."""
|
||||
from tracker.core.perioden import periode_date_filter
|
||||
|
||||
# Find party
|
||||
partei = conn.execute(
|
||||
"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) = ?"
|
||||
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 = conn.execute(f"""
|
||||
SELECT COUNT(DISTINCT v.id) as c
|
||||
|
||||
@ -33,9 +33,13 @@ def list_ketten(
|
||||
typ: str | None = None,
|
||||
suche: str | None = None,
|
||||
partei: str | None = None,
|
||||
periode: str | None = None,
|
||||
parteien: str | None = None,
|
||||
conn=Depends(_db),
|
||||
):
|
||||
"""List Ketten with optional filters."""
|
||||
from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter
|
||||
|
||||
where_clauses = []
|
||||
params: list = []
|
||||
|
||||
@ -58,6 +62,22 @@ def list_ketten(
|
||||
where_clauses.append("k.thema LIKE ?")
|
||||
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 ""
|
||||
|
||||
total = conn.execute(
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from __future__ import annotations
|
||||
"""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
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["Statistics"])
|
||||
@ -112,30 +113,89 @@ def get_stats(conn=Depends(_db)):
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def get_dashboard_stats(conn=Depends(_db)):
|
||||
"""Dashboard-level aggregated stats for the new hub."""
|
||||
vorlagen_total = conn.execute("SELECT COUNT(*) as c FROM vorlagen").fetchone()["c"]
|
||||
ketten_total = conn.execute("SELECT COUNT(*) as c FROM ketten").fetchone()["c"]
|
||||
abstimmungen_total = conn.execute("SELECT COUNT(*) as c FROM abstimmungen").fetchone()["c"]
|
||||
def get_dashboard_stats(
|
||||
periode: str | None = None,
|
||||
parteien: str | None = None,
|
||||
conn=Depends(_db),
|
||||
):
|
||||
"""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("""
|
||||
SELECT typ, COUNT(*) as c FROM vorlagen
|
||||
WHERE typ IS NOT NULL
|
||||
# Build WHERE clauses for ketten
|
||||
k_where_parts: list[str] = []
|
||||
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
|
||||
""").fetchall()
|
||||
""".replace("WHERE AND", "WHERE"), v_params).fetchall()
|
||||
|
||||
ketten_nach_status = conn.execute("""
|
||||
SELECT status, COUNT(*) as c FROM ketten
|
||||
WHERE status IS NOT NULL
|
||||
ketten_nach_status = conn.execute(f"""
|
||||
SELECT status, COUNT(*) as c FROM ketten k
|
||||
{k_where + (' AND' if k_where else 'WHERE')} status IS NOT NULL
|
||||
GROUP BY status ORDER BY c DESC
|
||||
""").fetchall()
|
||||
""".replace("WHERE AND", "WHERE"), k_params).fetchall()
|
||||
|
||||
# Umsetzungsquote: umgesetzt, teilweise_umgesetzt, versandet aus ketten
|
||||
umgesetzt = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'umgesetzt'").fetchone()["c"]
|
||||
teilweise = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'teilweise_umgesetzt'").fetchone()["c"]
|
||||
versandet = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'versandet'").fetchone()["c"]
|
||||
beschlossen = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'beschlossen'").fetchone()["c"]
|
||||
abgelehnt = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'abgelehnt'").fetchone()["c"]
|
||||
# Umsetzungsquote
|
||||
def _k_count(status_val: str) -> int:
|
||||
extra = f" AND k.status = ?" if k_where else "WHERE k.status = ?"
|
||||
return conn.execute(
|
||||
f"SELECT COUNT(*) as c FROM ketten k {k_where}{extra}",
|
||||
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
|
||||
|
||||
return {
|
||||
|
||||
@ -95,9 +95,13 @@ def list_vorlagen(
|
||||
typ: str | None = None,
|
||||
suche: str | None = None,
|
||||
partei: str | None = None,
|
||||
periode: str | None = None,
|
||||
parteien: str | None = None,
|
||||
conn=Depends(_db),
|
||||
):
|
||||
"""List Vorlagen with optional filters."""
|
||||
from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter
|
||||
|
||||
where_clauses = []
|
||||
params: list = []
|
||||
|
||||
@ -112,6 +116,22 @@ def list_vorlagen(
|
||||
)
|
||||
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:
|
||||
# Try FTS5 first, fall back to LIKE
|
||||
has_fts = conn.execute(
|
||||
|
||||
46
backend/src/tracker/core/perioden.py
Normal file
46
backend/src/tracker/core/perioden.py
Normal 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()]
|
||||
@ -216,7 +216,10 @@ export const fetchSuchvorschlaege = (q: string) =>
|
||||
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 fetchFraktionDashboard = (kuerzel: string, jahr?: string) => {
|
||||
const params = jahr ? `?jahr=${jahr}` : '';
|
||||
return get<FraktionDashboard>(`/fraktionen/${kuerzel}/dashboard${params}`);
|
||||
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
|
||||
const p = new URLSearchParams();
|
||||
if (jahr) p.set('jahr', jahr);
|
||||
if (periode) p.set('periode', periode);
|
||||
const qs = p.toString();
|
||||
return get<FraktionDashboard>(`/fraktionen/${kuerzel}/dashboard${qs ? `?${qs}` : ''}`);
|
||||
};
|
||||
|
||||
84
frontend/src/lib/filters.svelte.ts
Normal file
84
frontend/src/lib/filters.svelte.ts
Normal 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(',')}`;
|
||||
}
|
||||
@ -1,7 +1,29 @@
|
||||
<script lang="ts">
|
||||
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 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>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
@ -59,6 +81,56 @@
|
||||
{/if}
|
||||
</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 -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
{@render children()}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import { filters, filterVersion } from '$lib/filters.svelte';
|
||||
|
||||
interface Vorlage {
|
||||
id: number;
|
||||
@ -95,9 +96,16 @@
|
||||
|
||||
async function loadData() {
|
||||
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([
|
||||
fetch(`${API_BASE}/stats/dashboard`),
|
||||
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10`),
|
||||
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
|
||||
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
|
||||
]);
|
||||
|
||||
if (dashRes.ok) {
|
||||
@ -117,7 +125,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reload when global filters change (also handles initial load)
|
||||
$effect(() => {
|
||||
filterVersion(); // track reactivity
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { filters, filterVersion } from '$lib/filters.svelte';
|
||||
|
||||
interface FraktionStats {
|
||||
fraktion: string;
|
||||
@ -53,12 +53,6 @@
|
||||
let loading = $state(true);
|
||||
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
|
||||
let selectedFraktion = $state('');
|
||||
let selectedStimme = $state('');
|
||||
@ -85,8 +79,8 @@
|
||||
loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedPerioden.length > 0) params.set('periode', selectedPerioden.join(','));
|
||||
if (selectedParteien.length > 0) params.set('parteien', selectedParteien.join(','));
|
||||
if (filters.perioden.length > 0) params.set('periode', filters.perioden.join(','));
|
||||
if (filters.parteien.length > 0) params.set('parteien', filters.parteien.join(','));
|
||||
const qs = params.toString();
|
||||
const suffix = qs ? `?${qs}` : '';
|
||||
|
||||
@ -97,10 +91,6 @@
|
||||
|
||||
if (frakRes.ok) {
|
||||
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();
|
||||
} catch (e) {
|
||||
@ -110,38 +100,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
function togglePeriode(p: string) {
|
||||
if (selectedPerioden.includes(p)) {
|
||||
selectedPerioden = selectedPerioden.filter(x => x !== p);
|
||||
} else {
|
||||
selectedPerioden = [...selectedPerioden, p];
|
||||
}
|
||||
// Reload when global filters change (also handles initial load)
|
||||
$effect(() => {
|
||||
filterVersion(); // track reactivity
|
||||
clearDetail();
|
||||
clearVergleich();
|
||||
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) {
|
||||
@ -242,53 +206,6 @@
|
||||
<p class="text-gray-500 text-sm mt-1">Analyse des Stimmverhaltens der Ratsfraktionen</p>
|
||||
</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}
|
||||
<div class="bg-red-50 text-red-700 p-4 rounded-lg mb-6">{error}</div>
|
||||
{/if}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
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);
|
||||
@ -17,7 +18,8 @@
|
||||
loading = true;
|
||||
error = null;
|
||||
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) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
@ -26,8 +28,9 @@
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
// Reload when filters change
|
||||
// Reload when filters change (including global filters)
|
||||
$effect(() => {
|
||||
filterVersion(); // track global filter changes
|
||||
if (kuerzel) loadData();
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
import { fetchKetten, fetchFraktionen, type KetteKurz, type Paginated } from '$lib/api';
|
||||
import { formatDate } from '$lib/status';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
|
||||
|
||||
let data: Paginated<KetteKurz> | null = $state(null);
|
||||
let error: string | null = $state(null);
|
||||
@ -30,11 +31,12 @@
|
||||
async function load() {
|
||||
loading = true;
|
||||
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 (filterTyp) params.typ = filterTyp;
|
||||
if (filterSuche) params.suche = filterSuche;
|
||||
if (filterPartei) params.partei = filterPartei;
|
||||
params = mergeFilterParams(params);
|
||||
data = await fetchKetten(params);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler';
|
||||
@ -68,6 +70,13 @@
|
||||
syncFromUrl();
|
||||
load();
|
||||
});
|
||||
|
||||
// Reload when global filters change
|
||||
$effect(() => {
|
||||
filterVersion();
|
||||
currentPage = 1;
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { fetchVorlagen, fetchFraktionen, fetchSuchvorschlaege, type VorlageKurz, type Paginated, type SuchVorschlag } from '$lib/api';
|
||||
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 parteien = $state<{ kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]);
|
||||
@ -88,10 +89,11 @@
|
||||
async function load() {
|
||||
loading = true;
|
||||
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 (filterSuche) params.suche = filterSuche;
|
||||
if (filterPartei) params.partei = filterPartei;
|
||||
params = mergeFilterParams(params);
|
||||
data = await fetchVorlagen(params);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler';
|
||||
@ -124,6 +126,13 @@
|
||||
syncFromUrl();
|
||||
load();
|
||||
});
|
||||
|
||||
// Reload when global filters change
|
||||
$effect(() => {
|
||||
filterVersion();
|
||||
currentPage = 1;
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user