feat: Fristen-Tracking — Termine und Wiedervorlagen an Ketten (#17)
Neue Features: - fristen-Tabelle: Typ, Datum, Status (offen/überfällig/erfüllt), Quelle (manuell/KI) - API: GET/POST/PATCH/DELETE /api/fristen + /api/fristen/ueberfaellig - KI-Extraktion: Prompts extrahieren automatisch Fristen aus Beschlusstexten - /fristen Seite: Tabelle/Cards mit Farbcodierung + Filter + Pagination - Explorer Panel 2: Fristen pro Kette + Formular zum Hinzufügen - Dashboard: Überfällige-Fristen-Kachel (rot wenn > 0) - Navigation: Fristen-Link Closes #17
This commit is contained in:
parent
f8bc893a54
commit
0e7aa065e5
@ -72,9 +72,17 @@ Erstelle eine strukturierte Zusammenfassung im JSON-Format:
|
|||||||
"begruendung": "Warum wird das gefordert? (kurz)",
|
"begruendung": "Warum wird das gefordert? (kurz)",
|
||||||
"thema": "Hauptthema (z.B. Verkehr, Soziales, Umwelt)",
|
"thema": "Hauptthema (z.B. Verkehr, Soziales, Umwelt)",
|
||||||
"partei": "Antragstellende Fraktion falls erkennbar",
|
"partei": "Antragstellende Fraktion falls erkennbar",
|
||||||
"orte": []
|
"orte": [],
|
||||||
|
"fristen": []
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
Zusätzlich: Gibt es im Text genannte Fristen, Termine, Zeitangaben oder Zusagen mit Zeithorizont?
|
||||||
|
Wenn ja, ergänze im JSON:
|
||||||
|
"fristen": [
|
||||||
|
{{"typ": "überarbeitung|bericht|prüfung|umsetzung|sonstiges", "datum": "YYYY-MM-DD", "beschreibung": "Was soll bis wann passieren"}}
|
||||||
|
]
|
||||||
|
Wenn keine Fristen erkennbar: "fristen": []
|
||||||
|
|
||||||
NUR JSON ausgeben, keine Erklärungen."""
|
NUR JSON ausgeben, keine Erklärungen."""
|
||||||
|
|
||||||
|
|
||||||
@ -104,9 +112,13 @@ Bewerte NUR als JSON:
|
|||||||
"bewertung": "erfuellt|teilweise|abgewiegelt|nebelkerze|vertagt|unklar",
|
"bewertung": "erfuellt|teilweise|abgewiegelt|nebelkerze|vertagt|unklar",
|
||||||
"begruendung": "1-2 Sätze warum",
|
"begruendung": "1-2 Sätze warum",
|
||||||
"kernpunkt_erfuellt": true/false,
|
"kernpunkt_erfuellt": true/false,
|
||||||
"details": "Was konkret beschlossen/abgelehnt wurde"
|
"details": "Was konkret beschlossen/abgelehnt wurde",
|
||||||
|
"fristen": [{{"typ": "überarbeitung|bericht|prüfung|umsetzung|sonstiges", "datum": "YYYY-MM-DD", "beschreibung": "Was soll bis wann passieren"}}]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
Zusätzlich: Gibt es im Text genannte Fristen, Termine, Zeitangaben oder Zusagen mit Zeithorizont?
|
||||||
|
Wenn ja, ergänze "fristen" entsprechend. Wenn keine Fristen erkennbar: "fristen": []
|
||||||
|
|
||||||
Bewertungsskala:
|
Bewertungsskala:
|
||||||
- 1.0: Forderung vollständig erfüllt, konkreter Beschluss
|
- 1.0: Forderung vollständig erfüllt, konkreter Beschluss
|
||||||
- 0.7-0.9: Weitgehend erfüllt, kleine Abweichungen
|
- 0.7-0.9: Weitgehend erfüllt, kleine Abweichungen
|
||||||
@ -119,6 +131,30 @@ class BewertungRequest(BaseModel):
|
|||||||
anmerkung: str = ""
|
anmerkung: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_ki_fristen(conn, fristen_list: list, kette_id: int | None, vorlage_id: int | None):
|
||||||
|
"""Insert KI-extracted fristen into the fristen table."""
|
||||||
|
if not fristen_list:
|
||||||
|
return
|
||||||
|
for f in fristen_list:
|
||||||
|
typ = f.get("typ", "sonstiges")
|
||||||
|
datum = f.get("datum")
|
||||||
|
beschreibung = f.get("beschreibung")
|
||||||
|
if not datum:
|
||||||
|
continue
|
||||||
|
# Check for duplicate (same kette, same datum, same beschreibung)
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM fristen WHERE kette_id = ? AND datum = ? AND beschreibung = ? AND quelle = 'ki_extraktion'",
|
||||||
|
(kette_id, datum, beschreibung),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO fristen (kette_id, vorlage_id, typ, datum, beschreibung, quelle, status, erstellt_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'ki_extraktion', 'offen', ?)""",
|
||||||
|
(kette_id, vorlage_id, typ, datum, beschreibung, datetime.now().isoformat()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _call_qwen(prompt: str) -> dict | None:
|
def _call_qwen(prompt: str) -> dict | None:
|
||||||
key = _get_key("QWEN_API_KEY")
|
key = _get_key("QWEN_API_KEY")
|
||||||
if not key:
|
if not key:
|
||||||
@ -178,6 +214,18 @@ def _run_zusammenfassung(vorlage_id: int, anmerkung: str, job_id: str):
|
|||||||
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))
|
||||||
|
|
||||||
|
# Extract and insert KI-detected fristen
|
||||||
|
kette_row_for_fristen = conn.execute(
|
||||||
|
"SELECT kette_id FROM ketten_glieder WHERE vorlage_id = ? LIMIT 1",
|
||||||
|
(vorlage_id,),
|
||||||
|
).fetchone()
|
||||||
|
_insert_ki_fristen(
|
||||||
|
conn,
|
||||||
|
result.get("fristen", []),
|
||||||
|
kette_row_for_fristen["kette_id"] if kette_row_for_fristen else None,
|
||||||
|
vorlage_id,
|
||||||
|
)
|
||||||
|
|
||||||
# Log
|
# Log
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO bewertungs_log (vorlage_id, typ, anmerkung, modell, prompt_version, bewertung_vorher, bewertung_nachher, erstellt_at)
|
"""INSERT INTO bewertungs_log (vorlage_id, typ, anmerkung, modell, prompt_version, bewertung_vorher, bewertung_nachher, erstellt_at)
|
||||||
@ -320,6 +368,9 @@ def _run_ketten_bewertung(kette_id: int, anmerkung: str, job_id: str):
|
|||||||
(new_status, begruendung, kette_id),
|
(new_status, begruendung, kette_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract and insert KI-detected fristen
|
||||||
|
_insert_ki_fristen(conn, result.get("fristen", []), kette_id, kette["ursprung_id"])
|
||||||
|
|
||||||
# Log
|
# Log
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO bewertungs_log
|
"""INSERT INTO bewertungs_log
|
||||||
|
|||||||
198
backend/src/tracker/api/routes/fristen.py
Normal file
198
backend/src/tracker/api/routes/fristen.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
"""API routes for Fristen-Tracking (Issue #17)."""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from tracker.db.session import get_connection
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/fristen", tags=["fristen"])
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class FristCreate(BaseModel):
|
||||||
|
kette_id: int
|
||||||
|
vorlage_id: Optional[int] = None
|
||||||
|
typ: str # überarbeitung, bericht, prüfung, umsetzung, sonstiges
|
||||||
|
datum: str # YYYY-MM-DD
|
||||||
|
beschreibung: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FristUpdate(BaseModel):
|
||||||
|
status: Optional[str] = None
|
||||||
|
datum: Optional[str] = None
|
||||||
|
beschreibung: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_fristen(
|
||||||
|
kette_id: Optional[int] = None,
|
||||||
|
status: Optional[str] = Query(None, description="offen, überfällig, erfüllt, alle"),
|
||||||
|
typ: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 50,
|
||||||
|
conn=Depends(_db),
|
||||||
|
):
|
||||||
|
"""Fristen auflisten mit Joins auf ketten + vorlagen."""
|
||||||
|
where_clauses = []
|
||||||
|
params: list = []
|
||||||
|
|
||||||
|
if kette_id is not None:
|
||||||
|
where_clauses.append("f.kette_id = ?")
|
||||||
|
params.append(kette_id)
|
||||||
|
|
||||||
|
if status and status != "alle":
|
||||||
|
if status == "überfällig":
|
||||||
|
where_clauses.append("(f.status = 'überfällig' OR (f.status = 'offen' AND f.datum < date('now')))")
|
||||||
|
else:
|
||||||
|
where_clauses.append("f.status = ?")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if typ:
|
||||||
|
where_clauses.append("f.typ = ?")
|
||||||
|
params.append(typ)
|
||||||
|
|
||||||
|
where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
||||||
|
|
||||||
|
# Count
|
||||||
|
total = conn.execute(f"SELECT COUNT(*) as cnt FROM fristen f{where_sql}", params).fetchone()["cnt"]
|
||||||
|
|
||||||
|
# Query with joins
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""SELECT f.*,
|
||||||
|
k.status AS kette_status,
|
||||||
|
v_ursprung.aktenzeichen AS kette_aktenzeichen,
|
||||||
|
v_ursprung.betreff AS kette_betreff,
|
||||||
|
v_frist.aktenzeichen AS vorlage_aktenzeichen
|
||||||
|
FROM fristen f
|
||||||
|
LEFT JOIN ketten k ON f.kette_id = k.id
|
||||||
|
LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id
|
||||||
|
LEFT JOIN vorlagen v_frist ON f.vorlage_id = v_frist.id
|
||||||
|
{where_sql}
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN f.status = 'überfällig' OR (f.status = 'offen' AND f.datum < date('now')) THEN 0
|
||||||
|
WHEN f.status = 'offen' THEN 1
|
||||||
|
ELSE 2 END,
|
||||||
|
f.datum ASC
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
params + [page_size, offset],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
item = dict(r)
|
||||||
|
# Mark overdue in response
|
||||||
|
if item["status"] == "offen" and item["datum"] and item["datum"] < date.today().isoformat():
|
||||||
|
item["status"] = "überfällig"
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
def create_frist(body: FristCreate, conn=Depends(_db)):
|
||||||
|
"""Neue Frist manuell anlegen."""
|
||||||
|
# Validate kette exists
|
||||||
|
kette = conn.execute("SELECT id FROM ketten WHERE id = ?", (body.kette_id,)).fetchone()
|
||||||
|
if not kette:
|
||||||
|
raise HTTPException(status_code=404, detail="Kette nicht gefunden")
|
||||||
|
|
||||||
|
if body.vorlage_id:
|
||||||
|
vorlage = conn.execute("SELECT id FROM vorlagen WHERE id = ?", (body.vorlage_id,)).fetchone()
|
||||||
|
if not vorlage:
|
||||||
|
raise HTTPException(status_code=404, detail="Vorlage nicht gefunden")
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO fristen (kette_id, vorlage_id, typ, datum, beschreibung, quelle, status, erstellt_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'manuell', 'offen', ?)""",
|
||||||
|
(body.kette_id, body.vorlage_id, body.typ, body.datum, body.beschreibung, datetime.now().isoformat()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
frist_id = cursor.lastrowid
|
||||||
|
|
||||||
|
row = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{frist_id}")
|
||||||
|
def update_frist(frist_id: int, body: FristUpdate, conn=Depends(_db)):
|
||||||
|
"""Frist aktualisieren (Status, Datum, Beschreibung)."""
|
||||||
|
existing = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Frist nicht gefunden")
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if body.status is not None:
|
||||||
|
updates.append("status = ?")
|
||||||
|
params.append(body.status)
|
||||||
|
if body.datum is not None:
|
||||||
|
updates.append("datum = ?")
|
||||||
|
params.append(body.datum)
|
||||||
|
if body.beschreibung is not None:
|
||||||
|
updates.append("beschreibung = ?")
|
||||||
|
params.append(body.beschreibung)
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Änderungen angegeben")
|
||||||
|
|
||||||
|
updates.append("aktualisiert_at = ?")
|
||||||
|
params.append(datetime.now().isoformat())
|
||||||
|
params.append(frist_id)
|
||||||
|
|
||||||
|
conn.execute(f"UPDATE fristen SET {', '.join(updates)} WHERE id = ?", params)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
row = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{frist_id}")
|
||||||
|
def delete_frist(frist_id: int, conn=Depends(_db)):
|
||||||
|
"""Frist löschen."""
|
||||||
|
existing = conn.execute("SELECT * FROM fristen WHERE id = ?", (frist_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=404, detail="Frist nicht gefunden")
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM fristen WHERE id = ?", (frist_id,))
|
||||||
|
conn.commit()
|
||||||
|
return {"ok": True, "deleted": frist_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ueberfaellig")
|
||||||
|
def get_ueberfaellige(conn=Depends(_db)):
|
||||||
|
"""Überfällige Fristen abrufen und automatisch Status setzen."""
|
||||||
|
today = date.today().isoformat()
|
||||||
|
|
||||||
|
# Update status for overdue fristen
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE fristen SET status = 'überfällig', aktualisiert_at = ? WHERE status = 'offen' AND datum < ?",
|
||||||
|
(datetime.now().isoformat(), today),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT f.*,
|
||||||
|
k.status AS kette_status,
|
||||||
|
v_ursprung.aktenzeichen AS kette_aktenzeichen,
|
||||||
|
v_ursprung.betreff AS kette_betreff
|
||||||
|
FROM fristen f
|
||||||
|
LEFT JOIN ketten k ON f.kette_id = k.id
|
||||||
|
LEFT JOIN vorlagen v_ursprung ON k.ursprung_id = v_ursprung.id
|
||||||
|
WHERE f.status = 'überfällig'
|
||||||
|
ORDER BY f.datum ASC""",
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {"items": [dict(r) for r in rows], "total": len(rows)}
|
||||||
@ -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, ampel, bewertung, fraktionen, ketten, orte, stats, vorlagen
|
from tracker.api.routes import abstimmungen, ampel, bewertung, fraktionen, fristen, ketten, orte, stats, vorlagen
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Antragstracker Hagen",
|
title="Antragstracker Hagen",
|
||||||
@ -20,7 +20,7 @@ app = FastAPI(
|
|||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_methods=["GET", "POST"],
|
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -32,6 +32,7 @@ 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.include_router(ampel.router, prefix="/api")
|
||||||
|
app.include_router(fristen.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@ -259,6 +259,50 @@ export interface AmpelDefinition {
|
|||||||
|
|
||||||
export const fetchAmpelDefinition = () => get<AmpelDefinition>('/ampel/definition');
|
export const fetchAmpelDefinition = () => get<AmpelDefinition>('/ampel/definition');
|
||||||
|
|
||||||
|
// Fristen API
|
||||||
|
export interface Frist {
|
||||||
|
id: number;
|
||||||
|
kette_id: number | null;
|
||||||
|
vorlage_id: number | null;
|
||||||
|
typ: string;
|
||||||
|
datum: string;
|
||||||
|
beschreibung: string | null;
|
||||||
|
quelle: string;
|
||||||
|
status: string;
|
||||||
|
erstellt_at: string | null;
|
||||||
|
aktualisiert_at: string | null;
|
||||||
|
kette_status: string | null;
|
||||||
|
kette_aktenzeichen: string | null;
|
||||||
|
kette_betreff: string | null;
|
||||||
|
vorlage_aktenzeichen: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchFristen = (params: Record<string, string>) => {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return get<Paginated<Frist>>(`/fristen?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchUeberfaelligeFristen = () =>
|
||||||
|
get<{ items: Frist[]; total: number }>('/fristen/ueberfaellig');
|
||||||
|
|
||||||
|
export const createFrist = (body: { kette_id: number; vorlage_id?: number; typ: string; datum: string; beschreibung?: string }) =>
|
||||||
|
post<Frist>('/fristen', body);
|
||||||
|
|
||||||
|
export async function patchFrist(id: number, body: { status?: string; datum?: string; beschreibung?: string }): Promise<Frist> {
|
||||||
|
const res = await fetch(`${BASE}/fristen/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFrist(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`${BASE}/fristen/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => {
|
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);
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
<a href="/vorlagen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Vorlagen</a>
|
<a href="/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>
|
||||||
<a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a>
|
<a href="/karte" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Karte</a>
|
||||||
|
<a href="/fristen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fristen</a>
|
||||||
<a href="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a>
|
<a href="/fraktionen" class="text-gray-600 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium">Fraktionen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -77,6 +78,7 @@
|
|||||||
<a href="/vorlagen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Vorlagen</a>
|
<a href="/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>
|
||||||
<a href="/karte" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Karte</a>
|
<a href="/karte" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Karte</a>
|
||||||
|
<a href="/fristen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fristen</a>
|
||||||
<a href="/fraktionen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fraktionen</a>
|
<a href="/fraktionen" onclick={() => menuOpen = false} class="block text-gray-600 hover:text-gray-900 hover:bg-gray-50 px-3 py-3 rounded-md text-base font-medium">Fraktionen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
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 { fetchAmpelDefinition, fetchUeberfaelligeFristen, type AmpelDefinition } from '$lib/api';
|
||||||
import Ampel from '$lib/components/Ampel.svelte';
|
import Ampel from '$lib/components/Ampel.svelte';
|
||||||
|
|
||||||
interface Vorlage {
|
interface Vorlage {
|
||||||
@ -33,6 +33,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 ampelDef = $state<AmpelDefinition | null>(null);
|
||||||
|
let ueberfaelligeCount = $state(0);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
@ -111,11 +112,14 @@
|
|||||||
try { ampelDef = await fetchAmpelDefinition(); } catch {}
|
try { ampelDef = await fetchAmpelDefinition(); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [dashRes, antraegeRes] = await Promise.all([
|
const [dashRes, antraegeRes, fristenRes] = await Promise.all([
|
||||||
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
|
fetch(`${API_BASE}/stats/dashboard${dashSuffix}`),
|
||||||
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
|
fetch(`${API_BASE}/vorlagen?typ=antrag&page_size=10${vorlagenSuffix}`),
|
||||||
|
fetchUeberfaelligeFristen().catch(() => ({ items: [], total: 0 })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
ueberfaelligeCount = fristenRes.total;
|
||||||
|
|
||||||
if (dashRes.ok) {
|
if (dashRes.ok) {
|
||||||
stats = await dashRes.json();
|
stats = await dashRes.json();
|
||||||
} else {
|
} else {
|
||||||
@ -181,6 +185,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Überfällige Fristen Kachel -->
|
||||||
|
<button onclick={() => goto('/fristen?status=überfällig')}
|
||||||
|
class="w-full mb-8 rounded-xl shadow-sm border p-5 flex items-center gap-4 cursor-pointer hover:shadow-md transition-shadow text-left
|
||||||
|
{ueberfaelligeCount > 0
|
||||||
|
? 'bg-red-50 border-red-200 hover:bg-red-100'
|
||||||
|
: 'bg-white border-gray-200 hover:bg-gray-50'}">
|
||||||
|
<div class="text-3xl">⏰</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold {ueberfaelligeCount > 0 ? 'text-red-600' : 'text-gray-400'}">
|
||||||
|
{ueberfaelligeCount}
|
||||||
|
</div>
|
||||||
|
<div class="{ueberfaelligeCount > 0 ? 'text-red-700' : 'text-gray-500'} text-sm">
|
||||||
|
überfällige Fristen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if ueberfaelligeCount > 0}
|
||||||
|
<span class="ml-auto text-sm text-red-600 font-medium">Anzeigen →</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Vorlagen nach Typ -->
|
<!-- Vorlagen nach Typ -->
|
||||||
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
|
<section class="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-8">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">📊 Vorlagen nach Typ</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-4">📊 Vorlagen nach Typ</h2>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { fetchKetten, fetchKette, fetchVorlage, reevalVorlage, fetchJobStatus, type KetteKurz, type KetteDetail, type VorlageDetail, type Paginated } from '$lib/api';
|
import { fetchKetten, fetchKette, fetchVorlage, reevalVorlage, fetchJobStatus, fetchFristen, createFrist, patchFrist, type KetteKurz, type KetteDetail, type VorlageDetail, type Paginated, type Frist } from '$lib/api';
|
||||||
import { formatDate, typLabel } from '$lib/status';
|
import { formatDate, typLabel } from '$lib/status';
|
||||||
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
|
import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte';
|
||||||
import Ampel from '$lib/components/Ampel.svelte';
|
import Ampel from '$lib/components/Ampel.svelte';
|
||||||
@ -32,6 +32,60 @@
|
|||||||
let showVolltext = $state(false);
|
let showVolltext = $state(false);
|
||||||
let showVersionen = $state(false);
|
let showVersionen = $state(false);
|
||||||
let showUmsetzungVersionen = $state(false);
|
let showUmsetzungVersionen = $state(false);
|
||||||
|
|
||||||
|
// Fristen state
|
||||||
|
let ketteFristen: Frist[] = $state([]);
|
||||||
|
let fristenLoading = $state(false);
|
||||||
|
let showFristForm = $state(false);
|
||||||
|
let newFristTyp = $state('sonstiges');
|
||||||
|
let newFristDatum = $state('');
|
||||||
|
let newFristBeschreibung = $state('');
|
||||||
|
|
||||||
|
async function loadKetteFristen(ketteId: number) {
|
||||||
|
fristenLoading = true;
|
||||||
|
try {
|
||||||
|
const data = await fetchFristen({ kette_id: String(ketteId), page_size: '100' });
|
||||||
|
ketteFristen = data.items;
|
||||||
|
} catch (e) {
|
||||||
|
ketteFristen = [];
|
||||||
|
} finally {
|
||||||
|
fristenLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFrist() {
|
||||||
|
if (!selectedKette || !newFristDatum) return;
|
||||||
|
try {
|
||||||
|
await createFrist({
|
||||||
|
kette_id: selectedKette.id,
|
||||||
|
typ: newFristTyp,
|
||||||
|
datum: newFristDatum,
|
||||||
|
beschreibung: newFristBeschreibung || undefined,
|
||||||
|
});
|
||||||
|
showFristForm = false;
|
||||||
|
newFristTyp = 'sonstiges';
|
||||||
|
newFristDatum = '';
|
||||||
|
newFristBeschreibung = '';
|
||||||
|
await loadKetteFristen(selectedKette.id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markFristErfuellt(fristId: number) {
|
||||||
|
try {
|
||||||
|
await patchFrist(fristId, { status: 'erfüllt' });
|
||||||
|
if (selectedKette) await loadKetteFristen(selectedKette.id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fristStatusColors: Record<string, string> = {
|
||||||
|
'offen': 'bg-yellow-100 text-yellow-700',
|
||||||
|
'überfällig': 'bg-red-100 text-red-700',
|
||||||
|
'erfüllt': 'bg-green-100 text-green-700',
|
||||||
|
};
|
||||||
let showReeval = $state(false);
|
let showReeval = $state(false);
|
||||||
let reevalAnmerkung = $state('');
|
let reevalAnmerkung = $state('');
|
||||||
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
|
let reevalStatus = $state<'idle' | 'running' | 'done' | 'error'>('idle');
|
||||||
@ -126,6 +180,7 @@
|
|||||||
mobileTab = 'kette';
|
mobileTab = 'kette';
|
||||||
try {
|
try {
|
||||||
selectedKette = await fetchKette(id);
|
selectedKette = await fetchKette(id);
|
||||||
|
loadKetteFristen(id);
|
||||||
// Auto-select the first glied (most recent = last position)
|
// Auto-select the first glied (most recent = last position)
|
||||||
if (selectedKette.glieder.length > 0) {
|
if (selectedKette.glieder.length > 0) {
|
||||||
const sorted = [...selectedKette.glieder].sort((a, b) => b.position - a.position);
|
const sorted = [...selectedKette.glieder].sort((a, b) => b.position - a.position);
|
||||||
@ -390,6 +445,70 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Fristen -->
|
||||||
|
<div class="px-4 py-3 border-b border-gray-100">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="text-xs font-medium text-gray-500 uppercase">Fristen</div>
|
||||||
|
<button onclick={() => showFristForm = !showFristForm}
|
||||||
|
class="text-[10px] text-green-600 hover:text-green-800 font-medium">
|
||||||
|
{showFristForm ? '✕ Abbrechen' : '+ Frist'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showFristForm}
|
||||||
|
<div class="space-y-2 mb-3 p-2 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<select bind:value={newFristTyp}
|
||||||
|
class="w-full border border-gray-300 rounded px-2 py-1 text-xs bg-white">
|
||||||
|
<option value="überarbeitung">Überarbeitung</option>
|
||||||
|
<option value="bericht">Bericht</option>
|
||||||
|
<option value="prüfung">Prüfung</option>
|
||||||
|
<option value="umsetzung">Umsetzung</option>
|
||||||
|
<option value="sonstiges">Sonstiges</option>
|
||||||
|
</select>
|
||||||
|
<input type="date" bind:value={newFristDatum}
|
||||||
|
class="w-full border border-gray-300 rounded px-2 py-1 text-xs" />
|
||||||
|
<input type="text" bind:value={newFristBeschreibung} placeholder="Beschreibung..."
|
||||||
|
class="w-full border border-gray-300 rounded px-2 py-1 text-xs" />
|
||||||
|
<button onclick={addFrist} disabled={!newFristDatum}
|
||||||
|
class="w-full bg-green-600 text-white rounded px-2 py-1 text-xs font-medium hover:bg-green-700 disabled:opacity-50">
|
||||||
|
Frist anlegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if fristenLoading}
|
||||||
|
<div class="text-xs text-gray-400 text-center py-2">Laden...</div>
|
||||||
|
{:else if ketteFristen.length === 0}
|
||||||
|
<div class="text-[11px] text-gray-400 text-center py-1">Keine Fristen</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
{#each ketteFristen as frist}
|
||||||
|
<div class="flex items-start gap-2 p-1.5 rounded {frist.status === 'überfällig' ? 'bg-red-50' : ''}">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="text-[11px] font-medium {frist.status === 'überfällig' ? 'text-red-700' : 'text-gray-900'}">
|
||||||
|
{frist.datum}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] px-1 py-0.5 rounded {fristStatusColors[frist.status] || 'bg-gray-100 text-gray-600'}">
|
||||||
|
{frist.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if frist.beschreibung}
|
||||||
|
<p class="text-[10px] text-gray-600 leading-tight mt-0.5 truncate">{frist.beschreibung}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if frist.status !== 'erfüllt'}
|
||||||
|
<button onclick={() => markFristErfuellt(frist.id)}
|
||||||
|
class="text-[10px] text-green-600 hover:text-green-800 shrink-0 mt-0.5" title="Als erfüllt markieren">
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Timeline -->
|
<!-- Timeline -->
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="text-xs font-medium text-gray-500 uppercase mb-3">Ketten-Glieder</div>
|
<div class="text-xs font-medium text-gray-500 uppercase mb-3">Ketten-Glieder</div>
|
||||||
|
|||||||
262
frontend/src/routes/fristen/+page.svelte
Normal file
262
frontend/src/routes/fristen/+page.svelte
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { fetchFristen, patchFrist, type Frist, type Paginated } from '$lib/api';
|
||||||
|
import { formatDate } from '$lib/status';
|
||||||
|
|
||||||
|
let fristen: Frist[] = $state([]);
|
||||||
|
let total = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let currentPage = $state(1);
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
let statusFilter = $state('alle');
|
||||||
|
let typFilter = $state('');
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'alle', label: 'Alle' },
|
||||||
|
{ value: 'offen', label: '🟡 Offen' },
|
||||||
|
{ value: 'überfällig', label: '🔴 Überfällig' },
|
||||||
|
{ value: 'erfüllt', label: '🟢 Erfüllt' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const typOptions = [
|
||||||
|
{ value: '', label: 'Alle Typen' },
|
||||||
|
{ value: 'überarbeitung', label: 'Überarbeitung' },
|
||||||
|
{ value: 'bericht', label: 'Bericht' },
|
||||||
|
{ value: 'prüfung', label: 'Prüfung' },
|
||||||
|
{ value: 'umsetzung', label: 'Umsetzung' },
|
||||||
|
{ value: 'sonstiges', label: 'Sonstiges' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
'offen': 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||||
|
'überfällig': 'bg-red-100 text-red-800 border-red-300',
|
||||||
|
'erfüllt': 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typLabels: Record<string, string> = {
|
||||||
|
'überarbeitung': 'Überarbeitung',
|
||||||
|
'bericht': 'Bericht',
|
||||||
|
'prüfung': 'Prüfung',
|
||||||
|
'umsetzung': 'Umsetzung',
|
||||||
|
'sonstiges': 'Sonstiges',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadFristen() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
page: String(currentPage),
|
||||||
|
page_size: String(PAGE_SIZE),
|
||||||
|
};
|
||||||
|
if (statusFilter !== 'alle') params.status = statusFilter;
|
||||||
|
if (typFilter) params.typ = typFilter;
|
||||||
|
|
||||||
|
const data = await fetchFristen(params);
|
||||||
|
fristen = data.items;
|
||||||
|
total = data.total;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markErfuellt(frist: Frist) {
|
||||||
|
try {
|
||||||
|
await patchFrist(frist.id, { status: 'erfüllt' });
|
||||||
|
await loadFristen();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fehler';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPage(p: number) {
|
||||||
|
currentPage = p;
|
||||||
|
loadFristen();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read initial status from URL query
|
||||||
|
onMount(() => {
|
||||||
|
const urlStatus = new URL(window.location.href).searchParams.get('status');
|
||||||
|
if (urlStatus) statusFilter = urlStatus;
|
||||||
|
loadFristen();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Reload when filters change
|
||||||
|
statusFilter;
|
||||||
|
typFilter;
|
||||||
|
currentPage = 1;
|
||||||
|
loadFristen();
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalPages = $derived(Math.ceil(total / PAGE_SIZE));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Fristen - Antragstracker Hagen</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">⏰ Fristen</h1>
|
||||||
|
<p class="text-gray-500 text-sm mt-1">Termine und Fristen im Überblick</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex flex-wrap gap-3 mb-6">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#each statusOptions as opt}
|
||||||
|
<button
|
||||||
|
onclick={() => statusFilter = opt.value}
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all
|
||||||
|
{statusFilter === opt.value
|
||||||
|
? 'bg-green-600 text-white shadow-sm'
|
||||||
|
: 'bg-white text-gray-600 border border-gray-300 hover:bg-gray-50'}">
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
bind:value={typFilter}
|
||||||
|
class="border border-gray-300 rounded-lg px-3 py-1.5 text-sm bg-white focus:ring-2 focus:ring-green-500">
|
||||||
|
{#each typOptions as opt}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="bg-red-50 text-red-700 p-4 rounded-lg mb-6">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-20">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600"></div>
|
||||||
|
</div>
|
||||||
|
{:else if fristen.length === 0}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center text-gray-500">
|
||||||
|
Keine Fristen gefunden
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Desktop Table -->
|
||||||
|
<div class="hidden md:block bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Kette</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quelle</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
{#each fristen as frist}
|
||||||
|
<tr class="hover:bg-gray-50 {frist.status === 'überfällig' ? 'bg-red-50/50' : ''}">
|
||||||
|
<td class="px-4 py-3 text-sm font-medium {frist.status === 'überfällig' ? 'text-red-700' : 'text-gray-900'}">
|
||||||
|
{frist.datum}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{typLabels[frist.typ] || frist.typ}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 max-w-md truncate">
|
||||||
|
{frist.beschreibung || '-'}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm">
|
||||||
|
{#if frist.kette_aktenzeichen}
|
||||||
|
<a href="/explorer" class="font-mono text-green-700 hover:underline text-xs">
|
||||||
|
{frist.kette_aktenzeichen}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border
|
||||||
|
{statusColors[frist.status] || 'bg-gray-100 text-gray-700 border-gray-300'}">
|
||||||
|
{frist.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-xs text-gray-500">
|
||||||
|
{frist.quelle === 'ki_extraktion' ? '🤖' : '✏️'}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
{#if frist.status !== 'erfüllt'}
|
||||||
|
<button
|
||||||
|
onclick={() => markErfuellt(frist)}
|
||||||
|
class="text-xs text-green-600 hover:text-green-800 font-medium">
|
||||||
|
✓ Erfüllt
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Cards -->
|
||||||
|
<div class="md:hidden space-y-3">
|
||||||
|
{#each fristen as frist}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border p-4
|
||||||
|
{frist.status === 'überfällig' ? 'border-red-300 bg-red-50/30' : 'border-gray-200'}">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-bold {frist.status === 'überfällig' ? 'text-red-700' : 'text-gray-900'}">
|
||||||
|
{frist.datum}
|
||||||
|
</span>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border
|
||||||
|
{statusColors[frist.status] || 'bg-gray-100 text-gray-700 border-gray-300'}">
|
||||||
|
{frist.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400">{frist.quelle === 'ki_extraktion' ? '🤖' : '✏️'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mb-1">{typLabels[frist.typ] || frist.typ}</div>
|
||||||
|
{#if frist.beschreibung}
|
||||||
|
<p class="text-sm text-gray-700 mb-2">{frist.beschreibung}</p>
|
||||||
|
{/if}
|
||||||
|
{#if frist.kette_aktenzeichen}
|
||||||
|
<a href="/explorer" class="text-xs font-mono text-green-700 hover:underline">
|
||||||
|
{frist.kette_aktenzeichen}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if frist.status !== 'erfüllt'}
|
||||||
|
<div class="mt-3 pt-2 border-t border-gray-100">
|
||||||
|
<button
|
||||||
|
onclick={() => markErfuellt(frist)}
|
||||||
|
class="text-sm text-green-600 hover:text-green-800 font-medium">
|
||||||
|
✓ Als erfüllt markieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="flex items-center justify-center gap-2 mt-6">
|
||||||
|
<button
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
onclick={() => goPage(currentPage - 1)}
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm border border-gray-300 hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
‹ Zurück
|
||||||
|
</button>
|
||||||
|
<span class="text-sm text-gray-500">Seite {currentPage} von {totalPages}</span>
|
||||||
|
<button
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
onclick={() => goPage(currentPage + 1)}
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm border border-gray-300 hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
Weiter ›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
44
scripts/migrate_fristen.py
Normal file
44
scripts/migrate_fristen.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Migration: Erstellt die fristen-Tabelle für Fristen-Tracking (Issue #17)."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).resolve().parent.parent / "data" / "tracker.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: Path = DB_PATH):
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS fristen (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kette_id INTEGER REFERENCES ketten(id),
|
||||||
|
vorlage_id INTEGER REFERENCES vorlagen(id),
|
||||||
|
typ TEXT NOT NULL,
|
||||||
|
datum DATE NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
quelle TEXT DEFAULT 'manuell',
|
||||||
|
status TEXT DEFAULT 'offen',
|
||||||
|
erstellt_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
aktualisiert_at TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Indices for common queries
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_kette_id ON fristen(kette_id)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_status ON fristen(status)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_datum ON fristen(datum)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_fristen_status_datum ON fristen(status, datum)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"✅ Migration erfolgreich: fristen-Tabelle in {db_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
path = Path(sys.argv[1]) if len(sys.argv) > 1 else DB_PATH
|
||||||
|
migrate(path)
|
||||||
Loading…
Reference in New Issue
Block a user