Compare commits
10 Commits
31b1e1bd7e
...
69edf8f64c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69edf8f64c | ||
|
|
c3e9f4b3e8 | ||
|
|
4d13b6828e | ||
|
|
abcb0ff8a2 | ||
|
|
0e7aa065e5 | ||
|
|
f8bc893a54 | ||
|
|
6db12e297d | ||
|
|
c6291a285a | ||
|
|
7358aa1b61 | ||
|
|
ea3e5cd329 |
19
.github/WORKFLOW.md
vendored
Normal file
19
.github/WORKFLOW.md
vendored
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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?"""
|
||||
|
||||
14
backend/src/tracker/api/routes/ampel.py
Normal file
14
backend/src/tracker/api/routes/ampel.py
Normal 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()
|
||||
@ -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", "")}
|
||||
|
||||
@ -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
|
||||
|
||||
198
backend/src/tracker/api/routes/fristen.py
Normal file
198
backend/src/tracker/api/routes/fristen.py
Normal 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)}
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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."""
|
||||
|
||||
104
backend/src/tracker/api/routes/sync.py
Normal file
104
backend/src/tracker/api/routes/sync.py
Normal 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.",
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
|
||||
236
backend/src/tracker/core/ampel.py
Normal file
236
backend/src/tracker/core/ampel.py
Normal 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,
|
||||
}
|
||||
80
backend/src/tracker/core/fraktionen_mapping.py
Normal file
80
backend/src/tracker/core/fraktionen_mapping.py
Normal 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]
|
||||
46
backend/src/tracker/core/perioden.py
Normal file
46
backend/src/tracker/core/perioden.py
Normal 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()]
|
||||
274
backend/src/tracker/core/rescrape.py
Normal file
274
backend/src/tracker/core/rescrape.py
Normal 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()
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
antragstracker:
|
||||
build: .
|
||||
|
||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"leaflet": "^1.9.4"
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.markercluster": "^1.5.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}` : ''}`);
|
||||
};
|
||||
|
||||
177
frontend/src/lib/components/Ampel.svelte
Normal file
177
frontend/src/lib/components/Ampel.svelte
Normal 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}
|
||||
84
frontend/src/lib/filters.svelte.ts
Normal file
84
frontend/src/lib/filters.svelte.ts
Normal 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(',')}`;
|
||||
}
|
||||
@ -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()}
|
||||
|
||||
@ -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">2004–2026</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">2004–2026</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}
|
||||
|
||||
@ -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}
|
||||
|
||||
830
frontend/src/routes/explorer/+page.svelte
Normal file
830
frontend/src/routes/explorer/+page.svelte
Normal 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>
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
262
frontend/src/routes/fristen/+page.svelte
Normal file
262
frontend/src/routes/fristen/+page.svelte
Normal 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}
|
||||
@ -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: '© <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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
113
scripts/deploy.sh
Executable 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'"
|
||||
@ -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__":
|
||||
|
||||
44
scripts/migrate_fristen.py
Normal file
44
scripts/migrate_fristen.py
Normal 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
64
scripts/migrate_strang.py
Normal 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
617
scripts/sync_oparl.py
Normal 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
1
static/_app/immutable/assets/app.DDT1WQ7Y.css
Normal file
1
static/_app/immutable/assets/app.DDT1WQ7Y.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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};
|
||||
1
static/_app/immutable/chunks/B-WTs0fq.js
Normal file
1
static/_app/immutable/chunks/B-WTs0fq.js
Normal 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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
1
static/_app/immutable/chunks/B89f14j0.js
Normal file
1
static/_app/immutable/chunks/B89f14j0.js
Normal 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};
|
||||
1
static/_app/immutable/chunks/BHBF0lbh.js
Normal file
1
static/_app/immutable/chunks/BHBF0lbh.js
Normal file
File diff suppressed because one or more lines are too long
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
2
static/_app/immutable/chunks/BwTTNG21.js
Normal file
2
static/_app/immutable/chunks/BwTTNG21.js
Normal 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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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
@ -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
1
static/_app/immutable/chunks/Cm2grUT-.js
Normal file
1
static/_app/immutable/chunks/Cm2grUT-.js
Normal 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};
|
||||
1
static/_app/immutable/chunks/CvtDgobB.js
Normal file
1
static/_app/immutable/chunks/CvtDgobB.js
Normal 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
2
static/_app/immutable/chunks/D5EBvEcH.js
Normal file
2
static/_app/immutable/chunks/D5EBvEcH.js
Normal 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};
|
||||
@ -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
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
1
static/_app/immutable/chunks/Do7Yo2YN.js
Normal file
1
static/_app/immutable/chunks/Do7Yo2YN.js
Normal 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};
|
||||
1
static/_app/immutable/chunks/DrzKg1h1.js
Normal file
1
static/_app/immutable/chunks/DrzKg1h1.js
Normal 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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
1
static/_app/immutable/chunks/nhOotKLT.js
Normal file
1
static/_app/immutable/chunks/nhOotKLT.js
Normal 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};
|
||||
1
static/_app/immutable/chunks/reyx9_7L.js
Normal file
1
static/_app/immutable/chunks/reyx9_7L.js
Normal file
File diff suppressed because one or more lines are too long
@ -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};
|
||||
@ -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};
|
||||
@ -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};
|
||||
@ -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
2
static/_app/immutable/entry/app.Bf2mSiZt.js
Normal file
2
static/_app/immutable/entry/app.Bf2mSiZt.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +0,0 @@
|
||||
import{l as o,a as r}from"../chunks/CTIvq_GE.js";export{o as load_css,r as start};
|
||||
1
static/_app/immutable/entry/start.CJw4zQCo.js
Normal file
1
static/_app/immutable/entry/start.CJw4zQCo.js
Normal file
@ -0,0 +1 @@
|
||||
import{l as o,a as r}from"../chunks/BHBF0lbh.js";export{o as load_css,r as start};
|
||||
@ -1 +0,0 @@
|
||||
import{l as o,a as r}from"../chunks/3I_XkZiy.js";export{o as load_css,r as start};
|
||||
@ -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};
|
||||
@ -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};
|
||||
1
static/_app/immutable/nodes/0.DUsCU6Z7.js
Normal file
1
static/_app/immutable/nodes/0.DUsCU6Z7.js
Normal 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};
|
||||
@ -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
Loading…
Reference in New Issue
Block a user