antragstracker/frontend/src/routes/fraktionen/[kuerzel]/+page.svelte
Dotty Dotter 31b1e1bd7e feat: Responsive Layout für Mobile (#12)
- Navigation: Hamburger-Menü mit Slide-Down auf Mobile
- Vorlagen + Ketten: Tabelle → Card-Layout auf Mobile (<md)
- Filter: vertikal gestackt auf kleinen Screens
- Suchfeld + Typeahead: volle Breite auf Mobile
- Vorlagen-Detail: Header + Sidebar responsive
- Fraktionen-Detail: Tabelle → Cards auf Mobile
- Abstimmungen: Stimmverhalten-Cards + scrollbare Koalitionsmatrix
- Touch-Targets überall ≥44px
- Keine horizontalen Scrollbars

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

197 lines
6.9 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';
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 {
data = await fetchFraktionDashboard(kuerzel, selectedJahr || undefined);
} catch (e) {
error = (e as Error).message;
}
loading = false;
}
onMount(loadData);
// Reload when filters change
$effect(() => {
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>