feat: Globale Multi-Select Filter (Ratsperiode + Parteien) auf Abstimmungen
- Filter-Bar oben: Ratsperioden (2004-2030) + Parteien als Toggle-Buttons - Multi-Select: Mehrere Perioden/Parteien gleichzeitig wählbar - Filter wirken auf Stimmverhalten-Tabelle UND Koalitionsmatrix - Backend: periode= und parteien= Query-Parameter auf /fraktionen + /koalitionsmatrix - Fraktions-Normalisierung: Mapping für 40+ DB-Varianten - Gestapelte Balken (grün/rot/gelb) statt nur grün - Filter bleiben beim Scrollen aktiv, Reset-Button wenn Filter gesetzt
This commit is contained in:
parent
ea3e5cd329
commit
7358aa1b61
@ -36,19 +36,83 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def _parteien_filter(parteien: str | None) -> tuple[str, list]:
|
||||||
|
"""Return (WHERE clause fragment, params) for multi-party filter on abstimmungen_fraktionen."""
|
||||||
|
if not parteien:
|
||||||
|
return "", []
|
||||||
|
selected = [p.strip() for p in parteien.split(",") if p.strip()]
|
||||||
|
# Expand each to all DB variants
|
||||||
|
all_variants = []
|
||||||
|
for p in selected:
|
||||||
|
all_variants.extend(get_all_variants(p))
|
||||||
|
if not all_variants:
|
||||||
|
return "", []
|
||||||
|
placeholders = ",".join("?" * len(all_variants))
|
||||||
|
return f"af_filter.fraktion IN ({placeholders})", all_variants
|
||||||
|
|
||||||
|
|
||||||
@router.get("/fraktionen")
|
@router.get("/fraktionen")
|
||||||
def get_fraktionen_uebersicht(conn=Depends(_db)):
|
def get_fraktionen_uebersicht(
|
||||||
"""Stimmverhalten aller Fraktionen aggregiert."""
|
periode: str | None = None,
|
||||||
rows = conn.execute("""
|
parteien: str | None = None,
|
||||||
SELECT fraktion,
|
conn=Depends(_db),
|
||||||
SUM(CASE WHEN stimme='ja' THEN 1 ELSE 0 END) as ja,
|
):
|
||||||
SUM(CASE WHEN stimme='nein' THEN 1 ELSE 0 END) as nein,
|
"""Stimmverhalten aller Fraktionen aggregiert, optional nach Ratsperiode/Parteien gefiltert."""
|
||||||
SUM(CASE WHEN stimme='enthaltung' THEN 1 ELSE 0 END) as enthaltung,
|
where_parts = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
per_clause, per_params = _periode_filter(periode)
|
||||||
|
if per_clause:
|
||||||
|
where_parts.append(per_clause)
|
||||||
|
params.extend(per_params)
|
||||||
|
|
||||||
|
# If parteien filter is set, only show those parties
|
||||||
|
par_clause, par_params = _parteien_filter(parteien)
|
||||||
|
if par_clause:
|
||||||
|
# Filter on the main fraktion column
|
||||||
|
par_clause_main = par_clause.replace("af_filter.fraktion", "af.fraktion")
|
||||||
|
where_parts.append(par_clause_main)
|
||||||
|
params.extend(par_params)
|
||||||
|
|
||||||
|
join_sql = "JOIN abstimmungen a ON af.abstimmung_id = a.id" if per_clause else ""
|
||||||
|
where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
|
||||||
|
|
||||||
|
rows = conn.execute(f"""
|
||||||
|
SELECT af.fraktion,
|
||||||
|
SUM(CASE WHEN af.stimme='ja' THEN 1 ELSE 0 END) as ja,
|
||||||
|
SUM(CASE WHEN af.stimme='nein' THEN 1 ELSE 0 END) as nein,
|
||||||
|
SUM(CASE WHEN af.stimme='enthaltung' THEN 1 ELSE 0 END) as enthaltung,
|
||||||
COUNT(*) as gesamt
|
COUNT(*) as gesamt
|
||||||
FROM abstimmungen_fraktionen
|
FROM abstimmungen_fraktionen af
|
||||||
GROUP BY fraktion
|
{join_sql}
|
||||||
|
{where_sql}
|
||||||
|
GROUP BY af.fraktion
|
||||||
ORDER BY gesamt DESC
|
ORDER BY gesamt DESC
|
||||||
""").fetchall()
|
""", params).fetchall()
|
||||||
|
|
||||||
# Normalize and aggregate
|
# Normalize and aggregate
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@ -75,14 +139,29 @@ def get_fraktionen_uebersicht(conn=Depends(_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/koalitionsmatrix")
|
@router.get("/koalitionsmatrix")
|
||||||
def get_koalitionsmatrix(conn=Depends(_db)):
|
def get_koalitionsmatrix(
|
||||||
|
periode: str | None = None,
|
||||||
|
parteien: str | None = None,
|
||||||
|
conn=Depends(_db),
|
||||||
|
):
|
||||||
"""Matrix: Wie oft stimmen Fraktionen gleich ab?"""
|
"""Matrix: Wie oft stimmen Fraktionen gleich ab?"""
|
||||||
# Alle Abstimmungen mit mindestens 2 Fraktionen
|
where_parts = ["af.stimme IN ('ja', 'nein')"]
|
||||||
abstimmungen = conn.execute("""
|
params: list = []
|
||||||
SELECT abstimmung_id, fraktion, stimme
|
|
||||||
FROM abstimmungen_fraktionen
|
per_clause, per_params = _periode_filter(periode)
|
||||||
WHERE stimme IN ('ja', 'nein')
|
if per_clause:
|
||||||
""").fetchall()
|
where_parts.append(per_clause)
|
||||||
|
params.extend(per_params)
|
||||||
|
|
||||||
|
join_sql = "JOIN abstimmungen a ON af.abstimmung_id = a.id" if per_clause else ""
|
||||||
|
where_sql = "WHERE " + " AND ".join(where_parts)
|
||||||
|
|
||||||
|
abstimmungen = conn.execute(f"""
|
||||||
|
SELECT af.abstimmung_id, af.fraktion, af.stimme
|
||||||
|
FROM abstimmungen_fraktionen af
|
||||||
|
{join_sql}
|
||||||
|
{where_sql}
|
||||||
|
""", params).fetchall()
|
||||||
|
|
||||||
# Gruppieren nach Abstimmung (normalisiert)
|
# Gruppieren nach Abstimmung (normalisiert)
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@ -104,6 +183,11 @@ def get_koalitionsmatrix(conn=Depends(_db)):
|
|||||||
if stimmen[f1] == stimmen[f2]:
|
if stimmen[f1] == stimmen[f2]:
|
||||||
matrix[f1][f2]["gleich"] += 1
|
matrix[f1][f2]["gleich"] += 1
|
||||||
|
|
||||||
|
# Filter factions if parteien specified
|
||||||
|
if parteien:
|
||||||
|
selected = set(p.strip() for p in parteien.split(",") if p.strip())
|
||||||
|
fraktionen = [f for f in fraktionen if f in selected]
|
||||||
|
|
||||||
# Als Liste für Frontend
|
# Als Liste für Frontend
|
||||||
result = []
|
result = []
|
||||||
for f1 in sorted(fraktionen):
|
for f1 in sorted(fraktionen):
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
enthaltung: number;
|
enthaltung: number;
|
||||||
gesamt: number;
|
gesamt: number;
|
||||||
ja_quote: number;
|
ja_quote: number;
|
||||||
|
ist_ratsfraktion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Uebereinstimmung {
|
interface Uebereinstimmung {
|
||||||
@ -52,6 +53,12 @@
|
|||||||
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('');
|
||||||
@ -74,20 +81,67 @@
|
|||||||
: '/api')
|
: '/api')
|
||||||
: '/api';
|
: '/api';
|
||||||
|
|
||||||
onMount(async () => {
|
async function loadAll() {
|
||||||
|
loading = true;
|
||||||
try {
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedPerioden.length > 0) params.set('periode', selectedPerioden.join(','));
|
||||||
|
if (selectedParteien.length > 0) params.set('parteien', selectedParteien.join(','));
|
||||||
|
const qs = params.toString();
|
||||||
|
const suffix = qs ? `?${qs}` : '';
|
||||||
|
|
||||||
const [frakRes, koalRes] = await Promise.all([
|
const [frakRes, koalRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/abstimmungen/fraktionen`),
|
fetch(`${API_BASE}/abstimmungen/fraktionen${suffix}`),
|
||||||
fetch(`${API_BASE}/abstimmungen/koalitionsmatrix`)
|
fetch(`${API_BASE}/abstimmungen/koalitionsmatrix${suffix}`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (frakRes.ok) fraktionen = await frakRes.json();
|
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();
|
if (koalRes.ok) koalitionsmatrix = await koalRes.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = `Fehler: ${e}`;
|
error = `Fehler: ${e}`;
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePeriode(p: string) {
|
||||||
|
if (selectedPerioden.includes(p)) {
|
||||||
|
selectedPerioden = selectedPerioden.filter(x => x !== p);
|
||||||
|
} else {
|
||||||
|
selectedPerioden = [...selectedPerioden, p];
|
||||||
|
}
|
||||||
|
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) {
|
async function loadDetails(fraktion: string, stimme: string, page: number = 1) {
|
||||||
@ -188,6 +242,53 @@
|
|||||||
<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}
|
||||||
@ -245,8 +346,12 @@
|
|||||||
<td class="px-4 py-3 text-center text-gray-600">{f.gesamt}</td>
|
<td class="px-4 py-3 text-center text-gray-600">{f.gesamt}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex-1 bg-gray-200 rounded-full h-2 max-w-24">
|
<div class="flex-1 flex rounded-full h-3 max-w-32 overflow-hidden bg-gray-200">
|
||||||
<div class="bg-green-500 h-2 rounded-full" style="width: {f.ja_quote}%"></div>
|
{#if f.gesamt > 0}
|
||||||
|
<div class="bg-green-500 h-full" style="width: {f.ja / f.gesamt * 100}%" title="Ja: {f.ja}"></div>
|
||||||
|
<div class="bg-red-500 h-full" style="width: {f.nein / f.gesamt * 100}%" title="Nein: {f.nein}"></div>
|
||||||
|
<div class="bg-yellow-400 h-full" style="width: {f.enthaltung / f.gesamt * 100}%" title="Enthaltung: {f.enthaltung}"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-600">{f.ja_quote}%</span>
|
<span class="text-sm text-gray-600">{f.ja_quote}%</span>
|
||||||
</div>
|
</div>
|
||||||
@ -268,8 +373,12 @@
|
|||||||
</button>
|
</button>
|
||||||
<span class="text-sm font-medium text-gray-600">{f.ja_quote}%</span>
|
<span class="text-sm font-medium text-gray-600">{f.ja_quote}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-200 rounded-full h-2.5 mb-3">
|
<div class="flex rounded-full h-2.5 mb-3 overflow-hidden bg-gray-200">
|
||||||
<div class="bg-green-500 h-2.5 rounded-full" style="width: {f.ja_quote}%"></div>
|
{#if f.gesamt > 0}
|
||||||
|
<div class="bg-green-500 h-full" style="width: {f.ja / f.gesamt * 100}%"></div>
|
||||||
|
<div class="bg-red-500 h-full" style="width: {f.nein / f.gesamt * 100}%"></div>
|
||||||
|
<div class="bg-yellow-400 h-full" style="width: {f.enthaltung / f.gesamt * 100}%"></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-4 gap-2 text-center text-xs">
|
<div class="grid grid-cols-4 gap-2 text-center text-xs">
|
||||||
<button onclick={() => loadDetails(f.fraktion, 'ja')} class="cursor-pointer hover:bg-green-50 rounded p-1 transition-colors">
|
<button onclick={() => loadDetails(f.fraktion, 'ja')} class="cursor-pointer hover:bg-green-50 rounded p-1 transition-colors">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user