antragstracker/frontend/src/routes/explorer/+page.svelte
Dotty Dotter 3fd1bc5bd7 feat: KI-Bewertungs-Versionierung — alte Versionen behalten
Backend:
- DELETE vor INSERT entfernt — neue Bewertungen werden hinzugefügt
- erstellt_at Timestamp bei jeder Neubewertung
- API liefert ki_versionen[] (ältere Versionen, neueste zuerst)

Frontend (Explorer + Vorlagen-Detail):
- Neueste Version als Hauptanzeige (wie bisher)
- Button 'X vorherige Versionen' → aufklappbar
- Jede Version mit Zeitstempel + prompt_version
2026-04-01 21:15:17 +02:00

586 lines
22 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 { onMount } from 'svelte';
import { fetchKetten, fetchKette, fetchVorlage, type KetteKurz, type KetteDetail, type VorlageDetail, type Paginated } from '$lib/api';
import { formatDate, typLabel } from '$lib/status';
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
import Ampel from '$lib/components/Ampel.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
// State
let ketten: KetteKurz[] = $state([]);
let kettenTotal = $state(0);
let selectedKette: KetteDetail | null = $state(null);
let selectedVorlage: VorlageDetail | null = $state(null);
let loading = $state(true);
let ketteLoading = $state(false);
let vorlageLoading = $state(false);
let error = $state('');
// Filters
let suche = $state('');
let strangFilter = $state('');
let statusFilter = $state('');
let currentPage = $state(1);
const PAGE_SIZE = 30;
// Active IDs
let activeKetteId = $state<number | null>(null);
let activeVorlageId = $state<number | null>(null);
// Mobile tab
let mobileTab = $state<'liste' | 'kette' | 'detail'>('liste');
let showVolltext = $state(false);
let showVersionen = $state(false);
const STRANG_TABS = [
{ value: '', label: 'Alle' },
{ value: 'antrag', label: 'Anträge' },
{ value: 'anfrage', label: 'Anfragen' },
{ value: 'beschlussvorlage', label: 'Beschlussvorlagen' },
{ value: 'mitteilung', label: 'Mitteilungen' },
];
const STATUS_OPTIONS = [
{ value: '', label: 'Alle Status' },
{ value: 'in_beratung', label: '⏳ In Beratung' },
{ value: 'beschlossen', label: '🟡 Beschlossen' },
{ value: 'umgesetzt', label: '🟢 Umgesetzt' },
{ value: 'teilweise_umgesetzt', label: '🟡 Teilweise' },
{ value: 'versandet', label: '🔴 Versandet' },
{ value: 'abgelehnt', label: '🔴 Abgelehnt' },
{ value: 'beantwortet', label: '🟢 Beantwortet' },
{ value: 'angefragt', label: '⏳ Angefragt' },
];
const FARB_MAP: Record<string, string> = {
gruen: '#22c55e',
gelb: '#eab308',
rot: '#ef4444',
amber: '#f59e0b',
grau: '#d1d5db',
blau: '#3b82f6',
};
async function loadKetten() {
loading = true;
try {
let params: Record<string, string> = {
page: String(currentPage),
page_size: String(PAGE_SIZE),
};
if (suche) params.suche = suche;
if (strangFilter) params.typ = strangFilter;
if (statusFilter) params.status = statusFilter;
params = mergeFilterParams(params);
const data = await fetchKetten(params);
ketten = data.items;
kettenTotal = data.total;
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
} finally {
loading = false;
}
}
async function selectKette(id: number) {
if (activeKetteId === id) return;
activeKetteId = id;
activeVorlageId = null;
selectedVorlage = null;
ketteLoading = true;
mobileTab = 'kette';
try {
selectedKette = await fetchKette(id);
// Auto-select the first glied (most recent = last position)
if (selectedKette.glieder.length > 0) {
const sorted = [...selectedKette.glieder].sort((a, b) => b.position - a.position);
selectVorlage(sorted[0].vorlage.id);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
} finally {
ketteLoading = false;
}
}
async function selectVorlage(id: number) {
if (activeVorlageId === id) return;
activeVorlageId = id;
vorlageLoading = true;
showVolltext = false;
mobileTab = 'detail';
try {
selectedVorlage = await fetchVorlage(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
} finally {
vorlageLoading = false;
}
}
function handleSearch(e: KeyboardEvent) {
if (e.key === 'Enter') {
currentPage = 1;
loadKetten();
}
}
function changeStrang(value: string) {
strangFilter = value;
currentPage = 1;
loadKetten();
}
function goPage(p: number) {
currentPage = p;
loadKetten();
}
onMount(() => {
loadKetten();
});
// Reload on global filter change
$effect(() => {
filterVersion();
currentPage = 1;
loadKetten();
});
// Sorted glieder for timeline (newest first)
let sortedGlieder = $derived(
selectedKette ? [...selectedKette.glieder].sort((a, b) => b.position - a.position) : []
);
let totalPages = $derived(Math.ceil(kettenTotal / PAGE_SIZE));
</script>
<svelte:head>
<title>Explorer - Antragstracker Hagen</title>
</svelte:head>
<!-- Mobile Tabs -->
<div class="lg:hidden flex border-b border-gray-200 mb-4 bg-white rounded-t-lg">
<button
onclick={() => mobileTab = 'liste'}
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
{mobileTab === 'liste' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}">
Liste
</button>
<button
onclick={() => mobileTab = 'kette'}
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
{mobileTab === 'kette' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}"
disabled={!selectedKette}>
Kette
</button>
<button
onclick={() => mobileTab = 'detail'}
class="flex-1 py-3 text-sm font-medium text-center border-b-2 transition-colors
{mobileTab === 'detail' ? 'border-green-600 text-green-700' : 'border-transparent text-gray-500 hover:text-gray-700'}"
disabled={!selectedVorlage}>
Detail
</button>
</div>
<!-- 3-Panel Layout -->
<div class="flex gap-0 lg:gap-0 h-[calc(100vh-12rem)] lg:h-[calc(100vh-11rem)]">
<!-- Panel 1: Ketten-Liste -->
<div class="w-full lg:w-[280px] lg:shrink-0 lg:border-r border-gray-200 flex flex-col bg-white rounded-l-lg lg:rounded-l-xl overflow-hidden
{mobileTab !== 'liste' ? 'hidden lg:flex' : 'flex'}">
<!-- Search & Filters -->
<div class="p-3 border-b border-gray-100 space-y-2">
<input
type="text"
bind:value={suche}
placeholder="Suche..."
onkeydown={handleSearch}
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
<div class="flex flex-wrap gap-1">
{#each STRANG_TABS as tab}
<button
onclick={() => changeStrang(tab.value)}
class="px-2 py-1 rounded text-xs font-medium transition-all
{strangFilter === tab.value
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
{tab.label}
</button>
{/each}
</div>
<select
bind:value={statusFilter}
onchange={() => { currentPage = 1; loadKetten(); }}
class="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-xs focus:ring-2 focus:ring-green-500 bg-white">
{#each STATUS_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Count -->
<div class="px-3 py-1.5 text-xs text-gray-400 border-b border-gray-50">
{kettenTotal} Ketten
</div>
<!-- List -->
<div class="flex-1 overflow-y-auto">
{#if loading && ketten.length === 0}
<div class="flex justify-center py-10">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
{:else if ketten.length === 0}
<div class="p-4 text-sm text-gray-500 text-center">Keine Ketten gefunden</div>
{:else}
{#each ketten as kette}
<button
onclick={() => selectKette(kette.id)}
class="w-full text-left px-3 py-2.5 border-b border-gray-50 hover:bg-gray-50 transition-colors
{activeKetteId === kette.id ? 'bg-green-50 border-l-2 border-l-green-600' : 'border-l-2 border-l-transparent'}">
<div class="flex items-center justify-between gap-2 mb-0.5">
<span class="font-mono text-xs font-medium text-green-700 truncate">
{kette.ursprung?.aktenzeichen || `#${kette.id}`}
</span>
{#if kette.ampel}
<span class="flex items-center gap-1 shrink-0">
<span
class="w-3 h-3 rounded-full"
style="background-color: {FARB_MAP[kette.ampel.farbe] || FARB_MAP.grau}"
></span>
<span class="text-[10px] text-gray-500">{kette.ampel.label}</span>
</span>
{/if}
</div>
<p class="text-xs text-gray-600 line-clamp-2 leading-snug">
{kette.thema || kette.ursprung?.betreff || '-'}
</p>
</button>
{/each}
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-center gap-2 p-3 border-t border-gray-100">
<button
disabled={currentPage <= 1}
onclick={() => goPage(currentPage - 1)}
class="px-2 py-1 rounded text-xs border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
</button>
<span class="text-xs text-gray-500">{currentPage}/{totalPages}</span>
<button
disabled={currentPage >= totalPages}
onclick={() => goPage(currentPage + 1)}
class="px-2 py-1 rounded text-xs border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
</button>
</div>
{/if}
{/if}
</div>
</div>
<!-- Panel 2: Kette Detail -->
<div class="w-full lg:w-[220px] lg:shrink-0 lg:border-r border-gray-200 flex flex-col bg-white overflow-hidden
{mobileTab !== 'kette' ? 'hidden lg:flex' : 'flex'}">
{#if ketteLoading}
<div class="flex justify-center py-10">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
{:else if selectedKette}
<div class="flex-1 overflow-y-auto">
<!-- Ampel -->
<div class="p-4 border-b border-gray-100">
<div class="text-xs font-medium text-gray-500 uppercase mb-3">
{selectedKette.ampel?.strang_label || selectedKette.typ || 'Status'}
</div>
{#if selectedKette.ampel}
<Ampel ampel={selectedKette.ampel} vertical />
{:else}
<StatusBadge status={selectedKette.status} />
{/if}
</div>
<!-- Timeline -->
<div class="p-3">
<div class="text-xs font-medium text-gray-500 uppercase mb-3">Ketten-Glieder</div>
<div class="relative">
<!-- Vertical line -->
<div class="absolute left-3 top-2 bottom-2 w-0.5 bg-gray-200"></div>
{#each sortedGlieder as glied, i}
<button
onclick={() => selectVorlage(glied.vorlage.id)}
class="relative w-full text-left pl-8 pr-2 py-2 rounded-lg hover:bg-gray-50 transition-colors mb-1
{activeVorlageId === glied.vorlage.id ? 'bg-green-50' : ''}">
<!-- Dot -->
<div class="absolute left-1.5 top-3.5 w-3 h-3 rounded-full border-2 transition-colors
{activeVorlageId === glied.vorlage.id
? 'bg-green-600 border-green-600'
: 'bg-white border-gray-300'}">
</div>
<!-- Content -->
<div class="flex items-center gap-1.5 mb-0.5">
<span class="font-mono text-[11px] font-medium text-green-700 truncate">
{glied.vorlage.aktenzeichen || `#${glied.vorlage.id}`}
</span>
</div>
{#if glied.rolle}
<span class="inline-block text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 mb-0.5">
{glied.rolle}
</span>
{/if}
{#if glied.vorlage.datum_eingang}
<div class="text-[10px] text-gray-400">
{formatDate(glied.vorlage.datum_eingang)}
</div>
{/if}
</button>
{/each}
</div>
</div>
</div>
{:else}
<div class="flex items-center justify-center h-full text-sm text-gray-400 p-4 text-center">
← Kette auswählen
</div>
{/if}
</div>
<!-- Panel 3: Vorlage Detail -->
<div class="w-full lg:flex-1 lg:min-w-0 flex flex-col bg-white rounded-r-lg lg:rounded-r-xl overflow-hidden
{mobileTab !== 'detail' ? 'hidden lg:flex' : 'flex'}">
{#if vorlageLoading}
<div class="flex justify-center py-10">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
{:else if selectedVorlage}
<div class="flex-1 overflow-y-auto p-4 sm:p-6 space-y-5">
<!-- Header -->
<div>
<div class="flex flex-wrap items-center gap-2 mb-2">
{#if selectedVorlage.aktenzeichen}
<h2 class="text-xl font-bold text-gray-900 font-mono">{selectedVorlage.aktenzeichen}</h2>
{/if}
{#if selectedVorlage.typ}
<span class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">{typLabel(selectedVorlage.typ)}</span>
{/if}
{#if selectedVorlage.ist_verwaltungsvorlage}
<span class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-700">Verwaltungsvorlage</span>
{/if}
</div>
{#if selectedVorlage.betreff}
<p class="text-gray-700">{selectedVorlage.betreff}</p>
{/if}
<div class="flex flex-wrap items-center gap-3 mt-2 text-sm text-gray-500">
{#if selectedVorlage.datum_eingang}
<span>Eingegangen: <strong>{formatDate(selectedVorlage.datum_eingang)}</strong></span>
{/if}
{#if selectedVorlage.web_url}
<a href={selectedVorlage.web_url} target="_blank" rel="noopener" class="text-green-600 hover:underline">ALLRIS ↗</a>
{/if}
{#if selectedVorlage.pdf_url}
<a href={selectedVorlage.pdf_url} target="_blank" rel="noopener" class="text-green-600 hover:underline">PDF ↗</a>
{/if}
<a href="/vorlagen/{selectedVorlage.id}" class="text-green-600 hover:underline">Vollansicht →</a>
</div>
<!-- Antragsteller -->
{#if selectedVorlage.antragsteller?.length > 0}
<div class="mt-2 flex items-center gap-2">
<span class="text-xs text-gray-500">Antragsteller:</span>
{#each selectedVorlage.antragsteller as p}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
style="background-color: {p.farbe || '#e5e7eb'}20; color: {p.farbe || '#4b5563'}; border: 1px solid {p.farbe || '#d1d5db'}">
{p.kuerzel}
</span>
{/each}
</div>
{/if}
</div>
<!-- KI-Zusammenfassung -->
{#if selectedVorlage.ki_zusammenfassung}
<div class="bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl border border-green-200 p-5">
<h3 class="text-sm font-semibold text-green-800 mb-2 flex items-center gap-1.5">
<span>🤖</span> KI-Zusammenfassung
</h3>
<p class="text-sm text-gray-700 mb-3">{selectedVorlage.ki_zusammenfassung.zusammenfassung}</p>
{#if selectedVorlage.ki_zusammenfassung.kernforderung}
<div class="mb-2">
<span class="text-xs font-medium text-green-700 uppercase">Kernforderung:</span>
<p class="text-sm text-gray-800 font-medium">{selectedVorlage.ki_zusammenfassung.kernforderung}</p>
</div>
{/if}
{#if selectedVorlage.ki_zusammenfassung.begruendung}
<div class="mb-2">
<span class="text-xs font-medium text-green-700 uppercase">Begründung:</span>
<p class="text-xs text-gray-600">{selectedVorlage.ki_zusammenfassung.begruendung}</p>
</div>
{/if}
<div class="flex flex-wrap gap-1.5 mt-3">
{#if selectedVorlage.ki_zusammenfassung.thema}
<span class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-800">📂 {selectedVorlage.ki_zusammenfassung.thema}</span>
{/if}
{#if selectedVorlage.ki_zusammenfassung.partei}
<span class="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-800">🏛️ {selectedVorlage.ki_zusammenfassung.partei}</span>
{/if}
{#each selectedVorlage.ki_zusammenfassung.betroffene_orte || [] as ort}
<span class="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">📍 {ort}</span>
{/each}
</div>
</div>
{/if}
<!-- Vorherige KI-Versionen -->
{#if selectedVorlage.ki_versionen?.length}
<div>
<button onclick={() => showVersionen = !showVersionen}
class="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1">
<span>{showVersionen ? '▼' : '▶'}</span>
{selectedVorlage.ki_versionen.length} vorherige Version{selectedVorlage.ki_versionen.length > 1 ? 'en' : ''}
</button>
{#if showVersionen}
<div class="mt-2 space-y-3">
{#each selectedVorlage.ki_versionen as v, i}
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-400">Version {selectedVorlage.ki_versionen.length - i} · {v.erstellt_at || 'unbekannt'}</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-200 text-gray-500">{v.prompt_version || ''}</span>
</div>
<p class="text-sm text-gray-600">{v.zusammenfassung}</p>
{#if v.kernforderung}
<p class="text-xs text-gray-500 mt-1"><strong>Kernforderung:</strong> {v.kernforderung}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Umsetzungsbewertung -->
{#if selectedVorlage.umsetzungsbewertungen?.length}
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-900 mb-3">📊 Umsetzungsbewertung</h3>
{#each selectedVorlage.umsetzungsbewertungen as ub}
<div class="p-3 rounded-lg border {ub.score >= 0.7 ? 'border-green-200 bg-green-50' : ub.score >= 0.4 ? 'border-amber-200 bg-amber-50' : 'border-red-200 bg-red-50'}">
<div class="flex items-center gap-3 mb-1.5">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold
{ub.score >= 0.7 ? 'bg-green-200 text-green-800' : ub.score >= 0.4 ? 'bg-amber-200 text-amber-800' : 'bg-red-200 text-red-800'}">
{Math.round((ub.score || 0) * 100)}%
</div>
<span class="text-sm font-medium {ub.score >= 0.7 ? 'text-green-800' : ub.score >= 0.4 ? 'text-amber-800' : 'text-red-800'}">
{ub.score >= 0.7 ? 'Weitgehend umgesetzt' : ub.score >= 0.4 ? 'Teilweise umgesetzt' : 'Kaum umgesetzt'}
</span>
</div>
{#if ub.begruendung}
<p class="text-xs text-gray-700">{ub.begruendung}</p>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Volltext -->
{#if selectedVorlage.volltext_clean}
<div class="rounded-xl border border-gray-200 p-5">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-900">Volltext</h3>
<button onclick={() => showVolltext = !showVolltext} class="text-xs text-green-600 hover:underline">
{showVolltext ? 'Einklappen' : 'Aufklappen'}
</button>
</div>
{#if showVolltext}
<div class="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap text-xs">{selectedVorlage.volltext_clean}</div>
{:else}
<p class="text-xs text-gray-500 line-clamp-4">{selectedVorlage.volltext_clean}</p>
{/if}
</div>
{/if}
<!-- Beratungen -->
{#if selectedVorlage.beratungen?.length > 0}
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Beratungsfolge</h3>
<div class="space-y-2">
{#each selectedVorlage.beratungen as b}
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between p-2.5 rounded-lg border border-gray-100 gap-1.5">
<div>
{#if b.gremium}
<span class="text-sm font-medium text-gray-900">{b.gremium.name}</span>
{/if}
{#if b.rolle}
<span class="text-xs ml-1.5 text-gray-500">({b.rolle})</span>
{/if}
{#if b.ergebnis}
<div class="mt-0.5">
<span class="text-xs px-2 py-0.5 rounded
{b.ergebnis.includes('angenommen') || b.ergebnis.includes('empfohlen') ? 'bg-green-100 text-green-700' :
b.ergebnis.includes('abgelehnt') ? 'bg-red-100 text-red-700' :
b.ergebnis.includes('vertagt') ? 'bg-amber-100 text-amber-700' :
'bg-gray-100 text-gray-700'}">
{b.ergebnis}
</span>
</div>
{/if}
</div>
<span class="text-xs text-gray-500 shrink-0">{formatDate(b.sitzung_datum)}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Referenzen -->
{#if selectedVorlage.referenzen_ausgehend?.length > 0 || selectedVorlage.referenzen_eingehend?.length > 0}
<div class="rounded-xl border border-gray-200 p-5">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Referenzen</h3>
{#if selectedVorlage.referenzen_ausgehend?.length > 0}
<div class="mb-3">
<span class="text-xs font-medium text-gray-500 uppercase">Verweist auf</span>
<div class="space-y-1 mt-1">
{#each selectedVorlage.referenzen_ausgehend as ref}
<a href="/vorlagen/{ref.vorlage_id}" class="block p-2 rounded border border-gray-100 hover:bg-gray-50 text-xs">
<span class="font-mono font-medium text-green-700">{ref.aktenzeichen || `#${ref.vorlage_id}`}</span>
{#if ref.betreff}
<span class="text-gray-600 ml-1 truncate">{ref.betreff}</span>
{/if}
</a>
{/each}
</div>
</div>
{/if}
{#if selectedVorlage.referenzen_eingehend?.length > 0}
<div>
<span class="text-xs font-medium text-gray-500 uppercase">Referenziert von</span>
<div class="space-y-1 mt-1">
{#each selectedVorlage.referenzen_eingehend as ref}
<a href="/vorlagen/{ref.vorlage_id}" class="block p-2 rounded border border-gray-100 hover:bg-gray-50 text-xs">
<span class="font-mono font-medium text-green-700">{ref.aktenzeichen || `#${ref.vorlage_id}`}</span>
{#if ref.betreff}
<span class="text-gray-600 ml-1 truncate">{ref.betreff}</span>
{/if}
</a>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<div class="flex items-center justify-center h-full text-sm text-gray-400 p-4 text-center">
← Vorlage auswählen
</div>
{/if}
</div>
</div>