feat: Strang-basierte Klassifikation + Explorer + Ampel (#16)
Neue Features: - 4 Verfahrensstränge: Antrag, Anfrage, Beschlussvorlage, Mitteilung - Ampel-Visualisierung pro Kette (Fortschrittsanzeige mit Abzweigungen) - 3-Panel Explorer (/explorer): Liste | Kette+Ampel | Vorlage-Detail - KI-Bewertungs-Versionierung (alte Versionen aufklappbar) - Neubewertung triggert automatisch Umsetzungs-Score - Bewertungs-Log (bewertungs_log Tabelle) - Umsetzungsgrad an Kette (Score + Begründung) - Antragsteller + Beratungsergebnis pro Kettenglied - HAK und Hagen Aktiv als getrennte Fraktionen - Status-Filter im Explorer - Suche durchsucht Aktenzeichen + Betreff Backend: - tracker/core/ampel.py — Ampel-Definition + get_ampel() - tracker/core/perioden.py — Shared Perioden-Filter - Neues Feld: ketten.strang, ki_bewertungen.kette_id - GET /api/ampel/definition, erweiterte Ketten/Vorlagen-APIs Closes #16
This commit is contained in:
parent
6db12e297d
commit
f8bc893a54
6
.github/WORKFLOW.md
vendored
6
.github/WORKFLOW.md
vendored
@ -11,3 +11,9 @@
|
|||||||
## Hotfixes direkt auf main
|
## Hotfixes direkt auf main
|
||||||
|
|
||||||
Nur für: Typo-Fixes, Config-Änderungen, Doku-Updates
|
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] = []
|
referenzen_eingehend: list[ReferenzOut] = []
|
||||||
kette_id: int | None = None
|
kette_id: int | None = None
|
||||||
umsetzungsbewertungen: list[UmsetzungsBewertung] = []
|
umsetzungsbewertungen: list[UmsetzungsBewertung] = []
|
||||||
|
ampel: dict | None = None
|
||||||
|
ki_versionen: list[dict] | None = None
|
||||||
|
|
||||||
|
|
||||||
class KettenGliedOut(BaseModel):
|
class KettenGliedOut(BaseModel):
|
||||||
@ -102,6 +104,8 @@ class KetteKurz(BaseModel):
|
|||||||
letzte_aktivitaet: date | None = None
|
letzte_aktivitaet: date | None = None
|
||||||
vertagungen_count: int = 0
|
vertagungen_count: int = 0
|
||||||
glieder_count: int = 0
|
glieder_count: int = 0
|
||||||
|
strang: str | None = None
|
||||||
|
ampel: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
class KetteDetail(BaseModel):
|
class KetteDetail(BaseModel):
|
||||||
@ -114,9 +118,13 @@ class KetteDetail(BaseModel):
|
|||||||
letzte_aktivitaet: date | None = None
|
letzte_aktivitaet: date | None = None
|
||||||
vertagungen_count: int = 0
|
vertagungen_count: int = 0
|
||||||
begruendung: str | None = None
|
begruendung: str | None = None
|
||||||
glieder: list[KettenGliedOut] = []
|
glieder: list[dict] = []
|
||||||
antragsteller: list[ParteiOut] = []
|
antragsteller: list[ParteiOut] = []
|
||||||
graph: dict | None = None
|
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):
|
class PaginatedVorlagen(BaseModel):
|
||||||
|
|||||||
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()
|
||||||
@ -162,19 +162,43 @@ def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str):
|
|||||||
_jobs[job_id] = {"status": "error", "error": str(result)}
|
_jobs[job_id] = {"status": "error", "error": str(result)}
|
||||||
return
|
return
|
||||||
|
|
||||||
# Delete old, insert new
|
# Get previous version for logging
|
||||||
conn.execute("DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung'", (vorlage_id,))
|
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(
|
conn.execute(
|
||||||
"""INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version)
|
"""INSERT INTO ki_bewertungen (vorlage_id, typ, begruendung, anmerkungen, modell, prompt_version, erstellt_at)
|
||||||
VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval')""",
|
VALUES (?, 'zusammenfassung', ?, ?, 'qwen-plus-latest', 'v2-reeval', ?)""",
|
||||||
(vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False)),
|
(vorlage_id, result.get("zusammenfassung"), json.dumps(result, ensure_ascii=False),
|
||||||
|
datetime.now().isoformat()),
|
||||||
)
|
)
|
||||||
if result.get("kernforderung"):
|
if result.get("kernforderung"):
|
||||||
conn.execute("UPDATE vorlagen SET thema_kurz = ? WHERE id = ?", (result["kernforderung"][:200], vorlage_id))
|
conn.execute("UPDATE vorlagen SET thema_kurz = ? WHERE id = ?", (result["kernforderung"][:200], 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()
|
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()
|
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:
|
except Exception as e:
|
||||||
_jobs[job_id] = {"status": "error", "error": str(e)}
|
_jobs[job_id] = {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
@ -249,25 +273,67 @@ def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str):
|
|||||||
_jobs[job_id] = {"status": "error", "error": str(result)}
|
_jobs[job_id] = {"status": "error", "error": str(result)}
|
||||||
return
|
return
|
||||||
|
|
||||||
# Delete old umsetzung_match, insert new
|
# Keep old versions, insert new (linked to kette, not vorlage)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'umsetzung_match'",
|
"""INSERT INTO ki_bewertungen (vorlage_id, kette_id, typ, score, begruendung, anmerkungen, modell, prompt_version, erstellt_at)
|
||||||
(kette["ursprung_id"],),
|
VALUES (?, ?, 'umsetzung_match', ?, ?, ?, 'qwen-plus-latest', 'v2-reeval', ?)""",
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""INSERT INTO ki_bewertungen (vorlage_id, typ, score, begruendung, anmerkungen, modell, prompt_version)
|
|
||||||
VALUES (?, 'umsetzung_match', ?, ?, ?, 'qwen-plus-latest', 'v2-reeval')""",
|
|
||||||
(
|
(
|
||||||
kette["ursprung_id"],
|
kette["ursprung_id"],
|
||||||
|
kette_id,
|
||||||
result.get("score"),
|
result.get("score"),
|
||||||
result.get("begruendung"),
|
result.get("begruendung"),
|
||||||
json.dumps(result, ensure_ascii=False),
|
json.dumps(result, ensure_ascii=False),
|
||||||
|
datetime.now().isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Rebuild chain status
|
# Get previous scores for logging
|
||||||
from tracker.core.chains import build_single_chain
|
prev_ki = conn.execute(
|
||||||
build_single_chain(conn, kette["ursprung_id"])
|
"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),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from tracker.api.models import (
|
|||||||
ParteiOut,
|
ParteiOut,
|
||||||
VorlageKurz,
|
VorlageKurz,
|
||||||
)
|
)
|
||||||
|
from tracker.core.ampel import get_ampel, get_ampel_kompakt
|
||||||
from tracker.core.graph import get_kette_graph
|
from tracker.core.graph import get_kette_graph
|
||||||
from tracker.db.session import get_connection
|
from tracker.db.session import get_connection
|
||||||
|
|
||||||
@ -59,8 +60,11 @@ def list_ketten(
|
|||||||
params.append(partei)
|
params.append(partei)
|
||||||
|
|
||||||
if suche:
|
if suche:
|
||||||
where_clauses.append("k.thema LIKE ?")
|
where_clauses.append(
|
||||||
params.append(f"%{suche}%")
|
"(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)
|
# Global filter: Ratsperiode (filter on letzte_aktivitaet)
|
||||||
per_clause, per_params = periode_date_filter(periode, "k.letzte_aktivitaet")
|
per_clause, per_params = periode_date_filter(periode, "k.letzte_aktivitaet")
|
||||||
@ -81,13 +85,14 @@ def list_ketten(
|
|||||||
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||||
|
|
||||||
total = conn.execute(
|
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"]
|
).fetchone()["cnt"]
|
||||||
|
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
f"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit,
|
f"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit,
|
||||||
k.letzte_aktivitaet, k.vertagungen_count, k.ursprung_id,
|
k.letzte_aktivitaet, k.vertagungen_count, k.ursprung_id,
|
||||||
|
k.strang,
|
||||||
v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
|
v.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
|
||||||
v.ist_verwaltungsvorlage,
|
v.ist_verwaltungsvorlage,
|
||||||
(SELECT COUNT(*) FROM ketten_glieder kg WHERE kg.kette_id = k.id) as glieder_count
|
(SELECT COUNT(*) FROM ketten_glieder kg WHERE kg.kette_id = k.id) as glieder_count
|
||||||
@ -117,6 +122,8 @@ def list_ketten(
|
|||||||
letzte_aktivitaet=r["letzte_aktivitaet"],
|
letzte_aktivitaet=r["letzte_aktivitaet"],
|
||||||
vertagungen_count=r["vertagungen_count"],
|
vertagungen_count=r["vertagungen_count"],
|
||||||
glieder_count=r["glieder_count"],
|
glieder_count=r["glieder_count"],
|
||||||
|
strang=r["strang"],
|
||||||
|
ampel=get_ampel_kompakt(r["strang"] or "", r["status"] or ""),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@ -130,6 +137,7 @@ def get_kette(kette_id: int, conn=Depends(_db)):
|
|||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit,
|
"""SELECT k.id, k.typ, k.thema, k.status, k.status_seit,
|
||||||
k.letzte_aktivitaet, k.vertagungen_count, k.begruendung, k.ursprung_id,
|
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.aktenzeichen, v.typ as v_typ, v.betreff, v.datum_eingang,
|
||||||
v.ist_verwaltungsvorlage
|
v.ist_verwaltungsvorlage
|
||||||
FROM ketten k
|
FROM ketten k
|
||||||
@ -153,19 +161,59 @@ def get_kette(kette_id: int, conn=Depends(_db)):
|
|||||||
(kette_id,),
|
(kette_id,),
|
||||||
).fetchall()
|
).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 = [
|
glieder = [
|
||||||
KettenGliedOut(
|
{
|
||||||
vorlage=VorlageKurz(
|
**KettenGliedOut(
|
||||||
id=g["id"],
|
vorlage=VorlageKurz(
|
||||||
aktenzeichen=g["aktenzeichen"],
|
id=g["id"],
|
||||||
typ=g["typ"],
|
aktenzeichen=g["aktenzeichen"],
|
||||||
betreff=g["betreff"],
|
typ=g["typ"],
|
||||||
datum_eingang=g["datum_eingang"],
|
betreff=g["betreff"],
|
||||||
ist_verwaltungsvorlage=bool(g["ist_verwaltungsvorlage"]),
|
datum_eingang=g["datum_eingang"],
|
||||||
),
|
ist_verwaltungsvorlage=bool(g["ist_verwaltungsvorlage"]),
|
||||||
position=g["position"],
|
),
|
||||||
rolle=g["rolle"],
|
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
|
for g in glieder_rows
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -183,6 +231,41 @@ def get_kette(kette_id: int, conn=Depends(_db)):
|
|||||||
# Graph/Perlenschnur data
|
# Graph/Perlenschnur data
|
||||||
graph = get_kette_graph(conn, kette_id)
|
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(
|
return KetteDetail(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
ursprung=VorlageKurz(
|
ursprung=VorlageKurz(
|
||||||
@ -203,4 +286,8 @@ def get_kette(kette_id: int, conn=Depends(_db)):
|
|||||||
glieder=glieder,
|
glieder=glieder,
|
||||||
antragsteller=antragsteller,
|
antragsteller=antragsteller,
|
||||||
graph=graph,
|
graph=graph,
|
||||||
|
strang=strang,
|
||||||
|
ampel=ampel_data,
|
||||||
|
umsetzung=umsetzung,
|
||||||
|
umsetzung_versionen=umsetzung_versionen if umsetzung_versionen else None,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -198,6 +198,13 @@ def get_dashboard_stats(
|
|||||||
abgelehnt = _k_count("abgelehnt")
|
abgelehnt = _k_count("abgelehnt")
|
||||||
total_bewertet = umgesetzt + teilweise + versandet + beschlossen + 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 {
|
return {
|
||||||
"vorlagen_total": vorlagen_total,
|
"vorlagen_total": vorlagen_total,
|
||||||
"ketten_total": ketten_total,
|
"ketten_total": ketten_total,
|
||||||
@ -212,6 +219,7 @@ def get_dashboard_stats(
|
|||||||
"abgelehnt": abgelehnt,
|
"abgelehnt": abgelehnt,
|
||||||
"total_bewertet": total_bewertet,
|
"total_bewertet": total_bewertet,
|
||||||
},
|
},
|
||||||
|
"nach_strang": [{"strang": r["strang"], "anzahl": r["c"]} for r in strang_rows],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -281,24 +281,46 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)):
|
|||||||
# Referenzen
|
# Referenzen
|
||||||
refs = get_references_for_vorlage(conn, vorlage_id)
|
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(
|
kette_row = conn.execute(
|
||||||
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1",
|
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1",
|
||||||
(vorlage_id,),
|
(vorlage_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
# KI-Zusammenfassung
|
kette_ampel = None
|
||||||
ki_row = conn.execute(
|
if kette_row:
|
||||||
"SELECT anmerkungen FROM ki_bewertungen WHERE vorlage_id = ? AND typ = 'zusammenfassung' LIMIT 1",
|
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,),
|
(vorlage_id,),
|
||||||
).fetchone()
|
).fetchall()
|
||||||
ki_zusammenfassung = None
|
ki_zusammenfassung = None
|
||||||
if ki_row and ki_row["anmerkungen"]:
|
ki_versionen = []
|
||||||
try:
|
for i, ki_row in enumerate(ki_rows):
|
||||||
ki_data = json.loads(ki_row["anmerkungen"])
|
if ki_row["anmerkungen"]:
|
||||||
ki_zusammenfassung = KiZusammenfassung(**ki_data)
|
try:
|
||||||
except (json.JSONDecodeError, TypeError):
|
ki_data = json.loads(ki_row["anmerkungen"])
|
||||||
pass
|
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
|
# Umsetzungsbewertungen
|
||||||
from tracker.api.models import UmsetzungsBewertung
|
from tracker.api.models import UmsetzungsBewertung
|
||||||
@ -340,4 +362,6 @@ def get_vorlage(vorlage_id: int, conn=Depends(_db)):
|
|||||||
kette_id=kette_row["kette_id"] if kette_row else None,
|
kette_id=kette_row["kette_id"] if kette_row else None,
|
||||||
ki_zusammenfassung=ki_zusammenfassung,
|
ki_zusammenfassung=ki_zusammenfassung,
|
||||||
umsetzungsbewertungen=umsetzungsbewertungen,
|
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,
|
||||||
|
}
|
||||||
@ -17,10 +17,12 @@ FRAKTION_MAPPING: dict[str, str] = {
|
|||||||
"Linke": "Linke",
|
"Linke": "Linke",
|
||||||
"Linke/HAK": "Linke/HAK",
|
"Linke/HAK": "Linke/HAK",
|
||||||
|
|
||||||
# Hagen Aktiv
|
# Hagen Aktiv (Freie Wählergemeinschaft)
|
||||||
"HAGEN AKTIV": "Hagen Aktiv",
|
"HAGEN AKTIV": "Hagen Aktiv",
|
||||||
"Hagen Aktiv": "Hagen Aktiv",
|
"Hagen Aktiv": "Hagen Aktiv",
|
||||||
"HAK": "Hagen Aktiv",
|
|
||||||
|
# HAK (Hagener Aktivistenkreis) — NICHT Hagen Aktiv!
|
||||||
|
"HAK": "HAK",
|
||||||
"HAK/Die Linke": "HAK/Linke",
|
"HAK/Die Linke": "HAK/Linke",
|
||||||
|
|
||||||
# BfHo / Die PARTEI
|
# BfHo / Die PARTEI
|
||||||
@ -59,7 +61,7 @@ FRAKTION_MAPPING: dict[str, str] = {
|
|||||||
# Ratsfraktionen (für Stimmverhalten/Koalitionsmatrix relevant)
|
# Ratsfraktionen (für Stimmverhalten/Koalitionsmatrix relevant)
|
||||||
RATSFRAKTIONEN = {
|
RATSFRAKTIONEN = {
|
||||||
"SPD", "CDU", "Grüne", "FDP", "AfD", "Linke", "Hagen Aktiv",
|
"SPD", "CDU", "Grüne", "FDP", "AfD", "Linke", "Hagen Aktiv",
|
||||||
"BfHo", "BfHo/Die PARTEI", "BSW", "Freie Wähler",
|
"HAK", "BfHo", "BfHo/Die PARTEI", "BSW", "Die PARTEI",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
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, ketten, orte, stats, vorlagen
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Antragstracker Hagen",
|
title="Antragstracker Hagen",
|
||||||
@ -31,6 +31,7 @@ app.include_router(abstimmungen.router, prefix="/api")
|
|||||||
app.include_router(orte.router, prefix="/api")
|
app.include_router(orte.router, prefix="/api")
|
||||||
app.include_router(fraktionen.router, prefix="/api")
|
app.include_router(fraktionen.router, prefix="/api")
|
||||||
app.include_router(bewertung.router, prefix="/api")
|
app.include_router(bewertung.router, prefix="/api")
|
||||||
|
app.include_router(ampel.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@ -68,6 +68,34 @@ export interface VorlageDetail extends VorlageKurz {
|
|||||||
kette_id: number | null;
|
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 {
|
export interface KetteKurz {
|
||||||
id: number;
|
id: number;
|
||||||
ursprung: VorlageKurz | null;
|
ursprung: VorlageKurz | null;
|
||||||
@ -78,6 +106,8 @@ export interface KetteKurz {
|
|||||||
letzte_aktivitaet: string | null;
|
letzte_aktivitaet: string | null;
|
||||||
vertagungen_count: number;
|
vertagungen_count: number;
|
||||||
glieder_count: number;
|
glieder_count: number;
|
||||||
|
strang: string | null;
|
||||||
|
ampel: AmpelKompakt | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KettenGliedOut {
|
export interface KettenGliedOut {
|
||||||
@ -102,6 +132,8 @@ export interface KetteDetail {
|
|||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
} | null;
|
} | null;
|
||||||
|
strang: string | null;
|
||||||
|
ampel: AmpelData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
@ -216,6 +248,17 @@ export const fetchSuchvorschlaege = (q: string) =>
|
|||||||
get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`);
|
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 fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen');
|
||||||
|
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');
|
||||||
|
|
||||||
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
|
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
if (jahr) p.set('jahr', jahr);
|
if (jahr) p.set('jahr', jahr);
|
||||||
|
|||||||
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}
|
||||||
@ -37,6 +37,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<div class="hidden sm:flex sm:ml-8 space-x-4">
|
<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="/" 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="/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="/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="/abstimmungen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Abstimmungen</a>
|
||||||
@ -71,6 +72,7 @@
|
|||||||
<div class="sm:hidden border-t border-gray-200 bg-white">
|
<div class="sm:hidden border-t border-gray-200 bg-white">
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
<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="/" 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="/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="/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="/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>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { filters, filterVersion } from '$lib/filters.svelte';
|
import { filters, filterVersion } from '$lib/filters.svelte';
|
||||||
|
import { fetchAmpelDefinition, type AmpelDefinition } from '$lib/api';
|
||||||
|
import Ampel from '$lib/components/Ampel.svelte';
|
||||||
|
|
||||||
interface Vorlage {
|
interface Vorlage {
|
||||||
id: number;
|
id: number;
|
||||||
@ -30,6 +32,7 @@
|
|||||||
|
|
||||||
let stats = $state<DashboardStats | null>(null);
|
let stats = $state<DashboardStats | null>(null);
|
||||||
let antraege = $state<Vorlage[]>([]);
|
let antraege = $state<Vorlage[]>([]);
|
||||||
|
let ampelDef = $state<AmpelDefinition | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@ -103,6 +106,11 @@
|
|||||||
const dashSuffix = fqs ? `?${fqs}` : '';
|
const dashSuffix = fqs ? `?${fqs}` : '';
|
||||||
const vorlagenSuffix = fqs ? `&${fqs}` : '';
|
const vorlagenSuffix = fqs ? `&${fqs}` : '';
|
||||||
|
|
||||||
|
// Load ampel definition once
|
||||||
|
if (!ampelDef) {
|
||||||
|
try { ampelDef = await fetchAmpelDefinition(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const [dashRes, antraegeRes] = await Promise.all([
|
const [dashRes, antraegeRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
|
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
|
||||||
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
|
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
|
||||||
@ -310,4 +318,53 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</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>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
703
frontend/src/routes/explorer/+page.svelte
Normal file
703
frontend/src/routes/explorer/+page.svelte
Normal file
@ -0,0 +1,703 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fetchKetten, fetchKette, fetchVorlage, reevalVorlage, fetchJobStatus, type KetteKurz, type KetteDetail, type VorlageDetail, type Paginated } from '$lib/api';
|
||||||
|
import { 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);
|
||||||
|
let showReeval = $state(false);
|
||||||
|
let reevalAnmerkung = $state('');
|
||||||
|
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
// 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}
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
KI bewertet…
|
||||||
|
</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>
|
||||||
@ -9,6 +9,7 @@
|
|||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let showVolltext = $state(false);
|
let showVolltext = $state(false);
|
||||||
let showReeval = $state(false);
|
let showReeval = $state(false);
|
||||||
|
let showVersionen = $state(false);
|
||||||
let reevalAnmerkung = $state('');
|
let reevalAnmerkung = $state('');
|
||||||
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
|
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
|
||||||
let reevalError = $state('');
|
let reevalError = $state('');
|
||||||
@ -201,6 +202,33 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Umsetzungsbewertung -->
|
||||||
{#if vorlage.umsetzungsbewertungen?.length}
|
{#if vorlage.umsetzungsbewertungen?.length}
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
|||||||
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)
|
||||||
Loading…
Reference in New Issue
Block a user