Compare commits

...

10 Commits

Author SHA1 Message Date
Dotty Dotter
69edf8f64c feat: Geocoding-Script verbessert + Karten-Clustering (#5, #6)
- scripts/geocode_pending.py: Nominatim mit Hagen-Fokus, Rate-Limiting 1/s
- 2.293 Orte geocodiert (vorher 608), 31k+ noch offen (läuft weiter)
- Karte: Marker-Clustering für bessere Performance
- 27k+ Orte-Einträge → Clustering nötig

Teilweise Closes #5, Closes #6
2026-04-02 15:42:25 +02:00
Dotty Dotter
c3e9f4b3e8 feat: Automatischer OParl-Sync (#3)
- scripts/sync_oparl.py: 5-Phasen-Sync (Import → Scrape → Ketten → Status → FTS5)
- Inkrementell: Nur neue Papers, stoppt nach 3 leeren Seiten
- Dry-Run-Modus (--dry-run)
- API: GET /api/sync/status + POST /api/sync/trigger
- Cron-fähig (Exit 0/1, stdout-Logging)
- Sync-State in data/sync_state.json
- 11 neue Vorlagen beim Dry-Run erkannt

Closes #3
2026-04-02 15:26:34 +02:00
Dotty Dotter
4d13b6828e feat: Deploy-Scripts für VServer (#8)
- scripts/deploy.sh: Code-Sync + Docker-Build + Container-Restart + Health-Check
- scripts/deploy-db.sh: DB-Upload + Migrationen (FTS5, Strang, Fristen)
- Dockerfile: Multi-stage Build (Node 20 + Python 3.12)
- Production deployed + verifiziert (antraege.toppyr.de health OK)

Closes #8
2026-04-02 15:23:36 +02:00
Dotty Dotter
abcb0ff8a2 feat: ALLRIS-Rescrape vor KI-Neubewertung (#10)
- Neues Modul tracker/core/rescrape.py: Scrapt ALLRIS-Seiten live
- rescrape_vorlage(): Beratungsfolge + Beschlusstexte + PDF-Volltext
- rescrape_kette(): Alle Glieder + neue Suffix-Suche
- Eingebaut in Neubewertung: Phase 1 Rescrape → Phase 2 KI
- Status-Engine: Abstimmungen als Fallback für Beschluss-Erkennung
- Frontend: Phase-Anzeige (Daten aktualisieren / KI bewertet)
- Fehlertoleranz: Bei ALLRIS-Ausfall trotzdem KI mit alten Daten
- Rate-Limiting 1s zwischen Requests

Closes #10
2026-04-02 15:20:50 +02:00
Dotty Dotter
0e7aa065e5 feat: Fristen-Tracking — Termine und Wiedervorlagen an Ketten (#17)
Neue Features:
- fristen-Tabelle: Typ, Datum, Status (offen/überfällig/erfüllt), Quelle (manuell/KI)
- API: GET/POST/PATCH/DELETE /api/fristen + /api/fristen/ueberfaellig
- KI-Extraktion: Prompts extrahieren automatisch Fristen aus Beschlusstexten
- /fristen Seite: Tabelle/Cards mit Farbcodierung + Filter + Pagination
- Explorer Panel 2: Fristen pro Kette + Formular zum Hinzufügen
- Dashboard: Überfällige-Fristen-Kachel (rot wenn > 0)
- Navigation: Fristen-Link

Closes #17
2026-04-02 00:43:40 +02:00
Dotty Dotter
f8bc893a54 feat: Strang-basierte Klassifikation + Explorer + Ampel (#16)
Neue Features:
- 4 Verfahrensstränge: Antrag, Anfrage, Beschlussvorlage, Mitteilung
- Ampel-Visualisierung pro Kette (Fortschrittsanzeige mit Abzweigungen)
- 3-Panel Explorer (/explorer): Liste | Kette+Ampel | Vorlage-Detail
- KI-Bewertungs-Versionierung (alte Versionen aufklappbar)
- Neubewertung triggert automatisch Umsetzungs-Score
- Bewertungs-Log (bewertungs_log Tabelle)
- Umsetzungsgrad an Kette (Score + Begründung)
- Antragsteller + Beratungsergebnis pro Kettenglied
- HAK und Hagen Aktiv als getrennte Fraktionen
- Status-Filter im Explorer
- Suche durchsucht Aktenzeichen + Betreff

Backend:
- tracker/core/ampel.py — Ampel-Definition + get_ampel()
- tracker/core/perioden.py — Shared Perioden-Filter
- Neues Feld: ketten.strang, ki_bewertungen.kette_id
- GET /api/ampel/definition, erweiterte Ketten/Vorlagen-APIs

Closes #16
2026-04-02 00:36:30 +02:00
Dotty Dotter
6db12e297d docs: Git-Workflow mit Feature-Branches dokumentiert 2026-04-01 14:59:36 +02:00
Dotty Dotter
c6291a285a feat: Globale Filter (Ratsperiode + Parteien) seitenübergreifend (#15)
Layout:
- Sticky Filter-Bar unter Navigation auf ALLEN Seiten
- Ratsperioden als Multi-Select Toggle-Buttons
- Parteien-Buttons mit Parteifarben aus DB
- Reset-Button bei aktiven Filtern

Backend:
- Shared utility tracker/core/perioden.py (Perioden-Mapping + Filter-Helper)
- GET /api/vorlagen: periode + parteien Parameter
- GET /api/ketten: periode + parteien Parameter
- GET /api/stats/dashboard: periode + parteien Parameter
- GET /api/fraktionen/{kuerzel}/dashboard: periode Parameter

Frontend:
- Shared reactive state (filters.svelte.ts) mit Svelte 5 Runes
- $effect() in allen Seiten reagiert auf Filteränderungen
- Dashboard, Vorlagen, Ketten, Abstimmungen, Fraktionen nutzen globale Filter
- Filter-Bar aus Abstimmungen entfernt (jetzt im Layout)

Closes #15
2026-04-01 14:58:10 +02:00
Dotty Dotter
7358aa1b61 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
2026-04-01 14:46:57 +02:00
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
130 changed files with 5311 additions and 435 deletions

19
.github/WORKFLOW.md vendored Normal file
View File

@ -0,0 +1,19 @@
# Git-Workflow: Feature-Branches
## Regeln
1. **Jedes Issue → eigener Branch:** `feature/<issue-nr>-kurztitel`
2. **Entwicklung:** Alle Commits in den Feature-Branch
3. **Fertig:** Squash-Merge in `main` mit `Closes #<nr>` im Commit
4. **Branch löschen** nach Merge
5. **Revert:** `git revert <merge-commit>` macht ganzes Feature rückgängig
## Hotfixes direkt auf main
Nur für: Typo-Fixes, Config-Änderungen, Doku-Updates
## Feature/16 — Offene Punkte
- [ ] Neubewertung: Alte Bewertungen behalten, Versionen untereinander anzeigen (neueste zuerst, mit Zeitstempel + Anlass)
- [ ] Nutzungsanleitung schreiben (Entwurf zur Abstimmung mit Tobias)
- [ ] Branch in main squash-mergen wenn alles passt

View File

@ -84,6 +84,8 @@ class VorlageDetail(BaseModel):
referenzen_eingehend: list[ReferenzOut] = []
kette_id: int | None = None
umsetzungsbewertungen: list[UmsetzungsBewertung] = []
ampel: dict | None = None
ki_versionen: list[dict] | None = None
class KettenGliedOut(BaseModel):
@ -102,6 +104,8 @@ class KetteKurz(BaseModel):
letzte_aktivitaet: date | None = None
vertagungen_count: int = 0
glieder_count: int = 0
strang: str | None = None
ampel: dict | None = None
class KetteDetail(BaseModel):
@ -114,9 +118,13 @@ class KetteDetail(BaseModel):
letzte_aktivitaet: date | None = None
vertagungen_count: int = 0
begruendung: str | None = None
glieder: list[KettenGliedOut] = []
glieder: list[dict] = []
antragsteller: list[ParteiOut] = []
graph: dict | None = None
strang: str | None = None
ampel: dict | None = None
umsetzung: dict | None = None
umsetzung_versionen: list[dict] | None = None
class PaginatedVorlagen(BaseModel):

View File

@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from tracker.core.fraktionen_mapping import normalize_fraktion, get_all_variants, is_ratsfraktion
from tracker.db.session import get_connection
router = APIRouter(prefix="/abstimmungen", tags=["Abstimmungen"])
@ -35,51 +36,125 @@ 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."""
from tracker.core.perioden import periode_date_filter
return periode_date_filter(periode, "a.sitzung_datum")
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")
def get_fraktionen_uebersicht(conn=Depends(_db)):
"""Stimmverhalten aller Fraktionen aggregiert."""
rows = conn.execute("""
SELECT fraktion,
SUM(CASE WHEN stimme='ja' THEN 1 ELSE 0 END) as ja,
SUM(CASE WHEN stimme='nein' THEN 1 ELSE 0 END) as nein,
SUM(CASE WHEN stimme='enthaltung' THEN 1 ELSE 0 END) as enthaltung,
def get_fraktionen_uebersicht(
periode: str | None = None,
parteien: str | None = None,
conn=Depends(_db),
):
"""Stimmverhalten aller Fraktionen aggregiert, optional nach Ratsperiode/Parteien gefiltert."""
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
FROM abstimmungen_fraktionen
GROUP BY fraktion
FROM abstimmungen_fraktionen af
{join_sql}
{where_sql}
GROUP BY af.fraktion
ORDER BY gesamt DESC
""").fetchall()
""", params).fetchall()
return [
{
"fraktion": r[0],
"ja": r[1],
"nein": r[2],
"enthaltung": r[3],
"gesamt": r[4],
"ja_quote": round(r[1] / r[4] * 100, 1) if r[4] > 0 else 0
}
for r in rows
]
# Normalize and aggregate
from collections import defaultdict
agg: dict[str, dict] = defaultdict(lambda: {"ja": 0, "nein": 0, "enthaltung": 0, "gesamt": 0})
for r in rows:
name = normalize_fraktion(r[0])
agg[name]["ja"] += r[1]
agg[name]["nein"] += r[2]
agg[name]["enthaltung"] += r[3]
agg[name]["gesamt"] += r[4]
result = []
for name, d in sorted(agg.items(), key=lambda x: -x[1]["gesamt"]):
result.append({
"fraktion": name,
"ja": d["ja"],
"nein": d["nein"],
"enthaltung": d["enthaltung"],
"gesamt": d["gesamt"],
"ja_quote": round(d["ja"] / d["gesamt"] * 100, 1) if d["gesamt"] > 0 else 0,
"ist_ratsfraktion": is_ratsfraktion(name),
})
return result
@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?"""
# Alle Abstimmungen mit mindestens 2 Fraktionen
abstimmungen = conn.execute("""
SELECT abstimmung_id, fraktion, stimme
FROM abstimmungen_fraktionen
WHERE stimme IN ('ja', 'nein')
""").fetchall()
where_parts = ["af.stimme IN ('ja', 'nein')"]
params: list = []
per_clause, per_params = _periode_filter(periode)
if per_clause:
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
# Gruppieren nach Abstimmung (normalisiert)
from collections import defaultdict
by_abstimmung = defaultdict(dict)
for aid, fraktion, stimme in abstimmungen:
by_abstimmung[aid][fraktion] = stimme
name = normalize_fraktion(fraktion)
# Bei Mehrfach-Vorkommen pro Abstimmung: letzten Wert nehmen
by_abstimmung[aid][name] = stimme
# Paarweise Übereinstimmung zählen
fraktionen = list(set(r[1] for r in abstimmungen))
fraktionen = sorted(set(name for stimmen in by_abstimmung.values() for name in stimmen))
matrix = {f1: {f2: {"gleich": 0, "gesamt": 0} for f2 in fraktionen} for f1 in fraktionen}
for aid, stimmen in by_abstimmung.items():
@ -90,6 +165,11 @@ def get_koalitionsmatrix(conn=Depends(_db)):
if stimmen[f1] == stimmen[f2]:
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
result = []
for f1 in sorted(fraktionen):
@ -107,6 +187,135 @@ def get_koalitionsmatrix(conn=Depends(_db)):
return result
@router.get("/details")
def get_abstimmungen_details(
fraktion: str | None = None,
stimme: str | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
conn=Depends(_db),
):
"""Paginierte Liste von Abstimmungen gefiltert nach Fraktion und/oder Stimme."""
where_clauses = []
params: list = []
if fraktion:
variants = get_all_variants(fraktion)
placeholders = ",".join("?" * len(variants))
where_clauses.append(f"af.fraktion IN ({placeholders})")
params.extend(variants)
if stimme:
where_clauses.append("af.stimme = ?")
params.append(stimme)
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
total = conn.execute(
f"""SELECT COUNT(*) FROM abstimmungen_fraktionen af
JOIN abstimmungen a ON af.abstimmung_id = a.id
JOIN vorlagen v ON a.vorlage_id = v.id
{where_sql}""",
params,
).fetchone()[0]
offset = (page - 1) * page_size
rows = conn.execute(
f"""SELECT a.id, a.sitzung_datum, a.ergebnis,
v.id as vorlage_id, v.aktenzeichen, v.betreff,
af.fraktion, af.stimme, af.anzahl
FROM abstimmungen_fraktionen af
JOIN abstimmungen a ON af.abstimmung_id = a.id
JOIN vorlagen v ON a.vorlage_id = v.id
{where_sql}
ORDER BY a.sitzung_datum DESC
LIMIT ? OFFSET ?""",
params + [page_size, offset],
).fetchall()
return {
"items": [
{
"id": r[0],
"sitzung_datum": r[1],
"ergebnis": r[2],
"vorlage_id": r[3],
"aktenzeichen": r[4],
"betreff": r[5],
"fraktion": r[6],
"stimme": r[7],
"anzahl": r[8],
}
for r in rows
],
"total": total,
"page": page,
"page_size": page_size,
}
@router.get("/vergleich")
def get_abstimmungen_vergleich(
f1: str = Query(..., description="Erste Fraktion"),
f2: str = Query(..., description="Zweite Fraktion"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
conn=Depends(_db),
):
"""Abstimmungen wo beide Fraktionen abgestimmt haben, mit ihrem jeweiligen Verhalten."""
# Find abstimmungen where both factions voted
v1 = get_all_variants(f1)
v2 = get_all_variants(f2)
ph1 = ",".join("?" * len(v1))
ph2 = ",".join("?" * len(v2))
total = conn.execute(
f"""SELECT COUNT(DISTINCT af1.abstimmung_id)
FROM abstimmungen_fraktionen af1
JOIN abstimmungen_fraktionen af2 ON af1.abstimmung_id = af2.abstimmung_id
WHERE af1.fraktion IN ({ph1}) AND af2.fraktion IN ({ph2})""",
v1 + v2,
).fetchone()[0]
offset = (page - 1) * page_size
rows = conn.execute(
f"""SELECT a.id, a.sitzung_datum, a.ergebnis,
v.id as vorlage_id, v.aktenzeichen, v.betreff,
af1.stimme as stimme1, af1.anzahl as anzahl1,
af2.stimme as stimme2, af2.anzahl as anzahl2
FROM abstimmungen_fraktionen af1
JOIN abstimmungen_fraktionen af2 ON af1.abstimmung_id = af2.abstimmung_id
JOIN abstimmungen a ON af1.abstimmung_id = a.id
JOIN vorlagen v ON a.vorlage_id = v.id
WHERE af1.fraktion IN ({ph1}) AND af2.fraktion IN ({ph2})
ORDER BY a.sitzung_datum DESC
LIMIT ? OFFSET ?""",
v1 + v2 + [page_size, offset],
).fetchall()
return {
"f1": f1,
"f2": f2,
"items": [
{
"id": r[0],
"sitzung_datum": r[1],
"ergebnis": r[2],
"vorlage_id": r[3],
"aktenzeichen": r[4],
"betreff": r[5],
"stimme_f1": r[6],
"anzahl_f1": r[7],
"stimme_f2": r[8],
"anzahl_f2": r[9],
}
for r in rows
],
"total": total,
"page": page,
"page_size": page_size,
}
@router.get("/ablehnungen")
def get_ablehnungsverhalten(conn=Depends(_db)):
"""Wer lehnt wessen Anträge ab?"""

View File

@ -0,0 +1,14 @@
from __future__ import annotations
"""API routes for Ampel definitions."""
from fastapi import APIRouter
from tracker.core.ampel import get_ampel_definition
router = APIRouter(prefix="/ampel", tags=["Ampel"])
@router.get("/definition")
def ampel_definition():
"""Gibt die komplette Strang-Definition zurück (für Legende im Frontend)."""
return get_ampel_definition()

View File

@ -12,6 +12,9 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from tracker.db.session import get_connection
import logging
logger = logging.getLogger(__name__)
# Note: Re-evaluation should re-scrape ALLRIS data before KI evaluation
# to exclude transfer errors. See Gitea issue for full spec.
# IMPORTANT: Destructive changes (deleting old bewertungen etc.) only after
@ -72,9 +75,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 +115,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 +134,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:
@ -146,6 +185,16 @@ def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str):
try:
conn = get_connection()
# --- Rescrape ALLRIS data before KI evaluation ---
_jobs[job_id]["phase"] = "rescrape"
try:
from tracker.core.rescrape import rescrape_vorlage
rescrape_vorlage(conn, vorlage_id)
except Exception as e:
logger.warning("Rescrape failed for vorlage %s: %s", vorlage_id, e)
_jobs[job_id]["phase"] = "ki_bewertung"
# Reload fresh data after rescrape
row = conn.execute("SELECT volltext_clean, aktenzeichen FROM vorlagen WHERE id = ?", (vorlage_id,)).fetchone()
if not row or not row["volltext_clean"]:
_jobs[job_id] = {"status": "error", "error": "Kein Volltext vorhanden"}
@ -162,19 +211,55 @@ def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str):
_jobs[job_id] = {"status": "error", "error": str(result)}
return
# Delete old, insert new
conn.execute("DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung'", (vorlage_id,))
# Get previous version for logging
prev = conn.execute(
"SELECT begruendung FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' ORDER BY id DESC LIMIT 1",
(vorlage_id,),
).fetchone()
# Keep old versions, insert new
conn.execute(
"""INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version)
VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval')""",
(vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False)),
"""INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version, erstellt_at)
VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval', ?)""",
(vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False),
datetime.now().isoformat()),
)
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)
VALUES (?, 'zusammenfassung', ?, 'qwen-plus-latest', 'v2-reeval', ?, ?, ?)""",
(vorlage_id, anmerkung, prev["begruendung"] if prev else None,
result.get("zusammenfassung"), datetime.now().isoformat()),
)
conn.commit()
# Auto-trigger Ketten-Bewertung wenn Vorlage in einer Kette ist
kette_row = conn.execute(
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1",
(vorlage_id,),
).fetchone()
conn.close()
_jobs[job_id] = {"status": "done", "result": result}
if kette_row:
_jobs[job_id] = {"status": "running", "result": result, "phase": "umsetzung"}
_run_ketten_bewertung(kette_row["kette_id"], anmerkung, job_id)
else:
_jobs[job_id] = {"status": "done", "result": result}
except Exception as e:
_jobs[job_id] = {"status": "error", "error": str(e)}
@ -184,6 +269,15 @@ def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str):
try:
conn = get_connection()
# --- Rescrape all Glieder before KI evaluation ---
_jobs[job_id]["phase"] = "rescrape"
try:
from tracker.core.rescrape import rescrape_kette
rescrape_kette(conn, kette_id)
except Exception as e:
logger.warning("Rescrape failed for kette %s: %s", kette_id, e)
_jobs[job_id]["phase"] = "ki_bewertung"
# Get kette + ursprung
kette = conn.execute(
"SELECT k.*, v.aktenzeichen, v.volltext_clean FROM ketten k JOIN vorlagen v ON k.ursprung_id = v.id WHERE k.id = ?",
@ -249,25 +343,70 @@ def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str):
_jobs[job_id] = {"status": "error", "error": str(result)}
return
# Delete old umsetzung_match, insert new
# Keep old versions, insert new (linked to kette, not vorlage)
conn.execute(
"DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'umsetzung_match'",
(kette["ursprung_id"],),
)
conn.execute(
"""INSERT INTO ki_bewertungen (vorlage_id, typ, score, begruendung, anmerkungen, modell, prompt_version)
VALUES (?, 'umsetzung_match', ?, ?, ?, 'qwen-plus-latest', 'v2-reeval')""",
"""INSERT INTO ki_bewertungen (vorlage_id, kette_id, typ, score, begruendung, anmerkungen, modell, prompt_version, erstellt_at)
VALUES (?, ?, 'umsetzung_match', ?, ?, ?, 'qwen-plus-latest', 'v2-reeval', ?)""",
(
kette["ursprung_id"],
kette_id,
result.get("score"),
result.get("begruendung"),
json.dumps(result, ensure_ascii=False),
datetime.now().isoformat(),
),
)
# Rebuild chain status
from tracker.core.chains import build_single_chain
build_single_chain(conn, kette["ursprung_id"])
# Get previous scores for logging
prev_ki = conn.execute(
"SELECT score, begruendung FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'umsetzung_match' ORDER BY id DESC LIMIT 1",
(kette["ursprung_id"],),
).fetchone()
prev_status = kette["status"]
# Update chain status based on KI score
score = result.get("score", 0)
bewertung = result.get("bewertung", "")
# Map KI bewertung → Ketten-Status
if score >= 0.7:
new_status = "umgesetzt"
begruendung = f"KI-Bewertung: {score*100:.0f}% umgesetzt. {result.get('begruendung', '')}"
elif score >= 0.4:
new_status = "teilweise_umgesetzt"
begruendung = f"KI-Bewertung: {score*100:.0f}% teilweise umgesetzt. {result.get('begruendung', '')}"
elif bewertung == "abgewiegelt" or bewertung == "nebelkerze":
new_status = "abgewiegelt"
begruendung = f"KI-Bewertung: {score*100:.0f}% — {bewertung}. {result.get('begruendung', '')}"
elif score < 0.3:
new_status = "versandet"
begruendung = f"KI-Bewertung: {score*100:.0f}%. {result.get('begruendung', '')}"
else:
new_status = kette["status"] # Keep current
begruendung = kette["begruendung"]
conn.execute(
"UPDATE ketten SET status = ?, begruendung = ? WHERE id = ?",
(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
(vorlage_id, kette_id, typ, anmerkung, modell, prompt_version,
score_vorher, score_nachher, status_vorher, status_nachher,
bewertung_vorher, bewertung_nachher, erstellt_at)
VALUES (?, ?, 'umsetzung', ?, 'qwen-plus-latest', 'v2-reeval',
?, ?, ?, ?, ?, ?, ?)""",
(kette["ursprung_id"], kette_id, anmerkung,
prev_ki["score"] if prev_ki else None, score,
prev_status, new_status,
prev_ki["begruendung"] if prev_ki else None, result.get("begruendung"),
datetime.now().isoformat()),
)
conn.commit()
conn.close()
@ -309,4 +448,5 @@ def get_job_status(job_id: str):
"""Check status of a re-evaluation job."""
if job_id not in _jobs:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
return _jobs[job_id]
job = _jobs[job_id]
return {**job, "phase": job.get("phase", "")}

View File

@ -53,9 +53,12 @@ def list_fraktionen(conn=Depends(_db)):
def fraktion_dashboard(
kuerzel: str,
jahr: Optional[int] = None,
periode: Optional[str] = None,
conn=Depends(_db),
):
"""Dashboard for a single Fraktion with Umsetzungsanalyse."""
from tracker.core.perioden import periode_date_filter
# Find party
partei = conn.execute(
"SELECT id, kuerzel, name, farbe FROM parteien WHERE kuerzel = ?", (kuerzel,)
@ -70,6 +73,12 @@ def fraktion_dashboard(
jahr_filter = "AND strftime('%Y', v.datum_eingang) = ?"
params.append(str(jahr))
# Global filter: Ratsperiode
per_clause, per_params = periode_date_filter(periode, "v.datum_eingang")
if per_clause:
jahr_filter += f" AND {per_clause}"
params.extend(per_params)
# Total Anträge
total = conn.execute(f"""
SELECT COUNT(DISTINCT v.id) as c

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

@ -11,6 +11,7 @@ from tracker.api.models import (
ParteiOut,
VorlageKurz,
)
from tracker.core.ampel import get_ampel, get_ampel_kompakt
from tracker.core.graph import get_kette_graph
from tracker.db.session import get_connection
@ -33,9 +34,13 @@ def list_ketten(
typ: str | None = None,
suche: str | None = None,
partei: str | None = None,
periode: str | None = None,
parteien: str | None = None,
conn=Depends(_db),
):
"""List Ketten with optional filters."""
from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter
where_clauses = []
params: list = []
@ -55,19 +60,39 @@ def list_ketten(
params.append(partei)
if suche:
where_clauses.append("k.thema LIKE ?")
params.append(f"%{suche}%")
where_clauses.append(
"(k.thema LIKE ? OR v.aktenzeichen LIKE ? OR v.betreff LIKE ?)"
)
like = f"%{suche}%"
params.extend([like, like, like])
# Global filter: Ratsperiode (filter on letzte_aktivitaet)
per_clause, per_params = periode_date_filter(periode, "k.letzte_aktivitaet")
if per_clause:
where_clauses.append(per_clause)
params.extend(per_params)
# Global filter: Parteien (multi-select on Ursprung-Antragsteller)
p_kuerzel = parteien_kuerzel_filter(parteien)
if p_kuerzel:
placeholders = ",".join("?" * len(p_kuerzel))
where_clauses.append(
f"k.ursprung_id IN (SELECT a.vorlage_id FROM antragsteller a "
f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({placeholders}))"
)
params.extend(p_kuerzel)
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
total = conn.execute(
f"SELECT COUNT(*) as cnt FROM ketten k {where_sql}", params
f"SELECT COUNT(*) as cnt FROM ketten k LEFT JOIN vorlagen v ON k.ursprung_id = v.id {where_sql}", params
).fetchone()["cnt"]
offset = (page - 1) * page_size
rows = conn.execute(
f"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit,
k.letzte_aktivitaet, k.vertagungen_count, k.ursprung_id,
k.strang,
v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
v.ist_verwaltungsvorlage,
(SELECT COUNT(*) FROM ketten_glieder kg WHERE kg.kette_id = k.id) as glieder_count
@ -97,6 +122,8 @@ def list_ketten(
letzte_aktivitaet=r["letzte_aktivitaet"],
vertagungen_count=r["vertagungen_count"],
glieder_count=r["glieder_count"],
strang=r["strang"],
ampel=get_ampel_kompakt(r["strang"] or "", r["status"] or ""),
)
for r in rows
]
@ -110,6 +137,7 @@ def get_kette(kette_id: int, conn=Depends(_db)):
row = conn.execute(
"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit,
k.letzte_aktivitaet, k.vertagungen_count, k.begruendung, k.ursprung_id,
k.strang,
v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
v.ist_verwaltungsvorlage
FROM ketten k
@ -133,19 +161,59 @@ def get_kette(kette_id: int, conn=Depends(_db)):
(kette_id,),
).fetchall()
# Collect IDs for batch queries
glied_ids = [g["id"] for g in glieder_rows]
placeholders = ",".join("?" * len(glied_ids)) if glied_ids else "0"
# Antragsteller per Glied
glied_ast: dict[int, list] = {}
if glied_ids:
ast_rows = conn.execute(
f"""SELECT a.vorlage_id, p.kuerzel, p.name, p.farbe
FROM antragsteller a JOIN parteien p ON a.partei_id = p.id
WHERE a.vorlage_id IN ({placeholders})""",
glied_ids,
).fetchall()
for a in ast_rows:
glied_ast.setdefault(a["vorlage_id"], []).append(
{"kuerzel": a["kuerzel"], "name": a["name"], "farbe": a["farbe"]}
)
# Beratungen per Glied (Gremium + Ergebnis)
glied_beratungen: dict[int, list] = {}
if glied_ids:
ber_rows = conn.execute(
f"""SELECT b.vorlage_id, g.name as gremium, b.ergebnis, b.beschlusstext, b.sitzung_datum
FROM beratungen b LEFT JOIN gremien g ON b.gremium_id = g.id
WHERE b.vorlage_id IN ({placeholders})
ORDER BY b.sitzung_datum""",
glied_ids,
).fetchall()
for b in ber_rows:
glied_beratungen.setdefault(b["vorlage_id"], []).append({
"gremium": b["gremium"],
"ergebnis": b["ergebnis"],
"beschlusstext": b["beschlusstext"][:200] if b["beschlusstext"] else None,
"sitzung_datum": b["sitzung_datum"],
})
glieder = [
KettenGliedOut(
vorlage=VorlageKurz(
id=g["id"],
aktenzeichen=g["aktenzeichen"],
typ=g["typ"],
betreff=g["betreff"],
datum_eingang=g["datum_eingang"],
ist_verwaltungsvorlage=bool(g["ist_verwaltungsvorlage"]),
),
position=g["position"],
rolle=g["rolle"],
)
{
**KettenGliedOut(
vorlage=VorlageKurz(
id=g["id"],
aktenzeichen=g["aktenzeichen"],
typ=g["typ"],
betreff=g["betreff"],
datum_eingang=g["datum_eingang"],
ist_verwaltungsvorlage=bool(g["ist_verwaltungsvorlage"]),
),
position=g["position"],
rolle=g["rolle"],
).model_dump(),
"antragsteller": glied_ast.get(g["id"], []),
"beratungen": glied_beratungen.get(g["id"], []),
}
for g in glieder_rows
]
@ -163,6 +231,41 @@ def get_kette(kette_id: int, conn=Depends(_db)):
# Graph/Perlenschnur data
graph = get_kette_graph(conn, kette_id)
# Umsetzungsbewertung (alle Versionen für diese Kette, neueste zuerst)
umsetzung = None
umsetzung_versionen = []
import json as _json
ub_rows = conn.execute(
"""SELECT score, begruendung, anmerkungen, erstellt_at, prompt_version
FROM ki_bewertungen
WHERE kette_id = ? AND typ = 'umsetzung_match'
ORDER BY id DESC""",
(kette_id,),
).fetchall()
for i, ub_row in enumerate(ub_rows):
details = {}
if ub_row["anmerkungen"]:
try:
details = _json.loads(ub_row["anmerkungen"])
except Exception:
pass
entry = {
"score": ub_row["score"],
"bewertung": details.get("bewertung", ""),
"begruendung": ub_row["begruendung"],
"kernpunkt_erfuellt": details.get("kernpunkt_erfuellt"),
"details": details.get("details", ""),
"erstellt_at": ub_row["erstellt_at"],
"prompt_version": ub_row["prompt_version"],
}
if i == 0:
umsetzung = entry
else:
umsetzung_versionen.append(entry)
strang = row["strang"]
ampel_data = get_ampel(strang or "", row["status"] or "")
return KetteDetail(
id=row["id"],
ursprung=VorlageKurz(
@ -183,4 +286,8 @@ def get_kette(kette_id: int, conn=Depends(_db)):
glieder=glieder,
antragsteller=antragsteller,
graph=graph,
strang=strang,
ampel=ampel_data,
umsetzung=umsetzung,
umsetzung_versionen=umsetzung_versionen if umsetzung_versionen else None,
)

View File

@ -1,8 +1,9 @@
from __future__ import annotations
"""API routes for Dashboard statistics."""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter
from tracker.db.session import get_connection
router = APIRouter(prefix="/stats", tags=["Statistics"])
@ -111,6 +112,117 @@ def get_stats(conn=Depends(_db)):
}
@router.get("/dashboard")
def get_dashboard_stats(
periode: str | None = None,
parteien: str | None = None,
conn=Depends(_db),
):
"""Dashboard-level aggregated stats for the new hub, with optional global filters."""
# Build WHERE clauses for vorlagen
v_where_parts: list[str] = []
v_params: list = []
per_clause, per_params = periode_date_filter(periode, "v.datum_eingang")
if per_clause:
v_where_parts.append(per_clause)
v_params.extend(per_params)
p_kuerzel = parteien_kuerzel_filter(parteien)
if p_kuerzel:
ph = ",".join("?" * len(p_kuerzel))
v_where_parts.append(
f"v.id IN (SELECT a.vorlage_id FROM antragsteller a "
f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({ph}))"
)
v_params.extend(p_kuerzel)
v_where = ("WHERE " + " AND ".join(v_where_parts)) if v_where_parts else ""
# Build WHERE clauses for ketten
k_where_parts: list[str] = []
k_params: list = []
k_per_clause, k_per_params = periode_date_filter(periode, "k.letzte_aktivitaet")
if k_per_clause:
k_where_parts.append(k_per_clause)
k_params.extend(k_per_params)
if p_kuerzel:
ph = ",".join("?" * len(p_kuerzel))
k_where_parts.append(
f"k.ursprung_id IN (SELECT a.vorlage_id FROM antragsteller a "
f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({ph}))"
)
k_params.extend(p_kuerzel)
k_where = ("WHERE " + " AND ".join(k_where_parts)) if k_where_parts else ""
# Build WHERE for abstimmungen
a_where_parts: list[str] = []
a_params: list = []
a_per_clause, a_per_params = periode_date_filter(periode, "ab.sitzung_datum")
if a_per_clause:
a_where_parts.append(a_per_clause)
a_params.extend(a_per_params)
a_where = ("WHERE " + " AND ".join(a_where_parts)) if a_where_parts else ""
vorlagen_total = conn.execute(
f"SELECT COUNT(*) as c FROM vorlagen v {v_where}", v_params
).fetchone()["c"]
ketten_total = conn.execute(
f"SELECT COUNT(*) as c FROM ketten k {k_where}", k_params
).fetchone()["c"]
abstimmungen_total = conn.execute(
f"SELECT COUNT(*) as c FROM abstimmungen ab {a_where}", a_params
).fetchone()["c"]
vorlagen_nach_typ = conn.execute(f"""
SELECT typ, COUNT(*) as c FROM vorlagen v
{v_where + (' AND' if v_where else 'WHERE')} typ IS NOT NULL
GROUP BY typ ORDER BY c DESC
""".replace("WHERE AND", "WHERE"), v_params).fetchall()
ketten_nach_status = conn.execute(f"""
SELECT status, COUNT(*) as c FROM ketten k
{k_where + (' AND' if k_where else 'WHERE')} status IS NOT NULL
GROUP BY status ORDER BY c DESC
""".replace("WHERE AND", "WHERE"), k_params).fetchall()
# Umsetzungsquote
def _k_count(status_val: str) -> int:
extra = f" AND k.status = ?" if k_where else "WHERE k.status = ?"
return conn.execute(
f"SELECT COUNT(*) as c FROM ketten k {k_where}{extra}",
k_params + [status_val],
).fetchone()["c"]
umgesetzt = _k_count("umgesetzt")
teilweise = _k_count("teilweise_umgesetzt")
versandet = _k_count("versandet")
beschlossen = _k_count("beschlossen")
abgelehnt = _k_count("abgelehnt")
total_bewertet = umgesetzt + teilweise + versandet + beschlossen + abgelehnt
# Aufschlüsselung nach Strang
strang_rows = conn.execute(f"""
SELECT strang, COUNT(*) as c FROM ketten k
{k_where + (' AND' if k_where else 'WHERE')} strang IS NOT NULL
GROUP BY strang ORDER BY c DESC
""".replace("WHERE AND", "WHERE"), k_params).fetchall()
return {
"vorlagen_total": vorlagen_total,
"ketten_total": ketten_total,
"vorlagen_nach_typ": [{"typ": r["typ"], "anzahl": r["c"]} for r in vorlagen_nach_typ],
"ketten_nach_status": [{"status": r["status"], "anzahl": r["c"]} for r in ketten_nach_status],
"abstimmungen_total": abstimmungen_total,
"umsetzungsquote": {
"umgesetzt": umgesetzt,
"teilweise": teilweise,
"versandet": versandet,
"beschlossen": beschlossen,
"abgelehnt": abgelehnt,
"total_bewertet": total_bewertet,
},
"nach_strang": [{"strang": r["strang"], "anzahl": r["c"]} for r in strang_rows],
}
@router.get("/ketten-stats")
def get_ketten_stats(conn=Depends(_db)):
"""Aggregated Ketten status distribution with breakdowns."""

View File

@ -0,0 +1,104 @@
from __future__ import annotations
"""API routes for OParl sync management."""
import json
import threading
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/sync", tags=["sync"])
# Sync state file (written by sync_oparl.py)
PROJECT_ROOT = Path(__file__).resolve().parents[5]
SYNC_STATE_PATH = PROJECT_ROOT / "data" / "sync_state.json"
# Background job tracking
_sync_job: dict | None = None
def _load_sync_state() -> dict:
"""Load last sync state from file."""
if SYNC_STATE_PATH.exists():
try:
return json.loads(SYNC_STATE_PATH.read_text())
except Exception:
pass
return {}
@router.get("/status")
def sync_status():
"""Zeigt letzten Sync-Zeitpunkt + Statistiken."""
state = _load_sync_state()
# Laufender Job?
if _sync_job and _sync_job.get("status") == "running":
return {
"running": True,
"started_at": _sync_job.get("started_at"),
"last_sync": state,
}
if not state:
return {
"running": False,
"last_sync": None,
"message": "Noch kein Sync durchgeführt",
}
return {
"running": False,
"last_sync": state,
}
def _run_sync():
"""Background-Thread für den Sync."""
global _sync_job
try:
# Import hier, damit der Server nicht beim Start abbricht
import sys
sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
sys.path.insert(0, str(PROJECT_ROOT / "backend" / "src"))
from sync_oparl import sync
result = sync(dry_run=False)
_sync_job = {
"status": "done",
"started_at": _sync_job["started_at"] if _sync_job else None,
"finished_at": datetime.now().isoformat(),
"result": result,
}
except Exception as e:
_sync_job = {
"status": "error",
"error": str(e),
"started_at": _sync_job["started_at"] if _sync_job else None,
"finished_at": datetime.now().isoformat(),
}
@router.post("/trigger")
def trigger_sync():
"""Triggert einen OParl-Sync als Background-Job."""
global _sync_job
if _sync_job and _sync_job.get("status") == "running":
raise HTTPException(status_code=409, detail="Sync läuft bereits")
_sync_job = {
"status": "running",
"started_at": datetime.now().isoformat(),
}
t = threading.Thread(target=_run_sync, daemon=True)
t.start()
return {
"status": "started",
"started_at": _sync_job["started_at"],
"message": "Sync gestartet. Status unter GET /api/sync/status abrufbar.",
}

View File

@ -95,9 +95,13 @@ def list_vorlagen(
typ: str | None = None,
suche: str | None = None,
partei: str | None = None,
periode: str | None = None,
parteien: str | None = None,
conn=Depends(_db),
):
"""List Vorlagen with optional filters."""
from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter
where_clauses = []
params: list = []
@ -112,6 +116,22 @@ def list_vorlagen(
)
params.append(partei)
# Global filter: Ratsperiode
per_clause, per_params = periode_date_filter(periode, "v.datum_eingang")
if per_clause:
where_clauses.append(per_clause)
params.extend(per_params)
# Global filter: Parteien (multi-select)
p_kuerzel = parteien_kuerzel_filter(parteien)
if p_kuerzel:
placeholders = ",".join("?" * len(p_kuerzel))
where_clauses.append(
f"v.id IN (SELECT a.vorlage_id FROM antragsteller a "
f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({placeholders}))"
)
params.extend(p_kuerzel)
if suche:
# Try FTS5 first, fall back to LIKE
has_fts = conn.execute(
@ -261,24 +281,46 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)):
# Referenzen
refs = get_references_for_vorlage(conn, vorlage_id)
# Kette-Zugehörigkeit
# Kette-Zugehörigkeit + Ampel
from tracker.core.ampel import get_ampel
kette_row = conn.execute(
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1",
(vorlage_id,),
).fetchone()
# KI-Zusammenfassung
ki_row = conn.execute(
"SELECT anmerkungen FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' LIMIT 1",
kette_ampel = None
if kette_row:
kette_info = conn.execute(
"SELECT strang, status FROM ketten WHERE id = ?",
(kette_row["kette_id"],),
).fetchone()
if kette_info and kette_info["strang"]:
kette_ampel = get_ampel(kette_info["strang"], kette_info["status"] or "")
# KI-Zusammenfassung (alle Versionen, neueste zuerst)
ki_rows = conn.execute(
"SELECT anmerkungen, erstellt_at, prompt_version FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' ORDER BY id DESC",
(vorlage_id,),
).fetchone()
).fetchall()
ki_zusammenfassung = None
if ki_row and ki_row["anmerkungen"]:
try:
ki_data = json.loads(ki_row["anmerkungen"])
ki_zusammenfassung = KiZusammenfassung(**ki_data)
except (json.JSONDecodeError, TypeError):
pass
ki_versionen = []
for i, ki_row in enumerate(ki_rows):
if ki_row["anmerkungen"]:
try:
ki_data = json.loads(ki_row["anmerkungen"])
if i == 0:
ki_zusammenfassung = KiZusammenfassung(**ki_data)
else:
ki_versionen.append({
"zusammenfassung": ki_data.get("zusammenfassung", ""),
"kernforderung": ki_data.get("kernforderung", ""),
"begruendung": ki_data.get("begruendung", ""),
"thema": ki_data.get("thema", ""),
"erstellt_at": ki_row["erstellt_at"],
"prompt_version": ki_row["prompt_version"],
})
except (json.JSONDecodeError, TypeError):
pass
# Umsetzungsbewertungen
from tracker.api.models import UmsetzungsBewertung
@ -320,4 +362,6 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)):
kette_id=kette_row["kette_id"] if kette_row else None,
ki_zusammenfassung=ki_zusammenfassung,
umsetzungsbewertungen=umsetzungsbewertungen,
ampel=kette_ampel,
ki_versionen=ki_versionen if ki_versionen else None,
)

View File

@ -0,0 +1,236 @@
"""Ampel-Darstellungsschicht: Strang-basierte Klassifikation mit Ampel-Visualisierung.
Dies ist eine reine Darstellungsschicht über der bestehenden Status-Engine (core/status.py).
Die Status-Engine bleibt unverändert die Ampel mappt deren Ergebnisse auf visuelle Schritte.
"""
from __future__ import annotations
# Definiert die Zustände pro Strang in Reihenfolge
STRANG_ZUSTAENDE: dict[str, list[dict]] = {
"antrag": [
{"id": "eingereicht", "label": "Eingereicht", "endfarbe": None},
{"id": "in_beratung", "label": "In Beratung", "endfarbe": None},
{"id": "beschlossen", "label": "Beschlossen", "endfarbe": "gelb"},
{"id": "umgesetzt", "label": "Umgesetzt", "endfarbe": "gruen"},
],
"beschlussvorlage": [
{"id": "vorgelegt", "label": "Vorgelegt", "endfarbe": None},
{"id": "in_beratung", "label": "In Beratung", "endfarbe": None},
{"id": "beschlossen", "label": "Beschlossen", "endfarbe": "gelb"},
{"id": "umgesetzt", "label": "Umgesetzt", "endfarbe": "gruen"},
],
"anfrage": [
{"id": "angefragt", "label": "Angefragt", "endfarbe": "gelb"},
{"id": "beantwortet", "label": "Beantwortet", "endfarbe": "gruen"},
],
"mitteilung": [
{"id": "vorgelegt", "label": "Vorgelegt", "endfarbe": None},
{"id": "zur_kenntnis", "label": "Zur Kenntnis genommen", "endfarbe": "grau"},
],
}
# Endstatus die von der Hauptreihe abzweigen
ABZWEIGUNGEN: dict[str, dict] = {
"abgelehnt": {"label": "Abgelehnt", "farbe": "rot"},
"abgewiegelt": {"label": "Abgewiegelt", "farbe": "rot"},
"versandet": {"label": "Versandet", "farbe": "rot"},
"teilweise_umgesetzt": {"label": "Teilweise umgesetzt", "farbe": "amber"},
"verwiesen": {"label": "Verwiesen", "farbe": "gelb"},
"zurueckgezogen": {"label": "Zurückgezogen", "farbe": "grau"},
}
# Labels für Stränge
STRANG_LABELS: dict[str, str] = {
"antrag": "Antrag",
"beschlussvorlage": "Beschlussvorlage",
"anfrage": "Anfrage",
"mitteilung": "Mitteilung",
"sonstig": "Sonstig",
}
# Kontrollfragen pro Strang
KONTROLLFRAGEN: dict[str, str | None] = {
"antrag": "Hat die Verwaltung umgesetzt?",
"beschlussvorlage": "Wurde so umgesetzt wie beschlossen?",
"anfrage": "Wurde befriedigend geantwortet?",
"mitteilung": None,
}
# Mapping: DB-Status → (Schritt-ID der letzten erreichten Position, ist Abzweigung?)
# Für jeden Strang kann das Mapping unterschiedlich sein.
# Wir definieren ein generisches Mapping und strang-spezifische Overrides.
_STATUS_TO_SCHRITT: dict[str, dict[str, tuple[str, bool]]] = {
"antrag": {
"eingereicht": ("eingereicht", False),
"in_beratung": ("in_beratung", False),
"vertagt": ("in_beratung", False), # Vertagt = noch in Beratung
"beschlossen": ("beschlossen", False),
"umgesetzt": ("umgesetzt", False),
"versandet": ("beschlossen", True),
"abgelehnt": ("in_beratung", True),
"teilweise_umgesetzt": ("beschlossen", True),
"verwiesen": ("in_beratung", True),
"zurückgezogen": ("in_beratung", True),
"zurueckgezogen": ("in_beratung", True),
"abgewiegelt": ("beschlossen", True),
"offen": ("in_beratung", False),
},
"beschlussvorlage": {
"eingereicht": ("vorgelegt", False),
"vorgelegt": ("vorgelegt", False),
"in_beratung": ("in_beratung", False),
"vertagt": ("in_beratung", False),
"beschlossen": ("beschlossen", False),
"umgesetzt": ("umgesetzt", False),
"versandet": ("beschlossen", True),
"abgelehnt": ("in_beratung", True),
"teilweise_umgesetzt": ("beschlossen", True),
"verwiesen": ("in_beratung", True),
"zurückgezogen": ("in_beratung", True),
"zurueckgezogen": ("in_beratung", True),
"abgewiegelt": ("beschlossen", True),
"offen": ("in_beratung", False),
},
"anfrage": {
"angefragt": ("angefragt", False),
"beantwortet": ("beantwortet", False),
"offen": ("angefragt", False),
"abgewiegelt": ("angefragt", True),
"versandet": ("angefragt", True),
"zurückgezogen": ("angefragt", True),
"zurueckgezogen": ("angefragt", True),
},
"mitteilung": {
"vorgelegt": ("vorgelegt", False),
"zur_kenntnis": ("zur_kenntnis", False),
"eingereicht": ("vorgelegt", False),
"beantwortet": ("zur_kenntnis", False), # Mapped to equivalent
"beschlossen": ("zur_kenntnis", False),
},
}
def _normalize_abzweigung_id(status: str) -> str:
"""Normalize status string to ABZWEIGUNGEN key."""
mapping = {
"zurückgezogen": "zurueckgezogen",
}
return mapping.get(status, status)
def get_ampel(strang: str, aktueller_status: str) -> dict | None:
"""Gibt die Ampel-Daten für Frontend zurück.
Returns None if strang is unknown or 'sonstig'.
"""
if not strang or strang not in STRANG_ZUSTAENDE:
return None
schritte_def = STRANG_ZUSTAENDE[strang]
status_map = _STATUS_TO_SCHRITT.get(strang, {})
# Determine position and whether it's a branch-off
mapping = status_map.get(aktueller_status or "")
if mapping is None:
# Unknown status — show first step as active
aktiver_schritt_id = schritte_def[0]["id"]
ist_abzweigung = False
else:
aktiver_schritt_id, ist_abzweigung = mapping
# Find index of active step
schritt_ids = [s["id"] for s in schritte_def]
try:
aktiver_idx = schritt_ids.index(aktiver_schritt_id)
except ValueError:
aktiver_idx = 0
# Build schritte list
schritte = []
for i, s in enumerate(schritte_def):
if ist_abzweigung:
# Bei Abzweigung: alle bis aktiver_idx sind "erreicht", keiner ist "aktiv"
erreicht = i <= aktiver_idx
aktiv = False
else:
erreicht = i <= aktiver_idx
aktiv = i == aktiver_idx
# Farbe bestimmen
if aktiv and s["endfarbe"]:
farbe = s["endfarbe"]
elif aktiv:
farbe = "blau" # Aktiver Schritt ohne spezielle Endfarbe
elif erreicht:
farbe = "grau" # Bereits durchlaufen
else:
farbe = "grau" # Noch nicht erreicht
schritte.append({
"id": s["id"],
"label": s["label"],
"aktiv": aktiv,
"erreicht": erreicht,
"farbe": farbe,
})
# Abzweigung
abzweigung = None
if ist_abzweigung:
norm_status = _normalize_abzweigung_id(aktueller_status or "")
if norm_status in ABZWEIGUNGEN:
abzw = ABZWEIGUNGEN[norm_status]
abzweigung = {
"id": norm_status,
"label": abzw["label"],
"farbe": abzw["farbe"],
}
return {
"strang": strang,
"strang_label": STRANG_LABELS.get(strang, strang.capitalize()),
"kontrollfrage": KONTROLLFRAGEN.get(strang),
"schritte": schritte,
"abzweigung": abzweigung,
}
def get_ampel_kompakt(strang: str, aktueller_status: str) -> dict | None:
"""Kompakte Ampel-Version für Listen: nur aktueller Schritt + Farbe."""
ampel = get_ampel(strang, aktueller_status)
if not ampel:
return None
if ampel["abzweigung"]:
return {
"schritt": ampel["abzweigung"]["label"],
"farbe": ampel["abzweigung"]["farbe"],
"ist_abzweigung": True,
}
aktiver = next((s for s in ampel["schritte"] if s["aktiv"]), None)
if aktiver:
return {
"schritt": aktiver["label"],
"farbe": aktiver["farbe"],
"ist_abzweigung": False,
}
return None
def get_ampel_definition() -> dict:
"""Gibt die komplette Strang-Definition zurück (für Legende im Frontend)."""
return {
"straenge": {
strang: {
"label": STRANG_LABELS.get(strang, strang.capitalize()),
"kontrollfrage": KONTROLLFRAGEN.get(strang),
"schritte": schritte,
}
for strang, schritte in STRANG_ZUSTAENDE.items()
},
"abzweigungen": ABZWEIGUNGEN,
}

View File

@ -0,0 +1,80 @@
"""Normalisierung von Fraktionsnamen in Abstimmungen.
Verschiedene Schreibweisen kanonischer Name.
"""
# Mapping: DB-Wert → normalisierter Name
FRAKTION_MAPPING: dict[str, str] = {
# Grüne
"Bündnis 90 / Die Grünen": "Grüne",
"Bündnis 90/ Die Grünen": "Grüne",
"Bündnis 90/Die Grünen": "Grüne",
"Grüne": "Grüne",
# Linke
"Die Linke": "Linke",
"Die Linke.": "Linke",
"Linke": "Linke",
"Linke/HAK": "Linke/HAK",
# Hagen Aktiv (Freie Wählergemeinschaft)
"HAGEN AKTIV": "Hagen Aktiv",
"Hagen Aktiv": "Hagen Aktiv",
# HAK (Hagener Aktivistenkreis) — NICHT Hagen Aktiv!
"HAK": "HAK",
"HAK/Die Linke": "HAK/Linke",
# BfHo / Die PARTEI
"BfHo": "BfHo",
"BFHo/DIE PARTEI": "BfHo/Die PARTEI",
"BFHo/Die PARTEI": "BfHo/Die PARTEI",
"BfH/Die PARTEI": "BfHo/Die PARTEI",
"BfHo/Die PARTEI": "BfHo/Die PARTEI",
"Bürger für Hohenlimburg": "BfHo",
"Bürger für Hohenlimburg / Die PARTEI": "BfHo/Die PARTEI",
# Einzelvertreter
"Einzelmitglied": "Einzelvertreter",
"Einzelvertreter": "Einzelvertreter",
"Parteilos": "Einzelvertreter",
# Verbände (Naturschutz-Beirat etc.)
"BUND NRW e. V.": "BUND",
"LFV NRW e. V.": "LFV",
"LJV NRW e. V.": "LJV",
"LNU NRW e. V.": "LNU",
"LSB NRW e. V.": "LSB",
"LV WLI e. V.": "WLI",
"LVG NRW e. V.": "LVG",
"NABU NRW e. V.": "NABU",
"SDW NRW e. V.": "SDW",
"WBV NRW e. V.": "WBV",
"WLV e. V.": "WLV",
# Rest
"OB": "OB",
"Vertreter*innen der Jugendhilfe": "Jugendhilfe",
"Freie Wähler": "Freie Wähler",
}
# Ratsfraktionen (für Stimmverhalten/Koalitionsmatrix relevant)
RATSFRAKTIONEN = {
"SPD", "CDU", "Grüne", "FDP", "AfD", "Linke", "Hagen Aktiv",
"HAK", "BfHo", "BfHo/Die PARTEI", "BSW", "Die PARTEI",
}
def normalize_fraktion(name: str) -> str:
"""Normalize a faction name to canonical form."""
return FRAKTION_MAPPING.get(name, name)
def is_ratsfraktion(normalized_name: str) -> bool:
"""Check if a normalized name is a council faction (vs. Beirat etc.)."""
return normalized_name in RATSFRAKTIONEN
def get_all_variants(canonical: str) -> list[str]:
"""Get all DB variants for a canonical faction name."""
return [k for k, v in FRAKTION_MAPPING.items() if v == canonical] or [canonical]

View File

@ -0,0 +1,46 @@
"""Shared Perioden filter utilities for all routers."""
from __future__ import annotations
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"),
}
def parse_perioden(periode: str | None) -> list[str]:
"""Parse comma-separated perioden string into valid period keys."""
if not periode:
return []
return [p.strip() for p in periode.split(",") if p.strip() in PERIODEN]
def periode_date_filter(periode: str | None, date_column: str) -> tuple[str, list]:
"""Return (WHERE clause fragment, params) for filtering a date column by Ratsperiode.
Args:
periode: Comma-separated perioden string (e.g. "2020-2025,2025-2030")
date_column: SQL column expression to filter on (e.g. "v.datum_eingang", "a.sitzung_datum")
Returns:
Tuple of (SQL fragment, list of params). Empty string if no filter.
"""
selected = parse_perioden(periode)
if not selected:
return "", []
conditions = []
params = []
for p in selected:
start, end = PERIODEN[p]
conditions.append(f"({date_column} >= ? AND {date_column} <= ?)")
params.extend([start, end])
return f"({' OR '.join(conditions)})", params
def parteien_kuerzel_filter(parteien: str | None) -> list[str]:
"""Parse comma-separated parteien string into kuerzel list."""
if not parteien:
return []
return [p.strip() for p in parteien.split(",") if p.strip()]

View File

@ -0,0 +1,274 @@
"""
ALLRIS Rescrape-Modul.
Scrapet Beratungsfolge, Beschlusstexte und PDF-Volltext für eine Vorlage
oder alle Glieder einer Kette. Eigenständig importierbar aus dem Backend.
"""
from __future__ import annotations
import logging
import re
import subprocess
import tempfile
import time
import httpx
from bs4 import BeautifulSoup
from tracker.db.session import get_connection
logger = logging.getLogger(__name__)
ALLRIS_BASE = "https://allris.hagen.de"
DELAY_SECONDS = 1.0
HTTP_TIMEOUT = 30
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def _get(url: str) -> httpx.Response:
"""GET with timeout and redirect following."""
return httpx.get(url, timeout=HTTP_TIMEOUT, follow_redirects=True)
# ---------------------------------------------------------------------------
# Scraping functions (adapted from scripts/scrape_beratungsfolge.py)
# ---------------------------------------------------------------------------
def _scrape_vorlage_page(url: str) -> list[dict]:
"""Scrape Beratungsfolge von einer ALLRIS Vorlagen-Seite."""
resp = _get(url)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
beratungen: list[dict] = []
for link in soup.find_all("a", href=True):
href = link["href"]
if "to020" not in href or "TOLFDNR=" not in href:
continue
tolfdnr_match = re.search(r"TOLFDNR=(\d+)", href)
if not tolfdnr_match:
continue
tolfdnr = tolfdnr_match.group(1)
beschlussart = link.get_text(strip=True)
# Sitzungsinfo aus vorherigem Link
sitzung_name = None
prev = link.find_previous("a", href=re.compile(r"to010.*SILFDNR="))
if prev:
sitzung_name = prev.get_text(strip=True)
to_url = href if href.startswith("http") else ALLRIS_BASE + href
beratungen.append({
"tolfdnr": tolfdnr,
"beschlussart": beschlussart,
"sitzung_name": sitzung_name,
"to_url": to_url,
})
return beratungen
def _scrape_to_page(url: str) -> dict:
"""Scrape Beschlusstext und Wortprotokoll von einer TO-Seite."""
resp = _get(url)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
result: dict = {
"beschlusstext": None,
"wortprotokoll": None,
"sitzung_datum": None,
}
# Datum aus Titel
title = soup.find("h1", class_="title")
if title:
date_match = re.search(r"(\d{2}\.\d{2}\.\d{4})", title.get_text())
if date_match:
result["sitzung_datum"] = date_match.group(1)
# Texte in <span style="font-family:…Arial…">
text_spans = soup.find_all("span", style=re.compile(r"font-family.*Arial"))
texts = [s.get_text(strip=True) for s in text_spans if s.get_text(strip=True)]
if texts:
result["beschlusstext"] = texts[-1]
if len(texts) > 1:
result["wortprotokoll"] = "\n\n".join(texts[:-1])
return result
def _extract_pdf_text(url: str) -> str | None:
"""Download PDF and extract text via PyMuPDF."""
try:
import pymupdf
except ImportError:
logger.warning("pymupdf not installed, skipping PDF extraction")
return None
resp = httpx.get(url, timeout=60, follow_redirects=True)
resp.raise_for_status()
if len(resp.content) < 100:
return None
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as tmp:
tmp.write(resp.content)
tmp.flush()
doc = pymupdf.open(tmp.name)
parts = [page.get_text() for page in doc]
doc.close()
text = "\n".join(parts).strip()
return text if len(text) >= 50 else None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def rescrape_vorlage(conn_or_none, vorlage_id: int) -> dict:
"""
Rescrape ALLRIS data for a single Vorlage.
Opens its OWN DB connection (thread-safe).
``conn_or_none`` is accepted for signature compat but ignored
we always create a fresh connection so this is safe from threads.
Returns: {"updated_beratungen": N, "updated_volltext": bool, "errors": [...]}
"""
own_conn = get_connection()
try:
return _rescrape_vorlage_impl(own_conn, vorlage_id)
finally:
own_conn.close()
def _rescrape_vorlage_impl(conn, vorlage_id: int) -> dict:
result = {"updated_beratungen": 0, "updated_volltext": False, "errors": []}
row = conn.execute(
"SELECT web_url, aktenzeichen, pdf_url, volltext_clean FROM vorlagen WHERE id = ?",
(vorlage_id,),
).fetchone()
if not row:
result["errors"].append(f"Vorlage {vorlage_id} nicht gefunden")
return result
web_url = row["web_url"]
pdf_url = row["pdf_url"]
volltext_clean = row["volltext_clean"]
# --- 1. Beratungsfolge scrapen ---
if web_url:
try:
beratungen = _scrape_vorlage_page(web_url)
logger.info("Vorlage %s: %d Beratungen gefunden", vorlage_id, len(beratungen))
for b in beratungen:
time.sleep(DELAY_SECONDS)
try:
to_details = _scrape_to_page(b["to_url"])
except Exception as e:
result["errors"].append(f"TO {b['tolfdnr']}: {e}")
to_details = {}
# Upsert: try update first, then insert
cur = conn.execute(
"""UPDATE beratungen
SET to_url = ?, beschlussart = ?,
beschlusstext = ?, wortprotokoll = ?,
scraped_at = CURRENT_TIMESTAMP
WHERE vorlage_id = ? AND tolfdnr = ?""",
(
b["to_url"],
b["beschlussart"],
to_details.get("beschlusstext"),
to_details.get("wortprotokoll"),
vorlage_id,
b["tolfdnr"],
),
)
if cur.rowcount == 0:
conn.execute(
"""INSERT INTO beratungen
(vorlage_id, to_url, tolfdnr, beschlussart,
beschlusstext, wortprotokoll, scraped_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
(
vorlage_id,
b["to_url"],
b["tolfdnr"],
b["beschlussart"],
to_details.get("beschlusstext"),
to_details.get("wortprotokoll"),
),
)
result["updated_beratungen"] += 1
conn.commit()
except Exception as e:
result["errors"].append(f"Beratungsfolge: {e}")
logger.exception("Fehler beim Scrapen der Beratungsfolge für Vorlage %s", vorlage_id)
# --- 2. PDF-Volltext ---
if pdf_url and not volltext_clean:
try:
time.sleep(DELAY_SECONDS)
text = _extract_pdf_text(pdf_url)
if text:
conn.execute(
"UPDATE vorlagen SET volltext_clean = ? WHERE id = ?",
(text, vorlage_id),
)
conn.commit()
result["updated_volltext"] = True
logger.info("Vorlage %s: Volltext extrahiert (%d Zeichen)", vorlage_id, len(text))
except Exception as e:
result["errors"].append(f"PDF: {e}")
logger.exception("Fehler bei PDF-Extraktion für Vorlage %s", vorlage_id)
return result
def rescrape_kette(conn_or_none, kette_id: int) -> dict:
"""
Rescrape all Glieder of a Kette.
Opens its OWN DB connection (thread-safe).
Returns: {"vorlage_results": [...], "total_beratungen": N, "total_volltext": N, "errors": [...]}
"""
own_conn = get_connection()
try:
glieder = own_conn.execute(
"SELECT vorlage_id FROM ketten_glieder WHERE kette_id = ?",
(kette_id,),
).fetchall()
summary = {
"vorlage_results": [],
"total_beratungen": 0,
"total_volltext": 0,
"errors": [],
}
for g in glieder:
vid = g["vorlage_id"]
r = _rescrape_vorlage_impl(own_conn, vid)
summary["vorlage_results"].append({"vorlage_id": vid, **r})
summary["total_beratungen"] += r["updated_beratungen"]
summary["total_volltext"] += int(r["updated_volltext"])
summary["errors"].extend(r["errors"])
return summary
finally:
own_conn.close()

View File

@ -65,6 +65,12 @@ def _status_anfrage(
for b in beratungen
)
# Fallback: Check abstimmungen for Kenntnisnahme
if not has_kenntnisnahme:
abst = _get_beschluss_from_abstimmungen(conn, member_ids)
if abst:
has_kenntnisnahme = True
# Check KI-Match score for Antwort
ki_score = _get_ki_score(conn, ursprung_id, "antwort_match")
@ -138,10 +144,18 @@ def _status_antrag(
berichte = [m for m in members if m["typ"] == "bericht"]
has_bericht = len(berichte) > 0
# Determine beschluss from beratungen
# Determine beschluss from beratungen + abstimmungen
beschluss = _get_beschluss(beratungen)
beschluss_details = _get_beschluss_details(beratungen)
# Fallback: Check abstimmungen if beratungen don't show a decision
if not beschluss or beschluss not in ("angenommen", "abgelehnt", "verwiesen"):
abst_beschluss = _get_beschluss_from_abstimmungen(conn, member_ids)
if abst_beschluss:
beschluss = abst_beschluss["beschluss"]
if not beschluss_details:
beschluss_details = abst_beschluss["details"]
if beschluss == "abgelehnt":
return {"status": "abgelehnt", "status_seit": _latest_date(beratungen), "vertagungen_count": vertagungen,
"begruendung": f"In Beratung abgelehnt. {beschluss_details}"}
@ -293,3 +307,31 @@ def _get_beschluss(beratungen: list[sqlite3.Row]) -> str | None:
return "angenommen"
return None
def _get_beschluss_from_abstimmungen(conn: sqlite3.Connection, member_ids: list[int]) -> dict | None:
"""Check abstimmungen table for a decision when beratungen don't have one."""
if not member_ids:
return None
placeholders = ",".join("?" * len(member_ids))
rows = conn.execute(
f"""SELECT ergebnis, sitzung_datum FROM abstimmungen
WHERE vorlage_id IN ({placeholders}) AND ergebnis IS NOT NULL
ORDER BY sitzung_datum DESC NULLS LAST""",
member_ids,
).fetchall()
for r in rows:
ergebnis = (r["ergebnis"] or "").lower()
datum = r["sitzung_datum"] or "?"
if "abgelehnt" in ergebnis:
return {"beschluss": "abgelehnt", "details": f"Abstimmung: abgelehnt ({datum})"}
if any(kw in ergebnis for kw in ("beschlossen", "angenommen", "zugestimmt")):
return {"beschluss": "angenommen", "details": f"Abstimmung: {r['ergebnis']} ({datum})"}
if "kenntnis" in ergebnis:
return {"beschluss": "angenommen", "details": f"Abstimmung: {r['ergebnis']} ({datum})"}
if "vertagt" in ergebnis:
continue
if "verwiesen" in ergebnis:
return {"beschluss": "verwiesen", "details": f"Abstimmung: verwiesen ({datum})"}
return None

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, bewertung, fraktionen, ketten, orte, stats, vorlagen
from tracker.api.routes import abstimmungen, ampel, bewertung, fraktionen, fristen, ketten, orte, stats, sync, 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=["*"],
)
@ -31,6 +31,9 @@ app.include_router(abstimmungen.router, prefix="/api")
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.include_router(sync.router, prefix="/api")
@app.get("/api/health")

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
antragstracker:
build: .

View File

@ -9,7 +9,8 @@
"version": "0.0.1",
"dependencies": {
"@types/leaflet": "^1.9.21",
"leaflet": "^1.9.4"
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
@ -1560,7 +1561,17 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/leaflet.markercluster": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
"license": "MIT",
"peerDependencies": {
"leaflet": "^1.3.1"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",

View File

@ -25,6 +25,7 @@
},
"dependencies": {
"@types/leaflet": "^1.9.21",
"leaflet": "^1.9.4"
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3"
}
}

View File

@ -68,6 +68,34 @@ export interface VorlageDetail extends VorlageKurz {
kette_id: number | null;
}
export interface AmpelSchritt {
id: string;
label: string;
aktiv: boolean;
erreicht: boolean;
farbe: string;
}
export interface AmpelAbzweigung {
id: string;
label: string;
farbe: string;
}
export interface AmpelData {
strang: string;
strang_label: string;
kontrollfrage: string | null;
schritte: AmpelSchritt[];
abzweigung: AmpelAbzweigung | null;
}
export interface AmpelKompakt {
schritt: string;
farbe: string;
ist_abzweigung: boolean;
}
export interface KetteKurz {
id: number;
ursprung: VorlageKurz | null;
@ -78,6 +106,8 @@ export interface KetteKurz {
letzte_aktivitaet: string | null;
vertagungen_count: number;
glieder_count: number;
strang: string | null;
ampel: AmpelKompakt | null;
}
export interface KettenGliedOut {
@ -102,6 +132,8 @@ export interface KetteDetail {
nodes: GraphNode[];
edges: GraphEdge[];
} | null;
strang: string | null;
ampel: AmpelData | null;
}
export interface GraphNode {
@ -202,7 +234,7 @@ export const reevalKette = (id: number, anmerkung: string) =>
post<{ job_id: string; status: string }>(`/bewertung/ketten/${id}`, { anmerkung });
export const fetchJobStatus = (jobId: string) =>
get<{ status: string; result?: object; error?: string }>(`/bewertung/status/${jobId}`);
get<{ status: string; result?: object; error?: string; phase?: string }>(`/bewertung/status/${jobId}`);
export interface SuchVorschlag {
id: number;
@ -216,7 +248,65 @@ export const fetchSuchvorschlaege = (q: string) =>
get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`);
export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen');
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => {
const params = jahr ? `?jahr=${jahr}` : '';
return get<FraktionDashboard>(`/fraktionen/${kuerzel}/dashboard${params}`);
export interface AmpelDefinition {
straenge: Record<string, {
label: string;
kontrollfrage: string | null;
schritte: { id: string; label: string; endfarbe: string | null }[];
}>;
abzweigungen: Record<string, { label: string; farbe: string }>;
}
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);
if (periode) p.set('periode', periode);
const qs = p.toString();
return get<FraktionDashboard>(`/fraktionen/${kuerzel}/dashboard${qs ? `?${qs}` : ''}`);
};

View File

@ -0,0 +1,177 @@
<script lang="ts">
/**
* Ampel-Visualisierung: Zeigt den Fortschritt einer Kette als Schritt-Indikator.
* Horizontal (default) oder vertikal, compact oder normal.
*/
interface AmpelSchritt {
id: string;
label: string;
aktiv: boolean;
erreicht: boolean;
farbe: string;
}
interface AmpelAbzweigung {
id: string;
label: string;
farbe: string;
}
interface AmpelData {
strang: string;
strang_label: string;
kontrollfrage: string | null;
schritte: AmpelSchritt[];
abzweigung: AmpelAbzweigung | null;
}
interface Props {
ampel: AmpelData | null;
compact?: boolean;
vertical?: boolean;
}
let { ampel, compact = false, vertical = false }: Props = $props();
const FARB_MAP: Record<string, string> = {
gruen: '#22c55e',
gelb: '#eab308',
rot: '#ef4444',
amber: '#f59e0b',
grau: '#d1d5db',
blau: '#3b82f6',
};
function farbeHex(farbe: string): string {
return FARB_MAP[farbe] || FARB_MAP.grau;
}
// Find the step where the branch-off originates (last reached step)
function abzweigungIndex(): number {
if (!ampel?.schritte) return -1;
let lastReached = -1;
for (let i = 0; i < ampel.schritte.length; i++) {
if (ampel.schritte[i].erreicht) lastReached = i;
}
return lastReached;
}
</script>
{#if ampel}
{#if compact}
<!-- Compact: Just colored dots inline -->
<div class="flex items-center gap-0.5" title="{ampel.strang_label}">
{#each ampel.schritte as schritt, i}
{#if i > 0}
<div class="w-1 h-0.5 rounded-full" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
{/if}
<div
class="rounded-full shrink-0"
style="width: {schritt.aktiv ? '10px' : '8px'}; height: {schritt.aktiv ? '10px' : '8px'}; {schritt.aktiv
? `background-color: ${farbeHex(schritt.farbe)};`
: schritt.erreicht
? `background-color: ${farbeHex('grau')};`
: `border: 1.5px solid #d1d5db; background: white;`}"
title="{schritt.label}{schritt.aktiv ? ' (aktuell)' : ''}"
></div>
{/each}
{#if ampel.abzweigung}
<div class="w-1 h-0.5 rounded-full" style="background-color: #d1d5db; border-top: 1px dashed #9ca3af;"></div>
<div
class="rounded-full shrink-0"
style="width: 10px; height: 10px; background-color: {farbeHex(ampel.abzweigung.farbe)};"
title="{ampel.abzweigung.label}"
></div>
{/if}
</div>
{:else if vertical}
<!-- Vertical layout for Panel 2 -->
<div class="flex flex-col items-center">
{#each ampel.schritte as schritt, i}
{#if i > 0}
<div class="w-0.5 h-4" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
{/if}
<div class="flex items-center gap-3">
<div
class="rounded-full shrink-0 transition-all"
style="width: 20px; height: 20px; {schritt.aktiv
? `background-color: ${farbeHex(schritt.farbe)}; box-shadow: 0 0 0 3px ${farbeHex(schritt.farbe)}30;`
: schritt.erreicht
? `background-color: ${farbeHex('grau')};`
: `border: 2px solid #d1d5db; background: white;`}"
></div>
<span class="text-xs {schritt.aktiv ? 'font-semibold text-gray-900' : 'text-gray-500'} whitespace-nowrap">
{schritt.label}
</span>
</div>
<!-- Abzweigung branch off this step -->
{#if ampel.abzweigung && i === abzweigungIndex()}
<div class="flex items-start ml-2.5">
<div class="flex flex-col items-center">
<div class="w-0.5 h-3 border-l-2 border-dashed" style="border-color: {farbeHex(ampel.abzweigung.farbe)};"></div>
<div
class="rounded-full shrink-0"
style="width: 20px; height: 20px; background-color: {farbeHex(ampel.abzweigung.farbe)}; box-shadow: 0 0 0 3px {farbeHex(ampel.abzweigung.farbe)}30;"
></div>
</div>
<span class="text-xs font-semibold ml-2 mt-3" style="color: {farbeHex(ampel.abzweigung.farbe)}">
{ampel.abzweigung.label}
</span>
</div>
{/if}
{/each}
{#if ampel.kontrollfrage}
<p class="text-xs italic text-gray-400 mt-3 text-center">{ampel.kontrollfrage}</p>
{/if}
</div>
{:else}
<!-- Horizontal (default) -->
<div class="flex flex-col items-start">
<div class="flex items-center">
{#each ampel.schritte as schritt, i}
{#if i > 0}
<div class="h-0.5 w-4 sm:w-6" style="background-color: {schritt.erreicht ? '#9ca3af' : '#e5e7eb'}"></div>
{/if}
<div class="flex flex-col items-center">
<div
class="rounded-full shrink-0 transition-all"
style="width: 24px; height: 24px; {schritt.aktiv
? `background-color: ${farbeHex(schritt.farbe)}; box-shadow: 0 0 0 3px ${farbeHex(schritt.farbe)}30;`
: schritt.erreicht
? `background-color: ${farbeHex('grau')};`
: `border: 2px solid #d1d5db; background: white;`}"
></div>
<span class="text-[10px] mt-1 {schritt.aktiv ? 'font-semibold text-gray-900' : 'text-gray-400'} text-center max-w-[60px] leading-tight">
{schritt.label}
</span>
</div>
{/each}
</div>
<!-- Abzweigung -->
{#if ampel.abzweigung}
{@const idx = abzweigungIndex()}
{#if idx >= 0}
<!-- Position branch under the correct step -->
<div class="flex items-start" style="margin-left: {idx * (24 + 24)}px;">
<div class="flex flex-col items-center ml-3">
<div class="h-3 border-l-2 border-dashed" style="border-color: {farbeHex(ampel.abzweigung.farbe)};"></div>
<div
class="rounded-full shrink-0"
style="width: 24px; height: 24px; background-color: {farbeHex(ampel.abzweigung.farbe)}; box-shadow: 0 0 0 3px {farbeHex(ampel.abzweigung.farbe)}30;"
></div>
<span class="text-[10px] mt-1 font-semibold text-center max-w-[70px] leading-tight" style="color: {farbeHex(ampel.abzweigung.farbe)}">
{ampel.abzweigung.label}
</span>
</div>
</div>
{/if}
{/if}
{#if ampel.kontrollfrage}
<p class="text-xs italic text-gray-400 mt-2">{ampel.kontrollfrage}</p>
{/if}
</div>
{/if}
{/if}

View File

@ -0,0 +1,84 @@
/**
* Shared global filter state for Ratsperiode + Parteien.
* Uses Svelte 5 Runes ($state).
*/
export const PERIODEN = ['2025-2030', '2020-2025', '2014-2020', '2009-2014', '2004-2009'];
export interface Fraktion {
kuerzel: string;
name: string;
farbe: string | null;
anzahl: number;
}
// Reactive filter state — module-level $state
export const filters = $state({
perioden: [] as string[],
parteien: [] as string[],
});
// Available fraktionen (loaded once from API)
export const fraktionenList = $state<{ items: Fraktion[] }>({ items: [] });
export function togglePeriode(p: string) {
if (filters.perioden.includes(p)) {
filters.perioden = filters.perioden.filter((x) => x !== p);
} else {
filters.perioden = [...filters.perioden, p];
}
}
export function togglePartei(p: string) {
if (filters.parteien.includes(p)) {
filters.parteien = filters.parteien.filter((x) => x !== p);
} else {
filters.parteien = [...filters.parteien, p];
}
}
export function clearFilters() {
filters.perioden = [];
filters.parteien = [];
}
export function hasActiveFilters(): boolean {
return filters.perioden.length > 0 || filters.parteien.length > 0;
}
/**
* Build URLSearchParams from active filters.
* Append to any existing params for API calls.
*/
export function filterParams(): URLSearchParams {
const params = new URLSearchParams();
if (filters.perioden.length > 0) {
params.set('periode', filters.perioden.join(','));
}
if (filters.parteien.length > 0) {
params.set('parteien', filters.parteien.join(','));
}
return params;
}
/**
* Merge filter params into an existing Record<string, string>.
*/
export function mergeFilterParams(existing: Record<string, string>): Record<string, string> {
const merged = { ...existing };
if (filters.perioden.length > 0) {
merged.periode = filters.perioden.join(',');
}
if (filters.parteien.length > 0) {
merged.parteien = filters.parteien.join(',');
}
return merged;
}
/**
* Returns a reactive "version" that changes when filters change.
* Use this in $effect() to trigger reloads.
*/
export function filterVersion(): string {
return `${filters.perioden.join(',')}_${filters.parteien.join(',')}`;
}

View File

@ -1,7 +1,29 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { filters, fraktionenList, PERIODEN, togglePeriode, togglePartei, clearFilters, hasActiveFilters, type Fraktion } from '$lib/filters.svelte';
let { children } = $props();
let menuOpen = $state(false);
const API_BASE = typeof window !== 'undefined'
? (window.location.port === '5173'
? `http://${window.location.hostname}:8099/api`
: '/api')
: '/api';
onMount(async () => {
if (fraktionenList.items.length === 0) {
try {
const res = await fetch(`${API_BASE}/fraktionen`);
if (res.ok) {
fraktionenList.items = await res.json();
}
} catch (e) {
console.error('Fraktionen laden fehlgeschlagen:', e);
}
}
});
</script>
<div class="min-h-screen bg-gray-50">
@ -15,10 +37,12 @@
</a>
<div class="hidden sm:flex sm:ml-8 space-x-4">
<a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Dashboard</a>
<a href="/explorer" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Explorer</a>
<a href="/ketten" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Ketten</a>
<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>
@ -49,16 +73,68 @@
<div class="sm:hidden border-t border-gray-200 bg-white">
<div class="px-2 pt-2 pb-3 space-y-1">
<a href="/" 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">Dashboard</a>
<a href="/explorer" 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">Explorer</a>
<a href="/ketten" 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">Ketten</a>
<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>
{/if}
</nav>
<!-- Global Filter Bar -->
<div class="sticky top-0 z-40 bg-white/95 backdrop-blur-sm border-b border-gray-200 shadow-sm">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5">
<div class="flex flex-col sm:flex-row gap-2.5 sm:items-center">
<!-- Ratsperioden -->
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-medium text-gray-500 uppercase shrink-0">Periode</span>
<div class="flex flex-wrap gap-1">
{#each PERIODEN as p}
<button onclick={() => togglePeriode(p)}
class="px-2 py-1 rounded-md text-xs font-medium transition-all
{filters.perioden.includes(p)
? 'bg-green-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
{p}
</button>
{/each}
</div>
</div>
<!-- Separator -->
<div class="hidden sm:block w-px h-6 bg-gray-300"></div>
<!-- Parteien -->
<div class="flex items-center gap-2 flex-wrap flex-1 min-w-0">
<span class="text-xs font-medium text-gray-500 uppercase shrink-0">Partei</span>
<div class="flex flex-wrap gap-1">
{#each fraktionenList.items as f}
<button onclick={() => togglePartei(f.kuerzel)}
class="px-2 py-1 rounded-md text-xs font-medium transition-all border"
style={filters.parteien.includes(f.kuerzel)
? `background-color: ${f.farbe || '#6b7280'}; color: white; border-color: ${f.farbe || '#6b7280'};`
: `background-color: white; color: ${f.farbe || '#6b7280'}; border-color: ${f.farbe || '#6b7280'}40;`}>
{f.kuerzel}
</button>
{/each}
</div>
</div>
<!-- Reset -->
{#if hasActiveFilters()}
<button onclick={clearFilters}
class="text-xs text-gray-500 hover:text-gray-700 border border-gray-300 rounded-md px-2.5 py-1 hover:bg-gray-50 whitespace-nowrap shrink-0 transition-colors">
✕ Reset
</button>
{/if}
</div>
</div>
</div>
<!-- Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{@render children()}

View File

@ -1,6 +1,10 @@
<script lang="ts">
import '../app.css';
import { goto } from '$app/navigation';
import { filters, filterVersion } from '$lib/filters.svelte';
import { fetchAmpelDefinition, fetchUeberfaelligeFristen, type AmpelDefinition } from '$lib/api';
import Ampel from '$lib/components/Ampel.svelte';
interface Vorlage {
id: number;
aktenzeichen: string;
@ -9,137 +13,382 @@
datum_eingang: string;
ist_verwaltungsvorlage: boolean;
}
interface Stats {
vorlagen: number;
beratungen: number;
ketten: number;
gremien: number;
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<Stats>({ vorlagen: 0, beratungen: 0, ketten: 0, gremien: 0 });
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('');
// API-Base: In Produktion relativ, in Dev mit Port
const API_BASE = typeof window !== 'undefined'
? (window.location.port === '5173'
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() {
console.log('API_BASE:', API_BASE);
try {
// Stats laden
console.log('Fetching health...');
const statsRes = await fetch(`${API_BASE}/health`);
console.log('Health response:', statsRes.status);
if (statsRes.ok) {
// Vorlagen zählen
console.log('Fetching vorlagen...');
const vorlagenRes = await fetch(`${API_BASE}/vorlagen?page_size=1`);
console.log('Vorlagen response:', vorlagenRes.status);
const vorlagenData = await vorlagenRes.json();
stats.vorlagen = vorlagenData.total;
// Ketten zählen
const kettenRes = await fetch(`${API_BASE}/ketten?page_size=1`);
const kettenData = await kettenRes.json();
stats.ketten = kettenData.total;
} else {
error = `Health check failed: ${statsRes.status}`;
const fp = new URLSearchParams();
if (filters.perioden.length > 0) fp.set('periode', filters.perioden.join(','));
if (filters.parteien.length > 0) fp.set('parteien', filters.parteien.join(','));
const fqs = fp.toString();
const dashSuffix = fqs ? `?${fqs}` : '';
const vorlagenSuffix = fqs ? `&${fqs}` : '';
// Load ampel definition once
if (!ampelDef) {
try { ampelDef = await fetchAmpelDefinition(); } catch {}
}
// Letzte 10 Anträge laden
const antraegeRes = await fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10`);
console.log('Antraege response:', antraegeRes.status);
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 {
error = `Dashboard-Stats fehler: ${dashRes.status}`;
}
if (antraegeRes.ok) {
const data = await antraegeRes.json();
console.log('Antraege data:', data.items.length);
antraege = data.items;
}
} catch (e) {
console.error('API Fehler:', e);
error = `Fehler: ${e}`;
} finally {
loading = false;
console.log('Loading done, antraege:', antraege.length);
}
}
loadData();
// Reload when global filters change (also handles initial load)
$effect(() => {
filterVersion(); // track reactivity
loadData();
});
</script>
<svelte:head>
<title>Antragstracker Hagen</title>
</svelte:head>
<main class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-green-700 text-white py-6 shadow-lg">
<div class="max-w-6xl mx-auto px-4">
<h1 class="text-2xl sm:text-3xl font-bold">🏛️ Antragstracker Hagen</h1>
<p class="text-green-100 mt-1">Kommunale Anträge & Anfragen nachverfolgen</p>
</div>
</header>
<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>
<div class="max-w-6xl mx-auto px-4 py-8">
<!-- Stats Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-4 text-center">
<div class="text-3xl font-bold text-green-600">{stats.vorlagen.toLocaleString()}</div>
<div class="text-gray-500 text-sm">Vorlagen</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>
<!-- Ü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="bg-white rounded-lg shadow p-4 text-center">
<div class="text-3xl font-bold text-blue-600">{stats.ketten.toLocaleString()}</div>
<div class="text-gray-500 text-sm">Ketten</div>
</div>
<div class="bg-white rounded-lg shadow p-4 text-center">
<div class="text-3xl font-bold text-purple-600">41</div>
<div class="text-gray-500 text-sm">Gremien</div>
</div>
<div class="bg-white rounded-lg shadow p-4 text-center">
<div class="text-3xl font-bold text-orange-600">20042026</div>
<div class="text-gray-500 text-sm">Zeitraum</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>
<!-- Aktuelle Anträge -->
<section class="bg-white rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-800">📋 Aktuelle Anträge</h2>
<!-- 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>
{#if error}
<div class="p-6 text-center text-red-500">{error}</div>
{:else if loading}
<div class="p-6 text-center text-gray-500">Lade Daten... (API: {API_BASE})</div>
{:else 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 class="px-6 py-4 hover:bg-gray-50 cursor-pointer">
<!-- 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">
<div class="flex items-center gap-2">
<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 line-clamp-2">{antrag.betreff}</p>
<p class="mt-1 text-gray-700 text-sm line-clamp-2">{antrag.betreff}</p>
</div>
<span class="text-xs px-2 py-1 rounded-full bg-yellow-100 text-yellow-800">
⏳ offen
</span>
</div>
</li>
{/each}
</ul>
</a>
</li>
{/each}
</ul>
{/if}
</section>
<!-- Ampel-Legende -->
{#if ampelDef}
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">🚦 Ampel-Legende</h2>
<p class="text-sm text-gray-500 mb-4">So liest sich die Fortschrittsanzeige im <a href="/explorer" class="text-green-600 hover:underline">Explorer</a>.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#each Object.entries(ampelDef.straenge) as [key, strang]}
<div class="border border-gray-100 rounded-lg p-4">
<h3 class="text-sm font-semibold text-gray-800 mb-2">{strang.label}</h3>
{#if strang.kontrollfrage}
<p class="text-xs italic text-gray-500 mb-3">{strang.kontrollfrage}</p>
{/if}
<!-- Example ampel: show all steps as reached, last one active -->
<Ampel ampel={{
strang: key,
strang_label: strang.label,
kontrollfrage: null,
schritte: strang.schritte.map((s, i) => ({
id: s.id,
label: s.label,
aktiv: i === strang.schritte.length - 1,
erreicht: true,
farbe: i === strang.schritte.length - 1 ? (s.endfarbe || 'blau') : 'grau',
})),
abzweigung: null,
}} />
</div>
{/each}
</div>
<!-- Abzweigungen -->
{#if Object.keys(ampelDef.abzweigungen).length > 0}
<div class="mt-4 pt-4 border-t border-gray-100">
<h3 class="text-sm font-semibold text-gray-800 mb-2">Abzweigungen</h3>
<div class="flex flex-wrap gap-3">
{#each Object.entries(ampelDef.abzweigungen) as [key, abzw]}
{@const farbMap = { rot: '#ef4444', amber: '#f59e0b', gelb: '#eab308', grau: '#d1d5db' }}
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full" style="background-color: {farbMap[abzw.farbe] || '#d1d5db'}"></span>
<span class="text-xs text-gray-600">{abzw.label}</span>
</div>
{/each}
</div>
</div>
{/if}
</section>
</div>
</main>
{/if}
{/if}

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { filters, filterVersion } from '$lib/filters.svelte';
interface FraktionStats {
fraktion: string;
ja: number;
@ -8,46 +9,175 @@
enthaltung: number;
gesamt: number;
ja_quote: number;
ist_ratsfraktion?: boolean;
}
interface Uebereinstimmung {
quote: number;
gleich: number;
gesamt: number;
}
interface KoalitionsRow {
fraktion: string;
uebereinstimmung: Record<string, Uebereinstimmung>;
}
interface AbstimmungDetail {
id: number;
sitzung_datum: string | null;
ergebnis: string | null;
vorlage_id: number;
aktenzeichen: string | null;
betreff: string | null;
fraktion: string;
stimme: string;
anzahl: number | null;
}
interface VergleichItem {
id: number;
sitzung_datum: string | null;
ergebnis: string | null;
vorlage_id: number;
aktenzeichen: string | null;
betreff: string | null;
stimme_f1: string;
anzahl_f1: number | null;
stimme_f2: string;
anzahl_f2: number | null;
}
let fraktionen = $state<FraktionStats[]>([]);
let koalitionsmatrix = $state<KoalitionsRow[]>([]);
let loading = $state(true);
let error = $state('');
const API_BASE = typeof window !== 'undefined'
? (window.location.port === '5173'
// Filter state for detail view
let selectedFraktion = $state('');
let selectedStimme = $state('');
let detailItems = $state<AbstimmungDetail[]>([]);
let detailTotal = $state(0);
let detailPage = $state(1);
let detailLoading = $state(false);
// Vergleich state
let vergleichF1 = $state('');
let vergleichF2 = $state('');
let vergleichItems = $state<VergleichItem[]>([]);
let vergleichTotal = $state(0);
let vergleichPage = $state(1);
let vergleichLoading = $state(false);
const API_BASE = typeof window !== 'undefined'
? (window.location.port === '5173'
? `http://${window.location.hostname}:8099/api`
: '/api')
: '/api';
onMount(async () => {
async function loadAll() {
loading = true;
try {
const params = new URLSearchParams();
if (filters.perioden.length > 0) params.set('periode', filters.perioden.join(','));
if (filters.parteien.length > 0) params.set('parteien', filters.parteien.join(','));
const qs = params.toString();
const suffix = qs ? `?${qs}` : '';
const [frakRes, koalRes] = await Promise.all([
fetch(`${API_BASE}/abstimmungen/fraktionen`),
fetch(`${API_BASE}/abstimmungen/koalitionsmatrix`)
fetch(`${API_BASE}/abstimmungen/fraktionen${suffix}`),
fetch(`${API_BASE}/abstimmungen/koalitionsmatrix${suffix}`)
]);
if (frakRes.ok) fraktionen = await frakRes.json();
if (frakRes.ok) {
fraktionen = await frakRes.json();
}
if (koalRes.ok) koalitionsmatrix = await koalRes.json();
} catch (e) {
error = `Fehler: ${e}`;
} finally {
loading = false;
}
}
// Reload when global filters change (also handles initial load)
$effect(() => {
filterVersion(); // track reactivity
clearDetail();
clearVergleich();
loadAll();
});
async function loadDetails(fraktion: string, stimme: string, page: number = 1) {
selectedFraktion = fraktion;
selectedStimme = stimme;
detailPage = page;
detailLoading = true;
try {
const params = new URLSearchParams();
if (fraktion) params.set('fraktion', fraktion);
if (stimme) params.set('stimme', stimme);
params.set('page', String(page));
params.set('page_size', '20');
const res = await fetch(`${API_BASE}/abstimmungen/details?${params}`);
if (res.ok) {
const data = await res.json();
detailItems = data.items;
detailTotal = data.total;
}
} catch (e) {
console.error('Detail-Fehler:', e);
} finally {
detailLoading = false;
}
// Scroll to detail section
setTimeout(() => {
document.getElementById('detail-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
async function loadVergleich(f1: string, f2: string, page: number = 1) {
vergleichF1 = f1;
vergleichF2 = f2;
vergleichPage = page;
vergleichLoading = true;
try {
const params = new URLSearchParams({ f1, f2, page: String(page), page_size: '20' });
const res = await fetch(`${API_BASE}/abstimmungen/vergleich?${params}`);
if (res.ok) {
const data = await res.json();
vergleichItems = data.items;
vergleichTotal = data.total;
}
} catch (e) {
console.error('Vergleich-Fehler:', e);
} finally {
vergleichLoading = false;
}
setTimeout(() => {
document.getElementById('vergleich-section')?.scrollIntoView({ behavior: 'smooth' });
}, 100);
}
function clearDetail() {
selectedFraktion = '';
selectedStimme = '';
detailItems = [];
detailTotal = 0;
}
function clearVergleich() {
vergleichF1 = '';
vergleichF2 = '';
vergleichItems = [];
vergleichTotal = 0;
}
function getColor(quote: number): string {
if (quote >= 90) return 'bg-green-500';
if (quote >= 70) return 'bg-green-400';
@ -55,10 +185,16 @@
if (quote >= 30) return 'bg-orange-400';
return 'bg-red-400';
}
function getTextColor(quote: number): string {
return quote >= 50 ? 'text-white' : 'text-gray-900';
}
function stimmeColor(stimme: string): string {
if (stimme === 'ja') return 'text-green-700 bg-green-50';
if (stimme === 'nein') return 'text-red-700 bg-red-50';
return 'text-yellow-700 bg-yellow-50';
}
</script>
<svelte:head>
@ -82,7 +218,8 @@
<!-- Fraktionen Übersicht -->
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">📊 Stimmverhalten nach Fraktion</h2>
<p class="text-sm text-gray-500 mb-4">Klicke auf eine Fraktion oder Zelle, um die Abstimmungen zu sehen.</p>
<!-- Desktop Table -->
<div class="hidden md:block overflow-x-auto">
<table class="w-full">
@ -99,15 +236,39 @@
<tbody class="divide-y divide-gray-100">
{#each fraktionen as f}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{f.fraktion}</td>
<td class="px-4 py-3 text-center text-green-600 font-medium">{f.ja}</td>
<td class="px-4 py-3 text-center text-red-600 font-medium">{f.nein}</td>
<td class="px-4 py-3 text-center text-yellow-600 font-medium">{f.enthaltung}</td>
<td class="px-4 py-3">
<button onclick={() => loadDetails(f.fraktion, '')}
class="font-medium text-gray-900 hover:text-green-700 hover:underline cursor-pointer">
{f.fraktion}
</button>
</td>
<td class="px-4 py-3 text-center">
<button onclick={() => loadDetails(f.fraktion, 'ja')}
class="text-green-600 font-medium hover:bg-green-100 px-2 py-1 rounded cursor-pointer transition-colors">
{f.ja}
</button>
</td>
<td class="px-4 py-3 text-center">
<button onclick={() => loadDetails(f.fraktion, 'nein')}
class="text-red-600 font-medium hover:bg-red-100 px-2 py-1 rounded cursor-pointer transition-colors">
{f.nein}
</button>
</td>
<td class="px-4 py-3 text-center">
<button onclick={() => loadDetails(f.fraktion, 'enthaltung')}
class="text-yellow-600 font-medium hover:bg-yellow-100 px-2 py-1 rounded cursor-pointer transition-colors">
{f.enthaltung}
</button>
</td>
<td class="px-4 py-3 text-center text-gray-600">{f.gesamt}</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="flex-1 bg-gray-200 rounded-full h-2 max-w-24">
<div class="bg-green-500 h-2 rounded-full" style="width: {f.ja_quote}%"></div>
<div class="flex-1 flex rounded-full h-3 max-w-32 overflow-hidden bg-gray-200">
{#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>
<span class="text-sm text-gray-600">{f.ja_quote}%</span>
</div>
@ -123,25 +284,32 @@
{#each fraktionen as f}
<div class="border border-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<span class="font-medium text-gray-900">{f.fraktion}</span>
<button onclick={() => loadDetails(f.fraktion, '')}
class="font-medium text-gray-900 hover:text-green-700 hover:underline cursor-pointer">
{f.fraktion}
</button>
<span class="text-sm font-medium text-gray-600">{f.ja_quote}%</span>
</div>
<div class="bg-gray-200 rounded-full h-2.5 mb-3">
<div class="bg-green-500 h-2.5 rounded-full" style="width: {f.ja_quote}%"></div>
<div class="flex rounded-full h-2.5 mb-3 overflow-hidden bg-gray-200">
{#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 class="grid grid-cols-4 gap-2 text-center text-xs">
<div>
<button onclick={() => loadDetails(f.fraktion, 'ja')} class="cursor-pointer hover:bg-green-50 rounded p-1 transition-colors">
<div class="font-medium text-green-600">{f.ja}</div>
<div class="text-gray-500">Ja</div>
</div>
<div>
</button>
<button onclick={() => loadDetails(f.fraktion, 'nein')} class="cursor-pointer hover:bg-red-50 rounded p-1 transition-colors">
<div class="font-medium text-red-600">{f.nein}</div>
<div class="text-gray-500">Nein</div>
</div>
<div>
</button>
<button onclick={() => loadDetails(f.fraktion, 'enthaltung')} class="cursor-pointer hover:bg-yellow-50 rounded p-1 transition-colors">
<div class="font-medium text-yellow-600">{f.enthaltung}</div>
<div class="text-gray-500">Enth.</div>
</div>
</button>
<div>
<div class="font-medium text-gray-600">{f.gesamt}</div>
<div class="text-gray-500">Gesamt</div>
@ -152,11 +320,100 @@
</div>
</section>
<!-- Gefilterte Abstimmungen (Detail) -->
{#if selectedFraktion}
<section id="detail-section" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-8">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">
🔍 {selectedFraktion}{selectedStimme ? ` — ${selectedStimme}` : ''}
</h2>
<p class="text-sm text-gray-500">{detailTotal} Abstimmungen</p>
</div>
<button onclick={clearDetail}
class="text-sm text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 transition-colors">
✕ Schließen
</button>
</div>
{#if detailLoading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
{:else if detailItems.length === 0}
<p class="text-gray-500 py-4">Keine Abstimmungen gefunden.</p>
{:else}
<!-- Desktop -->
<div class="hidden md:block overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktenzeichen</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Betreff</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase">Stimme</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Ergebnis</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each detailItems as item}
<tr class="hover:bg-gray-50 cursor-pointer" onclick={() => goto(`/vorlagen/${item.vorlage_id}`)}>
<td class="px-3 py-2 text-gray-500 whitespace-nowrap">{item.sitzung_datum || ''}</td>
<td class="px-3 py-2">
<span class="font-mono text-green-700 text-xs bg-green-50 px-1.5 py-0.5 rounded">{item.aktenzeichen || ''}</span>
</td>
<td class="px-3 py-2 text-gray-700 max-w-md truncate">{item.betreff || ''}</td>
<td class="px-3 py-2 text-center">
<span class="px-2 py-0.5 rounded text-xs font-medium {stimmeColor(item.stimme)}">{item.stimme}</span>
</td>
<td class="px-3 py-2 text-gray-600 max-w-xs truncate">{item.ergebnis || ''}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="md:hidden space-y-2">
{#each detailItems as item}
<a href="/vorlagen/{item.vorlage_id}" class="block border border-gray-200 rounded-lg p-3 hover:shadow-sm transition-shadow">
<div class="flex items-center justify-between mb-1">
<span class="font-mono text-xs text-green-700 bg-green-50 px-1.5 py-0.5 rounded">{item.aktenzeichen || ''}</span>
<span class="px-2 py-0.5 rounded text-xs font-medium {stimmeColor(item.stimme)}">{item.stimme}</span>
</div>
<p class="text-sm text-gray-700 line-clamp-2">{item.betreff || ''}</p>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>{item.sitzung_datum || ''}</span>
<span class="truncate ml-2">{item.ergebnis || ''}</span>
</div>
</a>
{/each}
</div>
<!-- Pagination -->
{#if detailTotal > 20}
{@const totalPages = Math.ceil(detailTotal / 20)}
<div class="flex justify-center mt-4 space-x-2">
<button disabled={detailPage <= 1} onclick={() => loadDetails(selectedFraktion, selectedStimme, detailPage - 1)}
class="px-3 py-2 rounded-lg text-sm border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
« Zurück
</button>
<span class="px-3 py-2 text-sm text-gray-600">Seite {detailPage} von {totalPages}</span>
<button disabled={detailPage >= totalPages} onclick={() => loadDetails(selectedFraktion, selectedStimme, detailPage + 1)}
class="px-3 py-2 rounded-lg text-sm border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
Weiter »
</button>
</div>
{/if}
{/if}
</section>
{/if}
<!-- Koalitionsmatrix -->
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">🤝 Koalitionsmatrix</h2>
<p class="text-sm text-gray-500 mb-4">Wie oft stimmen Fraktionen gleich ab? (nur Ja/Nein-Stimmen)</p>
<p class="text-sm text-gray-500 mb-4">Wie oft stimmen Fraktionen gleich ab? Klicke auf eine Zelle für Details.</p>
{#if koalitionsmatrix.length > 0}
{@const allFraktionen = koalitionsmatrix.map(r => r.fraktion).sort()}
<div class="overflow-x-auto -mx-4 sm:mx-0 px-4 sm:px-0">
@ -185,10 +442,11 @@
{:else if row.uebereinstimmung[f2]}
{@const data = row.uebereinstimmung[f2]}
<td class="p-1">
<div class="w-10 h-10 {getColor(data.quote)} {getTextColor(data.quote)} rounded flex items-center justify-center font-medium"
title="{row.fraktion} & {f2}: {data.gleich}/{data.gesamt} ({data.quote}%)">
<button onclick={() => loadVergleich(row.fraktion, f2)}
class="w-10 h-10 {getColor(data.quote)} {getTextColor(data.quote)} rounded flex items-center justify-center font-medium cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-green-500 transition-all"
title="{row.fraktion} & {f2}: {data.gleich}/{data.gesamt} ({data.quote}%)">
{Math.round(data.quote)}
</div>
</button>
</td>
{:else}
<td class="p-1">
@ -203,7 +461,7 @@
</tbody>
</table>
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 sm:gap-4 text-xs text-gray-500">
<span>Legende:</span>
<span class="flex items-center gap-1"><span class="w-4 h-4 bg-green-500 rounded"></span> 90-100%</span>
@ -216,4 +474,97 @@
<p class="text-gray-500">Noch keine Koalitionsdaten verfügbar.</p>
{/if}
</section>
<!-- Fraktionsvergleich -->
{#if vergleichF1 && vergleichF2}
<section id="vergleich-section" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mt-8">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">
⚖️ {vergleichF1} vs. {vergleichF2}
</h2>
<p class="text-sm text-gray-500">{vergleichTotal} gemeinsame Abstimmungen</p>
</div>
<button onclick={clearVergleich}
class="text-sm text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 transition-colors">
✕ Schließen
</button>
</div>
{#if vergleichLoading}
<div class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
</div>
{:else if vergleichItems.length === 0}
<p class="text-gray-500 py-4">Keine gemeinsamen Abstimmungen gefunden.</p>
{:else}
<!-- Desktop -->
<div class="hidden md:block overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktenzeichen</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Betreff</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase">{vergleichF1}</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase">{vergleichF2}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Ergebnis</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{#each vergleichItems as item}
<tr class="hover:bg-gray-50 cursor-pointer" onclick={() => goto(`/vorlagen/${item.vorlage_id}`)}>
<td class="px-3 py-2 text-gray-500 whitespace-nowrap">{item.sitzung_datum || ''}</td>
<td class="px-3 py-2">
<span class="font-mono text-green-700 text-xs bg-green-50 px-1.5 py-0.5 rounded">{item.aktenzeichen || ''}</span>
</td>
<td class="px-3 py-2 text-gray-700 max-w-sm truncate">{item.betreff || ''}</td>
<td class="px-3 py-2 text-center">
<span class="px-2 py-0.5 rounded text-xs font-medium {stimmeColor(item.stimme_f1)}">{item.stimme_f1}</span>
</td>
<td class="px-3 py-2 text-center">
<span class="px-2 py-0.5 rounded text-xs font-medium {stimmeColor(item.stimme_f2)}">{item.stimme_f2}</span>
</td>
<td class="px-3 py-2 text-gray-600 max-w-xs truncate">{item.ergebnis || ''}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Cards -->
<div class="md:hidden space-y-2">
{#each vergleichItems as item}
<a href="/vorlagen/{item.vorlage_id}" class="block border border-gray-200 rounded-lg p-3 hover:shadow-sm transition-shadow">
<div class="flex items-center justify-between mb-1">
<span class="font-mono text-xs text-green-700 bg-green-50 px-1.5 py-0.5 rounded">{item.aktenzeichen || ''}</span>
<span class="text-xs text-gray-500">{item.sitzung_datum || ''}</span>
</div>
<p class="text-sm text-gray-700 line-clamp-2 mb-2">{item.betreff || ''}</p>
<div class="flex items-center gap-3 text-xs">
<span>{vergleichF1}: <span class="font-medium {stimmeColor(item.stimme_f1)} px-1.5 py-0.5 rounded">{item.stimme_f1}</span></span>
<span>{vergleichF2}: <span class="font-medium {stimmeColor(item.stimme_f2)} px-1.5 py-0.5 rounded">{item.stimme_f2}</span></span>
</div>
</a>
{/each}
</div>
<!-- Pagination -->
{#if vergleichTotal > 20}
{@const totalPages = Math.ceil(vergleichTotal / 20)}
<div class="flex justify-center mt-4 space-x-2">
<button disabled={vergleichPage <= 1} onclick={() => loadVergleich(vergleichF1, vergleichF2, vergleichPage - 1)}
class="px-3 py-2 rounded-lg text-sm border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
« Zurück
</button>
<span class="px-3 py-2 text-sm text-gray-600">Seite {vergleichPage} von {totalPages}</span>
<button disabled={vergleichPage >= totalPages} onclick={() => loadVergleich(vergleichF1, vergleichF2, vergleichPage + 1)}
class="px-3 py-2 rounded-lg text-sm border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
Weiter »
</button>
</div>
{/if}
{/if}
</section>
{/if}
{/if}

View File

@ -0,0 +1,830 @@
<script lang="ts">
import { onMount } from 'svelte';
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';
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);
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');
let reevalPhase = $state<string>('');
let reevalError = $state('');
async function triggerReeval() {
if (!selectedVorlage) return;
reevalStatus = 'running';
reevalError = '';
try {
const { job_id } = await reevalVorlage(selectedVorlage.id, reevalAnmerkung);
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000));
const status = await fetchJobStatus(job_id);
reevalPhase = status.phase || '';
if (status.status === 'done') {
reevalStatus = 'done';
selectedVorlage = await fetchVorlage(selectedVorlage!.id);
showReeval = false;
reevalAnmerkung = '';
return;
}
if (status.status === 'error') {
reevalStatus = 'error';
reevalError = status.error || 'Unbekannter Fehler';
return;
}
}
reevalStatus = 'error';
reevalError = 'Timeout nach 3 Minuten';
} catch (e) {
reevalStatus = 'error';
reevalError = e instanceof Error ? e.message : 'Fehler';
}
}
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);
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);
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>
<!-- Umsetzungsgrad -->
{#if selectedKette.umsetzung}
{@const u = selectedKette.umsetzung}
<div class="px-4 pb-3 border-b border-gray-100">
<div class="flex items-center gap-2 mb-1.5">
<div class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold
{u.score >= 0.7 ? 'bg-green-200 text-green-800' : u.score >= 0.4 ? 'bg-amber-200 text-amber-800' : 'bg-red-200 text-red-800'}">
{Math.round((u.score || 0) * 100)}%
</div>
<div>
<div class="text-xs font-semibold {u.score >= 0.7 ? 'text-green-800' : u.score >= 0.4 ? 'text-amber-800' : 'text-red-800'}">
{u.bewertung || (u.score >= 0.7 ? 'Umgesetzt' : u.score >= 0.4 ? 'Teilweise' : 'Kaum umgesetzt')}
</div>
{#if u.kernpunkt_erfuellt !== null && u.kernpunkt_erfuellt !== undefined}
<div class="text-[10px] text-gray-500">
Kernpunkt: {u.kernpunkt_erfuellt ? '✅ erfüllt' : '❌ nicht erfüllt'}
</div>
{/if}
</div>
</div>
{#if u.begruendung}
<p class="text-[11px] text-gray-600 leading-snug">{u.begruendung}</p>
{/if}
{#if u.details}
<p class="text-[10px] text-gray-500 mt-1 leading-snug">{u.details}</p>
{/if}
{#if selectedKette.umsetzung_versionen?.length}
<button onclick={() => showUmsetzungVersionen = !showUmsetzungVersionen}
class="text-[10px] text-gray-400 hover:text-gray-600 mt-2 flex items-center gap-1">
<span>{showUmsetzungVersionen ? '▼' : '▶'}</span>
{selectedKette.umsetzung_versionen.length} vorherige Bewertung{selectedKette.umsetzung_versionen.length > 1 ? 'en' : ''}
</button>
{#if showUmsetzungVersionen}
<div class="mt-2 space-y-2">
{#each selectedKette.umsetzung_versionen as v}
<div class="rounded border border-gray-200 bg-gray-50 p-2">
<div class="flex items-center gap-2 mb-1">
<span class="text-[10px] font-bold {v.score >= 0.7 ? 'text-green-700' : v.score >= 0.4 ? 'text-amber-700' : 'text-red-700'}">
{Math.round((v.score || 0) * 100)}%
</span>
<span class="text-[10px] text-gray-400">{v.erstellt_at || ''}</span>
</div>
<p class="text-[10px] text-gray-500">{v.begruendung}</p>
</div>
{/each}
</div>
{/if}
{/if}
</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>
<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>
<div class="flex flex-wrap items-center gap-1 mb-0.5">
{#if glied.rolle}
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
{glied.rolle}
</span>
{/if}
{#each glied.antragsteller || [] as a}
<span class="text-[10px] px-1 py-0.5 rounded font-medium"
style="background-color: {a.farbe || '#6b7280'}20; color: {a.farbe || '#6b7280'}; border: 1px solid {a.farbe || '#6b7280'}40;">
{a.kuerzel}
</span>
{/each}
</div>
{#each glied.beratungen || [] as b}
{#if b.beschlusstext || b.ergebnis}
<div class="text-[10px] text-gray-500 leading-tight">
<span class="font-medium">{b.gremium || '?'}</span>: {b.beschlusstext || b.ergebnis || ''}
</div>
{/if}
{/each}
{#if glied.vorlage.datum_eingang}
<div class="text-[10px] text-gray-400 mt-0.5">
{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 → jetzt in der Ketten-Ansicht (Panel 2) -->
<!-- Neu bewerten -->
<div class="rounded-xl border border-gray-200 p-4">
{#if !showReeval}
<button onclick={() => showReeval = true}
class="text-sm text-green-600 hover:text-green-800 font-medium flex items-center gap-1.5">
<span>🔄</span> Neu bewerten lassen
</button>
{:else}
<h3 class="text-sm font-semibold text-gray-900 mb-2">KI-Neubewertung anstoßen</h3>
<textarea bind:value={reevalAnmerkung} placeholder="Anmerkungen für die KI (optional)"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-3 h-20 resize-y focus:ring-2 focus:ring-green-500"
disabled={reevalStatus === 'running'}></textarea>
<div class="flex gap-2 items-center">
<button onclick={triggerReeval} disabled={reevalStatus === 'running'}
class="bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-green-700 disabled:opacity-50 transition-colors">
{#if reevalStatus === 'running'}
<span class="inline-flex items-center gap-2">
<span class="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></span>
{#if reevalPhase === 'rescrape'}
📡 Daten aktualisieren…
{:else if reevalPhase === 'ki_bewertung'}
🤖 KI bewertet…
{:else}
KI bewertet…
{/if}
</span>
{:else}
Bewertung starten
{/if}
</button>
{#if reevalStatus !== 'running'}
<button onclick={() => { showReeval = false; reevalStatus = 'idle'; }}
class="text-sm text-gray-500 hover:text-gray-700">Abbrechen</button>
{/if}
</div>
{#if reevalStatus === 'done'}
<p class="mt-2 text-sm text-green-700 font-medium">✅ Bewertung aktualisiert!</p>
{/if}
{#if reevalStatus === 'error'}
<p class="mt-2 text-sm text-red-600">{reevalError}</p>
{/if}
{/if}
</div>
<!-- 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>

View File

@ -5,6 +5,7 @@
import { KATEGORIEN } from '$lib/umsetzung';
import { formatDate } from '$lib/status';
import { onMount } from 'svelte';
import { filters, filterVersion } from '$lib/filters.svelte';
let data = $state<FraktionDashboard | null>(null);
let loading = $state(true);
@ -17,7 +18,8 @@
loading = true;
error = null;
try {
data = await fetchFraktionDashboard(kuerzel, selectedJahr || undefined);
const periode = filters.perioden.length > 0 ? filters.perioden.join(',') : undefined;
data = await fetchFraktionDashboard(kuerzel, selectedJahr || undefined, periode);
} catch (e) {
error = (e as Error).message;
}
@ -26,8 +28,9 @@
onMount(loadData);
// Reload when filters change
// Reload when filters change (including global filters)
$effect(() => {
filterVersion(); // track global filter changes
if (kuerzel) loadData();
});

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

@ -23,6 +23,7 @@
let selectedOrt = $state<Ort | null>(null);
let selectedVorlagen = $state<Vorlage[]>([]);
let loading = $state(true);
let markerCount = $state(0);
let map: any = null;
const API_BASE = typeof window !== 'undefined'
@ -65,9 +66,11 @@
await loadOrte();
// Leaflet dynamisch laden
// Leaflet + MarkerCluster dynamisch laden
const L = await import('leaflet');
await import('leaflet/dist/leaflet.css');
await import('leaflet.markercluster');
// MarkerCluster CSS via CDN (im head unten)
// Map initialisieren
map = L.map('map').setView(HAGEN_CENTER, HAGEN_ZOOM);
@ -76,16 +79,41 @@
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
// Marker hinzufügen
// MarkerClusterGroup mit Performance-Optionen
const clusterGroup = (L as any).markerClusterGroup({
chunkedLoading: true,
chunkInterval: 100,
chunkDelay: 10,
maxClusterRadius: 50,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
disableClusteringAtZoom: 17,
iconCreateFunction: function(cluster: any) {
const count = cluster.getChildCount();
let size = 'small';
let px = 30;
if (count >= 50) { size = 'large'; px = 50; }
else if (count >= 10) { size = 'medium'; px = 40; }
return L.divIcon({
html: `<div class="cluster-icon cluster-${size}">${count}</div>`,
className: 'custom-cluster',
iconSize: L.point(px, px)
});
}
});
// Marker in Batches hinzufügen (verhindert UI-Freeze)
for (const ort of orte) {
const radius = Math.min(6 + ort.vorlage_count * 1.5, 18);
const marker = L.circleMarker([ort.lat, ort.lon], {
radius: Math.min(8 + ort.vorlage_count * 2, 20),
fillColor: '#16a34a',
radius,
fillColor: getColor(ort.vorlage_count),
color: '#166534',
weight: 2,
opacity: 1,
weight: 1.5,
opacity: 0.9,
fillOpacity: 0.7
}).addTo(map);
});
marker.bindPopup(`
<strong>${ort.name}</strong><br>
@ -93,13 +121,26 @@
`);
marker.on('click', () => selectOrt(ort));
clusterGroup.addLayer(marker);
}
map.addLayer(clusterGroup);
markerCount = orte.length;
});
function getColor(count: number): string {
if (count >= 5) return '#dc2626'; // rot - viele Vorlagen
if (count >= 3) return '#f59e0b'; // orange
if (count >= 2) return '#16a34a'; // grün
return '#3b82f6'; // blau - eine Vorlage
}
</script>
<svelte:head>
<title>Karte - Antragstracker Hagen</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
</svelte:head>
<div class="mb-6">
@ -120,8 +161,20 @@
{/if}
</div>
<div class="mt-4 text-sm text-gray-500">
{orte.length} Orte geocodiert • Marker-Größe = Anzahl Vorlagen
<div class="mt-4 flex items-center gap-4 text-sm text-gray-500">
<span>{markerCount} Orte auf der Karte</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-blue-500"></span> 1
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-green-600"></span> 2
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-amber-500"></span> 3-4
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block w-3 h-3 rounded-full bg-red-600"></span> 5+
</span>
</div>
</div>
@ -176,3 +229,33 @@
</div>
</div>
</div>
<style>
:global(.custom-cluster) {
background: transparent !important;
}
:global(.cluster-icon) {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: white;
font-weight: 700;
font-size: 13px;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
:global(.cluster-small) {
background: rgba(22, 163, 74, 0.85);
width: 30px; height: 30px;
}
:global(.cluster-medium) {
background: rgba(245, 158, 11, 0.85);
width: 40px; height: 40px;
font-size: 14px;
}
:global(.cluster-large) {
background: rgba(220, 38, 38, 0.85);
width: 50px; height: 50px;
font-size: 15px;
}
</style>

View File

@ -5,6 +5,7 @@
import { fetchKetten, fetchFraktionen, type KetteKurz, type Paginated } from '$lib/api';
import { formatDate } from '$lib/status';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
let data: Paginated<KetteKurz> | null = $state(null);
let error: string | null = $state(null);
@ -30,11 +31,12 @@
async function load() {
loading = true;
try {
const params: Record<string, string> = { page: String(currentPage), page_size: '30' };
let params: Record<string, string> = { page: String(currentPage), page_size: '30' };
if (filterStatus) params.status = filterStatus;
if (filterTyp) params.typ = filterTyp;
if (filterSuche) params.suche = filterSuche;
if (filterPartei) params.partei = filterPartei;
params = mergeFilterParams(params);
data = await fetchKetten(params);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
@ -68,6 +70,13 @@
syncFromUrl();
load();
});
// Reload when global filters change
$effect(() => {
filterVersion();
currentPage = 1;
load();
});
</script>
<svelte:head>

View File

@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { fetchVorlagen, fetchFraktionen, fetchSuchvorschlaege, type VorlageKurz, type Paginated, type SuchVorschlag } from '$lib/api';
import { formatDate } from '$lib/status';
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
let data: Paginated<VorlageKurz & { antragsteller?: { kuerzel: string; name: string; farbe: string | null }[] }> | null = $state(null);
let parteien = $state<{ kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]);
@ -88,10 +89,11 @@
async function load() {
loading = true;
try {
const params: Record<string, string> = { page: String(currentPage), page_size: '50' };
let params: Record<string, string> = { page: String(currentPage), page_size: '50' };
if (filterTyp) params.typ = filterTyp;
if (filterSuche) params.suche = filterSuche;
if (filterPartei) params.partei = filterPartei;
params = mergeFilterParams(params);
data = await fetchVorlagen(params);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
@ -124,6 +126,13 @@
syncFromUrl();
load();
});
// Reload when global filters change
$effect(() => {
filterVersion();
currentPage = 1;
load();
});
</script>
<svelte:head>

View File

@ -9,6 +9,7 @@
let error: string | null = $state(null);
let showVolltext = $state(false);
let showReeval = $state(false);
let showVersionen = $state(false);
let reevalAnmerkung = $state('');
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
let reevalError = $state('');
@ -201,6 +202,33 @@
</div>
{/if}
<!-- Vorherige KI-Versionen -->
{#if vorlage.ki_versionen?.length}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<button onclick={() => showVersionen = !showVersionen}
class="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1.5">
<span>{showVersionen ? '▼' : '▶'}</span>
{vorlage.ki_versionen.length} vorherige KI-Version{vorlage.ki_versionen.length > 1 ? 'en' : ''}
</button>
{#if showVersionen}
<div class="mt-3 space-y-3">
{#each vorlage.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 {vorlage.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 vorlage.umsetzungsbewertungen?.length}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">

View File

@ -1,78 +1,148 @@
#!/bin/bash
# Deploy DB zum VServer
# Usage: ./scripts/deploy-db.sh [--dry-run]
set -e
# Usage: ./scripts/deploy-db.sh [--dry-run] [--migrate-only] [--skip-migrate]
#
# Modes:
# (default) Upload DB + run migrations + restart container
# --dry-run Nur anzeigen, nichts ändern
# --migrate-only Nur Migrationen auf Remote-DB ausführen (kein Upload)
# --skip-migrate DB hochladen, aber keine Migrationen
#
# Migrationen: FTS5, Strang, Fristen
LOCAL_DB="data/tracker_remote.db"
set -euo pipefail
LOCAL_DB="data/tracker.db"
REMOTE_DB="/opt/antragstracker/data/tracker.db"
REMOTE_HOST="vserver"
CONTAINER="antragstracker-hagen"
DRY_RUN=false
MIGRATE_ONLY=false
SKIP_MIGRATE=false
if [ "$1" = "--dry-run" ]; then
DRY_RUN=true
echo "🔍 DRY RUN — keine Änderungen"
echo ""
fi
for arg in "$@"; do
case $arg in
--dry-run) DRY_RUN=true ;;
--migrate-only) MIGRATE_ONLY=true ;;
--skip-migrate) SKIP_MIGRATE=true ;;
-h|--help)
echo "Usage: $0 [--dry-run] [--migrate-only] [--skip-migrate]"
exit 0 ;;
*) echo "Unknown arg: $arg"; exit 1 ;;
esac
done
cd "$(dirname "$0")/.."
echo "=== DB Deploy ==="
echo "Lokale DB: $(du -sh $LOCAL_DB | cut -f1)"
# 0. Schema-Validierung
echo "0. Schema prüfen..."
EXPECTED_TABLES="vorlagen beratungen ketten ketten_glieder ki_bewertungen parteien antragsteller gremien orte referenzen"
MISSING=""
for tbl in $EXPECTED_TABLES; do
EXISTS=$(sqlite3 "$LOCAL_DB" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$tbl'" 2>/dev/null)
if [ "$EXISTS" != "1" ]; then
MISSING="$MISSING $tbl"
# --- Schema-Validierung (nur bei Upload) ---
if [ "$MIGRATE_ONLY" = false ]; then
# Fallback zu tracker_remote.db wenn tracker.db nicht existiert
if [ ! -f "$LOCAL_DB" ]; then
if [ -f "data/tracker_remote.db" ]; then
LOCAL_DB="data/tracker_remote.db"
echo " → Nutze data/tracker_remote.db"
else
echo "❌ Keine lokale DB gefunden (data/tracker.db oder data/tracker_remote.db)"
exit 1
fi
fi
done
if [ -n "$MISSING" ]; then
echo "❌ FEHLER: Fehlende Tabellen:$MISSING"
exit 1
fi
echo " ✓ Alle ${#EXPECTED_TABLES[@]} Tabellen vorhanden"
echo "Lokale DB: $(du -sh $LOCAL_DB | cut -f1)"
# Stats
echo ""
echo " Vorlagen: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM vorlagen')"
echo " Ketten: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM ketten')"
echo " KI-Bew.: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM ki_bewertungen')"
echo " Matches: $(sqlite3 "$LOCAL_DB" "SELECT COUNT(*) FROM ki_bewertungen WHERE typ='umsetzung_match'")"
echo " Orte: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM orte WHERE lat IS NOT NULL')"
echo ""
echo "Schema prüfen..."
EXPECTED_TABLES="vorlagen beratungen ketten ketten_glieder ki_bewertungen parteien antragsteller gremien orte referenzen"
MISSING=""
for tbl in $EXPECTED_TABLES; do
EXISTS=$(sqlite3 "$LOCAL_DB" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='$tbl'" 2>/dev/null)
if [ "$EXISTS" != "1" ]; then
MISSING="$MISSING $tbl"
fi
done
# Flush WAL journal
echo " Flushing WAL..."
sqlite3 "$LOCAL_DB" "PRAGMA wal_checkpoint(TRUNCATE)" 2>/dev/null
echo ""
if [ -n "$MISSING" ]; then
echo "❌ Fehlende Tabellen:$MISSING"
exit 1
fi
echo " ✓ Alle Tabellen vorhanden"
if $DRY_RUN; then
echo "🔍 Würde hochladen: $(du -sh $LOCAL_DB | cut -f1) nach $REMOTE_HOST:$REMOTE_DB"
echo "🔍 Prod-DB Größe: $(ssh $REMOTE_HOST "du -sh $REMOTE_DB 2>/dev/null | cut -f1" || echo "N/A")"
# Stats
echo ""
echo "DRY RUN fertig. Ohne --dry-run ausführen zum Deployen."
echo " Vorlagen: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM vorlagen')"
echo " Ketten: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM ketten')"
echo " KI-Bew.: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM ki_bewertungen')"
echo " Orte: $(sqlite3 "$LOCAL_DB" 'SELECT COUNT(*) FROM orte WHERE lat IS NOT NULL')"
echo ""
# Flush WAL journal
echo " WAL flushen..."
sqlite3 "$LOCAL_DB" "PRAGMA wal_checkpoint(TRUNCATE)" 2>/dev/null
fi
if [ "$DRY_RUN" = true ]; then
echo ""
echo "🔍 DRY RUN"
if [ "$MIGRATE_ONLY" = false ]; then
echo " Würde hochladen: $(du -sh $LOCAL_DB | cut -f1) nach $REMOTE_HOST:$REMOTE_DB"
echo " Prod-DB: $(ssh $REMOTE_HOST "du -sh $REMOTE_DB 2>/dev/null | cut -f1" || echo "N/A")"
fi
if [ "$SKIP_MIGRATE" = false ]; then
echo " Migrationen: FTS5, Strang, Fristen"
fi
echo ""
echo "DRY RUN fertig."
exit 0
fi
# 1. Backup auf VServer
BACKUP_NAME="${REMOTE_DB}.bak-$(date +%Y%m%d-%H%M)"
echo "1. Backup Prod-DB..."
ssh $REMOTE_HOST "cp $REMOTE_DB $BACKUP_NAME"
echo "$BACKUP_NAME"
# --- Upload ---
if [ "$MIGRATE_ONLY" = false ]; then
# Backup auf VServer
BACKUP_NAME="${REMOTE_DB}.bak-$(date +%Y%m%d-%H%M)"
echo "1. Backup Prod-DB..."
ssh $REMOTE_HOST "cp $REMOTE_DB $BACKUP_NAME 2>/dev/null || echo ' (keine bestehende DB)'"
echo "$BACKUP_NAME"
# 2. Upload
echo "2. Upload..."
scp "$LOCAL_DB" "$REMOTE_HOST:$REMOTE_DB"
# Container stoppen für sicheren DB-Write
echo "2. Container stoppen..."
ssh $REMOTE_HOST "cd /opt/antragstracker && docker compose stop antragstracker" 2>&1 | grep -v "level=warning" || true
# 3. Restart
echo "3. Restart Backend..."
ssh $REMOTE_HOST 'cd /opt/antragstracker && docker compose restart antragstracker' 2>&1 | grep -v "level=warning"
echo "3. Upload..."
scp "$LOCAL_DB" "$REMOTE_HOST:$REMOTE_DB"
echo " ✓ Upload fertig"
fi
# --- Migrationen ---
if [ "$SKIP_MIGRATE" = false ]; then
echo "4. Migrationen ausführen..."
# Migrationen im Docker-Container auf dem VServer
ssh $REMOTE_HOST "cd /opt/antragstracker && docker compose run --rm --no-deps antragstracker python scripts/migrate_fts5.py /app/data/tracker.db" 2>&1 | grep -v "level=warning" || true
echo " ✓ FTS5"
ssh $REMOTE_HOST "cd /opt/antragstracker && docker compose run --rm --no-deps antragstracker python scripts/migrate_strang.py" 2>&1 | grep -v "level=warning" || true
echo " ✓ Strang"
ssh $REMOTE_HOST "cd /opt/antragstracker && docker compose run --rm --no-deps antragstracker python scripts/migrate_fristen.py" 2>&1 | grep -v "level=warning" || true
echo " ✓ Fristen"
fi
# --- Container starten ---
echo "5. Container starten..."
ssh $REMOTE_HOST "cd /opt/antragstracker && docker compose up -d" 2>&1 | grep -v "level=warning" || true
# --- Health-Check ---
sleep 5
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://antraege.toppyr.de/api/health" 2>/dev/null || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo " ✓ Health-Check OK"
else
echo " ⚠️ Health-Check: HTTP $HTTP_STATUS"
fi
echo ""
echo "✅ Deploy fertig"
echo "Backup: $BACKUP_NAME"
echo "Rollback: ssh $REMOTE_HOST 'cp $BACKUP_NAME $REMOTE_DB && cd /opt/antragstracker && docker compose restart antragstracker'"
echo "✅ DB-Deploy fertig"
if [ "$MIGRATE_ONLY" = false ]; then
echo "Backup: $BACKUP_NAME"
echo "Rollback: ssh $REMOTE_HOST 'cp $BACKUP_NAME $REMOTE_DB && cd /opt/antragstracker && docker compose restart antragstracker'"
fi

113
scripts/deploy.sh Executable file
View File

@ -0,0 +1,113 @@
#!/bin/bash
# Deploy Antragstracker auf VServer
# Usage: ./scripts/deploy.sh [--build-only] [--full] [--skip-frontend]
#
# Modes:
# (default) Build + deploy code, restart container
# --build-only Build Docker image on server, don't restart
# --full Full deploy: frontend build + code sync + docker rebuild + restart
# --skip-frontend Skip local frontend build (use existing build/)
#
# Voraussetzung: ssh vserver funktioniert
set -euo pipefail
VSERVER="vserver"
REMOTE_DIR="/opt/antragstracker"
CONTAINER="antragstracker-hagen"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Parse args
BUILD_ONLY=false
SKIP_FRONTEND=false
for arg in "$@"; do
case $arg in
--build-only) BUILD_ONLY=true ;;
--full) ;; # default behavior
--skip-frontend) SKIP_FRONTEND=true ;;
-h|--help)
echo "Usage: $0 [--build-only] [--full] [--skip-frontend]"
exit 0 ;;
*) echo "Unknown arg: $arg"; exit 1 ;;
esac
done
cd "$PROJECT_DIR"
# 1. Frontend bauen (lokal)
if [ "$SKIP_FRONTEND" = false ]; then
echo "🔨 Frontend bauen..."
cd frontend && npm run build && cd ..
echo " ✓ Frontend build fertig ($(du -sh frontend/build | cut -f1))"
else
echo "⏭️ Frontend-Build übersprungen"
fi
# 2. Code zum VServer synchronisieren
echo "📦 Code synchronisieren..."
tar czf /tmp/antragstracker-deploy.tar.gz \
--exclude='.git' \
--exclude='.venv' \
--exclude='venv' \
--exclude='node_modules' \
--exclude='frontend/.svelte-kit' \
--exclude='data' \
--exclude='*.log' \
--exclude='*.pyc' \
--exclude='__pycache__' \
--exclude='.DS_Store' \
--exclude='.pytest_cache' \
--exclude='tracker.db*' \
--exclude='antraege.db' \
--exclude='.github' \
--exclude='logs' \
-C "$(dirname "$PROJECT_DIR")" "$(basename "$PROJECT_DIR")"
scp /tmp/antragstracker-deploy.tar.gz "$VSERVER:/tmp/"
ssh "$VSERVER" "cd /opt && tar xzf /tmp/antragstracker-deploy.tar.gz && rm /tmp/antragstracker-deploy.tar.gz"
rm -f /tmp/antragstracker-deploy.tar.gz
echo " ✓ Code synchronisiert"
# 3. Docker Image bauen (auf VServer)
echo "🐳 Docker Image bauen auf VServer..."
ssh "$VSERVER" "cd $REMOTE_DIR && docker compose build --no-cache"
echo " ✓ Image gebaut"
if [ "$BUILD_ONLY" = true ]; then
echo "⏹️ Build-only Mode — Container wird nicht neu gestartet"
exit 0
fi
# 4. Container neu starten
echo "🔄 Container neu starten..."
ssh "$VSERVER" "cd $REMOTE_DIR && docker compose up -d"
echo " ✓ Container gestartet"
# 5. Warten + Health-Check
echo "⏳ Warte auf Startup (5s)..."
sleep 5
echo "🏥 Health-Check..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://antraege.toppyr.de/api/health" 2>/dev/null || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo " ✓ Health-Check OK (HTTP $HTTP_STATUS)"
elif [ "$HTTP_STATUS" = "000" ]; then
sleep 5
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://antraege.toppyr.de/api/health" 2>/dev/null || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo " ✓ Health-Check OK (HTTP $HTTP_STATUS, 2. Versuch)"
else
echo " ⚠️ Health-Check fehlgeschlagen (HTTP $HTTP_STATUS)"
echo " Logs prüfen: ssh $VSERVER 'docker logs --tail 20 $CONTAINER'"
fi
else
echo " ⚠️ Health-Check: HTTP $HTTP_STATUS"
echo " Logs prüfen: ssh $VSERVER 'docker logs --tail 20 $CONTAINER'"
fi
echo ""
echo "✅ Deploy fertig!"
echo " URL: https://antraege.toppyr.de"
echo " Logs: ssh $VSERVER 'docker logs --tail 50 $CONTAINER'"

View File

@ -1,92 +1,196 @@
#!/usr/bin/env python3
"""Geocodiert pending Orte via Nominatim (1 req/s)."""
"""Geocodiert pending Orte via Nominatim (1 req/s, Hagen-fokussiert)."""
import argparse
import re
import sqlite3
import sys
import time
from pathlib import Path
from typing import Optional, Tuple
import httpx
DB = Path(__file__).resolve().parent.parent / "data" / "tracker_remote.db"
DB = Path(__file__).resolve().parent.parent / "data" / "tracker.db"
NOMINATIM = "https://nominatim.openstreetmap.org/search"
UA = "Antragstracker-Hagen/1.0 (tobias.roedel@econgood.org)"
HAGEN_BBOX = "7.35,51.30,7.65,51.45"
# Orte die zu generisch sind für sinnvolles Geocoding
SKIP_PATTERNS = [
r"^hagen$", r"^hagen,?\s*(nordrhein-westfalen|nrw)$",
r"^stadtgebiet", r"^gesamtes?\s+stadtgebiet",
r"^(bab|a)\s*\d", r"^bundesstraße\s+\d", r"^b\s*\d+$",
r"^(alle|diverse|verschiedene)\s+", r"^(stadt|kreis)\s+hagen$",
]
def should_skip(name: str) -> bool:
"""Orte überspringen die nicht sinnvoll geocodierbar sind."""
clean = name.strip().lower()
for pat in SKIP_PATTERNS:
if re.search(pat, clean):
return True
# Zu kurz / generisch
if len(clean) < 3:
return True
return False
def normalize_query(name: str) -> str:
"""Ortsnamen für Nominatim aufbereiten."""
clean = name.strip()
# Trailing "Hagen" entfernen um Duplikation zu vermeiden
clean = re.sub(r',?\s*Hagen\s*$', '', clean, flags=re.IGNORECASE).strip().rstrip(',').strip()
# "Hagen-" Prefix bei Stadtteilen behalten
if clean.lower().startswith('hagen-'):
clean = clean[6:].strip() + ', Hagen'
return clean
def geocode(client: httpx.Client, name: str) -> Optional[Tuple[float, float]]:
# Clean name: remove trailing "Hagen" to avoid duplication
clean = name.strip().rstrip(",").strip()
if clean.lower().endswith(" hagen"):
clean = clean[:-6].strip().rstrip(",").strip()
# Try multiple query variants, progressively less strict
"""Versuche einen Ort in Hagen zu geocodieren."""
clean = normalize_query(name)
queries = [
(f"{clean}, Hagen, Germany", True), # bounded to Hagen
(f"{clean}, Hagen, NRW", False), # unbounded fallback
(f"{name}, Germany", False), # original name
# Strikt in Hagen Bounding Box
(f"{clean}, Hagen", {"viewbox": HAGEN_BBOX, "bounded": 1}),
# Etwas lockerer
(f"{clean}, Hagen, NRW, Germany", {}),
# Originalname als Fallback
(f"{name}, Germany", {}),
]
for q, bounded in queries:
for q, extra_params in queries:
params = {"q": q, "format": "json", "limit": 1, "addressdetails": 1}
params.update(extra_params)
try:
params = {"q": q, "format": "json", "limit": 1}
if bounded:
params["viewbox"] = HAGEN_BBOX
params["bounded"] = 1
r = client.get(NOMINATIM, params=params,
headers={"User-Agent": UA}, timeout=10)
r = client.get(
NOMINATIM, params=params,
headers={"User-Agent": UA}, timeout=10
)
if r.status_code == 200 and r.json():
d = r.json()[0]
lat, lon = float(d["lat"]), float(d["lon"])
# Sanity check: roughly in Hagen area
# Sanity: muss grob in Hagen-Region liegen
if 51.25 <= lat <= 51.50 and 7.30 <= lon <= 7.70:
return lat, lon
except Exception:
pass
time.sleep(3.0) # Conservative: Nominatim blocks aggressively
time.sleep(1.1) # Nominatim Policy: 1 req/s
return None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=500)
parser = argparse.ArgumentParser(description="Geocode pending Orte in tracker.db")
parser.add_argument("--limit", type=int, default=500,
help="Max Orte pro Durchlauf (Default: 500)")
parser.add_argument("--retry-failed", action="store_true",
help="Auch fehlgeschlagene Orte erneut versuchen")
parser.add_argument("--dry-run", action="store_true",
help="Nur anzeigen, nichts schreiben")
args = parser.parse_args()
conn = sqlite3.connect(str(DB))
conn.row_factory = sqlite3.Row
# Status-Filter
status_filter = "geocode_status='pending'"
if args.retry_failed:
status_filter = "geocode_status IN ('pending','failed')"
# Erst generische Orte skippen
generics = conn.execute(
f"SELECT id, name FROM orte WHERE {status_filter}"
).fetchall()
skipped = 0
for row in generics:
if should_skip(row["name"]):
if not args.dry_run:
conn.execute(
"UPDATE orte SET geocode_status='skipped' WHERE id=?",
(row["id"],)
)
skipped += 1
if skipped:
conn.commit()
print(f"⏭️ {skipped} generische Orte übersprungen")
# Dann die geocodierbaren holen
pending = conn.execute(
"SELECT id, name FROM orte WHERE geocode_status='pending' ORDER BY vorlage_count DESC LIMIT ?",
f"SELECT id, name FROM orte WHERE {status_filter} "
"ORDER BY vorlage_count DESC, id LIMIT ?",
(args.limit,)
).fetchall()
total_pending = conn.execute("SELECT COUNT(*) FROM orte WHERE geocode_status='pending'").fetchone()[0]
print(f"Pending: {len(pending)} (von {total_pending} total)")
total_pending = conn.execute(
f"SELECT COUNT(*) FROM orte WHERE {status_filter}"
).fetchone()[0]
print(f"📍 Geocoding: {len(pending)} von {total_pending} pending Orten (Limit: {args.limit})")
if args.dry_run:
for row in pending[:20]:
print(f"{row['name']}")
return
success = 0
failed = 0
client = httpx.Client()
for i, row in enumerate(pending):
coords = geocode(client, row["name"])
if coords:
conn.execute("UPDATE orte SET lat=?, lon=?, geocode_status='success' WHERE id=?",
(coords[0], coords[1], row["id"]))
success += 1
if success % 20 == 0:
print(f" [{i+1}/{len(pending)}] ✓ {success} geocoded, ✗ {failed} failed")
else:
conn.execute("UPDATE orte SET geocode_status='failed' WHERE id=?", (row["id"],))
failed += 1
if (i + 1) % 50 == 0:
conn.commit()
conn.commit()
conn.close()
client.close()
total_geo = success # just this run
print(f"\n{success} | ✗ {failed} | Gesamt: {success + failed}")
start = time.time()
try:
for i, row in enumerate(pending):
coords = geocode(client, row["name"])
if coords:
conn.execute(
"UPDATE orte SET lat=?, lon=?, geocode_status='success' WHERE id=?",
(coords[0], coords[1], row["id"])
)
success += 1
sym = ""
else:
conn.execute(
"UPDATE orte SET geocode_status='failed' WHERE id=?",
(row["id"],)
)
failed += 1
sym = ""
elapsed = time.time() - start
rate = (i + 1) / elapsed if elapsed > 0 else 0
print(
f" [{i+1:4d}/{len(pending)}] {sym} {row['name'][:50]:<50s} "
f"(✓{success}{failed} | {rate:.1f}/s)",
end="\r"
)
# Periodisch committen
if (i + 1) % 25 == 0:
conn.commit()
except KeyboardInterrupt:
print("\n⚠️ Abgebrochen!")
finally:
conn.commit()
conn.close()
client.close()
elapsed = time.time() - start
print(f"\n\n{'='*60}")
print(f"✅ Fertig in {elapsed:.0f}s")
print(f"{success} geocodiert")
print(f"{failed} fehlgeschlagen")
print(f" ⏭️ {skipped} übersprungen")
# Gesamtstatus
conn2 = sqlite3.connect(str(DB))
stats = conn2.execute(
"SELECT geocode_status, COUNT(*) FROM orte GROUP BY geocode_status"
).fetchall()
conn2.close()
print(f"\n📊 Gesamt:")
for status, count in stats:
print(f" {status}: {count}")
if __name__ == "__main__":

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)

64
scripts/migrate_strang.py Normal file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Migration: Add 'strang' column to ketten and populate based on Ursprungs-Vorlage typ."""
import sqlite3
import sys
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / "data" / "tracker.db"
VORLAGE_TYP_TO_STRANG = {
"antrag": "antrag",
"anfrage": "anfrage",
"beschlussvorlage": "beschlussvorlage",
"mitteilungsvorlage": "mitteilung",
}
def migrate(db_path: Path = DB_PATH):
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
# Check if column already exists
cols = [r[1] for r in conn.execute("PRAGMA table_info(ketten)").fetchall()]
if "strang" not in cols:
print("Adding 'strang' column to ketten...")
conn.execute("ALTER TABLE ketten ADD COLUMN strang TEXT")
conn.commit()
print("Column added.")
else:
print("Column 'strang' already exists.")
# Populate strang based on Ursprungs-Vorlage typ
rows = conn.execute("""
SELECT k.id, v.typ as vorlage_typ
FROM ketten k
LEFT JOIN vorlagen v ON k.ursprung_id = v.id
WHERE k.strang IS NULL
""").fetchall()
if not rows:
print("All ketten already have strang set.")
conn.close()
return
print(f"Updating {len(rows)} ketten...")
counts = {}
for r in rows:
vorlage_typ = r["vorlage_typ"] or ""
strang = VORLAGE_TYP_TO_STRANG.get(vorlage_typ, "sonstig")
conn.execute("UPDATE ketten SET strang = ? WHERE id = ?", (strang, r["id"]))
counts[strang] = counts.get(strang, 0) + 1
conn.commit()
conn.close()
print("Done. Counts per strang:")
for strang, count in sorted(counts.items()):
print(f" {strang}: {count}")
if __name__ == "__main__":
db = Path(sys.argv[1]) if len(sys.argv) > 1 else DB_PATH
migrate(db)

617
scripts/sync_oparl.py Normal file
View File

@ -0,0 +1,617 @@
#!/usr/bin/env python3
"""
OParl-Sync für den Antragstracker Hagen.
Führt einen inkrementellen Sync durch:
1. Neue Papers von der OParl-API importieren
2. Beratungsfolge für neue Vorlagen scrapen (ALLRIS)
3. Neue Vorlagen in Ketten einordnen (Suffix-Matching)
4. Status-Engine für betroffene Ketten laufen lassen
5. FTS5-Index aktualisieren
6. Zusammenfassung ausgeben
Nutzung:
python scripts/sync_oparl.py # Normaler Sync
python scripts/sync_oparl.py --dry-run # Nur zeigen was passieren würde
python scripts/sync_oparl.py --full # Vollständiger Re-Import
Cron-fähig: Exit 0 bei Erfolg, Logging nach stdout.
# Täglich 06:00: OParl-Sync
# 0 6 * * * cd /path/to/antragstracker && PYTHONPATH=src python3 scripts/sync_oparl.py >> data/sync.log 2>&1
"""
import argparse
import functools
import json
import sqlite3
import sys
import time
from datetime import datetime
from pathlib import Path
# Unbuffered print
print = functools.partial(print, flush=True)
# Projekt-Root
PROJECT_ROOT = Path(__file__).resolve().parent.parent
DB_PATH = PROJECT_ROOT / "data" / "tracker.db"
SYNC_STATE_PATH = PROJECT_ROOT / "data" / "sync_state.json"
# Füge src zum Path hinzu für tracker-Imports
sys.path.insert(0, str(PROJECT_ROOT / "backend" / "src"))
def get_db() -> sqlite3.Connection:
"""Öffne DB-Verbindung."""
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA foreign_keys = ON")
return conn
def load_sync_state() -> dict:
"""Lade letzten Sync-State."""
if SYNC_STATE_PATH.exists():
try:
return json.loads(SYNC_STATE_PATH.read_text())
except Exception:
pass
return {}
def save_sync_state(state: dict):
"""Speichere Sync-State."""
SYNC_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
SYNC_STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False))
# ──────────────────────────────────────────────
# Phase 1: Inkrementeller OParl-Import
# ──────────────────────────────────────────────
def phase_import(conn: sqlite3.Connection, dry_run: bool = False, full: bool = False) -> list[int]:
"""Importiere neue Papers von der OParl-API.
Returns: Liste der neuen vorlage_ids.
"""
# Importiere die OParl-Parsing-Logik
sys.path.insert(0, str(PROJECT_ROOT / "scripts"))
import httpx
from import_oparl import (
PAPERS_URL,
fetch_page,
upsert_paper,
upsert_consultations,
insert_files,
build_suffix_references,
)
client = httpx.Client(
headers={"Accept": "application/json"},
follow_redirects=True,
)
try:
# Seitenanzahl ermitteln
first = fetch_page(client, PAPERS_URL, {"body": 1, "page": 1})
if not first or "pagination" not in first:
print(" FEHLER: Konnte OParl-API nicht erreichen")
return []
total_pages = first["pagination"]["totalPages"]
total_elements = first["pagination"]["totalElements"]
existing = conn.execute("SELECT COUNT(*) FROM vorlagen").fetchone()[0]
print(f" API: {total_elements} Papers auf {total_pages} Seiten, DB: {existing}")
if dry_run:
# Im Dry-Run: Nur erste Seite checken
new_ids = []
for paper in first.get("data", []):
oparl_id = paper.get("id")
if oparl_id:
exists = conn.execute(
"SELECT id FROM vorlagen WHERE oparl_id = ?", (oparl_id,)
).fetchone()
if not exists:
ref = paper.get("reference", "?")
name = paper.get("name", "?")[:60]
print(f" NEU: {ref}{name}")
new_ids.append(-1) # Placeholder
return new_ids
# Inkrementeller Import: von Seite 1, stoppe bei bekannten
new_ids = []
consecutive_known = 0
max_pages = total_pages if full else total_pages # Bei full alle Seiten
for page_num in range(1, max_pages + 1):
if page_num == 1:
data = first
else:
data = fetch_page(client, PAPERS_URL, {"body": 1, "page": page_num})
if not data or "data" not in data:
continue
page_new = 0
for paper in data["data"]:
vorlage_id, is_new = upsert_paper(conn, paper)
if vorlage_id:
upsert_consultations(conn, vorlage_id, paper)
insert_files(conn, vorlage_id, paper)
if is_new:
new_ids.append(vorlage_id)
page_new += 1
conn.commit()
if page_new > 0:
print(f" Seite {page_num}/{total_pages}: +{page_new} neue")
consecutive_known = 0
else:
consecutive_known += 1
# Inkrementell: nach 3 Seiten ohne neue aufhören
if not full and consecutive_known >= 3:
print(f" Stoppe nach {page_num} Seiten (3x ohne neue)")
break
time.sleep(0.3)
# Suffix-Referenzen aktualisieren
if new_ids:
build_suffix_references(conn)
return new_ids
finally:
client.close()
# ──────────────────────────────────────────────
# Phase 2: Beratungsfolge scrapen
# ──────────────────────────────────────────────
def phase_scrape(conn: sqlite3.Connection, new_ids: list[int], dry_run: bool = False) -> int:
"""Scrape Beratungsfolge für neue Vorlagen.
Returns: Anzahl gescrapte Vorlagen.
"""
if not new_ids:
return 0
from tracker.core.rescrape import _rescrape_vorlage_impl
scraped = 0
errors = 0
for vid in new_ids:
row = conn.execute(
"SELECT aktenzeichen, web_url FROM vorlagen WHERE id = ?", (vid,)
).fetchone()
if not row or not row["web_url"]:
continue
if dry_run:
print(f" Würde scrapen: {row['aktenzeichen']}")
scraped += 1
continue
try:
result = _rescrape_vorlage_impl(conn, vid)
n_ber = result.get("updated_beratungen", 0)
has_vt = result.get("updated_volltext", False)
errs = result.get("errors", [])
if errs:
errors += 1
print(f" ⚠️ {row['aktenzeichen']}: {n_ber} Beratungen, Fehler: {errs[0][:80]}")
elif n_ber > 0 or has_vt:
print(f"{row['aktenzeichen']}: {n_ber} Beratungen" +
(", Volltext extrahiert" if has_vt else ""))
scraped += 1
except Exception as e:
errors += 1
print(f"{row['aktenzeichen']}: {e}")
# Rate-Limiting: 1s zwischen ALLRIS-Requests
time.sleep(1.0)
conn.commit()
if errors:
print(f" {errors} Fehler beim Scrapen")
return scraped
# ──────────────────────────────────────────────
# Phase 3: Ketten-Zuordnung (Suffix-Matching)
# ──────────────────────────────────────────────
def phase_chains(conn: sqlite3.Connection, new_ids: list[int], dry_run: bool = False) -> dict:
"""Ordne neue Vorlagen in bestehende Ketten ein.
Returns: {"created": N, "extended": N, "affected_chain_ids": [...]}
"""
result = {"created": 0, "extended": 0, "affected_chain_ids": set()}
if not new_ids:
return result
for vid in new_ids:
row = conn.execute(
"SELECT id, aktenzeichen, aktenzeichen_basis, aktenzeichen_suffix, typ, betreff, datum_eingang "
"FROM vorlagen WHERE id = ?", (vid,)
).fetchone()
if not row:
continue
# Bereits in einer Kette?
already = conn.execute(
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ?", (vid,)
).fetchone()
if already:
result["affected_chain_ids"].add(already["kette_id"])
continue
basis_az = row["aktenzeichen_basis"]
suffix = row["aktenzeichen_suffix"]
if not basis_az:
continue
# Nur Suffix-Dokumente in Ketten einordnen
if suffix:
# Finde Basis-Dokument
basis = conn.execute(
"SELECT id, aktenzeichen, typ, betreff FROM vorlagen "
"WHERE aktenzeichen_basis = ? AND (aktenzeichen_suffix IS NULL OR aktenzeichen_suffix = '')",
(basis_az,)
).fetchone()
if not basis:
continue
# Ist die Basis schon in einer Kette?
existing_chain = conn.execute(
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ?",
(basis["id"],)
).fetchone()
if existing_chain:
kette_id = existing_chain["kette_id"]
if dry_run:
print(f" Würde {row['aktenzeichen']} → Kette {kette_id} hinzufügen")
else:
max_pos = conn.execute(
"SELECT MAX(position) as mp FROM ketten_glieder WHERE kette_id = ?",
(kette_id,)
).fetchone()["mp"] or 0
conn.execute(
"INSERT OR IGNORE INTO ketten_glieder (kette_id, vorlage_id, position, rolle) "
"VALUES (?, ?, ?, ?)",
(kette_id, vid, max_pos + 1, _rolle(row["typ"]))
)
print(f" {row['aktenzeichen']} → Kette {kette_id}")
result["extended"] += 1
result["affected_chain_ids"].add(kette_id)
else:
# Neue Kette erstellen
typ = _chain_type(basis["typ"])
if typ not in ("antrag", "anfrage"):
continue
if dry_run:
print(f" Würde neue Kette: {basis['aktenzeichen']} + {row['aktenzeichen']}")
else:
conn.execute(
"INSERT INTO ketten (ursprung_id, typ, thema, status, begruendung) "
"VALUES (?, ?, ?, 'eingereicht', ?)",
(basis["id"], typ, basis["betreff"],
f"Automatisch erstellt via Sync: {basis['aktenzeichen']} + {row['aktenzeichen']}")
)
kette_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
conn.execute(
"INSERT INTO ketten_glieder (kette_id, vorlage_id, position, rolle) VALUES (?, ?, 0, 'ursprung')",
(kette_id, basis["id"])
)
conn.execute(
"INSERT INTO ketten_glieder (kette_id, vorlage_id, position, rolle) VALUES (?, ?, 1, ?)",
(kette_id, vid, _rolle(row["typ"]))
)
print(f" 🆕 Kette {kette_id}: {basis['aktenzeichen']} + {row['aktenzeichen']}")
result["affected_chain_ids"].add(kette_id)
result["created"] += 1
else:
# Basis-Dokument: Checke ob es Suffixe gibt die schon in Ketten sind
chain_row = conn.execute("""
SELECT kg.kette_id FROM ketten_glieder kg
JOIN vorlagen v ON kg.vorlage_id = v.id
WHERE v.aktenzeichen_basis = ? AND v.aktenzeichen_suffix IS NOT NULL
LIMIT 1
""", (basis_az,)).fetchone()
if chain_row:
kette_id = chain_row["kette_id"]
if dry_run:
print(f" Würde Basis {row['aktenzeichen']} → Kette {kette_id} (Pos. 0)")
else:
# Basis an Position 0 einfügen, Rest hochschieben
conn.execute(
"UPDATE ketten_glieder SET position = position + 1 WHERE kette_id = ?",
(kette_id,)
)
conn.execute(
"INSERT OR IGNORE INTO ketten_glieder (kette_id, vorlage_id, position, rolle) "
"VALUES (?, ?, 0, 'ursprung')",
(kette_id, vid)
)
conn.execute(
"UPDATE ketten SET ursprung_id = ? WHERE id = ?",
(vid, kette_id)
)
print(f" 📎 Basis {row['aktenzeichen']} → Kette {kette_id} (Pos. 0)")
result["extended"] += 1
result["affected_chain_ids"].add(kette_id)
if not dry_run:
conn.commit()
return result
def _rolle(typ):
if typ in ("stellungnahme", "bericht"):
return "antwort"
return "folge"
def _chain_type(typ):
if typ == "antrag":
return "antrag"
elif typ == "anfrage":
return "anfrage"
return "sonstig"
# ──────────────────────────────────────────────
# Phase 4: Status-Engine
# ──────────────────────────────────────────────
def phase_status(conn: sqlite3.Connection, chain_ids: set[int], dry_run: bool = False) -> int:
"""Aktualisiere Status für betroffene Ketten.
Returns: Anzahl aktualisierter Ketten.
"""
if not chain_ids:
return 0
from tracker.core.status import compute_status
updated = 0
for kette_id in chain_ids:
kette = conn.execute(
"SELECT id, ursprung_id, typ FROM ketten WHERE id = ?", (kette_id,)
).fetchone()
if not kette:
continue
members = conn.execute("""
SELECT v.id, v.aktenzeichen, v.aktenzeichen_suffix, v.typ,
v.datum_eingang, v.betreff
FROM ketten_glieder kg
JOIN vorlagen v ON kg.vorlage_id = v.id
WHERE kg.kette_id = ?
ORDER BY kg.position
""", (kette_id,)).fetchall()
if not members:
continue
status_info = compute_status(conn, kette["ursprung_id"], kette["typ"], members)
if dry_run:
print(f" Kette {kette_id}: Status → {status_info['status']}")
else:
dates = [m["datum_eingang"] for m in members if m["datum_eingang"]]
letzte_aktivitaet = max(dates) if dates else None
conn.execute("""
UPDATE ketten SET status = ?, status_seit = ?, letzte_aktivitaet = ?,
vertagungen_count = ?, begruendung = ?
WHERE id = ?
""", (
status_info["status"],
status_info.get("status_seit"),
letzte_aktivitaet,
status_info.get("vertagungen_count", 0),
status_info.get("begruendung"),
kette_id,
))
updated += 1
if not dry_run:
conn.commit()
return updated
# ──────────────────────────────────────────────
# Phase 5: FTS5-Index aktualisieren
# ──────────────────────────────────────────────
def phase_fts(conn: sqlite3.Connection, new_ids: list[int], dry_run: bool = False) -> int:
"""Aktualisiere FTS5-Index für neue Vorlagen.
Returns: Anzahl indexierter Vorlagen.
"""
if not new_ids:
return 0
# Prüfe ob FTS5-Tabelle existiert
exists = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='vorlagen_fts'"
).fetchone()
if not exists:
print(" ⚠️ vorlagen_fts existiert nicht — FTS-Update übersprungen")
return 0
if dry_run:
return len(new_ids)
count = 0
for vid in new_ids:
row = conn.execute("""
SELECT v.id, v.aktenzeichen, v.betreff, v.volltext_clean,
kb.begruendung as zusammenfassung
FROM vorlagen v
LEFT JOIN ki_bewertungen kb ON kb.vorlage_id = v.id AND kb.typ = 'zusammenfassung'
WHERE v.id = ?
""", (vid,)).fetchone()
if not row:
continue
# Lösche ggf. alten Eintrag, dann neu einfügen
try:
conn.execute("INSERT INTO vorlagen_fts(vorlagen_fts, rowid, aktenzeichen, betreff, volltext, zusammenfassung) "
"VALUES('delete', ?, ?, ?, ?, ?)",
(row["id"], row["aktenzeichen"] or '', row["betreff"] or '',
row["volltext_clean"] or '', row["zusammenfassung"] or ''))
except Exception:
pass # Eintrag existierte nicht
conn.execute(
"INSERT INTO vorlagen_fts(rowid, aktenzeichen, betreff, volltext, zusammenfassung) "
"VALUES (?, ?, ?, ?, ?)",
(row["id"], row["aktenzeichen"] or '', row["betreff"] or '',
row["volltext_clean"] or '', row["zusammenfassung"] or '')
)
count += 1
conn.commit()
return count
# ──────────────────────────────────────────────
# Main
# ──────────────────────────────────────────────
def sync(dry_run: bool = False, full: bool = False) -> dict:
"""Führe den kompletten Sync durch.
Returns: Zusammenfassung als dict.
"""
start = datetime.now()
print(f"{'' * 60}")
print(f" OParl-Sync — {start.strftime('%Y-%m-%d %H:%M:%S')}")
if dry_run:
print(" ⚡ DRY-RUN — keine Änderungen")
if full:
print(" 🔄 FULL — kompletter Re-Import")
print(f"{'' * 60}")
conn = get_db()
summary = {
"started_at": start.isoformat(),
"dry_run": dry_run,
"new_vorlagen": 0,
"scraped": 0,
"chains_created": 0,
"chains_extended": 0,
"chains_status_updated": 0,
"fts_indexed": 0,
"errors": [],
}
try:
# Phase 1: Import
print(f"\n📥 Phase 1: OParl-Import {'(dry-run)' if dry_run else ''}...")
new_ids = phase_import(conn, dry_run=dry_run, full=full)
summary["new_vorlagen"] = len(new_ids)
print(f"{len(new_ids)} neue Vorlagen")
if not new_ids:
print("\n✅ Keine neuen Vorlagen — Sync abgeschlossen.")
summary["finished_at"] = datetime.now().isoformat()
summary["duration_s"] = (datetime.now() - start).total_seconds()
save_sync_state(summary)
return summary
# Phase 2: Beratungsfolge scrapen
print(f"\n🔍 Phase 2: Beratungsfolge scrapen {'(dry-run)' if dry_run else ''}...")
scraped = phase_scrape(conn, new_ids, dry_run=dry_run)
summary["scraped"] = scraped
print(f"{scraped} Vorlagen gescrapt")
# Phase 3: Ketten-Zuordnung
print(f"\n🔗 Phase 3: Ketten-Zuordnung {'(dry-run)' if dry_run else ''}...")
chain_result = phase_chains(conn, new_ids, dry_run=dry_run)
summary["chains_created"] = chain_result["created"]
summary["chains_extended"] = chain_result["extended"]
affected = chain_result["affected_chain_ids"]
print(f"{chain_result['created']} neue Ketten, {chain_result['extended']} erweitert")
# Phase 4: Status-Engine
print(f"\n⚡ Phase 4: Status-Engine {'(dry-run)' if dry_run else ''}...")
status_updated = phase_status(conn, affected, dry_run=dry_run)
summary["chains_status_updated"] = status_updated
print(f"{status_updated} Ketten aktualisiert")
# Phase 5: FTS5-Index
print(f"\n📝 Phase 5: FTS5-Index {'(dry-run)' if dry_run else ''}...")
fts_count = phase_fts(conn, new_ids, dry_run=dry_run)
summary["fts_indexed"] = fts_count
print(f"{fts_count} Einträge indexiert")
except Exception as e:
summary["errors"].append(str(e))
print(f"\n❌ Fehler: {e}")
import traceback
traceback.print_exc()
finally:
conn.close()
# Zusammenfassung
end = datetime.now()
duration = (end - start).total_seconds()
summary["finished_at"] = end.isoformat()
summary["duration_s"] = duration
if not dry_run:
save_sync_state(summary)
print(f"\n{'' * 60}")
print(f" Zusammenfassung")
print(f"{'' * 60}")
print(f" Neue Vorlagen: {summary['new_vorlagen']}")
print(f" Gescrapt: {summary['scraped']}")
print(f" Ketten erstellt: {summary['chains_created']}")
print(f" Ketten erweitert: {summary['chains_extended']}")
print(f" Status aktualisiert:{summary['chains_status_updated']}")
print(f" FTS indexiert: {summary['fts_indexed']}")
print(f" Dauer: {duration:.1f}s")
if summary["errors"]:
print(f" Fehler: {len(summary['errors'])}")
print(f"{'' * 60}")
return summary
def main():
parser = argparse.ArgumentParser(description="OParl-Sync für Antragstracker Hagen")
parser.add_argument("--dry-run", action="store_true",
help="Zeige was passieren würde, ohne zu importieren")
parser.add_argument("--full", action="store_true",
help="Vollständiger Re-Import (nicht nur inkrementell)")
args = parser.parse_args()
summary = sync(dry_run=args.dry_run, full=args.full)
# Exit 0 = Erfolg (cron-fähig)
sys.exit(1 if summary.get("errors") else 0)
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{a5 as l,a6 as o,a7 as u,a8 as n,a9 as d,B as m,F as p,a4 as _,aa as v,ab as b}from"./Cjw4vZKn.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#a=!0;constructor(t,s=!0){this.anchor=t,this.#a=s}#f=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var a=this.#e.get(s);a&&(this.#s.set(s,a.effect),this.#e.delete(s),a.fragment.lastChild.remove(),this.anchor.before(a.fragment),e=a.effect)}for(const[i,f]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(f);r&&(o(r.effect),this.#e.delete(f))}for(const[i,f]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(f,c),c.append(n()),this.#e.set(i,{effect:f,fragment:c})}else o(f);this.#i.delete(i),this.#s.delete(i)};this.#a||!e?(this.#i.add(i),u(f,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,a]of this.#e)s.includes(e)||(o(a.effect),this.#e.delete(e))};ensure(t,s){var e=m,a=b();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(a){var i=document.createDocumentFragment(),f=n();i.append(f),this.#e.set(t,{effect:d(()=>s(f)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),a){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#f),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#f(e)}}export{w as B};

View File

@ -0,0 +1 @@
import{N as i,R as n,T as d,U as v,V as u,W as h,X as l,Y as g}from"./reyx9_7L.js";const A=Symbol("is custom element"),N=Symbol("is html"),T=g?"link":"LINK";function M(r){if(i){var s=!1,e=()=>{if(!s){if(s=!0,r.hasAttribute("value")){var t=r.value;_(r,"value",null),r.value=t}if(r.hasAttribute("checked")){var o=r.checked;_(r,"checked",null),r.checked=o}}};r.__on_r=e,v(e),u()}}function _(r,s,e,t){var o=m(r);i&&(o[s]=r.getAttribute(s),s==="src"||s==="srcset"||s==="href"&&r.nodeName===T)||o[s]!==(o[s]=e)&&(s==="loading"&&(r[h]=e),e==null?r.removeAttribute(s):typeof e!="string"&&p(r).includes(s)?r[s]=e:r.setAttribute(s,e))}function m(r){return r.__attributes??={[A]:r.nodeName.includes("-"),[N]:r.namespaceURI===n}}var c=new Map;function p(r){var s=r.getAttribute("is")||r.nodeName,e=c.get(s);if(e)return e;c.set(s,e=[]);for(var t,o=r,f=Element.prototype;f!==o;){t=l(o);for(var a in t)t[a].set&&e.push(a);o=d(o)}return e}export{M as r,_ as s};

View File

@ -1 +0,0 @@
import{i as A,j as L,P as D,g as P,p as T,a as b,k as B,l as Y,D as x,m as M,o as N,q as U,v as h,w as q,x as w,y as z,z as $,S as j,L as y}from"./DAfY0XTB.js";import{c as C}from"./splFp8Bu.js";function F(r,a,t,s){var f=!U||(t&h)!==0,v=(t&M)!==0,E=(t&$)!==0,n=s,S=!0,g=()=>(S&&(S=!1,n=E?N(s):s),n);let u;if(v){var O=j in r||y in r;u=A(r,a)?.set??(O&&a in r?e=>r[a]=e:void 0)}var _,I=!1;v?[_,I]=C(()=>r[a]):_=r[a],_===void 0&&s!==void 0&&(_=g(),u&&(f&&L(),u(_)));var i;if(f?i=()=>{var e=r[a];return e===void 0?g():(S=!0,e)}:i=()=>{var e=r[a];return e!==void 0&&(n=void 0),e===void 0?n:e},f&&(t&D)===0)return i;if(u){var R=r.$$legacy;return(function(e,l){return arguments.length>0?((!f||!l||R||I)&&u(l?i():e),e):i()})}var c=!1,d=((t&q)!==0?w:z)(()=>(c=!1,i()));v&&P(d);var m=Y;return(function(e,l){if(arguments.length>0){const o=l?P(d):f&&v?T(e):e;return b(d,o),c=!0,n!==void 0&&(n=o),e}return B&&c||(m.f&x)!==0?d.v:P(d)})}export{F as p};

View File

@ -1,2 +0,0 @@
const e=[...`
\r\f \v\uFEFF`];function o(t,f,u){var n=t==null?"":""+t;if(f&&(n=n?n+" "+f:f),u){for(var s of Object.keys(u))if(u[s])n=n?n+" "+s:s;else if(n.length)for(var i=s.length,l=0;(l=n.indexOf(s,l))>=0;){var r=l+i;(l===0||e.includes(n[l-1]))&&(r===n.length||e.includes(n[r]))?n=(l===0?"":n.substring(0,l))+n.substring(r+1):l=r}}return n===""?null:n}function c(t,f){return t==null?null:String(t)}export{o as a,c as t};

View File

@ -0,0 +1 @@
import{J as O,aM as y,aL as E,aQ as S,l as m,C as A,U as P,b1 as C,a7 as R,ae as g,_ as h,b2 as D,b3 as H,b4 as I,N as d,a2 as u,b5 as W,a0 as k,b6 as B,Z as U,b7 as $}from"./reyx9_7L.js";const f=Symbol("events"),q=new Set,F=new Set;function j(e,r,t,i={}){function n(a){if(i.capture||G.call(r,a),!a.cancelBubble)return C(()=>t?.call(this,a))}return e.startsWith("pointer")||e.startsWith("touch")||e==="wheel"?P(()=>{r.addEventListener(e,n,i)}):r.addEventListener(e,n,i),n}function z(e,r,t,i,n){var a={capture:i,passive:n},s=j(e,r,t,a);(r===document.body||r===window||r===document||r instanceof HTMLMediaElement)&&A(()=>{r.removeEventListener(e,s,a)})}function K(e,r,t){(r[f]??={})[e]=t}function Y(e){for(var r=0;r<e.length;r++)q.add(e[r]);for(var t of F)t(e)}let L=null;function G(e){var r=this,t=r.ownerDocument,i=e.type,n=e.composedPath?.()||[],a=n[0]||e.target;L=e;var s=0,c=L===e&&e[f];if(c){var o=n.indexOf(c);if(o!==-1&&(r===document||r===window)){e[f]=r;return}var b=n.indexOf(r);if(b===-1)return;o<=b&&(s=o)}if(a=n[s]||e.target,a!==r){O(e,"currentTarget",{configurable:!0,get(){return a||t}});var N=S,x=m;y(null),E(null);try{for(var _,w=[];a!==null;){var p=a.assignedSlot||a.parentNode||a.host||null;try{var T=a[f]?.[i];T!=null&&(!a.disabled||e.target===a)&&T.call(a,e)}catch(v){_?w.push(v):_=v}if(e.cancelBubble||p===r||p===null)break;a=p}if(_){for(let v of w)queueMicrotask(()=>{throw v});throw _}}finally{e[f]=r,delete e.currentTarget,y(N),E(x)}}}const J=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:e=>e});function Q(e){return J?.createHTML(e)??e}function M(e){var r=R("template");return r.innerHTML=Q(e.replaceAll("<!>","<!---->")),r.content}function l(e,r){var t=m;t.nodes===null&&(t.nodes={start:e,end:r,a:null,t:null})}function ee(e,r){var t=(r&H)!==0,i=(r&I)!==0,n,a=!e.startsWith("<!>");return()=>{if(d)return l(u,null),u;n===void 0&&(n=M(a?e:"<!>"+e),t||(n=h(n)));var s=i||D?document.importNode(n,!0):n.cloneNode(!0);if(t){var c=h(s),o=s.lastChild;l(c,o)}else l(s,s);return s}}function V(e,r,t="svg"){var i=!e.startsWith("<!>"),n=`<${t}>${i?e:"<!>"+e}</${t}>`,a;return()=>{if(d)return l(u,null),u;if(!a){var s=M(n),c=h(s);a=h(c)}var o=a.cloneNode(!0);return l(o,o),o}}function re(e,r){return V(e,r,"svg")}function te(e=""){if(!d){var r=g(e+"");return l(r,r),r}var t=u;return t.nodeType!==B?(t.before(t=g()),U(t)):$(t),l(t,t),t}function ae(){if(d)return l(u,null),u;var e=document.createDocumentFragment(),r=document.createComment(""),t=g();return e.append(r,t),l(r,t),e}function ne(e,r){if(d){var t=m;((t.f&W)===0||t.nodes.end===null)&&(t.nodes.end=u),k();return}e!==null&&e.before(r)}const X="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(X);export{ne as a,K as b,ae as c,Y as d,l as e,ee as f,re as g,z as h,q as i,G as j,F as r,te as t};

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{A as s,B as v,e as o,G as c,H as b,I as m,J as h}from"./Cjw4vZKn.js";function d(e,r,f=!1){if(e.multiple){if(r==null)return;if(!b(r))return m();for(var a of e.options)a.selected=r.includes(i(a));return}for(a of e.options){var t=i(a);if(h(t,r)){a.selected=!0;return}}(!f||r!==void 0)&&(e.selectedIndex=-1)}function y(e){var r=new MutationObserver(()=>{d(e,e.__value)});r.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),c(()=>{r.disconnect()})}function S(e,r,f=r){var a=new WeakSet,t=!0;s(e,"change",u=>{var l=u?"[selected]":":checked",n;if(e.multiple)n=[].map.call(e.querySelectorAll(l),i);else{var _=e.querySelector(l)??e.querySelector("option:not([disabled])");n=_&&i(_)}f(n),e.__value=n,v!==null&&a.add(v)}),o(()=>{var u=r();if(e===document.activeElement){var l=v;if(a.has(l))return}if(d(e,u,t),t&&u===void 0){var n=e.querySelector(":checked");n!==null&&(u=i(n),f(u))}e.__value=u,t=!1}),y(e)}function i(e){return"__value"in e?e.__value:e.value}export{S as b};
import{A as s,B as v,e as o,C as c,E as b,F as m,G as h}from"./reyx9_7L.js";function d(e,r,f=!1){if(e.multiple){if(r==null)return;if(!b(r))return m();for(var a of e.options)a.selected=r.includes(i(a));return}for(a of e.options){var t=i(a);if(h(t,r)){a.selected=!0;return}}(!f||r!==void 0)&&(e.selectedIndex=-1)}function y(e){var r=new MutationObserver(()=>{d(e,e.__value)});r.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),c(()=>{r.disconnect()})}function S(e,r,f=r){var a=new WeakSet,t=!0;s(e,"change",u=>{var l=u?"[selected]":":checked",n;if(e.multiple)n=[].map.call(e.querySelectorAll(l),i);else{var _=e.querySelector(l)??e.querySelector("option:not([disabled])");n=_&&i(_)}f(n),e.__value=n,v!==null&&a.add(v)}),o(()=>{var u=r();if(e===document.activeElement){var l=v;if(a.has(l))return}if(d(e,u,t),t&&u===void 0){var n=e.querySelector(":checked");n!==null&&(u=i(n),f(u))}e.__value=u,t=!1}),y(e)}function i(e){return"__value"in e?e.__value:e.value}export{S as b};

View File

@ -1 +0,0 @@
import{ai as v,a8 as d,ac as u,aF as T,l,aG as p,aH as h,F as i,a4 as s,aI as E,Z as y,aJ as g,a2 as w,aK as N}from"./Cjw4vZKn.js";const M=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function x(t){return M?.createHTML(t)??t}function A(t){var r=v("template");return r.innerHTML=x(t.replaceAll("<!>","<!---->")),r.content}function a(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function I(t,r){var e=(r&p)!==0,f=(r&h)!==0,n,_=!t.startsWith("<!>");return()=>{if(i)return a(s,null),s;n===void 0&&(n=A(_?t:"<!>"+t),e||(n=u(n)));var o=f||T?document.importNode(n,!0):n.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;a(c,m)}else a(o,o);return o}}function O(t=""){if(!i){var r=d(t+"");return a(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),a(e,e),e}function P(){if(i)return a(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),a(r,e),t}function R(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{R as a,a as b,P as c,I as f,O as t};

View File

@ -1 +0,0 @@
import{Q as c,F as o,R as l,T as b,U as p,V as v,W as g,X as f,Y as m}from"./DAfY0XTB.js";import{B as y}from"./Duumi1XQ.js";function F(_,d,h=!1){var n;o&&(n=m,l());var s=new y(_),u=h?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,f(!1),s.ensure(a,r),f(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;d((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{F as i};

View File

@ -0,0 +1,2 @@
import{aG as Q,g as W,Q as G,o as K,aH as C,U as R,ay as $,a2 as u,N as p,l as y,aI as H,ai as X,a0 as Z,aq as ee,aJ as M,af as c,ae as q,ad as D,B as V,ag as te,aK as se,aL as Y,aM as L,aN as P,aO as re,aP as ie,aQ as ne,ao as U,as as ae,ac as F,Z as N,n as he,al as fe,aR as E,aj as oe,aF as de,aS as _e,aT as le,aU as O,_ as ce,a3 as z,aV as ue,a4 as pe,a6 as x,am as b,aW as ge,aD as ve,aX as ye,av as me,p as Ee,ar as be,a5 as Te,c as Re}from"./reyx9_7L.js";import{i as Ne,r as j,j as B,e as Se}from"./B89f14j0.js";function we(r){let e=0,t=$(0),i;return()=>{Q()&&(W(t),G(()=>(e===0&&(i=K(()=>r(()=>C(t)))),e+=1,()=>{R(()=>{e-=1,e===0&&(i?.(),i=void 0,C(t))})})))}}var Ae=oe|de;function De(r,e,t,i){new Fe(r,e,t,i)}class Fe{parent;is_pending=!1;transform_error;#t;#u=p?u:null;#i;#o;#e;#n=null;#s=null;#r=null;#a=null;#d=0;#f=0;#_=!1;#p=new Set;#g=new Set;#h=null;#m=we(()=>(this.#h=$(this.#d),()=>{this.#h=null}));constructor(e,t,i,f){this.#t=e,this.#i=t,this.#o=s=>{var n=y;n.b=this,n.f|=H,i(s)},this.parent=y.b,this.transform_error=f??this.parent?.transform_error??(s=>s),this.#e=X(()=>{if(p){const s=this.#u;Z();const n=s.data===ee;if(s.data.startsWith(M)){const a=JSON.parse(s.data.slice(M.length));this.#b(a)}else n?this.#T():this.#E()}else this.#v()},Ae),p&&(this.#t=u)}#E(){try{this.#n=c(()=>this.#o(this.#t))}catch(e){this.error(e)}}#b(e){const t=this.#i.failed;t&&(this.#r=c(()=>{t(this.#t,()=>e,()=>()=>{})}))}#T(){const e=this.#i.pending;e&&(this.is_pending=!0,this.#s=c(()=>e(this.#t)),R(()=>{var t=this.#a=document.createDocumentFragment(),i=q();t.append(i),this.#n=this.#c(()=>c(()=>this.#o(i))),this.#f===0&&(this.#t.before(t),this.#a=null,D(this.#s,()=>{this.#s=null}),this.#l(V))}))}#v(){try{if(this.is_pending=this.has_pending_snippet(),this.#f=0,this.#d=0,this.#n=c(()=>{this.#o(this.#t)}),this.#f>0){var e=this.#a=document.createDocumentFragment();te(this.#n,e);const t=this.#i.pending;this.#s=c(()=>t(this.#t))}else this.#l(V)}catch(t){this.error(t)}}#l(e){this.is_pending=!1,e.transfer_effects(this.#p,this.#g)}defer_effect(e){se(e,this.#p,this.#g)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#i.pending}#c(e){var t=y,i=ne,f=U;Y(this.#e),L(this.#e),P(this.#e.ctx);try{return re.ensure(),e()}catch(s){return ie(s),null}finally{Y(t),L(i),P(f)}}#y(e,t){if(!this.has_pending_snippet()){this.parent&&this.parent.#y(e,t);return}this.#f+=e,this.#f===0&&(this.#l(t),this.#s&&D(this.#s,()=>{this.#s=null}),this.#a&&(this.#t.before(this.#a),this.#a=null))}update_pending_count(e,t){this.#y(e,t),this.#d+=e,!(!this.#h||this.#_)&&(this.#_=!0,R(()=>{this.#_=!1,this.#h&&ae(this.#h,this.#d)}))}get_effect_pending(){return this.#m(),W(this.#h)}error(e){var t=this.#i.onerror;let i=this.#i.failed;if(!t&&!i)throw e;this.#n&&(F(this.#n),this.#n=null),this.#s&&(F(this.#s),this.#s=null),this.#r&&(F(this.#r),this.#r=null),p&&(N(this.#u),he(),N(fe()));var f=!1,s=!1;const n=()=>{if(f){le();return}f=!0,s&&_e(),this.#r!==null&&D(this.#r,()=>{this.#r=null}),this.#c(()=>{this.#v()})},l=a=>{try{s=!0,t?.(a,n),s=!1}catch(h){E(h,this.#e&&this.#e.parent)}i&&(this.#r=this.#c(()=>{try{return c(()=>{var h=y;h.b=this,h.f|=H,i(this.#t,()=>a,()=>n)})}catch(h){return E(h,this.#e.parent),null}}))};R(()=>{var a;try{a=this.transform_error(e)}catch(h){E(h,this.#e&&this.#e.parent);return}a!==null&&typeof a=="object"&&typeof a.then=="function"?a.then(l,h=>E(h,this.#e&&this.#e.parent)):l(a)})}}const Oe=["touchstart","touchmove"];function xe(r){return Oe.includes(r)}function He(r,e){var t=e==null?"":typeof e=="object"?`${e}`:e;t!==(r.__t??=r.nodeValue)&&(r.__t=t,r.nodeValue=`${t}`)}function Ie(r,e){return J(r,e)}function Me(r,e){O(),e.intro=e.intro??!1;const t=e.target,i=p,f=u;try{for(var s=ce(t);s&&(s.nodeType!==z||s.data!==ue);)s=pe(s);if(!s)throw x;b(!0),N(s);const n=J(r,{...e,anchor:s});return b(!1),n}catch(n){if(n instanceof Error&&n.message.split(`
`).some(l=>l.startsWith("https://svelte.dev/e/")))throw n;return n!==x&&console.warn("Failed to hydrate: ",n),e.recover===!1&&ge(),O(),ve(t),b(!1),Ie(r,e)}finally{b(i),N(f)}}const T=new Map;function J(r,{target:e,anchor:t,props:i={},events:f,context:s,intro:n=!0,transformError:l}){O();var a=void 0,h=ye(()=>{var m=t??e.appendChild(q());De(m,{pending:()=>{}},o=>{Ee({});var d=U;if(s&&(d.c=s),f&&(i.$$events=f),p&&Se(o,null),a=r(o,i)||{},p&&(y.nodes.end=u,u===null||u.nodeType!==z||u.data!==be))throw Te(),x;Re()},l);var S=new Set,w=o=>{for(var d=0;d<o.length;d++){var _=o[d];if(!S.has(_)){S.add(_);var v=xe(_);for(const A of[e,document]){var g=T.get(A);g===void 0&&(g=new Map,T.set(A,g));var k=g.get(_);k===void 0?(A.addEventListener(_,B,{passive:v}),g.set(_,1)):g.set(_,k+1)}}}};return w(me(Ne)),j.add(w),()=>{for(var o of S)for(const v of[e,document]){var d=T.get(v),_=d.get(o);--_==0?(v.removeEventListener(o,B),d.delete(o),d.size===0&&T.delete(v)):d.set(o,_)}j.delete(w),m!==t&&m.parentNode?.removeChild(m)}});return I.set(a,h),a}let I=new WeakMap;function Ve(r,e){const t=I.get(r);return t?(I.delete(r),t(e)):Promise.resolve()}export{Me as h,Ie as m,He as s,Ve as u};

View File

@ -1 +0,0 @@
import{a as y}from"./DVOkFnep.js";import{F as r}from"./DAfY0XTB.js";function a(t,e,f,i){var l=t.__style;if(r||l!==e){var s=y(e);(!r||s!==t.getAttribute("style"))&&(s==null?t.removeAttribute("style"):t.style.cssText=s),t.__style=e}return i}export{a as s};

View File

@ -1 +1 @@
import{a as b}from"./B08B5jt4.js";import{F as g}from"./Cjw4vZKn.js";function A(i,h,f,N,t,r){var o=i.__className;if(g||o!==f||o===void 0){var a=b(f,N,r);(!g||a!==i.getAttribute("class"))&&(a==null?i.removeAttribute("class"):i.className=a),i.__className=f}else if(r&&t!==r)for(var l in r){var u=!!r[l];(t==null||u!==!!t[l])&&i.classList.toggle(l,u)}return r}export{A as s};
import{a as b}from"./D5EBvEcH.js";import{N as g}from"./reyx9_7L.js";function A(i,h,f,N,t,r){var o=i.__className;if(g||o!==f||o===void 0){var a=b(f,N,r);(!g||a!==i.getAttribute("class"))&&(a==null?i.removeAttribute("class"):i.className=a),i.__className=f}else if(r&&t!==r)for(var l in r){var u=!!r[l];(t==null||u!==!!t[l])&&i.classList.toggle(l,u)}return r}export{A as s};

View File

@ -1 +0,0 @@
import{a1 as z,Q as Z,ao as G,F as A,W as M,a5 as J,R as re,g as U,U as ae,ap as fe,V as X,X as O,Y as H,a7 as K,aq as ie,ar as P,B as le,as as C,a2 as L,at as se,a4 as ue,y as oe,H as te,au as q,av as ve,aw as de,O as ce,ax as Q,ay as pe,D as ge,Z as $,a0 as j,az as B,af as _e,aA as Ee,aB as he,aC as me,a3 as Te,_ as Ce,a8 as V,aD as we,aE as Ae}from"./DAfY0XTB.js";function Ie(e,a){return a}function Se(e,a,s){for(var u=[],t=a.length,r,i=a.length,p=0;p<t;p++){let E=a[p];j(E,()=>{if(r){if(r.pending.delete(E),r.done.add(E),r.pending.size===0){var v=e.outrogroups;Y(e,q(r.done)),v.delete(r),v.size===0&&(e.outrogroups=null)}}else i-=1},!1)}if(i===0){var l=u.length===0&&s!==null;if(l){var c=s,f=c.parentNode;me(f),f.append(c),e.items.clear()}Y(e,a,!l)}else r={pending:new Set(a),done:new Set},(e.outrogroups??=new Set).add(r)}function Y(e,a,s=!0){var u;if(e.pending.size>0){u=new Set;for(const i of e.pending.values())for(const p of i)u.add(e.items.get(p).e)}for(var t=0;t<a.length;t++){var r=a[t];if(u?.has(r)){r.f|=C;const i=document.createDocumentFragment();Te(r,i)}else Ce(a[t],s)}}var W;function Ne(e,a,s,u,t,r=null){var i=e,p=new Map,l=(a&G)!==0;if(l){var c=e;i=A?M(J(c)):c.appendChild(z())}A&&re();var f=null,E=oe(()=>{var d=s();return te(d)?d:d==null?[]:q(d)}),v,h=new Map,m=!0;function I(d){(S.effect.f&ge)===0&&(S.pending.delete(d),S.fallback=f,De(S,v,i,a,u),f!==null&&(v.length===0?(f.f&C)===0?$(f):(f.f^=C,k(f,null,i)):j(f,()=>{f=null})))}function n(d){S.pending.delete(d)}var o=Z(()=>{v=U(E);var d=v.length;let _=!1;if(A){var b=ae(i)===fe;b!==(d===0)&&(i=X(),M(i),O(!1),_=!0)}for(var D=new Set,g=le,N=ue(),R=0;R<d;R+=1){A&&H.nodeType===K&&H.data===ie&&(i=H,_=!0,O(!1));var T=v[R],y=u(T,R),w=m?null:p.get(y);w?(w.v&&P(w.v,T),w.i&&P(w.i,R),N&&g.unskip_effect(w.e)):(w=Re(p,m?i:W??=z(),T,y,R,t,a,s),m||(w.e.f|=C),p.set(y,w)),D.add(y)}if(d===0&&r&&!f&&(m?f=L(()=>r(i)):(f=L(()=>r(W??=z())),f.f|=C)),d>D.size&&se(),A&&d>0&&M(X()),!m)if(h.set(g,D),N){for(const[ee,ne]of p)D.has(ee)||g.skip_effect(ne.e);g.oncommit(I),g.ondiscard(n)}else I(g);_&&O(!0),U(E)}),S={effect:o,items:p,pending:h,outrogroups:null,fallback:f};m=!1,A&&(i=H)}function x(e){for(;e!==null&&(e.f&Ee)===0;)e=e.next;return e}function De(e,a,s,u,t){var r=(u&he)!==0,i=a.length,p=e.items,l=x(e.effect.first),c,f=null,E,v=[],h=[],m,I,n,o;if(r)for(o=0;o<i;o+=1)m=a[o],I=t(m,o),n=p.get(I).e,(n.f&C)===0&&(n.nodes?.a?.measure(),(E??=new Set).add(n));for(o=0;o<i;o+=1){if(m=a[o],I=t(m,o),n=p.get(I).e,e.outrogroups!==null)for(const T of e.outrogroups)T.pending.delete(n),T.done.delete(n);if((n.f&B)!==0&&($(n),r&&(n.nodes?.a?.unfix(),(E??=new Set).delete(n))),(n.f&C)!==0)if(n.f^=C,n===l)k(n,null,s);else{var S=f?f.next:l;n===e.effect.last&&(e.effect.last=n.prev),n.prev&&(n.prev.next=n.next),n.next&&(n.next.prev=n.prev),F(e,f,n),F(e,n,S),k(n,S,s),f=n,v=[],h=[],l=x(f.next);continue}if(n!==l){if(c!==void 0&&c.has(n)){if(v.length<h.length){var d=h[0],_;f=d.prev;var b=v[0],D=v[v.length-1];for(_=0;_<v.length;_+=1)k(v[_],d,s);for(_=0;_<h.length;_+=1)c.delete(h[_]);F(e,b.prev,D.next),F(e,f,b),F(e,D,d),l=d,f=D,o-=1,v=[],h=[]}else c.delete(n),k(n,l,s),F(e,n.prev,n.next),F(e,n,f===null?e.effect.first:f.next),F(e,f,n),f=n;continue}for(v=[],h=[];l!==null&&l!==n;)(c??=new Set).add(l),h.push(l),l=x(l.next);if(l===null)continue}(n.f&C)===0&&v.push(n),f=n,l=x(n.next)}if(e.outrogroups!==null){for(const T of e.outrogroups)T.pending.size===0&&(Y(e,q(T.done)),e.outrogroups?.delete(T));e.outrogroups.size===0&&(e.outrogroups=null)}if(l!==null||c!==void 0){var g=[];if(c!==void 0)for(n of c)(n.f&B)===0&&g.push(n);for(;l!==null;)(l.f&B)===0&&l!==e.fallback&&g.push(l),l=x(l.next);var N=g.length;if(N>0){var R=(u&G)!==0&&i===0?s:null;if(r){for(o=0;o<N;o+=1)g[o].nodes?.a?.measure();for(o=0;o<N;o+=1)g[o].nodes?.a?.fix()}Se(e,g,R)}}r&&_e(()=>{if(E!==void 0)for(n of E)n.nodes?.a?.apply()})}function Re(e,a,s,u,t,r,i,p){var l=(i&ve)!==0?(i&de)===0?ce(s,!1,!1):Q(s):null,c=(i&pe)!==0?Q(t):null;return{v:l,i:c,e:L(()=>(r(a,l??s,c??t,p),()=>{e.delete(u)}))}}function k(e,a,s){if(e.nodes)for(var u=e.nodes.start,t=e.nodes.end,r=a&&(a.f&C)===0?a.nodes.start:s;u!==null;){var i=V(u);if(r.before(u),u===t)return;u=i}}function F(e,a,s){a===null?e.effect.first=s:a.next=s,s===null?e.effect.last=a:s.prev=a}function xe(e,a){let s=null,u=A;var t;if(A){s=H;for(var r=J(document.head);r!==null&&(r.nodeType!==K||r.data!==e);)r=V(r);if(r===null)O(!1);else{var i=V(r);r.remove(),M(i)}}A||(t=document.head.appendChild(z()));try{Z(()=>a(t),we|Ae)}finally{u&&(O(!0),M(s))}}export{Ne as e,xe as h,Ie as i};

View File

@ -1 +0,0 @@
import{c as y,a as p,f as _}from"./Bkzsmr9I.js";import{d as j,f as S,h as B,c as i,r as n,b as c,t as v,g as r,u as I}from"./Cjw4vZKn.js";import{s as m}from"./DfJQ0EIT.js";import{i as q}from"./kjB3f-xG.js";import{s as w}from"./RVjQLo13.js";import{s as d}from"./CWOupeSg.js";import{p as z}from"./fSdafo1a.js";import{s as A}from"./utcFFRIM.js";var C=_('<a><span class="mr-1"> </span> </a>'),D=_('<span><span class="mr-1"> </span> </span>');function N(x,o){j(o,!0);let h=z(o,"linked",3,!1);const s=I(()=>A(o.status));var u=y(),b=S(u);{var g=e=>{var t=C(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{w(t,"href",`/ketten?status=${o.status??""}`),d(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""} hover:opacity-80 transition-opacity`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)},k=e=>{var t=D(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{d(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""}`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)};q(b,e=>{h()&&o.status?e(g):e(k,-1)})}p(x,u),B()}export{N as S};

View File

@ -1 +0,0 @@
import{A as m,B as v,C as _,o as b,E as i,F as y}from"./Cjw4vZKn.js";function k(e,l,u=l){var s=new WeakSet;m(e,"input",async r=>{var a=r?e.defaultValue:e.value;if(a=o(e)?t(a):a,u(a),v!==null&&s.add(v),await _(),a!==(a=l())){var d=e.selectionStart,f=e.selectionEnd,n=e.value.length;if(e.value=a??"",f!==null){var c=e.value.length;d===f&&f===n&&c>n?(e.selectionStart=c,e.selectionEnd=c):(e.selectionStart=d,e.selectionEnd=Math.min(f,c))}}}),(y&&e.defaultValue!==e.value||b(l)==null&&e.value)&&(u(o(e)?t(e.value):e.value),v!==null&&s.add(v)),i(()=>{var r=l();if(e===document.activeElement){var a=v;if(s.has(a))return}o(e)&&r===t(e.value)||e.type==="date"&&!r&&!e.value||r!==e.value&&(e.value=r??"")})}function o(e){var l=e.type;return l==="number"||l==="range"}function t(e){return e===""?null:+e}export{k as b};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
const a=typeof window<"u"&&window.location.port==="5173"?`http://${window.location.hostname}:8099/api`:"/api";async function e(t){const n=await fetch(`${a}${t}`);if(!n.ok)throw new Error(`API error: ${n.status}`);return n.json()}const r=t=>{const n=new URLSearchParams(t).toString();return e(`/vorlagen?${n}`)},s=t=>e(`/vorlagen/${t}`),c=t=>{const n=new URLSearchParams(t).toString();return e(`/ketten?${n}`)},i=t=>e(`/ketten/${t}`),f=()=>e("/fraktionen"),h=(t,n)=>{const o=n?`?jahr=${n}`:"";return e(`/fraktionen/${t}/dashboard${o}`)};export{c as a,s as b,i as c,r as d,h as e,f};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{c as y,a as p,f as d}from"./B89f14j0.js";import{p as j,f as S,c as B,h as i,r as n,b as c,t as v,g as r,u as I}from"./reyx9_7L.js";import{s as m}from"./BwTTNG21.js";import{i as q}from"./Do7Yo2YN.js";import{s as w}from"./B-WTs0fq.js";import{s as _}from"./C7sCDBjT.js";import{p as z}from"./DfsAIpU3.js";import{s as A}from"./utcFFRIM.js";var C=d('<a><span class="mr-1"> </span> </a>'),D=d('<span><span class="mr-1"> </span> </span>');function N(x,o){j(o,!0);let h=z(o,"linked",3,!1);const s=I(()=>A(o.status));var u=y(),b=S(u);{var g=e=>{var t=C(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{w(t,"href",`/ketten?status=${o.status??""}`),_(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""} hover:opacity-80 transition-opacity`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)},k=e=>{var t=D(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{_(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""}`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)};q(b,e=>{h()&&o.status?e(g):e(k,-1)})}p(x,u),B()}export{N as S};

View File

@ -0,0 +1 @@
import{s as f,g as c}from"./Pu2RPHbX.js";import{C as l,J as b,K as a,M as _,g as d,d as p}from"./reyx9_7L.js";let u=!1,t=Symbol();function v(e,r,n){const s=n[r]??={store:null,source:_(void 0),unsubscribe:a};if(s.store!==e&&!(t in n))if(s.unsubscribe(),s.store=e??null,e==null)s.source.v=void 0,s.unsubscribe=a;else{var i=!0;s.unsubscribe=f(e,o=>{i?s.source.v=o:p(s.source,o)}),i=!1}return e&&t in n?c(e):d(s.source)}function y(){const e={};function r(){l(()=>{for(var n in e)e[n].unsubscribe();b(e,t,{enumerable:!1,value:!0})})}return[e,r]}function M(e){var r=u;try{return u=!1,[e(),u]}finally{u=r}}export{v as a,M as c,y as s};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{ae as y,ai as K,ap as Q,N as A,Z as b,_ as W,a0 as fe,g as U,ak as ae,aq as ie,al as P,am as H,a2 as M,a3 as $,ar as le,as as X,B as se,at as w,af as L,au as ue,ah as te,y as oe,E as de,av as Y,aw as ve,ax as ce,M as pe,ay as Z,az as ge,D as _e,ab as j,ad as ee,aA as B,U as Ee,aB as he,aC as me,aD as Te,ag as we,ac as Ce,a4 as q,aE as Ae,aF as Se}from"./reyx9_7L.js";function Ie(e,a){return a}function De(e,a,l){for(var r=[],t=a.length,i,f=a.length,o=0;o<t;o++){let E=a[o];ee(E,()=>{if(i){if(i.pending.delete(E),i.done.add(E),i.pending.size===0){var v=e.outrogroups;V(e,Y(i.done)),v.delete(i),v.size===0&&(e.outrogroups=null)}}else f-=1},!1)}if(f===0){var u=r.length===0&&l!==null;if(u){var p=l,s=p.parentNode;Te(s),s.append(p),e.items.clear()}V(e,a,!u)}else i={pending:new Set(a),done:new Set},(e.outrogroups??=new Set).add(i)}function V(e,a,l=!0){var r;if(e.pending.size>0){r=new Set;for(const f of e.pending.values())for(const o of f)r.add(e.items.get(o).e)}for(var t=0;t<a.length;t++){var i=a[t];if(r?.has(i)){i.f|=w;const f=document.createDocumentFragment();we(i,f)}else Ce(a[t],l)}}var G;function Re(e,a,l,r,t,i=null){var f=e,o=new Map,u=(a&Q)!==0;if(u){var p=e;f=A?b(W(p)):p.appendChild(y())}A&&fe();var s=null,E=oe(()=>{var c=l();return de(c)?c:c==null?[]:Y(c)}),v,h=new Map,m=!0;function F(c){(S.effect.f&_e)===0&&(S.pending.delete(c),S.fallback=s,Ne(S,v,f,a,r),s!==null&&(v.length===0?(s.f&w)===0?j(s):(s.f^=w,O(s,null,f)):ee(s,()=>{s=null})))}function n(c){S.pending.delete(c)}var d=K(()=>{v=U(E);var c=v.length;let _=!1;if(A){var k=ae(f)===ie;k!==(c===0)&&(f=P(),b(f),H(!1),_=!0)}for(var D=new Set,g=se,I=te(),N=0;N<c;N+=1){A&&M.nodeType===$&&M.data===le&&(f=M,_=!0,H(!1));var T=v[N],z=r(T,N),C=m?null:o.get(z);C?(C.v&&X(C.v,T),C.i&&X(C.i,N),I&&g.unskip_effect(C.e)):(C=xe(o,m?f:G??=y(),T,z,N,t,a,l),m||(C.e.f|=w),o.set(z,C)),D.add(z)}if(c===0&&i&&!s&&(m?s=L(()=>i(f)):(s=L(()=>i(G??=y())),s.f|=w)),c>D.size&&ue(),A&&c>0&&b(P()),!m)if(h.set(g,D),I){for(const[ne,re]of o)D.has(ne)||g.skip_effect(re.e);g.oncommit(F),g.ondiscard(n)}else F(g);_&&H(!0),U(E)}),S={effect:d,items:o,pending:h,outrogroups:null,fallback:s};m=!1,A&&(f=M)}function R(e){for(;e!==null&&(e.f&he)===0;)e=e.next;return e}function Ne(e,a,l,r,t){var i=(r&me)!==0,f=a.length,o=e.items,u=R(e.effect.first),p,s=null,E,v=[],h=[],m,F,n,d;if(i)for(d=0;d<f;d+=1)m=a[d],F=t(m,d),n=o.get(F).e,(n.f&w)===0&&(n.nodes?.a?.measure(),(E??=new Set).add(n));for(d=0;d<f;d+=1){if(m=a[d],F=t(m,d),n=o.get(F).e,e.outrogroups!==null)for(const T of e.outrogroups)T.pending.delete(n),T.done.delete(n);if((n.f&B)!==0&&(j(n),i&&(n.nodes?.a?.unfix(),(E??=new Set).delete(n))),(n.f&w)!==0)if(n.f^=w,n===u)O(n,null,l);else{var S=s?s.next:u;n===e.effect.last&&(e.effect.last=n.prev),n.prev&&(n.prev.next=n.next),n.next&&(n.next.prev=n.prev),x(e,s,n),x(e,n,S),O(n,S,l),s=n,v=[],h=[],u=R(s.next);continue}if(n!==u){if(p!==void 0&&p.has(n)){if(v.length<h.length){var c=h[0],_;s=c.prev;var k=v[0],D=v[v.length-1];for(_=0;_<v.length;_+=1)O(v[_],c,l);for(_=0;_<h.length;_+=1)p.delete(h[_]);x(e,k.prev,D.next),x(e,s,k),x(e,D,c),u=c,s=D,d-=1,v=[],h=[]}else p.delete(n),O(n,u,l),x(e,n.prev,n.next),x(e,n,s===null?e.effect.first:s.next),x(e,s,n),s=n;continue}for(v=[],h=[];u!==null&&u!==n;)(p??=new Set).add(u),h.push(u),u=R(u.next);if(u===null)continue}(n.f&w)===0&&v.push(n),s=n,u=R(n.next)}if(e.outrogroups!==null){for(const T of e.outrogroups)T.pending.size===0&&(V(e,Y(T.done)),e.outrogroups?.delete(T));e.outrogroups.size===0&&(e.outrogroups=null)}if(u!==null||p!==void 0){var g=[];if(p!==void 0)for(n of p)(n.f&B)===0&&g.push(n);for(;u!==null;)(u.f&B)===0&&u!==e.fallback&&g.push(u),u=R(u.next);var I=g.length;if(I>0){var N=(r&Q)!==0&&f===0?l:null;if(i){for(d=0;d<I;d+=1)g[d].nodes?.a?.measure();for(d=0;d<I;d+=1)g[d].nodes?.a?.fix()}De(e,g,N)}}i&&Ee(()=>{if(E!==void 0)for(n of E)n.nodes?.a?.apply()})}function xe(e,a,l,r,t,i,f,o){var u=(f&ve)!==0?(f&ce)===0?pe(l,!1,!1):Z(l):null,p=(f&ge)!==0?Z(t):null;return{v:u,i:p,e:L(()=>(i(a,u??l,p??t,o),()=>{e.delete(r)}))}}function O(e,a,l){if(e.nodes)for(var r=e.nodes.start,t=e.nodes.end,i=a&&(a.f&w)===0?a.nodes.start:l;r!==null;){var f=q(r);if(i.before(r),r===t)return;r=f}}function x(e,a,l){a===null?e.effect.first=l:a.next=l,l===null?e.effect.last=a:l.prev=a}function Me(e,a){let l=null,r=A;var t;if(A){l=M;for(var i=W(document.head);i!==null&&(i.nodeType!==$||i.data!==e);)i=q(i);if(i===null)H(!1);else{var f=q(i);i.remove(),b(f)}}A||(t=document.head.appendChild(y()));try{K(()=>a(t),Ae|Se)}finally{r&&(H(!0),b(l))}}const J=[...`
\r\f \v\uFEFF`];function Oe(e,a,l){var r=e==null?"":""+e;if(a&&(r=r?r+" "+a:a),l){for(var t of Object.keys(l))if(l[t])r=r?r+" "+t:t;else if(r.length)for(var i=t.length,f=0;(f=r.indexOf(t,f))>=0;){var o=f+i;(f===0||J.includes(r[f-1]))&&(o===r.length||J.includes(r[o]))?r=(f===0?"":r.substring(0,f))+r.substring(o+1):f=o}}return r===""?null:r}function be(e,a){return e==null?null:String(e)}export{Oe as a,Re as e,Me as h,Ie as i,be as t};

View File

@ -1 +0,0 @@
import{t as b}from"./DVOkFnep.js";import{F as g}from"./DAfY0XTB.js";function A(i,h,t,N,f,r){var o=i.__className;if(g||o!==t||o===void 0){var a=b(t,N,r);(!g||a!==i.getAttribute("class"))&&(a==null?i.removeAttribute("class"):i.className=a),i.__className=t}else if(r&&f!==r)for(var l in r){var u=!!r[l];(f==null||u!==!!f[l])&&i.classList.toggle(l,u)}return r}export{A as s};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{ab as v,a1 as d,a5 as u,aF as T,l,aG as p,aH as h,F as i,Y as s,aI as E,R as y,aJ as g,W as w,aK as N}from"./DAfY0XTB.js";const M=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function b(t){return M?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=b(t.replaceAll("<!>","<!---->")),r.content}function a(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function R(t,r){var e=(r&p)!==0,f=(r&h)!==0,n,_=!t.startsWith("<!>");return()=>{if(i)return a(s,null),s;n===void 0&&(n=x(_?t:"<!>"+t),e||(n=u(n)));var o=f||T?document.importNode(n,!0):n.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;a(c,m)}else a(o,o);return o}}function I(t=""){if(!i){var r=d(t+"");return a(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),a(e,e),e}function O(){if(i)return a(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),a(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const A="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(A);export{P as a,a as b,O as c,R as f,I as t};

View File

@ -1 +0,0 @@
import{am as o,an as t,q as c,o as l}from"./DAfY0XTB.js";function a(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&a(),c&&t.l!==null?u(t).m.push(e):o(()=>{const n=l(e);if(typeof n=="function")return n})}function u(e){var n=e.l;return n.u??={a:[],b:[],m:[]}}export{r as o};

View File

@ -1 +0,0 @@
import{N as o,o as a,ae as d}from"./DAfY0XTB.js";function p(s,u,n){if(s==null)return u(void 0),o;const t=a(()=>s.subscribe(u,n));return t.unsubscribe?()=>t.unsubscribe():t}const i=[];function _(s,u=o){let n=null;const t=new Set;function c(r){if(d(s,r)&&(s=r,n)){const b=!i.length;for(const e of t)e[1](),i.push(e,s);if(b){for(let e=0;e<i.length;e+=2)i[e][0](i[e+1]);i.length=0}}}function f(r){c(r(s))}function l(r,b=o){const e=[r,b];return t.add(e),t.size===1&&(n=u(c,f)||o),r(s),()=>{t.delete(e),t.size===0&&n&&(n(),n=null)}}return{set:c,update:f,subscribe:l}}function h(s){let u;return p(s,n=>u=n)(),u}export{h as g,p as s,_ as w};

View File

@ -1 +1 @@
import{A as m,B as v,C as _,o as b,E as i,F as y}from"./DAfY0XTB.js";function k(e,l,u=l){var s=new WeakSet;m(e,"input",async r=>{var a=r?e.defaultValue:e.value;if(a=o(e)?t(a):a,u(a),v!==null&&s.add(v),await _(),a!==(a=l())){var d=e.selectionStart,f=e.selectionEnd,n=e.value.length;if(e.value=a??"",f!==null){var c=e.value.length;d===f&&f===n&&c>n?(e.selectionStart=c,e.selectionEnd=c):(e.selectionStart=d,e.selectionEnd=Math.min(f,c))}}}),(y&&e.defaultValue!==e.value||b(l)==null&&e.value)&&(u(o(e)?t(e.value):e.value),v!==null&&s.add(v)),i(()=>{var r=l();if(e===document.activeElement){var a=v;if(s.has(a))return}o(e)&&r===t(e.value)||e.type==="date"&&!r&&!e.value||r!==e.value&&(e.value=r??"")})}function o(e){var l=e.type;return l==="number"||l==="range"}function t(e){return e===""?null:+e}export{k as b};
import{A as m,B as v,O as _,o as b,Q as i,N as y}from"./reyx9_7L.js";function E(e,l,u=l){var s=new WeakSet;m(e,"input",async r=>{var a=r?e.defaultValue:e.value;if(a=o(e)?t(a):a,u(a),v!==null&&s.add(v),await _(),a!==(a=l())){var d=e.selectionStart,f=e.selectionEnd,n=e.value.length;if(e.value=a??"",f!==null){var c=e.value.length;d===f&&f===n&&c>n?(e.selectionStart=c,e.selectionEnd=c):(e.selectionStart=d,e.selectionEnd=Math.min(f,c))}}}),(y&&e.defaultValue!==e.value||b(l)==null&&e.value)&&(u(o(e)?t(e.value):e.value),v!==null&&s.add(v)),i(()=>{var r=l();if(e===document.activeElement){var a=v;if(s.has(a))return}o(e)&&r===t(e.value)||e.type==="date"&&!r&&!e.value||r!==e.value&&(e.value=r??"")})}function o(e){var l=e.type;return l==="number"||l==="range"}function t(e){return e===""?null:+e}export{E as b};

View File

@ -1,2 +0,0 @@
import{F as l,af as u,ag as d,ah as g,ai as v,aj as h,ak as A,al as m}from"./DAfY0XTB.js";const i=[...`
\r\f \v\uFEFF`];function I(r,s,t){var e=r==null?"":""+r;if(s&&(e=e?e+" "+s:s),t){for(var n of Object.keys(t))if(t[n])e=e?e+" "+n:n;else if(e.length)for(var f=n.length,o=0;(o=e.indexOf(n,o))>=0;){var a=o+f;(o===0||i.includes(e[o-1]))&&(a===e.length||i.includes(e[a]))?e=(o===0?"":e.substring(0,o))+e.substring(a+1):o=a}}return e===""?null:e}function y(r,s){return r==null?null:String(r)}const p=Symbol("is custom element"),S=Symbol("is html"),L=h?"link":"LINK";function E(r){if(l){var s=!1,t=()=>{if(!s){if(s=!0,r.hasAttribute("value")){var e=r.value;c(r,"value",null),r.value=e}if(r.hasAttribute("checked")){var n=r.checked;c(r,"checked",null),r.checked=n}}};r.__on_r=t,u(t),d()}}function c(r,s,t,e){var n=M(r);l&&(n[s]=r.getAttribute(s),s==="src"||s==="srcset"||s==="href"&&r.nodeName===L)||n[s]!==(n[s]=t)&&(s==="loading"&&(r[g]=t),t==null?r.removeAttribute(s):typeof t!="string"&&N(r).includes(s)?r[s]=t:r.setAttribute(s,t))}function M(r){return r.__attributes??={[p]:r.nodeName.includes("-"),[S]:r.namespaceURI===v}}var _=new Map;function N(r){var s=r.getAttribute("is")||r.nodeName,t=_.get(s);if(t)return t;_.set(s,t=[]);for(var e,n=r,f=Element.prototype;f!==n;){e=m(n);for(var o in e)e[o].set&&t.push(o);n=A(n)}return t}export{y as a,E as r,c as s,I as t};

View File

@ -1 +0,0 @@
import{a8 as z,Y as G,F as A,a2 as M,ac as J,Z as ae,g as P,a0 as re,ao as fe,a1 as Q,a3 as O,a4 as H,ae as K,ap as ie,aq as U,B as le,ar as C,a9 as L,as as se,ab as ue,y as oe,H as te,at as V,au as ve,av as de,O as ce,aw as X,ax as pe,D as ge,a5 as W,a7 as $,ay as B,az as _e,aA as Ee,aa as he,a6 as me,af as Y,aB as j,Q as Te,aC as Ce,aD as we,aE as Ae}from"./Cjw4vZKn.js";function Ne(e,r){return r}function Se(e,r,s){for(var u=[],t=r.length,a,i=r.length,p=0;p<t;p++){let E=r[p];$(E,()=>{if(a){if(a.pending.delete(E),a.done.add(E),a.pending.size===0){var v=e.outrogroups;q(e,V(a.done)),v.delete(a),v.size===0&&(e.outrogroups=null)}}else i-=1},!1)}if(i===0){var l=u.length===0&&s!==null;if(l){var c=s,f=c.parentNode;Ee(f),f.append(c),e.items.clear()}q(e,r,!l)}else a={pending:new Set(r),done:new Set},(e.outrogroups??=new Set).add(a)}function q(e,r,s=!0){var u;if(e.pending.size>0){u=new Set;for(const i of e.pending.values())for(const p of i)u.add(e.items.get(p).e)}for(var t=0;t<r.length;t++){var a=r[t];if(u?.has(a)){a.f|=C;const i=document.createDocumentFragment();he(a,i)}else me(r[t],s)}}var Z;function Re(e,r,s,u,t,a=null){var i=e,p=new Map,l=(r&j)!==0;if(l){var c=e;i=A?M(J(c)):c.appendChild(z())}A&&ae();var f=null,E=oe(()=>{var d=s();return te(d)?d:d==null?[]:V(d)}),v,h=new Map,m=!0;function N(d){(S.effect.f&ge)===0&&(S.pending.delete(d),S.fallback=f,De(S,v,i,r,u),f!==null&&(v.length===0?(f.f&C)===0?W(f):(f.f^=C,k(f,null,i)):$(f,()=>{f=null})))}function n(d){S.pending.delete(d)}var o=G(()=>{v=P(E);var d=v.length;let _=!1;if(A){var b=re(i)===fe;b!==(d===0)&&(i=Q(),M(i),O(!1),_=!0)}for(var D=new Set,g=le,R=ue(),F=0;F<d;F+=1){A&&H.nodeType===K&&H.data===ie&&(i=H,_=!0,O(!1));var T=v[F],y=u(T,F),w=m?null:p.get(y);w?(w.v&&U(w.v,T),w.i&&U(w.i,F),R&&g.unskip_effect(w.e)):(w=Fe(p,m?i:Z??=z(),T,y,F,t,r,s),m||(w.e.f|=C),p.set(y,w)),D.add(y)}if(d===0&&a&&!f&&(m?f=L(()=>a(i)):(f=L(()=>a(Z??=z())),f.f|=C)),d>D.size&&se(),A&&d>0&&M(Q()),!m)if(h.set(g,D),R){for(const[ee,ne]of p)D.has(ee)||g.skip_effect(ne.e);g.oncommit(N),g.ondiscard(n)}else N(g);_&&O(!0),P(E)}),S={effect:o,items:p,pending:h,outrogroups:null,fallback:f};m=!1,A&&(i=H)}function x(e){for(;e!==null&&(e.f&_e)===0;)e=e.next;return e}function De(e,r,s,u,t){var a=(u&Ce)!==0,i=r.length,p=e.items,l=x(e.effect.first),c,f=null,E,v=[],h=[],m,N,n,o;if(a)for(o=0;o<i;o+=1)m=r[o],N=t(m,o),n=p.get(N).e,(n.f&C)===0&&(n.nodes?.a?.measure(),(E??=new Set).add(n));for(o=0;o<i;o+=1){if(m=r[o],N=t(m,o),n=p.get(N).e,e.outrogroups!==null)for(const T of e.outrogroups)T.pending.delete(n),T.done.delete(n);if((n.f&B)!==0&&(W(n),a&&(n.nodes?.a?.unfix(),(E??=new Set).delete(n))),(n.f&C)!==0)if(n.f^=C,n===l)k(n,null,s);else{var S=f?f.next:l;n===e.effect.last&&(e.effect.last=n.prev),n.prev&&(n.prev.next=n.next),n.next&&(n.next.prev=n.prev),I(e,f,n),I(e,n,S),k(n,S,s),f=n,v=[],h=[],l=x(f.next);continue}if(n!==l){if(c!==void 0&&c.has(n)){if(v.length<h.length){var d=h[0],_;f=d.prev;var b=v[0],D=v[v.length-1];for(_=0;_<v.length;_+=1)k(v[_],d,s);for(_=0;_<h.length;_+=1)c.delete(h[_]);I(e,b.prev,D.next),I(e,f,b),I(e,D,d),l=d,f=D,o-=1,v=[],h=[]}else c.delete(n),k(n,l,s),I(e,n.prev,n.next),I(e,n,f===null?e.effect.first:f.next),I(e,f,n),f=n;continue}for(v=[],h=[];l!==null&&l!==n;)(c??=new Set).add(l),h.push(l),l=x(l.next);if(l===null)continue}(n.f&C)===0&&v.push(n),f=n,l=x(n.next)}if(e.outrogroups!==null){for(const T of e.outrogroups)T.pending.size===0&&(q(e,V(T.done)),e.outrogroups?.delete(T));e.outrogroups.size===0&&(e.outrogroups=null)}if(l!==null||c!==void 0){var g=[];if(c!==void 0)for(n of c)(n.f&B)===0&&g.push(n);for(;l!==null;)(l.f&B)===0&&l!==e.fallback&&g.push(l),l=x(l.next);var R=g.length;if(R>0){var F=(u&j)!==0&&i===0?s:null;if(a){for(o=0;o<R;o+=1)g[o].nodes?.a?.measure();for(o=0;o<R;o+=1)g[o].nodes?.a?.fix()}Se(e,g,F)}}a&&Te(()=>{if(E!==void 0)for(n of E)n.nodes?.a?.apply()})}function Fe(e,r,s,u,t,a,i,p){var l=(i&ve)!==0?(i&de)===0?ce(s,!1,!1):X(s):null,c=(i&pe)!==0?X(t):null;return{v:l,i:c,e:L(()=>(a(r,l??s,c??t,p),()=>{e.delete(u)}))}}function k(e,r,s){if(e.nodes)for(var u=e.nodes.start,t=e.nodes.end,a=r&&(r.f&C)===0?r.nodes.start:s;u!==null;){var i=Y(u);if(a.before(u),u===t)return;u=i}}function I(e,r,s){r===null?e.effect.first=s:r.next=s,s===null?e.effect.last=r:s.prev=r}function xe(e,r){let s=null,u=A;var t;if(A){s=H;for(var a=J(document.head);a!==null&&(a.nodeType!==K||a.data!==e);)a=Y(a);if(a===null)O(!1);else{var i=Y(a);a.remove(),M(i)}}A||(t=document.head.appendChild(z()));try{G(()=>r(t),we|Ae)}finally{u&&(O(!0),M(s))}}export{Re as e,xe as h,Ne as i};

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
import{i as A,j as L,P as D,g as P,p as T,a as b,k as B,l as Y,D as x,m as M,o as N,q as U,v as h,w as q,x as w,y as z,z as $,S as j,L as y}from"./Cjw4vZKn.js";import{c as C}from"./DjXdyWBG.js";function F(r,a,t,s){var f=!U||(t&h)!==0,v=(t&M)!==0,E=(t&$)!==0,n=s,S=!0,g=()=>(S&&(S=!1,n=E?N(s):s),n);let u;if(v){var O=j in r||y in r;u=A(r,a)?.set??(O&&a in r?e=>r[a]=e:void 0)}var _,I=!1;v?[_,I]=C(()=>r[a]):_=r[a],_===void 0&&s!==void 0&&(_=g(),u&&(f&&L(),u(_)));var i;if(f?i=()=>{var e=r[a];return e===void 0?g():(S=!0,e)}:i=()=>{var e=r[a];return e!==void 0&&(n=void 0),e===void 0?n:e},f&&(t&D)===0)return i;if(u){var R=r.$$legacy;return(function(e,l){return arguments.length>0?((!f||!l||R||I)&&u(l?i():e),e):i()})}var c=!1,d=((t&q)!==0?w:z)(()=>(c=!1,i()));v&&P(d);var m=Y;return(function(e,l){if(arguments.length>0){const o=l?P(d):f&&v?T(e):e;return b(d,o),c=!0,n!==void 0&&(n=o),e}return B&&c||(m.f&x)!==0?d.v:P(d)})}export{F as p};
import{i as A,j as L,P as D,g as P,a as T,d as b,k as B,l as Y,D as x,m as M,o as N,q as U,v as h,w as q,x as w,y as z,z as $,S as j,L as y}from"./reyx9_7L.js";import{c as C}from"./CvtDgobB.js";function F(r,a,t,s){var f=!U||(t&h)!==0,v=(t&M)!==0,E=(t&$)!==0,n=s,S=!0,g=()=>(S&&(S=!1,n=E?N(s):s),n);let u;if(v){var O=j in r||y in r;u=A(r,a)?.set??(O&&a in r?e=>r[a]=e:void 0)}var _,I=!1;v?[_,I]=C(()=>r[a]):_=r[a],_===void 0&&s!==void 0&&(_=g(),u&&(f&&L(),u(_)));var i;if(f?i=()=>{var e=r[a];return e===void 0?g():(S=!0,e)}:i=()=>{var e=r[a];return e!==void 0&&(n=void 0),e===void 0?n:e},f&&(t&D)===0)return i;if(u){var R=r.$$legacy;return(function(e,l){return arguments.length>0?((!f||!l||R||I)&&u(l?i():e),e):i()})}var c=!1,d=((t&q)!==0?w:z)(()=>(c=!1,i()));v&&P(d);var m=Y;return(function(e,l){if(arguments.length>0){const o=l?P(d):f&&v?T(e):e;return b(d,o),c=!0,n!==void 0&&(n=o),e}return B&&c||(m.f&x)!==0?d.v:P(d)})}export{F as p};

View File

@ -1 +0,0 @@
import{s as f,g as c}from"./DiIboHMF.js";import{G as l,M as b,N as a,O as _,g as p,a as d}from"./Cjw4vZKn.js";let u=!1,t=Symbol();function v(e,r,n){const s=n[r]??={store:null,source:_(void 0),unsubscribe:a};if(s.store!==e&&!(t in n))if(s.unsubscribe(),s.store=e??null,e==null)s.source.v=void 0,s.unsubscribe=a;else{var i=!0;s.unsubscribe=f(e,o=>{i?s.source.v=o:d(s.source,o)}),i=!1}return e&&t in n?c(e):p(s.source)}function y(){const e={};function r(){l(()=>{for(var n in e)e[n].unsubscribe();b(e,t,{enumerable:!1,value:!0})})}return[e,r]}function N(e){var r=u;try{return u=!1,[e(),u]}finally{u=r}}export{v as a,N as c,y as s};

View File

@ -1 +1 @@
import{t as y}from"./B08B5jt4.js";import{F as r}from"./Cjw4vZKn.js";function n(t,e,f,i){var l=t.__style;if(r||l!==e){var s=y(e);(!r||s!==t.getAttribute("style"))&&(s==null?t.removeAttribute("style"):t.style.cssText=s),t.__style=e}return i}export{n as s};
import{t as y}from"./D5EBvEcH.js";import{N as r}from"./reyx9_7L.js";function n(t,e,f,i){var l=t.__style;if(r||l!==e){var s=y(e);(!r||s!==t.getAttribute("style"))&&(s==null?t.removeAttribute("style"):t.style.cssText=s),t.__style=e}return i}export{n as s};

View File

@ -0,0 +1 @@
import{ab as v,ac as c,ad as b,ae as u,af as _,B as g,N as o,a2 as m,ag as k,ah as y,ai as w,a0 as A,aj as F,ak as M,al as x,Z as B,am as p}from"./reyx9_7L.js";class E{anchor;#t=new Map;#s=new Map;#e=new Map;#a=new Set;#i=!0;constructor(t,s=!0){this.anchor=t,this.#i=s}#r=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)v(e),this.#a.delete(s);else{var r=this.#e.get(s);r&&(this.#s.set(s,r.effect),this.#e.delete(s),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),e=r.effect)}for(const[i,f]of this.#t){if(this.#t.delete(i),i===t)break;const a=this.#e.get(f);a&&(c(a.effect),this.#e.delete(f))}for(const[i,f]of this.#s){if(i===s||this.#a.has(i))continue;const a=()=>{if(Array.from(this.#t.values()).includes(i)){var h=document.createDocumentFragment();k(f,h),h.append(u()),this.#e.set(i,{effect:f,fragment:h})}else c(f);this.#a.delete(i),this.#s.delete(i)};this.#i||!e?(this.#a.add(i),b(f,a,!1)):a()}}};#f=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,r]of this.#e)s.includes(e)||(c(r.effect),this.#e.delete(e))};ensure(t,s){var e=g,r=y();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(r){var i=document.createDocumentFragment(),f=u();i.append(f),this.#e.set(t,{effect:_(()=>s(f)),fragment:i})}else this.#s.set(t,_(()=>s(this.anchor)));if(this.#t.set(e,t),r){for(const[a,n]of this.#s)a===t?e.unskip_effect(n):e.skip_effect(n);for(const[a,n]of this.#e)a===t?e.unskip_effect(n.effect):e.skip_effect(n.effect);e.oncommit(this.#r),e.ondiscard(this.#f)}else o&&(this.anchor=m),this.#r(e)}}function T(d,t,s=!1){var e;o&&(e=m,A());var r=new E(d),i=s?F:0;function f(a,n){if(o){var h=M(e);if(a!==parseInt(h.substring(1))){var l=x();B(l),r.anchor=l,p(!1),r.ensure(a,n),p(!0);return}}r.ensure(a,n)}w(()=>{var a=!1;t((n,h=0)=>{a=!0,f(h,n)}),a||f(-1,null)},i)}export{E as B,T as i};

View File

@ -0,0 +1 @@
import{an as t,ao as o,q as c,o as l}from"./reyx9_7L.js";function a(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){o===null&&a(),c&&o.l!==null?u(o).m.push(e):t(()=>{const n=l(e);if(typeof n=="function")return n})}function u(e){var n=e.l;return n.u??={a:[],b:[],m:[]}}export{r as o};

View File

@ -1 +0,0 @@
import{Z as l,_ as o,a0 as u,a1 as n,a2 as d,B as m,F as p,Y as _,a3 as v,a4 as k}from"./DAfY0XTB.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#a=!0;constructor(t,s=!0){this.anchor=t,this.#a=s}#f=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var a=this.#e.get(s);a&&(this.#s.set(s,a.effect),this.#e.delete(s),a.fragment.lastChild.remove(),this.anchor.before(a.fragment),e=a.effect)}for(const[i,f]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(f);r&&(o(r.effect),this.#e.delete(f))}for(const[i,f]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(f,c),c.append(n()),this.#e.set(i,{effect:f,fragment:c})}else o(f);this.#i.delete(i),this.#s.delete(i)};this.#a||!e?(this.#i.add(i),u(f,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,a]of this.#e)s.includes(e)||(o(a.effect),this.#e.delete(e))};ensure(t,s){var e=m,a=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(a){var i=document.createDocumentFragment(),f=n();i.append(f),this.#e.set(t,{effect:d(()=>s(f)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),a){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#f),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#f(e)}}export{w as B};

View File

@ -1 +0,0 @@
import{c as y,a as p,f as _}from"./DCPIP6Ym.js";import{d as j,f as S,h as B,c as i,r as n,b as c,t as v,g as r,u as I}from"./DAfY0XTB.js";import{s as m}from"./D2u1A_4g.js";import{i as q}from"./Br6sCvve.js";import{s as w}from"./DVOkFnep.js";import{s as d}from"./D6E-zrqv.js";import{p as z}from"./B-uV6-Xr.js";import{s as A}from"./utcFFRIM.js";var C=_('<a><span class="mr-1"> </span> </a>'),D=_('<span><span class="mr-1"> </span> </span>');function N(x,o){j(o,!0);let h=z(o,"linked",3,!1);const s=I(()=>A(o.status));var u=y(),b=S(u);{var g=e=>{var t=C(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{w(t,"href",`/ketten?status=${o.status??""}`),d(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""} hover:opacity-80 transition-opacity`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)},k=e=>{var t=D(),a=i(t),f=i(a,!0);n(a);var l=c(a);n(t),v(()=>{d(t,1,`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${r(s).color??""}`),m(f,r(s).emoji),m(l,` ${r(s).label??""}`)}),p(e,t)};q(b,e=>{h()&&o.status?e(g):e(k,-1)})}p(x,u),B()}export{N as S};

View File

@ -1 +0,0 @@
import{s as e}from"./3I_XkZiy.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};

View File

@ -1 +1 @@
import{N as o,o as a,al as d}from"./Cjw4vZKn.js";function p(s,u,e){if(s==null)return u(void 0),o;const t=a(()=>s.subscribe(u,e));return t.unsubscribe?()=>t.unsubscribe():t}const i=[];function _(s,u=o){let e=null;const t=new Set;function c(r){if(d(s,r)&&(s=r,e)){const b=!i.length;for(const n of t)n[1](),i.push(n,s);if(b){for(let n=0;n<i.length;n+=2)i[n][0](i[n+1]);i.length=0}}}function f(r){c(r(s))}function l(r,b=o){const n=[r,b];return t.add(n),t.size===1&&(e=u(c,f)||o),r(s),()=>{t.delete(n),t.size===0&&e&&(e(),e=null)}}return{set:c,update:f,subscribe:l}}function h(s){let u;return p(s,e=>u=e)(),u}export{h as g,p as s,_ as w};
import{K as o,o as a,aa as d}from"./reyx9_7L.js";function p(s,u,e){if(s==null)return u(void 0),o;const t=a(()=>s.subscribe(u,e));return t.unsubscribe?()=>t.unsubscribe():t}const i=[];function _(s,u=o){let e=null;const t=new Set;function c(r){if(d(s,r)&&(s=r,e)){const b=!i.length;for(const n of t)n[1](),i.push(n,s);if(b){for(let n=0;n<i.length;n+=2)i[n][0](i[n+1]);i.length=0}}}function f(r){c(r(s))}function l(r,b=o){const n=[r,b];return t.add(n),t.size===1&&(e=u(c,f)||o),r(s),()=>{t.delete(n),t.size===0&&e&&(e(),e=null)}}return{set:c,update:f,subscribe:l}}function h(s){let u;return p(s,e=>u=e)(),u}export{h as g,p as s,_ as w};

View File

@ -1 +0,0 @@
import{F as i,Q as n,R as d,T as v,U as u,V as h,W as l,X as g}from"./Cjw4vZKn.js";const A=Symbol("is custom element"),T=Symbol("is html"),m=h?"link":"LINK";function N(r){if(i){var s=!1,e=()=>{if(!s){if(s=!0,r.hasAttribute("value")){var t=r.value;_(r,"value",null),r.value=t}if(r.hasAttribute("checked")){var o=r.checked;_(r,"checked",null),r.checked=o}}};r.__on_r=e,n(e),d()}}function _(r,s,e,t){var o=p(r);i&&(o[s]=r.getAttribute(s),s==="src"||s==="srcset"||s==="href"&&r.nodeName===m)||o[s]!==(o[s]=e)&&(s==="loading"&&(r[v]=e),e==null?r.removeAttribute(s):typeof e!="string"&&L(r).includes(s)?r[s]=e:r.setAttribute(s,e))}function p(r){return r.__attributes??={[A]:r.nodeName.includes("-"),[T]:r.namespaceURI===u}}var c=new Map;function L(r){var s=r.getAttribute("is")||r.nodeName,e=c.get(s);if(e)return e;c.set(s,e=[]);for(var t,o=r,f=Element.prototype;f!==o;){t=g(o);for(var a in t)t[a].set&&e.push(a);o=l(o)}return e}export{N as r,_ as s};

View File

@ -1 +0,0 @@
import{Y as c,F as o,Z as l,_ as b,a0 as p,a1 as v,a2 as g,a3 as _,a4 as m}from"./Cjw4vZKn.js";import{B as y}from"./6IKeDOr0.js";function T(f,d,h=!1){var n;o&&(n=m,l());var s=new y(f),u=h?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,_(!1),s.ensure(a,r),_(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;d((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{T as i};

View File

@ -0,0 +1 @@
const r=typeof window<"u"&&window.location.port==="5173"?`http://${window.location.hostname}:8099/api`:"/api";async function n(t){const e=await fetch(`${r}${t}`);if(!e.ok)throw new Error(`API error: ${e.status}`);return e.json()}const s=t=>{const e=new URLSearchParams(t).toString();return n(`/vorlagen?${e}`)},c=t=>n(`/vorlagen/${t}`),i=t=>{const e=new URLSearchParams(t).toString();return n(`/ketten?${e}`)},h=t=>n(`/ketten/${t}`);async function a(t,e){const o=await fetch(`${r}${t}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!o.ok)throw new Error(`API error: ${o.status}`);return o.json()}const f=(t,e)=>a(`/bewertung/vorlagen/${t}`,{anmerkung:e}),g=(t,e)=>a(`/bewertung/ketten/${t}`,{anmerkung:e}),$=t=>n(`/bewertung/status/${t}`),u=t=>n(`/vorlagen/suggest?q=${encodeURIComponent(t)}`),w=()=>n("/fraktionen"),d=(t,e)=>{const o=e?`?jahr=${e}`:"";return n(`/fraktionen/${t}/dashboard${o}`)};export{i as a,c as b,h as c,$ as d,g as e,w as f,u as g,s as h,d as i,f as r};

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{s as f,g as c}from"./DIGUPa-Q.js";import{G as l,M as b,N as a,O as _,g as p,a as d}from"./DAfY0XTB.js";let u=!1,t=Symbol();function v(e,r,n){const s=n[r]??={store:null,source:_(void 0),unsubscribe:a};if(s.store!==e&&!(t in n))if(s.unsubscribe(),s.store=e??null,e==null)s.source.v=void 0,s.unsubscribe=a;else{var i=!0;s.unsubscribe=f(e,o=>{i?s.source.v=o:d(s.source,o)}),i=!1}return e&&t in n?c(e):p(s.source)}function y(){const e={};function r(){l(()=>{for(var n in e)e[n].unsubscribe();b(e,t,{enumerable:!1,value:!0})})}return[e,r]}function N(e){var r=u;try{return u=!1,[e(),u]}finally{u=r}}export{v as a,N as c,y as s};

View File

@ -1 +0,0 @@
import{am as o,an as t,q as c,o as l}from"./Cjw4vZKn.js";function a(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&a(),c&&t.l!==null?u(t).m.push(e):o(()=>{const n=l(e);if(typeof n=="function")return n})}function u(e){var n=e.l;return n.u??={a:[],b:[],m:[]}}export{r as o};

View File

@ -1 +1 @@
import{s as e}from"./CTIvq_GE.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};
import{s as e}from"./BHBF0lbh.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};

View File

@ -1 +0,0 @@
import{A as s,B as v,e as o,G as c,H as b,I as m,J as h}from"./DAfY0XTB.js";function d(e,r,f=!1){if(e.multiple){if(r==null)return;if(!b(r))return m();for(var a of e.options)a.selected=r.includes(i(a));return}for(a of e.options){var t=i(a);if(h(t,r)){a.selected=!0;return}}(!f||r!==void 0)&&(e.selectedIndex=-1)}function y(e){var r=new MutationObserver(()=>{d(e,e.__value)});r.observe(e,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),c(()=>{r.disconnect()})}function S(e,r,f=r){var a=new WeakSet,t=!0;s(e,"change",u=>{var l=u?"[selected]":":checked",n;if(e.multiple)n=[].map.call(e.querySelectorAll(l),i);else{var _=e.querySelector(l)??e.querySelector("option:not([disabled])");n=_&&i(_)}f(n),e.__value=n,v!==null&&a.add(v)}),o(()=>{var u=r();if(e===document.activeElement){var l=v;if(a.has(l))return}if(d(e,u,t),t&&u===void 0){var n=e.querySelector(":checked");n!==null&&(u=i(n),f(u))}e.__value=u,t=!1}),y(e)}function i(e){return"__value"in e?e.__value:e.value}export{S as b};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
import{l as o,a as r}from"../chunks/CTIvq_GE.js";export{o as load_css,r as start};

View File

@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/BHBF0lbh.js";export{o as load_css,r as start};

View File

@ -1 +0,0 @@
import{l as o,a as r}from"../chunks/3I_XkZiy.js";export{o as load_css,r as start};

View File

@ -1 +0,0 @@
import{a as d,f as x}from"../chunks/Bkzsmr9I.js";import{Y as i,_ as l,b as p,c as n,r as o}from"../chunks/Cjw4vZKn.js";import{B as c}from"../chunks/6IKeDOr0.js";/* empty css */function f(r,s,...e){var t=new c(r);i(()=>{const a=s()??null;t.ensure(a,a&&(m=>a(m,...e)))},l)}const g=!1,u=!1,k=Object.freeze(Object.defineProperty({__proto__:null,prerender:g,ssr:u},Symbol.toStringTag,{value:"Module"}));var v=x('<div class="min-h-screen bg-gray-50"><nav class="bg-white border-b border-gray-200 shadow-sm"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex justify-between h-16"><div class="flex items-center space-x-8"><a href="/" class="text-xl font-bold text-gray-900">Antragstracker <span class="text-green-600">Hagen</span></a> <div class="hidden sm:flex space-x-4"><a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Dashboard</a> <a href="/ketten" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Ketten</a> <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="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a></div></div></div></div></nav> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"><!></main></div>');function w(r,s){var e=v(),t=p(n(e),2),a=n(t);f(a,()=>s.children),o(t),o(e),d(r,e)}export{w as component,k as universal};

View File

@ -1 +0,0 @@
import{a as d,f as x}from"../chunks/DCPIP6Ym.js";import{Q as i,T as l,b as p,c as n,r as o}from"../chunks/DAfY0XTB.js";import{B as c}from"../chunks/Duumi1XQ.js";/* empty css */function f(r,s,...e){var t=new c(r);i(()=>{const a=s()??null;t.ensure(a,a&&(m=>a(m,...e)))},l)}const g=!1,u=!1,k=Object.freeze(Object.defineProperty({__proto__:null,prerender:g,ssr:u},Symbol.toStringTag,{value:"Module"}));var v=x('<div class="min-h-screen bg-gray-50"><nav class="bg-white border-b border-gray-200 shadow-sm"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex justify-between h-16"><div class="flex items-center space-x-8"><a href="/" class="text-xl font-bold text-gray-900">Antragstracker <span class="text-green-600">Hagen</span></a> <div class="hidden sm:flex space-x-4"><a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Dashboard</a> <a href="/ketten" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Ketten</a> <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="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a></div></div></div></div></nav> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"><!></main></div>');function w(r,s){var e=v(),t=p(n(e),2),a=n(t);f(a,()=>s.children),o(t),o(e),d(r,e)}export{w as component,k as universal};

View File

@ -0,0 +1 @@
import{d as D,b as d,a as c,f as A,g as B}from"../chunks/B89f14j0.js";import{ai as H,aj as N,t as P,h as t,b as s,g,r,d as i,s as R}from"../chunks/reyx9_7L.js";import{B as V,i as M}from"../chunks/Do7Yo2YN.js";import{s as z}from"../chunks/B-WTs0fq.js";/* empty css */function L(x,p,...e){var l=new V(x);H(()=>{const o=p()??null;l.ensure(o,o&&(m=>o(m,...e)))},N)}const q=!1,G=!1,ee=Object.freeze(Object.defineProperty({__proto__:null,prerender:q,ssr:G},Symbol.toStringTag,{value:"Module"}));var I=B('<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>'),J=B('<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg>'),Q=A('<div class="sm:hidden border-t border-gray-200 bg-white"><div class="px-2 pt-2 pb-3 space-y-1"><a href="/" class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Dashboard</a> <a href="/ketten" class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Ketten</a> <a href="/vorlagen" 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" 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" 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="/fraktionen" 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>'),U=A('<div class="min-h-screen bg-gray-50"><nav class="bg-white border-b border-gray-200 shadow-sm"><div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"><div class="flex justify-between h-16"><div class="flex items-center"><a href="/" class="text-xl font-bold text-gray-900 shrink-0">Antragstracker <span class="text-green-600">Hagen</span></a> <div class="hidden sm:flex sm:ml-8 space-x-4"><a href="/" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Dashboard</a> <a href="/ketten" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Ketten</a> <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="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a></div></div> <div class="flex items-center sm:hidden"><button class="inline-flex items-center justify-center p-3 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500" aria-label="Hauptmenü"><!></button></div></div></div> <!></nav> <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8"><!></main></div>');function ae(x,p){let e=R(!1);var l=U(),o=t(l),m=t(o),f=t(m),h=s(t(f),2),v=t(h),F=t(v);{var K=a=>{var n=I();c(a,n)},T=a=>{var n=J();c(a,n)};M(F,a=>{g(e)?a(K):a(T,-1)})}r(v),r(h),r(f),r(m);var C=s(m,2);{var E=a=>{var n=Q(),y=t(n),b=t(y),k=s(b,2),_=s(k,2),w=s(_,2),j=s(w,2),S=s(j,2);r(y),r(n),d("click",b,()=>i(e,!1)),d("click",k,()=>i(e,!1)),d("click",_,()=>i(e,!1)),d("click",w,()=>i(e,!1)),d("click",j,()=>i(e,!1)),d("click",S,()=>i(e,!1)),c(a,n)};M(C,a=>{g(e)&&a(E)})}r(o);var u=s(o,2),O=t(u);L(O,()=>p.children),r(u),r(l),P(()=>z(v,"aria-expanded",g(e))),d("click",v,()=>i(e,!g(e))),c(x,l)}D(["click"]);export{ae as component,ee as universal};

View File

@ -1 +0,0 @@
import{a as b,f as x}from"../chunks/DCPIP6Ym.js";import{an as k,aL as y,am as i,o as $,aM as l,aN as E,g as v,aO as L,x as M,aP as N,d as O,f as P,t as j,h as q,c as u,r as m,b as w}from"../chunks/DAfY0XTB.js";import{s as g}from"../chunks/D2u1A_4g.js";import{s as z,p as _}from"../chunks/CTIvq_GE.js";function A(r=!1){const t=k,e=t.l.u;if(!e)return;let a=()=>L(t.s);if(r){let o=0,s={};const f=M(()=>{let p=!1;const c=t.s;for(const n in c)c[n]!==s[n]&&(s[n]=c[n],p=!0);return p&&o++,o});a=()=>v(f)}e.b.length&&y(()=>{d(t,a),l(e.b)}),i(()=>{const o=$(()=>e.m.map(E));return()=>{for(const s of o)typeof s=="function"&&s()}}),e.a.length&&i(()=>{d(t,a),l(e.a)})}function d(r,t){if(r.l.s)for(const e of r.l.s)v(e);t()}N();const B={get error(){return _.error},get status(){return _.status}};z.updated.check;const h=B;var C=x("<h1> </h1> <p> </p>",1);function I(r,t){O(t,!1),A();var e=C(),a=P(e),o=u(a,!0);m(a);var s=w(a,2),f=u(s,!0);m(s),j(()=>{g(o,h.status),g(f,h.error?.message)}),b(r,e),q()}export{I as component};

Some files were not shown because too many files have changed in this diff Show More