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
|
||||
|
||||
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):
|
||||
|
||||
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,18 +162,42 @@ 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))
|
||||
|
||||
# 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()
|
||||
|
||||
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)}
|
||||
@ -249,25 +273,67 @@ 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),
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -59,8 +60,11 @@ 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")
|
||||
@ -81,13 +85,14 @@ def list_ketten(
|
||||
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
|
||||
@ -117,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
|
||||
]
|
||||
@ -130,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
|
||||
@ -153,8 +161,45 @@ 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(
|
||||
{
|
||||
**KettenGliedOut(
|
||||
vorlage=VorlageKurz(
|
||||
id=g["id"],
|
||||
aktenzeichen=g["aktenzeichen"],
|
||||
@ -165,7 +210,10 @@ def get_kette(kette_id: int, conn=Depends(_db)):
|
||||
),
|
||||
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
|
||||
]
|
||||
|
||||
@ -183,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(
|
||||
@ -203,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,
|
||||
)
|
||||
|
||||
@ -198,6 +198,13 @@ def get_dashboard_stats(
|
||||
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,
|
||||
@ -212,6 +219,7 @@ def get_dashboard_stats(
|
||||
"abgelehnt": abgelehnt,
|
||||
"total_bewertet": total_bewertet,
|
||||
},
|
||||
"nach_strang": [{"strang": r["strang"], "anzahl": r["c"]} for r in strang_rows],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -281,22 +281,44 @@ 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",
|
||||
(vorlage_id,),
|
||||
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,),
|
||||
).fetchall()
|
||||
ki_zusammenfassung = None
|
||||
if ki_row and ki_row["anmerkungen"]:
|
||||
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
|
||||
|
||||
@ -340,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,
|
||||
}
|
||||
@ -17,10 +17,12 @@ FRAKTION_MAPPING: dict[str, str] = {
|
||||
"Linke": "Linke",
|
||||
"Linke/HAK": "Linke/HAK",
|
||||
|
||||
# Hagen Aktiv
|
||||
# Hagen Aktiv (Freie Wählergemeinschaft)
|
||||
"HAGEN AKTIV": "Hagen Aktiv",
|
||||
"Hagen Aktiv": "Hagen Aktiv",
|
||||
"HAK": "Hagen Aktiv",
|
||||
|
||||
# HAK (Hagener Aktivistenkreis) — NICHT Hagen Aktiv!
|
||||
"HAK": "HAK",
|
||||
"HAK/Die Linke": "HAK/Linke",
|
||||
|
||||
# BfHo / Die PARTEI
|
||||
@ -59,7 +61,7 @@ FRAKTION_MAPPING: dict[str, str] = {
|
||||
# Ratsfraktionen (für Stimmverhalten/Koalitionsmatrix relevant)
|
||||
RATSFRAKTIONEN = {
|
||||
"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.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(
|
||||
title="Antragstracker Hagen",
|
||||
@ -31,6 +31,7 @@ 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.get("/api/health")
|
||||
|
||||
@ -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 {
|
||||
@ -216,6 +248,17 @@ 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 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) => {
|
||||
const p = new URLSearchParams();
|
||||
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>
|
||||
<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>
|
||||
@ -71,6 +72,7 @@
|
||||
<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>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
import '../app.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import { filters, filterVersion } from '$lib/filters.svelte';
|
||||
import { fetchAmpelDefinition, type AmpelDefinition } from '$lib/api';
|
||||
import Ampel from '$lib/components/Ampel.svelte';
|
||||
|
||||
interface Vorlage {
|
||||
id: number;
|
||||
@ -30,6 +32,7 @@
|
||||
|
||||
let stats = $state<DashboardStats | null>(null);
|
||||
let antraege = $state<Vorlage[]>([]);
|
||||
let ampelDef = $state<AmpelDefinition | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
@ -103,6 +106,11 @@
|
||||
const dashSuffix = fqs ? `?${fqs}` : '';
|
||||
const vorlagenSuffix = fqs ? `&${fqs}` : '';
|
||||
|
||||
// Load ampel definition once
|
||||
if (!ampelDef) {
|
||||
try { ampelDef = await fetchAmpelDefinition(); } catch {}
|
||||
}
|
||||
|
||||
const [dashRes, antraegeRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
|
||||
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
|
||||
@ -310,4 +318,53 @@
|
||||
</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>
|
||||
{/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 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">
|
||||
|
||||
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