feat: Fristen-Tracking (Issue #17)

- DB-Migration: fristen-Tabelle mit Indices
- Backend API: CRUD für Fristen (/api/fristen)
  - GET mit Filtern (kette_id, status, typ, Paginierung)
  - POST (manuell anlegen), PATCH (Status ändern), DELETE
  - GET /api/fristen/ueberfaellig (auto-Status-Update)
- KI-Extraktion: Prompts in bewertung.py erweitert
  - Zusammenfassung + Ketten-Bewertung extrahieren Fristen
  - Automatisches Einfügen mit quelle='ki_extraktion'
- Frontend /fristen: Übersichtsseite mit Tabelle/Cards
  - Filter nach Status + Typ, Farbcodierung, Paginierung
  - Button 'Als erfüllt markieren'
- Explorer Panel 2: Fristen-Sektion pro Kette
  - Anzeige + Formular zum Hinzufügen + Erfüllt-Button
- Dashboard: Kachel 'X überfällige Fristen' (rot wenn > 0)
- Navigation: Fristen-Link in Desktop + Mobile Menu
This commit is contained in:
Dotty Dotter 2026-04-02 00:43:01 +02:00
parent f8bc893a54
commit c10a16696b
9 changed files with 752 additions and 7 deletions

View File

@ -72,9 +72,17 @@ Erstelle eine strukturierte Zusammenfassung im JSON-Format:
"begruendung": "Warum wird das gefordert? (kurz)",
"thema": "Hauptthema (z.B. Verkehr, Soziales, Umwelt)",
"partei": "Antragstellende Fraktion falls erkennbar",
"orte": []
"orte": [],
"fristen": []
}}
Zusätzlich: Gibt es im Text genannte Fristen, Termine, Zeitangaben oder Zusagen mit Zeithorizont?
Wenn ja, ergänze im JSON:
"fristen": [
{{"typ": "überarbeitung|bericht|prüfung|umsetzung|sonstiges", "datum": "YYYY-MM-DD", "beschreibung": "Was soll bis wann passieren"}}
]
Wenn keine Fristen erkennbar: "fristen": []
NUR JSON ausgeben, keine Erklärungen."""
@ -104,9 +112,13 @@ Bewerte NUR als JSON:
"bewertung": "erfuellt|teilweise|abgewiegelt|nebelkerze|vertagt|unklar",
"begruendung": "1-2 Sätze warum",
"kernpunkt_erfuellt": true/false,
"details": "Was konkret beschlossen/abgelehnt wurde"
"details": "Was konkret beschlossen/abgelehnt wurde",
"fristen": [{{"typ": "überarbeitung|bericht|prüfung|umsetzung|sonstiges", "datum": "YYYY-MM-DD", "beschreibung": "Was soll bis wann passieren"}}]
}}
Zusätzlich: Gibt es im Text genannte Fristen, Termine, Zeitangaben oder Zusagen mit Zeithorizont?
Wenn ja, ergänze "fristen" entsprechend. Wenn keine Fristen erkennbar: "fristen": []
Bewertungsskala:
- 1.0: Forderung vollständig erfüllt, konkreter Beschluss
- 0.7-0.9: Weitgehend erfüllt, kleine Abweichungen
@ -119,6 +131,30 @@ class BewertungRequest(BaseModel):
anmerkung: str = ""
def _insert_ki_fristen(conn, fristen_list: list, kette_id: int | None, vorlage_id: int | None):
"""Insert KI-extracted fristen into the fristen table."""
if not fristen_list:
return
for f in fristen_list:
typ = f.get("typ", "sonstiges")
datum = f.get("datum")
beschreibung = f.get("beschreibung")
if not datum:
continue
# Check for duplicate (same kette, same datum, same beschreibung)
existing = conn.execute(
"SELECT id FROM fristen WHERE kette_id = ? AND datum = ? AND beschreibung = ? AND quelle = 'ki_extraktion'",
(kette_id, datum, beschreibung),
).fetchone()
if existing:
continue
conn.execute(
"""INSERT INTO fristen (kette_id, vorlage_id, typ, datum, beschreibung, quelle, status, erstellt_at)
VALUES (?, ?, ?, ?, ?, 'ki_extraktion', 'offen', ?)""",
(kette_id, vorlage_id, typ, datum, beschreibung, datetime.now().isoformat()),
)
def _call_qwen(prompt: str) -> dict | None:
key = _get_key("QWEN_API_KEY")
if not key:
@ -178,6 +214,18 @@ def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str):
if result.get("kernforderung"):
conn.execute("UPDATE vorlagen SET thema_kurz = ? WHERE id = ?", (result["kernforderung"][:200], vorlage_id))
# Extract and insert KI-detected fristen
kette_row_for_fristen = conn.execute(
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1",
(vorlage_id,),
).fetchone()
_insert_ki_fristen(
conn,
result.get("fristen", []),
kette_row_for_fristen["kette_id"] if kette_row_for_fristen else None,
vorlage_id,
)
# Log
conn.execute(
"""INSERT INTO bewertungs_log (vorlage_id, typ, anmerkung, modell, prompt_version, bewertung_vorher, bewertung_nachher, erstellt_at)
@ -320,6 +368,9 @@ def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str):
(new_status, begruendung, kette_id),
)
# Extract and insert KI-detected fristen
_insert_ki_fristen(conn, result.get("fristen", []), kette_id, kette["ursprung_id"])
# Log
conn.execute(
"""INSERT INTO bewertungs_log

View File

@ -0,0 +1,198 @@
from __future__ import annotations
"""API routes for Fristen-Tracking (Issue #17)."""
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from tracker.db.session import get_connection
router = APIRouter(prefix="/fristen", tags=["fristen"])
def _db():
conn = get_connection()
try:
yield conn
finally:
conn.close()
class FristCreate(BaseModel):
kette_id: int
vorlage_id: Optional[int] = None
typ: str # überarbeitung, bericht, prüfung, umsetzung, sonstiges
datum: str # YYYY-MM-DD
beschreibung: Optional[str] = None
class FristUpdate(BaseModel):
status: Optional[str] = None
datum: Optional[str] = None
beschreibung: Optional[str] = None
@router.get("")
def list_fristen(
kette_id: Optional[int] = None,
status: Optional[str] = Query(None, description="offen, überfällig, erfüllt, alle"),
typ: Optional[str] = None,
page: int = 1,
page_size: int = 50,
conn=Depends(_db),
):
"""Fristen auflisten mit Joins auf ketten + vorlagen."""
where_clauses = []
params: list = []
if kette_id is not None:
where_clauses.append("f.kette_id = ?")
params.append(kette_id)
if status and status != "alle":
if status == "überfällig":
where_clauses.append("(f.status = 'überfällig' OR (f.status = 'offen' AND f.datum < date('now')))")
else:
where_clauses.append("f.status = ?")
params.append(status)
if typ:
where_clauses.append("f.typ = ?")
params.append(typ)
where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
# Count
total = conn.execute(f"SELECT COUNT(*) as cnt FROM fristen f{where_sql}", params).fetchone()["cnt"]
# Query with joins
offset = (page - 1) * page_size
rows = conn.execute(
f"""SELECT f.*,
k.status AS kette_status,
v_ursprung.aktenzeichen AS kette_aktenzeichen,
v_ursprung.betreff AS kette_betreff,
v_frist.aktenzeichen AS vorlage_aktenzeichen
FROM fristen f
LEFT JOIN ketten k ON f.kette_id = k.id
LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id
LEFT JOIN vorlagen v_frist ON f.vorlage_id = v_frist.id
{where_sql}
ORDER BY
CASE WHEN f.status = 'überfällig' OR (f.status = 'offen' AND f.datum < date('now')) THEN 0
WHEN f.status = 'offen' THEN 1
ELSE 2 END,
f.datum ASC
LIMIT ? OFFSET ?""",
params + [page_size, offset],
).fetchall()
items = []
for r in rows:
item = dict(r)
# Mark overdue in response
if item["status"] == "offen" and item["datum"] and item["datum"] < date.today().isoformat():
item["status"] = "überfällig"
items.append(item)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.post("")
def create_frist(body: FristCreate, conn=Depends(_db)):
"""Neue Frist manuell anlegen."""
# Validate kette exists
kette = conn.execute("SELECT id FROM ketten WHERE id = ?", (body.kette_id,)).fetchone()
if not kette:
raise HTTPException(status_code=404, detail="Kette nicht gefunden")
if body.vorlage_id:
vorlage = conn.execute("SELECT id FROM vorlagen WHERE id = ?", (body.vorlage_id,)).fetchone()
if not vorlage:
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
cursor = conn.execute(
"""INSERT INTO fristen (kette_id, vorlage_id, typ, datum, beschreibung, quelle, status, erstellt_at)
VALUES (?, ?, ?, ?, ?, 'manuell', 'offen', ?)""",
(body.kette_id, body.vorlage_id, body.typ, body.datum, body.beschreibung, datetime.now().isoformat()),
)
conn.commit()
frist_id = cursor.lastrowid
row = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
return dict(row)
@router.patch("/{frist_id}")
def update_frist(frist_id: int, body: FristUpdate, conn=Depends(_db)):
"""Frist aktualisieren (Status, Datum, Beschreibung)."""
existing = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Frist nicht gefunden")
updates = []
params = []
if body.status is not None:
updates.append("status = ?")
params.append(body.status)
if body.datum is not None:
updates.append("datum = ?")
params.append(body.datum)
if body.beschreibung is not None:
updates.append("beschreibung = ?")
params.append(body.beschreibung)
if not updates:
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
updates.append("aktualisiert_at = ?")
params.append(datetime.now().isoformat())
params.append(frist_id)
conn.execute(f"UPDATE fristen SET {', '.join(updates)} WHERE id = ?", params)
conn.commit()
row = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
return dict(row)
@router.delete("/{frist_id}")
def delete_frist(frist_id: int, conn=Depends(_db)):
"""Frist löschen."""
existing = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Frist nicht gefunden")
conn.execute("DELETE FROM fristen WHERE id = ?", (frist_id,))
conn.commit()
return {"ok": True, "deleted": frist_id}
@router.get("/ueberfaellig")
def get_ueberfaellige(conn=Depends(_db)):
"""Überfällige Fristen abrufen und automatisch Status setzen."""
today = date.today().isoformat()
# Update status for overdue fristen
conn.execute(
"UPDATE fristen SET status = 'überfällig', aktualisiert_at = ? WHERE status = 'offen' AND datum < ?",
(datetime.now().isoformat(), today),
)
conn.commit()
rows = conn.execute(
"""SELECT f.*,
k.status AS kette_status,
v_ursprung.aktenzeichen AS kette_aktenzeichen,
v_ursprung.betreff AS kette_betreff
FROM fristen f
LEFT JOIN ketten k ON f.kette_id = k.id
LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id
WHERE f.status = 'überfällig'
ORDER BY f.datum ASC""",
).fetchall()
return {"items": [dict(r) for r in rows], "total": len(rows)}

View File

@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from tracker.api.routes import abstimmungen, ampel, bewertung, fraktionen, ketten, orte, stats, vorlagen
from tracker.api.routes import abstimmungen, ampel, bewertung, fraktionen, fristen, ketten, orte, stats, vorlagen
app = FastAPI(
title="Antragstracker Hagen",
@ -20,7 +20,7 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["*"],
)
@ -32,6 +32,7 @@ app.include_router(orte.router, prefix="/api")
app.include_router(fraktionen.router, prefix="/api")
app.include_router(bewertung.router, prefix="/api")
app.include_router(ampel.router, prefix="/api")
app.include_router(fristen.router, prefix="/api")
@app.get("/api/health")

View File

@ -259,6 +259,50 @@ export interface AmpelDefinition {
export const fetchAmpelDefinition = () => get<AmpelDefinition>('/ampel/definition');
// Fristen API
export interface Frist {
id: number;
kette_id: number | null;
vorlage_id: number | null;
typ: string;
datum: string;
beschreibung: string | null;
quelle: string;
status: string;
erstellt_at: string | null;
aktualisiert_at: string | null;
kette_status: string | null;
kette_aktenzeichen: string | null;
kette_betreff: string | null;
vorlage_aktenzeichen: string | null;
}
export const fetchFristen = (params: Record<string, string>) => {
const qs = new URLSearchParams(params).toString();
return get<Paginated<Frist>>(`/fristen?${qs}`);
};
export const fetchUeberfaelligeFristen = () =>
get<{ items: Frist[]; total: number }>('/fristen/ueberfaellig');
export const createFrist = (body: { kette_id: number; vorlage_id?: number; typ: string; datum: string; beschreibung?: string }) =>
post<Frist>('/fristen', body);
export async function patchFrist(id: number, body: { status?: string; datum?: string; beschreibung?: string }): Promise<Frist> {
const res = await fetch(`${BASE}/fristen/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export async function deleteFrist(id: number): Promise<void> {
const res = await fetch(`${BASE}/fristen/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
}
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
const p = new URLSearchParams();
if (jahr) p.set('jahr', jahr);

View File

@ -42,6 +42,7 @@
<a href="/vorlagen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Vorlagen</a>
<a href="/abstimmungen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Abstimmungen</a>
<a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a>
<a href="/fristen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fristen</a>
<a href="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a>
</div>
</div>
@ -77,6 +78,7 @@
<a href="/vorlagen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Vorlagen</a>
<a href="/abstimmungen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Abstimmungen</a>
<a href="/karte" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Karte</a>
<a href="/fristen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fristen</a>
<a href="/fraktionen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fraktionen</a>
</div>
</div>

View File

@ -2,7 +2,7 @@
import '../app.css';
import { goto } from '$app/navigation';
import { filters, filterVersion } from '$lib/filters.svelte';
import { fetchAmpelDefinition, type AmpelDefinition } from '$lib/api';
import { fetchAmpelDefinition, fetchUeberfaelligeFristen, type AmpelDefinition } from '$lib/api';
import Ampel from '$lib/components/Ampel.svelte';
interface Vorlage {
@ -33,6 +33,7 @@
let stats = $state<DashboardStats | null>(null);
let antraege = $state<Vorlage[]>([]);
let ampelDef = $state<AmpelDefinition | null>(null);
let ueberfaelligeCount = $state(0);
let loading = $state(true);
let error = $state('');
@ -111,11 +112,14 @@
try { ampelDef = await fetchAmpelDefinition(); } catch {}
}
const [dashRes, antraegeRes] = await Promise.all([
const [dashRes, antraegeRes, fristenRes] = await Promise.all([
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
fetchUeberfaelligeFristen().catch(() => ({ items: [], total: 0 })),
]);
ueberfaelligeCount = fristenRes.total;
if (dashRes.ok) {
stats = await dashRes.json();
} else {
@ -181,6 +185,26 @@
</div>
</div>
<!-- Überfällige Fristen Kachel -->
<button onclick={() => goto('/fristen?status=überfällig')}
class="w-full mb-8 rounded-xl shadow-sm border p-5 flex items-center gap-4 cursor-pointer hover:shadow-md transition-shadow text-left
{ueberfaelligeCount > 0
? 'bg-red-50 border-red-200 hover:bg-red-100'
: 'bg-white border-gray-200 hover:bg-gray-50'}">
<div class="text-3xl"></div>
<div>
<div class="text-2xl font-bold {ueberfaelligeCount > 0 ? 'text-red-600' : 'text-gray-400'}">
{ueberfaelligeCount}
</div>
<div class="{ueberfaelligeCount > 0 ? 'text-red-700' : 'text-gray-500'} text-sm">
überfällige Fristen
</div>
</div>
{#if ueberfaelligeCount > 0}
<span class="ml-auto text-sm text-red-600 font-medium">Anzeigen →</span>
{/if}
</button>
<!-- 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>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchKetten, fetchKette, fetchVorlage, reevalVorlage, fetchJobStatus, type KetteKurz, type KetteDetail, type VorlageDetail, type Paginated } from '$lib/api';
import { fetchKetten, fetchKette, fetchVorlage, reevalVorlage, fetchJobStatus, fetchFristen, createFrist, patchFrist, type KetteKurz, type KetteDetail, type VorlageDetail, type Paginated, type Frist } from '$lib/api';
import { formatDate, typLabel } from '$lib/status';
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
import Ampel from '$lib/components/Ampel.svelte';
@ -32,6 +32,60 @@
let showVolltext = $state(false);
let showVersionen = $state(false);
let showUmsetzungVersionen = $state(false);
// Fristen state
let ketteFristen: Frist[] = $state([]);
let fristenLoading = $state(false);
let showFristForm = $state(false);
let newFristTyp = $state('sonstiges');
let newFristDatum = $state('');
let newFristBeschreibung = $state('');
async function loadKetteFristen(ketteId: number) {
fristenLoading = true;
try {
const data = await fetchFristen({ kette_id: String(ketteId), page_size: '100' });
ketteFristen = data.items;
} catch (e) {
ketteFristen = [];
} finally {
fristenLoading = false;
}
}
async function addFrist() {
if (!selectedKette || !newFristDatum) return;
try {
await createFrist({
kette_id: selectedKette.id,
typ: newFristTyp,
datum: newFristDatum,
beschreibung: newFristBeschreibung || undefined,
});
showFristForm = false;
newFristTyp = 'sonstiges';
newFristDatum = '';
newFristBeschreibung = '';
await loadKetteFristen(selectedKette.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
}
}
async function markFristErfuellt(fristId: number) {
try {
await patchFrist(fristId, { status: 'erfüllt' });
if (selectedKette) await loadKetteFristen(selectedKette.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
}
}
const fristStatusColors: Record<string, string> = {
'offen': 'bg-yellow-100 text-yellow-700',
'überfällig': 'bg-red-100 text-red-700',
'erfüllt': 'bg-green-100 text-green-700',
};
let showReeval = $state(false);
let reevalAnmerkung = $state('');
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
@ -126,6 +180,7 @@
mobileTab = 'kette';
try {
selectedKette = await fetchKette(id);
loadKetteFristen(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);
@ -390,6 +445,70 @@
</div>
{/if}
<!-- Fristen -->
<div class="px-4 py-3 border-b border-gray-100">
<div class="flex items-center justify-between mb-2">
<div class="text-xs font-medium text-gray-500 uppercase">Fristen</div>
<button onclick={() => showFristForm = !showFristForm}
class="text-[10px] text-green-600 hover:text-green-800 font-medium">
{showFristForm ? '✕ Abbrechen' : '+ Frist'}
</button>
</div>
{#if showFristForm}
<div class="space-y-2 mb-3 p-2 bg-gray-50 rounded-lg border border-gray-200">
<select bind:value={newFristTyp}
class="w-full border border-gray-300 rounded px-2 py-1 text-xs bg-white">
<option value="überarbeitung">Überarbeitung</option>
<option value="bericht">Bericht</option>
<option value="prüfung">Prüfung</option>
<option value="umsetzung">Umsetzung</option>
<option value="sonstiges">Sonstiges</option>
</select>
<input type="date" bind:value={newFristDatum}
class="w-full border border-gray-300 rounded px-2 py-1 text-xs" />
<input type="text" bind:value={newFristBeschreibung} placeholder="Beschreibung..."
class="w-full border border-gray-300 rounded px-2 py-1 text-xs" />
<button onclick={addFrist} disabled={!newFristDatum}
class="w-full bg-green-600 text-white rounded px-2 py-1 text-xs font-medium hover:bg-green-700 disabled:opacity-50">
Frist anlegen
</button>
</div>
{/if}
{#if fristenLoading}
<div class="text-xs text-gray-400 text-center py-2">Laden...</div>
{:else if ketteFristen.length === 0}
<div class="text-[11px] text-gray-400 text-center py-1">Keine Fristen</div>
{:else}
<div class="space-y-1.5">
{#each ketteFristen as frist}
<div class="flex items-start gap-2 p-1.5 rounded {frist.status === 'überfällig' ? 'bg-red-50' : ''}">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<span class="text-[11px] font-medium {frist.status === 'überfällig' ? 'text-red-700' : 'text-gray-900'}">
{frist.datum}
</span>
<span class="text-[10px] px-1 py-0.5 rounded {fristStatusColors[frist.status] || 'bg-gray-100 text-gray-600'}">
{frist.status}
</span>
</div>
{#if frist.beschreibung}
<p class="text-[10px] text-gray-600 leading-tight mt-0.5 truncate">{frist.beschreibung}</p>
{/if}
</div>
{#if frist.status !== 'erfüllt'}
<button onclick={() => markFristErfuellt(frist.id)}
class="text-[10px] text-green-600 hover:text-green-800 shrink-0 mt-0.5" title="Als erfüllt markieren">
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Timeline -->
<div class="p-3">
<div class="text-xs font-medium text-gray-500 uppercase mb-3">Ketten-Glieder</div>

View File

@ -0,0 +1,262 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { fetchFristen, patchFrist, type Frist, type Paginated } from '$lib/api';
import { formatDate } from '$lib/status';
let fristen: Frist[] = $state([]);
let total = $state(0);
let loading = $state(true);
let error = $state('');
let currentPage = $state(1);
const PAGE_SIZE = 50;
let statusFilter = $state('alle');
let typFilter = $state('');
const statusOptions = [
{ value: 'alle', label: 'Alle' },
{ value: 'offen', label: '🟡 Offen' },
{ value: 'überfällig', label: '🔴 Überfällig' },
{ value: 'erfüllt', label: '🟢 Erfüllt' },
];
const typOptions = [
{ value: '', label: 'Alle Typen' },
{ value: 'überarbeitung', label: 'Überarbeitung' },
{ value: 'bericht', label: 'Bericht' },
{ value: 'prüfung', label: 'Prüfung' },
{ value: 'umsetzung', label: 'Umsetzung' },
{ value: 'sonstiges', label: 'Sonstiges' },
];
const statusColors: Record<string, string> = {
'offen': 'bg-yellow-100 text-yellow-800 border-yellow-300',
'überfällig': 'bg-red-100 text-red-800 border-red-300',
'erfüllt': 'bg-green-100 text-green-800 border-green-300',
};
const typLabels: Record<string, string> = {
'überarbeitung': 'Überarbeitung',
'bericht': 'Bericht',
'prüfung': 'Prüfung',
'umsetzung': 'Umsetzung',
'sonstiges': 'Sonstiges',
};
async function loadFristen() {
loading = true;
error = '';
try {
const params: Record<string, string> = {
page: String(currentPage),
page_size: String(PAGE_SIZE),
};
if (statusFilter !== 'alle') params.status = statusFilter;
if (typFilter) params.typ = typFilter;
const data = await fetchFristen(params);
fristen = data.items;
total = data.total;
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden';
} finally {
loading = false;
}
}
async function markErfuellt(frist: Frist) {
try {
await patchFrist(frist.id, { status: 'erfüllt' });
await loadFristen();
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
}
}
function goPage(p: number) {
currentPage = p;
loadFristen();
}
// Read initial status from URL query
onMount(() => {
const urlStatus = new URL(window.location.href).searchParams.get('status');
if (urlStatus) statusFilter = urlStatus;
loadFristen();
});
$effect(() => {
// Reload when filters change
statusFilter;
typFilter;
currentPage = 1;
loadFristen();
});
let totalPages = $derived(Math.ceil(total / PAGE_SIZE));
</script>
<svelte:head>
<title>Fristen - Antragstracker Hagen</title>
</svelte:head>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">⏰ Fristen</h1>
<p class="text-gray-500 text-sm mt-1">Termine und Fristen im Überblick</p>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3 mb-6">
<div class="flex gap-1">
{#each statusOptions as opt}
<button
onclick={() => statusFilter = opt.value}
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all
{statusFilter === opt.value
? 'bg-green-600 text-white shadow-sm'
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'}">
{opt.label}
</button>
{/each}
</div>
<select
bind:value={typFilter}
class="border border-gray-300 rounded-lg px-3 py-1.5 text-sm bg-white focus:ring-2 focus:ring-green-500">
{#each typOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</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 fristen.length === 0}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center text-gray-500">
Keine Fristen gefunden
</div>
{:else}
<!-- Desktop Table -->
<div class="hidden md:block bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Kette</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quelle</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each fristen as frist}
<tr class="hover:bg-gray-50 {frist.status === 'überfällig' ? 'bg-red-50/50' : ''}">
<td class="px-4 py-3 text-sm font-medium {frist.status === 'überfällig' ? 'text-red-700' : 'text-gray-900'}">
{frist.datum}
</td>
<td class="px-4 py-3 text-sm text-gray-600">
{typLabels[frist.typ] || frist.typ}
</td>
<td class="px-4 py-3 text-sm text-gray-700 max-w-md truncate">
{frist.beschreibung || '-'}
</td>
<td class="px-4 py-3 text-sm">
{#if frist.kette_aktenzeichen}
<a href="/explorer" class="font-mono text-green-700 hover:underline text-xs">
{frist.kette_aktenzeichen}
</a>
{:else}
<span class="text-gray-400">-</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border
{statusColors[frist.status] || 'bg-gray-100 text-gray-700 border-gray-300'}">
{frist.status}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500">
{frist.quelle === 'ki_extraktion' ? '🤖' : '✏️'}
</td>
<td class="px-4 py-3 text-right">
{#if frist.status !== 'erfüllt'}
<button
onclick={() => markErfuellt(frist)}
class="text-xs text-green-600 hover:text-green-800 font-medium">
✓ Erfüllt
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="md:hidden space-y-3">
{#each fristen as frist}
<div class="bg-white rounded-xl shadow-sm border p-4
{frist.status === 'überfällig' ? 'border-red-300 bg-red-50/30' : 'border-gray-200'}">
<div class="flex items-start justify-between mb-2">
<div>
<span class="text-sm font-bold {frist.status === 'überfällig' ? 'text-red-700' : 'text-gray-900'}">
{frist.datum}
</span>
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border
{statusColors[frist.status] || 'bg-gray-100 text-gray-700 border-gray-300'}">
{frist.status}
</span>
</div>
<span class="text-xs text-gray-400">{frist.quelle === 'ki_extraktion' ? '🤖' : '✏️'}</span>
</div>
<div class="text-xs text-gray-500 mb-1">{typLabels[frist.typ] || frist.typ}</div>
{#if frist.beschreibung}
<p class="text-sm text-gray-700 mb-2">{frist.beschreibung}</p>
{/if}
{#if frist.kette_aktenzeichen}
<a href="/explorer" class="text-xs font-mono text-green-700 hover:underline">
{frist.kette_aktenzeichen}
</a>
{/if}
{#if frist.status !== 'erfüllt'}
<div class="mt-3 pt-2 border-t border-gray-100">
<button
onclick={() => markErfuellt(frist)}
class="text-sm text-green-600 hover:text-green-800 font-medium">
✓ Als erfüllt markieren
</button>
</div>
{/if}
</div>
{/each}
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-center gap-2 mt-6">
<button
disabled={currentPage <= 1}
onclick={() => goPage(currentPage - 1)}
class="px-3 py-1.5 rounded-lg text-sm border border-gray-300 hover:bg-gray-50 disabled:opacity-50">
Zurück
</button>
<span class="text-sm text-gray-500">Seite {currentPage} von {totalPages}</span>
<button
disabled={currentPage >= totalPages}
onclick={() => goPage(currentPage + 1)}
class="px-3 py-1.5 rounded-lg text-sm border border-gray-300 hover:bg-gray-50 disabled:opacity-50">
Weiter
</button>
</div>
{/if}
{/if}

View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Migration: Erstellt die fristen-Tabelle für Fristen-Tracking (Issue #17)."""
import sqlite3
import sys
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent / "data" / "tracker.db"
def migrate(db_path: Path = DB_PATH):
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("""
CREATE TABLE IF NOT EXISTS fristen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kette_id INTEGER REFERENCES ketten(id),
vorlage_id INTEGER REFERENCES vorlagen(id),
typ TEXT NOT NULL,
datum DATE NOT NULL,
beschreibung TEXT,
quelle TEXT DEFAULT 'manuell',
status TEXT DEFAULT 'offen',
erstellt_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
aktualisiert_at TIMESTAMP
)
""")
# Indices for common queries
conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_kette_id ON fristen(kette_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_status ON fristen(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_datum ON fristen(datum)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_status_datum ON fristen(status, datum)")
conn.commit()
conn.close()
print(f"✅ Migration erfolgreich: fristen-Tabelle in {db_path}")
if __name__ == "__main__":
path = Path(sys.argv[1]) if len(sys.argv) > 1 else DB_PATH
migrate(path)