antragstracker/frontend/src/routes/+page.svelte
Dotty Dotter ea3e5cd329 feat: Intuitivere Bedienung — klickbare Stats + Abstimmungs-Filter + Fraktions-Normalisierung (#14)
Dashboard:
- Neuer Endpoint GET /api/stats/dashboard mit allen Kennzahlen
- Klickbare Kacheln: Vorlagen nach Typ, Ketten nach Status → navigieren zu Filterlisten
- Umsetzungsquote als horizontaler Balken mit klickbaren Segmenten

Abstimmungen:
- Stimmverhalten-Tabelle klickbar: Fraktion oder Ja/Nein/Enthaltung → filtert
- Neuer Endpoint GET /api/abstimmungen/details (?fraktion=&stimme=) mit Pagination
- Neuer Endpoint GET /api/abstimmungen/vergleich (?f1=&f2=) für Koalitionsmatrix-Drill-Down
- Koalitionsmatrix-Zellen klickbar → zeigt Abstimmungsvergleich beider Fraktionen

Fraktions-Normalisierung:
- fraktionen_mapping.py: 40+ DB-Varianten → kanonische Namen
- 'Bündnis 90 / Die Grünen' / 'Bündnis 90/Die Grünen' / 'Grüne' → 'Grüne'
- 'Die Linke' / 'Die Linke.' / 'Linke' → 'Linke'
- BfHo-Varianten, Hagen Aktiv, Einzelvertreter etc. normalisiert
- Mapping in allen Abstimmungs-Endpoints aktiv
- ist_ratsfraktion Flag in Fraktionen-Response

Closes #14
2026-04-01 14:32:06 +02:00

302 lines
11 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 '../app.css';
import { goto } from '$app/navigation';
interface Vorlage {
id: number;
aktenzeichen: string;
typ: string;
betreff: string;
datum_eingang: string;
ist_verwaltungsvorlage: boolean;
}
interface DashboardStats {
vorlagen_total: number;
ketten_total: number;
vorlagen_nach_typ: { typ: string; anzahl: number }[];
ketten_nach_status: { status: string; anzahl: number }[];
abstimmungen_total: number;
umsetzungsquote: {
umgesetzt: number;
teilweise: number;
versandet: number;
beschlossen: number;
abgelehnt: number;
total_bewertet: number;
};
}
let stats = $state<DashboardStats | null>(null);
let antraege = $state<Vorlage[]>([]);
let loading = $state(true);
let error = $state('');
const API_BASE = typeof window !== 'undefined'
? (window.location.port === '5173'
? `http://${window.location.hostname}:8099/api`
: '/api')
: '/api';
const statusColors: Record<string, string> = {
umgesetzt: 'bg-green-100 text-green-800 border-green-200',
teilweise_umgesetzt: 'bg-amber-100 text-amber-800 border-amber-200',
versandet: 'bg-gray-100 text-gray-700 border-gray-200',
beschlossen: 'bg-blue-100 text-blue-800 border-blue-200',
abgelehnt: 'bg-red-100 text-red-800 border-red-200',
in_beratung: 'bg-purple-100 text-purple-800 border-purple-200',
angefragt: 'bg-cyan-100 text-cyan-800 border-cyan-200',
beantwortet: 'bg-teal-100 text-teal-800 border-teal-200',
verwiesen: 'bg-indigo-100 text-indigo-800 border-indigo-200',
offen: 'bg-yellow-100 text-yellow-800 border-yellow-200',
};
const statusLabels: Record<string, string> = {
umgesetzt: 'Umgesetzt',
teilweise_umgesetzt: 'Teilw. umgesetzt',
versandet: 'Versandet',
beschlossen: 'Beschlossen',
abgelehnt: 'Abgelehnt',
in_beratung: 'In Beratung',
angefragt: 'Angefragt',
beantwortet: 'Beantwortet',
verwiesen: 'Verwiesen',
offen: 'Offen',
};
const typLabels: Record<string, string> = {
antrag: 'Anträge',
anfrage: 'Anfragen',
bericht: 'Berichte',
beschlussvorlage: 'Beschlussvorlagen',
mitteilungsvorlage: 'Mitteilungen',
stellungnahme: 'Stellungnahmen',
sonstig: 'Sonstige',
};
const typIcons: Record<string, string> = {
antrag: '📋',
anfrage: '❓',
bericht: '📄',
beschlussvorlage: '📑',
mitteilungsvorlage: '📨',
stellungnahme: '💬',
sonstig: '📁',
};
// Umsetzungsquote bar colors
const umsetzungBarColors: Record<string, string> = {
umgesetzt: 'bg-green-500',
teilweise: 'bg-amber-400',
beschlossen: 'bg-blue-400',
versandet: 'bg-gray-400',
abgelehnt: 'bg-red-400',
};
async function loadData() {
try {
const [dashRes, antraegeRes] = await Promise.all([
fetch(`${API_BASE}/stats/dashboard`),
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10`),
]);
if (dashRes.ok) {
stats = await dashRes.json();
} else {
error = `Dashboard-Stats fehler: ${dashRes.status}`;
}
if (antraegeRes.ok) {
const data = await antraegeRes.json();
antraege = data.items;
}
} catch (e) {
error = `Fehler: ${e}`;
} finally {
loading = false;
}
}
loadData();
</script>
<svelte:head>
<title>Antragstracker Hagen</title>
</svelte:head>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">🏛️ Dashboard</h1>
<p class="text-gray-500 text-sm mt-1">Kommunale Anträge & Anfragen auf einen Blick</p>
</div>
{#if error}
<div class="bg-red-50 text-red-700 p-4 rounded-lg mb-6">{error}</div>
{/if}
{#if loading}
<div class="flex justify-center py-20">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
</div>
{:else if stats}
<!-- Hauptzahlen -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<button onclick={() => goto('/vorlagen')}
class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 text-center cursor-pointer hover:shadow-md transition-shadow">
<div class="text-3xl font-bold text-green-600">{stats.vorlagen_total.toLocaleString()}</div>
<div class="text-gray-500 text-sm mt-1">Vorlagen</div>
</button>
<button onclick={() => goto('/ketten')}
class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 text-center cursor-pointer hover:shadow-md transition-shadow">
<div class="text-3xl font-bold text-blue-600">{stats.ketten_total.toLocaleString()}</div>
<div class="text-gray-500 text-sm mt-1">Ketten</div>
</button>
<button onclick={() => goto('/abstimmungen')}
class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 text-center cursor-pointer hover:shadow-md transition-shadow">
<div class="text-3xl font-bold text-purple-600">{stats.abstimmungen_total.toLocaleString()}</div>
<div class="text-gray-500 text-sm mt-1">Abstimmungen</div>
</button>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 text-center">
<div class="text-3xl font-bold text-orange-600">20042026</div>
<div class="text-gray-500 text-sm mt-1">Zeitraum</div>
</div>
</div>
<!-- Vorlagen nach Typ -->
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">📊 Vorlagen nach Typ</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{#each stats.vorlagen_nach_typ as vt}
<button onclick={() => goto(`/vorlagen?typ=${vt.typ}`)}
class="border border-gray-200 rounded-lg p-4 text-left cursor-pointer hover:shadow-md hover:border-green-300 transition-all group">
<div class="text-2xl mb-1">{typIcons[vt.typ] || '📁'}</div>
<div class="text-xl font-bold text-gray-900 group-hover:text-green-700">{vt.anzahl.toLocaleString()}</div>
<div class="text-sm text-gray-500">{typLabels[vt.typ] || vt.typ}</div>
</button>
{/each}
</div>
</section>
<!-- Ketten nach Status -->
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">🔗 Ketten nach Status</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
{#each stats.ketten_nach_status as ks}
<button onclick={() => goto(`/ketten?status=${ks.status}`)}
class="border rounded-lg p-4 text-left cursor-pointer hover:shadow-md transition-all {statusColors[ks.status] || 'bg-gray-50 text-gray-700 border-gray-200'}">
<div class="text-xl font-bold">{ks.anzahl.toLocaleString()}</div>
<div class="text-sm opacity-80">{statusLabels[ks.status] || ks.status}</div>
</button>
{/each}
</div>
</section>
<!-- Umsetzungsquote -->
{#if stats.umsetzungsquote.total_bewertet > 0}
{@const uq = stats.umsetzungsquote}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-1">📈 Umsetzungsquote</h2>
<p class="text-sm text-gray-500 mb-4">{uq.total_bewertet} Ketten mit Endergebnis</p>
<!-- Bar -->
<div class="flex rounded-full overflow-hidden h-8 mb-4">
{#if uq.umgesetzt > 0}
<button onclick={() => goto('/ketten?status=umgesetzt')}
class="{umsetzungBarColors.umgesetzt} hover:brightness-110 transition-all cursor-pointer flex items-center justify-center text-white text-xs font-medium"
style="width: {uq.umgesetzt / uq.total_bewertet * 100}%"
title="Umgesetzt: {uq.umgesetzt}">
{#if uq.umgesetzt / uq.total_bewertet > 0.05}{uq.umgesetzt}{/if}
</button>
{/if}
{#if uq.teilweise > 0}
<button onclick={() => goto('/ketten?status=teilweise_umgesetzt')}
class="{umsetzungBarColors.teilweise} hover:brightness-110 transition-all cursor-pointer flex items-center justify-center text-white text-xs font-medium"
style="width: {uq.teilweise / uq.total_bewertet * 100}%"
title="Teilweise umgesetzt: {uq.teilweise}">
{#if uq.teilweise / uq.total_bewertet > 0.05}{uq.teilweise}{/if}
</button>
{/if}
{#if uq.beschlossen > 0}
<button onclick={() => goto('/ketten?status=beschlossen')}
class="{umsetzungBarColors.beschlossen} hover:brightness-110 transition-all cursor-pointer flex items-center justify-center text-white text-xs font-medium"
style="width: {uq.beschlossen / uq.total_bewertet * 100}%"
title="Beschlossen: {uq.beschlossen}">
{#if uq.beschlossen / uq.total_bewertet > 0.05}{uq.beschlossen}{/if}
</button>
{/if}
{#if uq.versandet > 0}
<button onclick={() => goto('/ketten?status=versandet')}
class="{umsetzungBarColors.versandet} hover:brightness-110 transition-all cursor-pointer flex items-center justify-center text-white text-xs font-medium"
style="width: {uq.versandet / uq.total_bewertet * 100}%"
title="Versandet: {uq.versandet}">
{#if uq.versandet / uq.total_bewertet > 0.05}{uq.versandet}{/if}
</button>
{/if}
{#if uq.abgelehnt > 0}
<button onclick={() => goto('/ketten?status=abgelehnt')}
class="{umsetzungBarColors.abgelehnt} hover:brightness-110 transition-all cursor-pointer flex items-center justify-center text-white text-xs font-medium"
style="width: {uq.abgelehnt / uq.total_bewertet * 100}%"
title="Abgelehnt: {uq.abgelehnt}">
{#if uq.abgelehnt / uq.total_bewertet > 0.05}{uq.abgelehnt}{/if}
</button>
{/if}
</div>
<!-- Legend -->
<div class="flex flex-wrap gap-4 text-sm">
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded-full bg-green-500"></span>
Umgesetzt ({uq.umgesetzt})
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded-full bg-amber-400"></span>
Teilweise ({uq.teilweise})
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded-full bg-blue-400"></span>
Beschlossen ({uq.beschlossen})
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded-full bg-gray-400"></span>
Versandet ({uq.versandet})
</span>
<span class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded-full bg-red-400"></span>
Abgelehnt ({uq.abgelehnt})
</span>
</div>
</section>
{/if}
<!-- Aktuelle Anträge -->
<section class="bg-white rounded-xl shadow-sm border border-gray-200">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">📋 Aktuelle Anträge</h2>
<a href="/vorlagen?typ=antrag" class="text-sm text-green-600 hover:text-green-800 font-medium">Alle →</a>
</div>
{#if antraege.length === 0}
<div class="p-6 text-center text-gray-500">Keine Anträge gefunden</div>
{:else}
<ul class="divide-y divide-gray-100">
{#each antraege as antrag}
<li>
<a href="/vorlagen/{antrag.id}" class="block px-5 py-4 hover:bg-gray-50 transition-colors">
<div class="flex justify-between items-start">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-mono text-sm text-green-700 bg-green-50 px-2 py-0.5 rounded">
{antrag.aktenzeichen}
</span>
<span class="text-xs text-gray-400">{antrag.datum_eingang}</span>
</div>
<p class="mt-1 text-gray-700 text-sm line-clamp-2">{antrag.betreff}</p>
</div>
</div>
</a>
</li>
{/each}
</ul>
{/if}
</section>
{/if}