1295 lines
54 KiB
Python
1295 lines
54 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
AZA Dev Status – Tkinter-Fenster im App-Stil mit sechs Reitern:
|
||
1) Projekt-Status (aktueller Stand + editierbar + History)
|
||
2) Erledigt (abgeschlossene Schritte aus project_plan.json)
|
||
3) To-Do (aus project_todos.json, editierbar, Area-Tags, Persistence)
|
||
4) Roadmap (Milestones aus project_roadmap.json, editierbare Dropdowns)
|
||
5) Projektnotizen (aus Projekt-Notizen-Ordner)
|
||
6) Handover (Operational Runbook, editierbar, project_handover.md)
|
||
Aktualisiert sich alle 5 Minuten.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import tkinter as tk
|
||
from tkinter import ttk, filedialog
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
# ---- DEV STATUS EXPORT TEMPLATE (Master Snapshot) ----
|
||
DEV_STATUS_EXPORT_PROMPT = r"""DEV STATUS EXPORT – AZA
|
||
|
||
Bitte erstelle einen strukturierten Export (Markdown), basierend auf den aktuellen Projektdateien:
|
||
- project_status.json
|
||
- project_plan.json (Tab "Erledigt")
|
||
- project_todos.json (Tab "To-Do")
|
||
- project_roadmap.json (Tab "Roadmap")
|
||
|
||
Ziel: Ein „Master Snapshot" der aktuellen Situation für Übergabe/Go-Live.
|
||
|
||
FORMAT (genau diese Abschnitte):
|
||
1) Kontext
|
||
- Datum/Time
|
||
- Workspace: project_root + current_working_folder
|
||
- Current step / last completed / phase
|
||
- Do-not-break Regeln (kurz): /license/status Schema, Header X-API-Token, keine Secrets/Tokens loggen
|
||
|
||
2) Was ist fertig (Done)
|
||
- Liste der abgeschlossenen Steps (mit 1 Satz Ergebnis)
|
||
- Wichtigste Deliverables/Dateien je Step
|
||
|
||
3) Was ist aktiv/offen (In progress / Open)
|
||
- Liste der offenen Steps in sinnvoller Reihenfolge bis Sell-Ready/Go-Live
|
||
- Für jeden Step: Ziel, wichtigste Risiken, „Definition of Done"
|
||
|
||
4) To-Do Top 10 (priorisiert)
|
||
- Nur die 10 wichtigsten offenen Todos (mit ID, Kurzbeschreibung, Bereich)
|
||
- Falls vorhanden: Zuordnung zu Steps
|
||
|
||
5) Roadmap Snapshot
|
||
- Phasen/Milestones kurz
|
||
- Nächster Milestone und warum
|
||
|
||
6) Nächster Schritt (1 Step only)
|
||
- Empfiehl genau EINEN nächsten Schritt als nächstes Ticket
|
||
- Begründung in 2–3 Sätzen
|
||
- Keine Refactors, keine Breaking Changes
|
||
|
||
WICHTIG:
|
||
- Keine Token/Secrets ausgeben.
|
||
- Keine PHI.
|
||
- Keine langen Texte: kompakt, aber vollständig.
|
||
- Wenn Step 21/Browser/Web/Billing nicht als Step definiert ist, benenne explizit „fehlende Step-Definitionen" als Lücke.
|
||
"""
|
||
|
||
_BASE_DIR = Path(__file__).resolve().parent
|
||
_STATUS_FILE = _BASE_DIR / "project_status.json"
|
||
_PLAN_FILE = _BASE_DIR / "project_plan.json"
|
||
_TODOS_FILE = _BASE_DIR / "project_todos.json"
|
||
_ROADMAP_FILE = _BASE_DIR / "project_roadmap.json"
|
||
_PROJECT_HANDOVER_FILE = _BASE_DIR / "project_handover.md"
|
||
# Gleicher Pfad wie Projekt-Notizen-Fenster (aza_notizen_mixin: parent/notizen)
|
||
_NOTIZEN_DIR = _BASE_DIR.parent / "notizen"
|
||
_NOTIZEN_PROJEKT = _NOTIZEN_DIR / "projekt_status.md"
|
||
_NOTIZEN_CHANGELOG = _NOTIZEN_DIR / "changelog.md"
|
||
_HISTORY_FILE = _BASE_DIR / "project_status_history.jsonl"
|
||
_TOKEN_FILE = _BASE_DIR / "backend_token.txt"
|
||
_HANDOVER_FILE = _BASE_DIR / "handover.md"
|
||
_API_BASE = "http://127.0.0.1:8000"
|
||
_API_URL = _API_BASE + "/api/project/status"
|
||
_HEALTH_URL = _API_BASE + "/health"
|
||
_DEVICE_ID = "pc-local"
|
||
_STARTUP_SCRIPT = _BASE_DIR / "deploy" / "local_reset_and_start.ps1"
|
||
_HEALTH_POLL_MS = 3000
|
||
|
||
BG = "#B9ECFA"
|
||
FG = "#1a4d6d"
|
||
CARD_BG = "#E8F4FA"
|
||
TEXT_BG = "#F5FCFF"
|
||
ACCENT = "#5B8DB3"
|
||
DONE_BG = "#e6f5ec"
|
||
DONE_ACCENT = "#27AE60"
|
||
TODO_BG = "#fef9e7"
|
||
TODO_HIGH = "#E74C3C"
|
||
TODO_MED = "#F39C12"
|
||
TODO_LOW = "#7f8c8d"
|
||
ROAD_BG = "#eef2f7"
|
||
HAND_BG = "#f4f0eb"
|
||
FONT = "Segoe UI"
|
||
REFRESH_MS = 5 * 60 * 1000
|
||
|
||
_PRIO_ORDER = {"hoch": 0, "mittel": 1, "niedrig": 2}
|
||
_STATUS_ORDER = {"in arbeit": 0, "offen": 1, "erledigt": 2}
|
||
# project_todos.json schema mapping
|
||
_TODO_PRIO_MAP = {"HIGH": "HOCH", "MEDIUM": "MITTEL", "LOW": "NIEDRIG"}
|
||
_TODO_STATUS_MAP = {"open": "offen", "in_progress": "in Arbeit", "done": "erledigt"}
|
||
_TODO_STATUS_REV = {"offen": "open", "in arbeit": "in_progress", "erledigt": "done"}
|
||
|
||
_AREA_LABELS = {
|
||
"backend": ("Backend", "#3498db"),
|
||
"frontend": ("Frontend", "#9b59b6"),
|
||
"web": ("Web", "#e67e22"),
|
||
"billing": ("Billing", "#27ae60"),
|
||
"release": ("Release", "#e74c3c"),
|
||
"ops": ("Ops", "#1abc9c"),
|
||
"desktop": ("Desktop", "#8e44ad"),
|
||
"legal": ("Legal", "#2c3e50"),
|
||
"product": ("Product", "#d35400"),
|
||
"docs": ("Docs", "#16a085"),
|
||
}
|
||
|
||
_HANDOVER_DEFAULT = """\
|
||
# AZA - Master Handover / Operational Runbook
|
||
|
||
## Arbeitsmodus / Regeln
|
||
|
||
User bastelt nicht; nur Composer-Patches (meist Opus) oder 1 exakter Command mit Pfad.
|
||
|
||
## Lokaler Start
|
||
|
||
```
|
||
cd "C:\\Users\\surov\\Documents\\AZA\\backup 24.2.26"
|
||
powershell -ExecutionPolicy Bypass -File .\\deploy\\local_reset_and_start.ps1
|
||
```
|
||
|
||
## Step 14 - Docker/Compose Smoke-Test
|
||
|
||
**Ziel:** Container bauen, starten, smoke_suite PASS gegen Docker.
|
||
|
||
```
|
||
cd "C:\\Users\\surov\\Documents\\AZA\\backup 24.2.26"
|
||
powershell -ExecutionPolicy Bypass -File .\\deploy\\docker_smoke.ps1
|
||
```
|
||
|
||
## Do-Not-Break Regeln
|
||
|
||
1. Keine bestehenden API-Response-Formate aendern
|
||
2. Auth/Security nicht modifizieren
|
||
3. Keine Secrets loggen/printen
|
||
"""
|
||
|
||
|
||
# ── File helpers ───────────────────────────────────────────────────────────
|
||
|
||
def _read_json(path: Path) -> dict | None:
|
||
try:
|
||
with open(str(path), "r", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _save_status(data: dict) -> bool:
|
||
"""Atomic write: temp file then replace."""
|
||
tmp = _STATUS_FILE.with_suffix(".json.tmp")
|
||
try:
|
||
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||
with open(str(tmp), "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
os.replace(str(tmp), str(_STATUS_FILE))
|
||
return True
|
||
except Exception:
|
||
try:
|
||
tmp.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _save_json_atomic(path: Path, data: dict) -> bool:
|
||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||
try:
|
||
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
||
with open(str(tmp), "w", encoding="utf-8") as f:
|
||
f.write(text)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
os.replace(str(tmp), str(path))
|
||
return True
|
||
except Exception:
|
||
try:
|
||
tmp.unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
|
||
def _append_history(entry: dict) -> None:
|
||
try:
|
||
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
||
with open(str(_HISTORY_FILE), "a", encoding="utf-8") as f:
|
||
f.write(line)
|
||
f.flush()
|
||
os.fsync(f.fileno())
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _read_token() -> str:
|
||
tok = os.environ.get("MEDWORK_API_TOKEN", "").strip()
|
||
if not tok:
|
||
try:
|
||
with open(str(_TOKEN_FILE), "r", encoding="utf-8") as f:
|
||
tok = (f.readline() or "").strip()
|
||
except Exception:
|
||
tok = ""
|
||
return tok or ""
|
||
|
||
|
||
def _fetch_status_from_api() -> dict | None:
|
||
try:
|
||
from urllib.request import Request, urlopen
|
||
tok = _read_token()
|
||
if not tok:
|
||
return None
|
||
req = Request(_API_URL, headers={"X-API-Token": tok, "X-Device-Id": _DEVICE_ID})
|
||
with urlopen(req, timeout=3) as resp:
|
||
return json.loads(resp.read().decode("utf-8", errors="replace"))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _post_status_from_api(payload: dict) -> tuple[dict | None, str | None]:
|
||
"""POST /api/project/status. Returns (data, error_msg)."""
|
||
try:
|
||
from urllib.request import Request, urlopen
|
||
tok = _read_token()
|
||
if not tok:
|
||
return None, "Kein Token (backend_token.txt)"
|
||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
req = Request(
|
||
_API_URL,
|
||
data=body,
|
||
method="POST",
|
||
headers={
|
||
"X-API-Token": tok,
|
||
"X-Device-Id": _DEVICE_ID,
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
with urlopen(req, timeout=5) as resp:
|
||
return json.loads(resp.read().decode("utf-8", errors="replace")), None
|
||
except Exception as e:
|
||
return None, str(e) or "Unbekannter Fehler"
|
||
|
||
|
||
def _get_status_data() -> tuple[dict | None, str]:
|
||
"""Returns (data, source) where source is 'API' or 'lokal'."""
|
||
data = _fetch_status_from_api()
|
||
if data is not None:
|
||
return data, "API"
|
||
return _read_json(_STATUS_FILE), "lokal"
|
||
|
||
|
||
def _sort_todos(todos: list[dict]) -> list[dict]:
|
||
def key(t: dict) -> tuple:
|
||
p = _PRIO_ORDER.get(t.get("priority", "").lower(), 9)
|
||
s = _STATUS_ORDER.get(t.get("status", "").lower(), 9)
|
||
return (p, s, t.get("title", ""))
|
||
return sorted(todos, key=key)
|
||
|
||
|
||
class DevStatusWindow(tk.Toplevel):
|
||
|
||
def __init__(self, parent):
|
||
super().__init__(parent)
|
||
self.title("AZA Dev Status")
|
||
self.configure(bg=BG)
|
||
self.geometry("680x640")
|
||
self.minsize(540, 460)
|
||
self.attributes("-topmost", False)
|
||
|
||
try:
|
||
parent._register_window(self)
|
||
except Exception:
|
||
pass
|
||
|
||
self._build_ui()
|
||
self._refresh()
|
||
|
||
# ── UI ─────────────────────────────────────────────────────────────────
|
||
|
||
def _build_ui(self):
|
||
head = tk.Frame(self, bg=ACCENT, height=38)
|
||
head.pack(fill="x")
|
||
head.pack_propagate(False)
|
||
tk.Label(
|
||
head, text="AZA Dev Status", font=(FONT, 13, "bold"),
|
||
bg=ACCENT, fg="white",
|
||
).pack(side="left", padx=12)
|
||
self._source_lbl = tk.Label(
|
||
head, text="", font=(FONT, 8), bg=ACCENT, fg="#c0dae8",
|
||
)
|
||
self._source_lbl.pack(side="right", padx=12)
|
||
|
||
self._build_backend_bar()
|
||
|
||
style = ttk.Style(self)
|
||
style.configure("Dev.TNotebook", background=BG)
|
||
style.configure("Dev.TNotebook.Tab", font=(FONT, 10), padding=[8, 4])
|
||
|
||
self._nb = ttk.Notebook(self, style="Dev.TNotebook")
|
||
self._nb.pack(fill="both", expand=True, padx=8, pady=(6, 4))
|
||
|
||
self._tab_status = tk.Frame(self._nb, bg=BG)
|
||
self._tab_done = tk.Frame(self._nb, bg=BG)
|
||
self._tab_todo = tk.Frame(self._nb, bg=BG)
|
||
self._tab_road = tk.Frame(self._nb, bg=BG)
|
||
self._tab_notizen = tk.Frame(self._nb, bg=BG)
|
||
self._tab_hand = tk.Frame(self._nb, bg=BG)
|
||
self._nb.add(self._tab_status, text=" Projekt-Status ")
|
||
self._nb.add(self._tab_done, text=" Erledigt ")
|
||
self._nb.add(self._tab_todo, text=" To-Do ")
|
||
self._nb.add(self._tab_road, text=" Roadmap ")
|
||
self._nb.add(self._tab_notizen, text=" Projektnotizen ")
|
||
self._nb.add(self._tab_hand, text=" Handover ")
|
||
|
||
self._build_status_tab()
|
||
self._build_scrollable(self._tab_done, "_done_inner")
|
||
self._build_todo_tab()
|
||
self._build_roadmap_tab()
|
||
self._build_notizen_tab()
|
||
self._build_handover_tab()
|
||
|
||
self._toast_var = tk.StringVar(value="")
|
||
self._toast_lbl = tk.Label(
|
||
self, textvariable=self._toast_var, font=(FONT, 8),
|
||
bg=BG, fg=DONE_ACCENT, anchor="w",
|
||
)
|
||
self._toast_lbl.pack(fill="x", padx=10, pady=(0, 2))
|
||
|
||
def _build_backend_bar(self):
|
||
bar = tk.Frame(self, bg=CARD_BG, bd=1, relief="groove")
|
||
bar.pack(fill="x", padx=8, pady=(4, 0))
|
||
tk.Label(bar, text="Backend:", font=(FONT, 9, "bold"), bg=CARD_BG, fg=FG).pack(side="left", padx=(8, 4))
|
||
self._be_start_btn = tk.Button(
|
||
bar, text="Backend starten", font=(FONT, 9), width=14,
|
||
relief="groove", bd=1, bg="#c8e6c9", fg="#1b5e20",
|
||
command=self._start_backend,
|
||
)
|
||
self._be_start_btn.pack(side="left", padx=(0, 4))
|
||
self._be_stop_btn = tk.Button(
|
||
bar, text="Stoppen", font=(FONT, 9), width=8,
|
||
relief="groove", bd=1, bg="#ffcdd2", fg="#b71c1c",
|
||
command=self._stop_backend,
|
||
)
|
||
self._be_stop_btn.pack(side="left", padx=(0, 6))
|
||
self._be_status_lbl = tk.Label(
|
||
bar, text="Backend: ?", font=(FONT, 9), bg=CARD_BG, fg=FG, anchor="w",
|
||
)
|
||
self._be_status_lbl.pack(side="left", padx=6, fill="x", expand=True)
|
||
self._backend_proc = None
|
||
self._health_polling = False
|
||
self.after(500, self._health_poll)
|
||
|
||
# ── Backend control ────────────────────────────────────────────────────
|
||
|
||
def _ping_health(self) -> bool:
|
||
try:
|
||
from urllib.request import Request, urlopen
|
||
req = Request(_HEALTH_URL)
|
||
with urlopen(req, timeout=2) as resp:
|
||
return resp.status == 200
|
||
except Exception:
|
||
return False
|
||
|
||
def _health_poll(self):
|
||
if not self.winfo_exists():
|
||
return
|
||
alive = self._ping_health()
|
||
if alive:
|
||
self._be_status_lbl.config(text="Backend: RUNNING", fg=DONE_ACCENT)
|
||
else:
|
||
self._be_status_lbl.config(text="Backend: DOWN", fg=TODO_HIGH)
|
||
self.after(_HEALTH_POLL_MS, self._health_poll)
|
||
|
||
def _start_backend(self):
|
||
script = _STARTUP_SCRIPT
|
||
if not script.is_file():
|
||
self._toast("Startscript nicht gefunden: deploy/local_reset_and_start.ps1", TODO_HIGH)
|
||
return
|
||
try:
|
||
CREATE_NEW_CONSOLE = 0x00000010
|
||
self._backend_proc = subprocess.Popen(
|
||
[
|
||
"powershell.exe", "-ExecutionPolicy", "Bypass",
|
||
"-File", str(script),
|
||
],
|
||
cwd=str(_BASE_DIR),
|
||
creationflags=CREATE_NEW_CONSOLE,
|
||
)
|
||
self._be_status_lbl.config(text="Backend: start requested...", fg=TODO_MED)
|
||
self._toast("Backend-Start angefordert")
|
||
except Exception as exc:
|
||
self._toast(f"Start fehlgeschlagen: {exc}", TODO_HIGH)
|
||
|
||
def _stop_backend(self):
|
||
try:
|
||
subprocess.Popen(
|
||
[
|
||
"powershell.exe", "-ExecutionPolicy", "Bypass", "-Command",
|
||
"Get-NetTCPConnection -LocalPort 8000 -ErrorAction SilentlyContinue"
|
||
" | Select-Object -ExpandProperty OwningProcess -Unique"
|
||
" | ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }",
|
||
],
|
||
creationflags=0x08000000,
|
||
)
|
||
self._be_status_lbl.config(text="Backend: stop requested...", fg=TODO_MED)
|
||
self._toast("Backend-Stop angefordert")
|
||
except Exception as exc:
|
||
self._toast(f"Stop fehlgeschlagen: {exc}", TODO_HIGH)
|
||
|
||
def _build_status_tab(self):
|
||
tab = self._tab_status
|
||
|
||
# Status aktualisieren
|
||
upd_frame = tk.Frame(tab, bg=CARD_BG, bd=1, relief="groove")
|
||
upd_frame.pack(fill="x", padx=6, pady=8)
|
||
tk.Label(upd_frame, text="Status aktualisieren", font=(FONT, 10, "bold"), bg=CARD_BG, fg=FG).pack(anchor="w", padx=10, pady=(8, 4))
|
||
|
||
self._status_entries: dict[str, tk.Entry] = {}
|
||
for i, (display, key) in enumerate([
|
||
("Phase", "phase"),
|
||
("Aktueller Step", "current_step"),
|
||
("Letzter abgeschl. Step", "last_completed_step"),
|
||
("Naechster Step", "next_step"),
|
||
("Letztes Update", "last_update"),
|
||
("Notizen", "notes"),
|
||
]):
|
||
row = tk.Frame(upd_frame, bg=CARD_BG)
|
||
row.pack(fill="x", padx=10, pady=2)
|
||
tk.Label(row, text=display + ":", font=(FONT, 9), bg=CARD_BG, fg=FG, width=20, anchor="w").pack(side="left", padx=(0, 4))
|
||
ent = tk.Entry(row, font=(FONT, 9), bg=TEXT_BG, fg=FG, bd=1, relief="solid")
|
||
ent.pack(side="left", fill="x", expand=True)
|
||
self._status_entries[key] = ent
|
||
|
||
btn_row = tk.Frame(upd_frame, bg=CARD_BG)
|
||
btn_row.pack(fill="x", padx=10, pady=(6, 8))
|
||
tk.Button(btn_row, text="Reload", font=(FONT, 9), width=8, relief="groove", bd=1, command=self._reload_status).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_row, text="Save", font=(FONT, 9), width=8, relief="groove", bd=1, bg="#c8e6c9", fg="#1b5e20", command=self._save_status).pack(side="left", padx=(0, 6))
|
||
tk.Button(btn_row, text="Copy JSON", font=(FONT, 9), width=10, relief="groove", bd=1, command=self._copy_status_json).pack(side="left", padx=(0, 6))
|
||
self._status_feedback = tk.Label(btn_row, text="", font=(FONT, 8), bg=CARD_BG, fg=DONE_ACCENT, anchor="w")
|
||
self._status_feedback.pack(side="left", padx=12)
|
||
|
||
self._status_fields = {k: None for k in self._status_entries}
|
||
|
||
hist_bar = tk.Frame(tab, bg=BG)
|
||
hist_bar.pack(fill="x", padx=8, pady=(4, 2))
|
||
tk.Label(hist_bar, text="History (letzte 15 Einträge)", font=(FONT, 10, "bold"), bg=BG, fg=FG, anchor="w").pack(side="left")
|
||
tk.Button(hist_bar, text="Reload History", font=(FONT, 8), width=12, relief="groove", bd=1, command=self._load_history).pack(side="right")
|
||
|
||
hist_frame = tk.Frame(tab, bg=TEXT_BG, bd=1, relief="sunken")
|
||
hist_frame.pack(fill="both", expand=True, padx=6, pady=(0, 6))
|
||
sb = tk.Scrollbar(hist_frame, orient="vertical")
|
||
sb.pack(side="right", fill="y")
|
||
self._hist_text = tk.Text(
|
||
hist_frame, font=(FONT, 9), bg=TEXT_BG, fg=FG,
|
||
wrap="word", state="disabled", bd=0, yscrollcommand=sb.set,
|
||
)
|
||
self._hist_text.pack(fill="both", expand=True)
|
||
sb.config(command=self._hist_text.yview)
|
||
|
||
def _build_todo_tab(self):
|
||
tab = self._tab_todo
|
||
btn_bar = tk.Frame(tab, bg=BG)
|
||
btn_bar.pack(fill="x", padx=6, pady=(6, 2))
|
||
self._todo_save_btn = tk.Button(
|
||
btn_bar, text="Speichern", font=(FONT, 9), width=12,
|
||
relief="groove", bd=1, bg="#c8e6c9", fg="#1b5e20",
|
||
command=self._save_todos,
|
||
)
|
||
self._todo_save_btn.pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
btn_bar, text="Kopieren", font=(FONT, 9), width=10,
|
||
relief="groove", bd=1, bg="#e0e0e0", fg=FG,
|
||
command=self._copy_todos_markdown,
|
||
).pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
btn_bar, text="Export .md", font=(FONT, 9), width=10,
|
||
relief="groove", bd=1, bg="#e0e0e0", fg=FG,
|
||
command=self._export_todos_markdown,
|
||
).pack(side="left", padx=(0, 6))
|
||
self._build_scrollable(tab, "_todo_inner")
|
||
|
||
def _build_roadmap_tab(self):
|
||
tab = self._tab_road
|
||
btn_bar = tk.Frame(tab, bg=BG)
|
||
btn_bar.pack(fill="x", padx=6, pady=(6, 2))
|
||
tk.Button(
|
||
btn_bar, text="Neu laden", font=(FONT, 9), width=10,
|
||
relief="groove", bd=1, command=self._load_roadmap,
|
||
).pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
btn_bar, text="Speichern", font=(FONT, 9), width=10,
|
||
relief="groove", bd=1, bg="#c8e6c9", fg="#1b5e20",
|
||
command=self._save_roadmap,
|
||
).pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
btn_bar, text="Kopieren", font=(FONT, 9), width=10,
|
||
relief="groove", bd=1, bg="#e0e0e0", fg=FG,
|
||
command=self._copy_roadmap_markdown,
|
||
).pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
btn_bar, text="Export .md", font=(FONT, 9), width=10,
|
||
relief="groove", bd=1, bg="#e0e0e0", fg=FG,
|
||
command=self._export_roadmap_markdown,
|
||
).pack(side="left", padx=(0, 6))
|
||
self._build_scrollable(tab, "_road_inner")
|
||
|
||
def _build_scrollable(self, tab: tk.Frame, attr: str):
|
||
container = tk.Frame(tab, bg=BG)
|
||
container.pack(fill="both", expand=True, padx=6, pady=6)
|
||
canvas = tk.Canvas(container, bg=BG, highlightthickness=0)
|
||
sb = tk.Scrollbar(container, orient="vertical", command=canvas.yview)
|
||
inner = tk.Frame(canvas, bg=BG)
|
||
inner.bind("<Configure>", lambda e, c=canvas: c.configure(scrollregion=c.bbox("all")))
|
||
canvas.create_window((0, 0), window=inner, anchor="nw")
|
||
canvas.configure(yscrollcommand=sb.set)
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
sb.pack(side="right", fill="y")
|
||
|
||
def _mw(event, c=canvas):
|
||
c.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
||
canvas.bind("<Enter>", lambda e, c=canvas: c.bind_all("<MouseWheel>", lambda ev: _mw(ev, c)))
|
||
canvas.bind("<Leave>", lambda e, c=canvas: c.unbind_all("<MouseWheel>"))
|
||
setattr(self, attr, inner)
|
||
|
||
def _build_handover_tab(self):
|
||
tab = self._tab_hand
|
||
|
||
btn_bar = tk.Frame(tab, bg=BG)
|
||
btn_bar.pack(fill="x", padx=6, pady=(6, 2))
|
||
|
||
tk.Button(
|
||
btn_bar, text="Neu laden", font=(FONT, 9), width=10,
|
||
relief="groove", bd=1, command=self._load_handover,
|
||
).pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
btn_bar, text="Speichern", font=(FONT, 9), width=10,
|
||
relief="groove", bd=1, bg="#c8e6c9", fg="#1b5e20",
|
||
command=self._save_handover,
|
||
).pack(side="left", padx=(0, 6))
|
||
tk.Button(
|
||
btn_bar, text="Alles kopieren", font=(FONT, 9), width=12,
|
||
relief="groove", bd=1, bg="#e0e0e0", fg=FG,
|
||
command=self._handover_copy,
|
||
).pack(side="left", padx=(0, 6))
|
||
|
||
self._hand_path_lbl = tk.Label(
|
||
btn_bar, text="", font=(FONT, 8), bg=BG, fg="#999", anchor="w",
|
||
)
|
||
self._hand_path_lbl.pack(side="left", fill="x", expand=True)
|
||
|
||
frame = tk.Frame(tab, bg=HAND_BG, bd=1, relief="sunken")
|
||
frame.pack(fill="both", expand=True, padx=6, pady=(0, 6))
|
||
sb = tk.Scrollbar(frame, orient="vertical")
|
||
sb.pack(side="right", fill="y")
|
||
self._hand_text = tk.Text(
|
||
frame, font=(FONT, 10), bg=HAND_BG, fg="#2c2c2c",
|
||
wrap="word", bd=0, yscrollcommand=sb.set,
|
||
padx=12, pady=8,
|
||
)
|
||
self._hand_text.pack(fill="both", expand=True)
|
||
sb.config(command=self._hand_text.yview)
|
||
self._hand_text.tag_configure("h1", font=(FONT, 14, "bold"), foreground=FG, spacing3=6)
|
||
self._hand_text.tag_configure("h2", font=(FONT, 12, "bold"), foreground=ACCENT, spacing1=10, spacing3=4)
|
||
self._hand_text.tag_configure("code", font=("Consolas", 9), background="#e8e4df", foreground="#1a1a1a")
|
||
self._hand_text.tag_configure("bold", font=(FONT, 10, "bold"))
|
||
self._hand_text.tag_configure("rule", foreground=TODO_HIGH, font=(FONT, 10))
|
||
|
||
def _build_notizen_tab(self):
|
||
"""Tab mit Inhalt aus Projekt-Notizen-Fenster (projekt_status.md, changelog.md)."""
|
||
tab = self._tab_notizen
|
||
btn_bar = tk.Frame(tab, bg=BG)
|
||
btn_bar.pack(fill="x", padx=6, pady=(6, 2))
|
||
tk.Label(
|
||
btn_bar, text="Aus Projekt-Notizen-Fenster (projekt_status.md, changelog.md)",
|
||
font=(FONT, 8), bg=BG, fg="#666", anchor="w",
|
||
).pack(side="left")
|
||
self._notizen_open_btn = tk.Button(
|
||
btn_bar, text="Projekt-Notizen oeffnen", font=(FONT, 9), width=20,
|
||
relief="groove", bd=1, bg="#e0e0e0", fg=FG,
|
||
command=self._open_projekt_notizen,
|
||
)
|
||
self._notizen_open_btn.pack(side="right", padx=(6, 0))
|
||
frame = tk.Frame(tab, bg=HAND_BG, bd=1, relief="sunken")
|
||
frame.pack(fill="both", expand=True, padx=6, pady=(0, 6))
|
||
sb = tk.Scrollbar(frame, orient="vertical")
|
||
sb.pack(side="right", fill="y")
|
||
self._notizen_text = tk.Text(
|
||
frame, font=(FONT, 10), bg=HAND_BG, fg="#2c2c2c",
|
||
wrap="word", state="disabled", bd=0, yscrollcommand=sb.set,
|
||
padx=12, pady=8,
|
||
)
|
||
self._notizen_text.pack(fill="both", expand=True)
|
||
sb.config(command=self._notizen_text.yview)
|
||
self._notizen_text.tag_configure("h1", font=(FONT, 14, "bold"), foreground=FG, spacing3=6)
|
||
self._notizen_text.tag_configure("h2", font=(FONT, 12, "bold"), foreground=ACCENT, spacing1=10, spacing3=4)
|
||
self._notizen_text.tag_configure("code", font=("Consolas", 9), background="#e8e4df", foreground="#1a1a1a")
|
||
|
||
def _open_projekt_notizen(self):
|
||
"""Projekt-Notizen-Fenster ueber Parent-App oeffnen."""
|
||
try:
|
||
parent = self.master
|
||
if hasattr(parent, "open_notizen_window"):
|
||
parent.open_notizen_window()
|
||
self._toast("Projekt-Notizen geoeffnet")
|
||
else:
|
||
self._toast("Projekt-Notizen nicht verfuegbar", TODO_HIGH)
|
||
except Exception as exc:
|
||
self._toast(f"Fehler: {exc}", TODO_HIGH)
|
||
|
||
# ── Toast feedback ─────────────────────────────────────────────────────
|
||
|
||
def _toast(self, msg: str, color: str = DONE_ACCENT, ms: int = 3000):
|
||
self._toast_var.set(msg)
|
||
self._toast_lbl.config(fg=color)
|
||
if self.winfo_exists():
|
||
self.after(ms, lambda: self._toast_var.set(""))
|
||
|
||
# ── Todo status action (project_todos.json) ─────────────────────────────
|
||
|
||
def _set_todo_status(self, todo_id: str, new_status: str):
|
||
data = _read_json(_TODOS_FILE)
|
||
if data is None:
|
||
self._toast("Fehler: project_todos.json nicht lesbar", TODO_HIGH)
|
||
return
|
||
items = data.get("items", [])
|
||
target = None
|
||
for t in items:
|
||
if t.get("id") == todo_id:
|
||
target = t
|
||
break
|
||
if target is None:
|
||
self._toast(f"Fehler: {todo_id} nicht gefunden", TODO_HIGH)
|
||
return
|
||
old_status = target.get("status", "open")
|
||
new_status_key = _TODO_STATUS_REV.get(new_status.lower(), new_status.lower().replace(" ", "_"))
|
||
if old_status == new_status_key:
|
||
return
|
||
target["status"] = new_status_key
|
||
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||
if not _save_json_atomic(_TODOS_FILE, data):
|
||
self._toast("Fehler beim Speichern", TODO_HIGH)
|
||
return
|
||
self._toast(f"{todo_id}: {old_status} -> {new_status_key}")
|
||
self._load_todos()
|
||
|
||
def _save_todos(self):
|
||
data = _read_json(_TODOS_FILE)
|
||
if data is None:
|
||
self._toast("Fehler: project_todos.json nicht lesbar", TODO_HIGH)
|
||
return
|
||
items = data.get("items", [])
|
||
entries = getattr(self, "_todo_detail_entries", {})
|
||
for item in items:
|
||
tid = item.get("id", "")
|
||
ent = entries.get(tid)
|
||
if ent is not None and ent.winfo_exists():
|
||
try:
|
||
item["details"] = ent.get().strip()
|
||
except Exception:
|
||
pass
|
||
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||
if not _save_json_atomic(_TODOS_FILE, data):
|
||
self._toast("Fehler beim Speichern", TODO_HIGH)
|
||
return
|
||
self._toast("To-Dos gespeichert")
|
||
|
||
def _refresh_data_only(self):
|
||
"""Re-read local file and refresh all tabs without API call."""
|
||
try:
|
||
status_data = _read_json(_STATUS_FILE)
|
||
self._load_status(status_data)
|
||
self._load_history()
|
||
self._load_todos()
|
||
self._load_roadmap()
|
||
self._load_notizen()
|
||
except Exception:
|
||
pass
|
||
|
||
# ── Daten laden ────────────────────────────────────────────────────────
|
||
|
||
def _refresh(self):
|
||
try:
|
||
status_data, source = _get_status_data()
|
||
self._source_lbl.config(text=f"Quelle: {source} · alle 5 Min.")
|
||
self._load_status(status_data)
|
||
self._load_history()
|
||
self._load_completed()
|
||
self._load_todos()
|
||
self._load_roadmap()
|
||
self._load_notizen()
|
||
self._load_handover()
|
||
except Exception:
|
||
pass
|
||
if self.winfo_exists():
|
||
self.after(REFRESH_MS, self._refresh)
|
||
|
||
def _load_status(self, data: dict | None):
|
||
if data is None:
|
||
for ent in getattr(self, "_status_entries", {}).values():
|
||
if ent and ent.winfo_exists():
|
||
ent.delete(0, "end")
|
||
return
|
||
for key, ent in getattr(self, "_status_entries", {}).items():
|
||
if ent and ent.winfo_exists():
|
||
val = data.get(key, "")
|
||
ent.delete(0, "end")
|
||
ent.insert(0, str(val) if val is not None else "")
|
||
|
||
def _reload_status(self):
|
||
data = _fetch_status_from_api()
|
||
if data is None:
|
||
data = _read_json(_STATUS_FILE)
|
||
if data:
|
||
self._load_status(data)
|
||
self._load_history()
|
||
self._status_feedback.config(text="Geladen", fg=DONE_ACCENT)
|
||
self.after(2000, lambda: self._status_feedback.config(text=""))
|
||
else:
|
||
self._status_feedback.config(text="Fehler: Backend nicht erreichbar", fg=TODO_HIGH)
|
||
|
||
def _save_status(self):
|
||
payload = {}
|
||
for key, ent in getattr(self, "_status_entries", {}).items():
|
||
if ent and ent.winfo_exists():
|
||
val = ent.get().strip()
|
||
if key in ("current_step", "last_completed_step", "next_step"):
|
||
try:
|
||
payload[key] = int(val) if val else 0
|
||
except ValueError:
|
||
payload[key] = 0
|
||
else:
|
||
payload[key] = val
|
||
data, err = _post_status_from_api(payload)
|
||
if err:
|
||
self._status_feedback.config(text=f"Fehler: {err}", fg=TODO_HIGH)
|
||
return
|
||
self._load_status(data)
|
||
self._load_history()
|
||
self._status_feedback.config(text="Gespeichert", fg=DONE_ACCENT)
|
||
self.after(2000, lambda: self._status_feedback.config(text=""))
|
||
|
||
def _copy_status_json(self):
|
||
payload = {}
|
||
for key, ent in getattr(self, "_status_entries", {}).items():
|
||
if ent and ent.winfo_exists():
|
||
val = ent.get().strip()
|
||
if key in ("current_step", "last_completed_step", "next_step"):
|
||
try:
|
||
payload[key] = int(val) if val else 0
|
||
except ValueError:
|
||
payload[key] = 0
|
||
else:
|
||
payload[key] = val
|
||
try:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(json.dumps(payload, indent=2, ensure_ascii=False))
|
||
self.update()
|
||
self._status_feedback.config(text="Kopiert", fg=DONE_ACCENT)
|
||
self.after(1500, lambda: self._status_feedback.config(text=""))
|
||
except Exception:
|
||
self._status_feedback.config(text="Kopieren fehlgeschlagen", fg=TODO_HIGH)
|
||
|
||
# ── Export/Copy helpers (To-Do & Roadmap) ─────────────────────────────
|
||
|
||
def _todos_to_markdown(self, data: dict) -> str:
|
||
items = data.get("items", []) if isinstance(data, dict) else []
|
||
lines = []
|
||
lines.append("AZA – To-Do Export")
|
||
lines.append("=" * 40)
|
||
lines.append(f"Export: {datetime.now(timezone.utc).isoformat()}")
|
||
updated = (data.get("updated_at") or "").strip() if isinstance(data, dict) else ""
|
||
if updated:
|
||
lines.append(f"updated_at: {updated}")
|
||
lines.append("")
|
||
if not items:
|
||
lines.append("(keine To-Dos)")
|
||
return "\n".join(lines) + "\n"
|
||
for it in items:
|
||
tid = it.get("id", "")
|
||
title = it.get("title", "")
|
||
status = it.get("status", "")
|
||
prio = it.get("priority", "")
|
||
area = it.get("area", [])
|
||
if isinstance(area, str):
|
||
area_txt = area
|
||
elif isinstance(area, list):
|
||
area_txt = ", ".join([str(a) for a in area])
|
||
else:
|
||
area_txt = ""
|
||
desc = (it.get("description") or "").strip()
|
||
details = (it.get("details") or "").strip()
|
||
lines.append(f"- **{tid}** [{prio}] ({status}) — {title}")
|
||
if area_txt:
|
||
lines.append(f" - Area: {area_txt}")
|
||
if desc:
|
||
lines.append(f" - Desc: {desc}")
|
||
if details:
|
||
lines.append(f" - Details: {details}")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
def _roadmap_to_markdown(self, data: dict) -> str:
|
||
phases = data.get("phases", []) if isinstance(data, dict) else []
|
||
lines = []
|
||
lines.append("AZA – Roadmap Export")
|
||
lines.append("=" * 40)
|
||
lines.append(f"Export: {datetime.now(timezone.utc).isoformat()}")
|
||
updated = (data.get("updated_at") or "").strip() if isinstance(data, dict) else ""
|
||
if updated:
|
||
lines.append(f"updated_at: {updated}")
|
||
lines.append("")
|
||
if not phases:
|
||
lines.append("(keine Roadmap-Phasen)")
|
||
return "\n".join(lines) + "\n"
|
||
for ph in phases:
|
||
name = ph.get("name", "Phase")
|
||
st = ph.get("status", "open")
|
||
lines.append(f"## {name} ({st})")
|
||
ms = ph.get("milestones", []) or []
|
||
if not ms:
|
||
lines.append("- (keine Milestones)")
|
||
lines.append("")
|
||
continue
|
||
for m in ms:
|
||
mn = m.get("name", "")
|
||
mst = m.get("status", "")
|
||
lines.append(f"- [{mst}] {mn}")
|
||
lines.append("")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
def _copy_todos_markdown(self):
|
||
data = _read_json(_TODOS_FILE) or {}
|
||
md = self._todos_to_markdown(data)
|
||
try:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(md)
|
||
self.update()
|
||
self._toast("To-Dos kopiert (Markdown)")
|
||
except Exception:
|
||
self._toast("Kopieren fehlgeschlagen", TODO_HIGH)
|
||
|
||
def _export_todos_markdown(self):
|
||
data = _read_json(_TODOS_FILE) or {}
|
||
md = self._todos_to_markdown(data)
|
||
dest = filedialog.asksaveasfilename(
|
||
title="To-Dos exportieren (Markdown)",
|
||
defaultextension=".md",
|
||
filetypes=[("Markdown", "*.md"), ("Text", "*.txt"), ("Alle Dateien", "*.*")],
|
||
initialfile="aza_todos_export.md",
|
||
)
|
||
if not dest:
|
||
return
|
||
try:
|
||
Path(dest).write_text(md, encoding="utf-8")
|
||
self._toast(f"Exportiert: {dest}")
|
||
except Exception as exc:
|
||
self._toast(f"Export fehlgeschlagen: {exc}", TODO_HIGH)
|
||
|
||
def _copy_roadmap_markdown(self):
|
||
data = _read_json(_ROADMAP_FILE) or {}
|
||
md = self._roadmap_to_markdown(data)
|
||
try:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(md)
|
||
self.update()
|
||
self._toast("Roadmap kopiert (Markdown)")
|
||
except Exception:
|
||
self._toast("Kopieren fehlgeschlagen", TODO_HIGH)
|
||
|
||
def _export_roadmap_markdown(self):
|
||
data = _read_json(_ROADMAP_FILE) or {}
|
||
md = self._roadmap_to_markdown(data)
|
||
dest = filedialog.asksaveasfilename(
|
||
title="Roadmap exportieren (Markdown)",
|
||
defaultextension=".md",
|
||
filetypes=[("Markdown", "*.md"), ("Text", "*.txt"), ("Alle Dateien", "*.*")],
|
||
initialfile="aza_roadmap_export.md",
|
||
)
|
||
if not dest:
|
||
return
|
||
try:
|
||
Path(dest).write_text(md, encoding="utf-8")
|
||
self._toast(f"Exportiert: {dest}")
|
||
except Exception as exc:
|
||
self._toast(f"Export fehlgeschlagen: {exc}", TODO_HIGH)
|
||
|
||
def _load_history(self):
|
||
lines: list[str] = []
|
||
if _HISTORY_FILE.is_file():
|
||
try:
|
||
with open(str(_HISTORY_FILE), "r", encoding="utf-8") as f:
|
||
all_lines = f.readlines()
|
||
lines = [ln for ln in all_lines if ln.strip()][-15:]
|
||
except Exception:
|
||
pass
|
||
self._hist_text.config(state="normal")
|
||
self._hist_text.delete("1.0", "end")
|
||
if not lines:
|
||
self._hist_text.insert("end", "(noch keine History-Einträge)\n")
|
||
else:
|
||
for line in lines:
|
||
try:
|
||
e = json.loads(line.strip())
|
||
ts = (e.get("ts") or e.get("ts_utc") or "")[:19].replace("T", " ")
|
||
action = e.get("action", "")
|
||
if action == "todo_status_change":
|
||
self._hist_text.insert(
|
||
"end",
|
||
f"{ts} | {e.get('todo_id','')} "
|
||
f"{e.get('old_status','')} -> {e.get('new_status','')} "
|
||
f"| Step {e.get('current_step','')}\n",
|
||
)
|
||
elif action in ("update", "read"):
|
||
st = e.get("status", {})
|
||
self._hist_text.insert(
|
||
"end",
|
||
f"{ts} | {action} | Step {st.get('current_step','')} | "
|
||
f"{st.get('phase','')} | {st.get('last_update','')}\n",
|
||
)
|
||
else:
|
||
st = e.get("status", e)
|
||
self._hist_text.insert(
|
||
"end",
|
||
f"{ts} | Step {st.get('current_step', e.get('current_step',''))} | "
|
||
f"{st.get('phase', e.get('phase',''))} | {st.get('notes', e.get('notes',''))}\n",
|
||
)
|
||
except Exception:
|
||
pass
|
||
self._hist_text.config(state="disabled")
|
||
self._hist_text.see("end")
|
||
|
||
# ── Erledigt ───────────────────────────────────────────────────────────
|
||
|
||
def _load_completed(self):
|
||
plan = _read_json(_PLAN_FILE)
|
||
for w in self._done_inner.winfo_children():
|
||
w.destroy()
|
||
completed = (plan or {}).get("completed", [])
|
||
if not completed:
|
||
tk.Label(self._done_inner, text="(keine Einträge)", font=(FONT, 10), bg=BG, fg=FG).pack(pady=10)
|
||
return
|
||
tk.Label(
|
||
self._done_inner, text=f"{len(completed)} Schritte abgeschlossen",
|
||
font=(FONT, 11, "bold"), bg=BG, fg=DONE_ACCENT, anchor="w",
|
||
).pack(fill="x", pady=(0, 6))
|
||
for item in completed:
|
||
self._render_done_card(item)
|
||
|
||
def _render_done_card(self, item: dict):
|
||
card = tk.Frame(self._done_inner, bg=DONE_BG, bd=1, relief="groove")
|
||
card.pack(fill="x", pady=2)
|
||
header = tk.Frame(card, bg=DONE_BG)
|
||
header.pack(fill="x", padx=8, pady=(6, 0))
|
||
tk.Label(header, text=f"✓ Step {item.get('step','')}", font=(FONT, 9, "bold"), bg=DONE_BG, fg=DONE_ACCENT).pack(side="left")
|
||
tk.Label(header, text=item.get("title", ""), font=(FONT, 10, "bold"), bg=DONE_BG, fg=FG).pack(side="left", padx=(8, 0))
|
||
desc = item.get("description", "")
|
||
if desc:
|
||
tk.Label(card, text=desc, font=(FONT, 9), bg=DONE_BG, fg="#3a6a5a", anchor="w", wraplength=560, justify="left").pack(fill="x", padx=(32, 8), pady=(0, 2))
|
||
files = ", ".join(item.get("files", []))
|
||
if files:
|
||
tk.Label(card, text=files, font=(FONT, 8), bg=DONE_BG, fg="#7a9a8a", anchor="w").pack(fill="x", padx=(32, 8), pady=(0, 6))
|
||
|
||
# ── To-Do (project_todos.json) ──────────────────────────────────────────
|
||
|
||
def _load_todos(self):
|
||
for w in self._todo_inner.winfo_children():
|
||
w.destroy()
|
||
self._todo_detail_entries = {}
|
||
data = _read_json(_TODOS_FILE)
|
||
if data is None:
|
||
if not _TODOS_FILE.is_file():
|
||
_default = {"version": 1, "updated_at": None, "items": []}
|
||
try:
|
||
with open(str(_TODOS_FILE), "w", encoding="utf-8") as f:
|
||
json.dump(_default, f, indent=2, ensure_ascii=False)
|
||
data = _default
|
||
except Exception:
|
||
pass
|
||
if data is None:
|
||
tk.Label(
|
||
self._todo_inner, text="project_todos.json nicht lesbar oder nicht vorhanden.",
|
||
font=(FONT, 10), bg=BG, fg=TODO_HIGH, wraplength=500,
|
||
).pack(pady=20)
|
||
return
|
||
items = data.get("items", [])
|
||
if not items:
|
||
tk.Label(
|
||
self._todo_inner, text="Keine Aufgaben in project_todos.json.",
|
||
font=(FONT, 11), bg=BG, fg=FG,
|
||
).pack(pady=20)
|
||
return
|
||
|
||
def _norm(t):
|
||
prio = _TODO_PRIO_MAP.get(t.get("priority", "").upper(), t.get("priority", ""))
|
||
st = _TODO_STATUS_MAP.get(t.get("status", "").lower(), t.get("status", ""))
|
||
return {"priority": prio, "status": st, "title": t.get("title", "")}
|
||
|
||
sorted_items = sorted(items, key=lambda t: (
|
||
_PRIO_ORDER.get(_norm(t)["priority"].lower(), 9),
|
||
_STATUS_ORDER.get(_norm(t)["status"].lower(), 9),
|
||
t.get("title", ""),
|
||
))
|
||
active = [t for t in sorted_items if t.get("status", "").lower() != "done"]
|
||
done = [t for t in sorted_items if t.get("status", "").lower() == "done"]
|
||
|
||
summary = tk.Frame(self._todo_inner, bg=CARD_BG, bd=1, relief="groove")
|
||
summary.pack(fill="x", pady=(0, 8))
|
||
tk.Label(
|
||
summary, text=f"Offen / In Arbeit: {len(active)}",
|
||
font=(FONT, 11, "bold"), bg=CARD_BG, fg=TODO_HIGH, padx=10, pady=4,
|
||
).pack(side="left")
|
||
tk.Label(
|
||
summary, text=f"Erledigt: {len(done)}",
|
||
font=(FONT, 11, "bold"), bg=CARD_BG, fg=DONE_ACCENT, padx=10, pady=4,
|
||
).pack(side="right")
|
||
|
||
current_prio = None
|
||
for item in sorted_items:
|
||
prio = _TODO_PRIO_MAP.get(item.get("priority", "").upper(), item.get("priority", "").upper())
|
||
if prio != current_prio:
|
||
current_prio = prio
|
||
sep = tk.Frame(self._todo_inner, bg=BG)
|
||
sep.pack(fill="x", pady=(8, 2))
|
||
prio_colors = {"HOCH": TODO_HIGH, "MITTEL": TODO_MED, "NIEDRIG": TODO_LOW}
|
||
tk.Label(sep, text=f"── {prio} ──", font=(FONT, 9, "bold"), bg=BG, fg=prio_colors.get(prio, FG)).pack(side="left", padx=4)
|
||
self._render_todo_card(item)
|
||
|
||
def _render_todo_card(self, item: dict):
|
||
st = item.get("status", "open").lower()
|
||
cur_status = _TODO_STATUS_MAP.get(st, st)
|
||
is_done = st == "done"
|
||
bg = DONE_BG if is_done else TODO_BG
|
||
card = tk.Frame(self._todo_inner, bg=bg, bd=1, relief="groove")
|
||
card.pack(fill="x", pady=2)
|
||
|
||
prio = _TODO_PRIO_MAP.get(item.get("priority", "").upper(), item.get("priority", ""))
|
||
prio_color = TODO_LOW
|
||
prio_sym = "○"
|
||
if prio == "HOCH":
|
||
prio_color = TODO_HIGH
|
||
prio_sym = "●"
|
||
elif prio == "MITTEL":
|
||
prio_color = TODO_MED
|
||
prio_sym = "◐"
|
||
|
||
header = tk.Frame(card, bg=bg)
|
||
header.pack(fill="x", padx=8, pady=(6, 0))
|
||
if is_done:
|
||
tk.Label(header, text="✓", font=(FONT, 10, "bold"), bg=bg, fg=DONE_ACCENT).pack(side="left")
|
||
else:
|
||
tk.Label(header, text=prio_sym, font=(FONT, 10), bg=bg, fg=prio_color).pack(side="left")
|
||
|
||
title_font = (FONT, 10, "bold") if not is_done else (FONT, 10, "overstrike")
|
||
tk.Label(header, text=item.get("title", ""), font=title_font, bg=bg, fg=FG).pack(side="left", padx=(6, 0))
|
||
st_color = DONE_ACCENT if is_done else (TODO_HIGH if cur_status == "in Arbeit" else prio_color)
|
||
tk.Label(header, text=f"[{cur_status}]", font=(FONT, 9), bg=bg, fg=st_color).pack(side="right")
|
||
|
||
area = item.get("area", "")
|
||
if area:
|
||
area_key = area.lower()
|
||
label_text, label_color = _AREA_LABELS.get(area_key, (area, "#95a5a6"))
|
||
tag_fr = tk.Frame(card, bg=bg)
|
||
tag_fr.pack(fill="x", padx=(30, 8), pady=(2, 0))
|
||
tk.Label(tag_fr, text=f" {label_text} ", font=(FONT, 8), bg=label_color, fg="white", padx=3).pack(side="left")
|
||
|
||
detail = tk.Frame(card, bg=bg)
|
||
detail.pack(fill="x", padx=(30, 8), pady=(0, 2))
|
||
ent = tk.Entry(detail, font=(FONT, 9), bg=TEXT_BG, fg="#5a5540", bd=1, relief="solid")
|
||
ent.insert(0, item.get("details", ""))
|
||
ent.pack(fill="x", pady=(2, 0))
|
||
tid = item.get("id", "")
|
||
if tid:
|
||
self._todo_detail_entries[tid] = ent
|
||
|
||
btn_row = tk.Frame(card, bg=bg)
|
||
btn_row.pack(fill="x", padx=(30, 8), pady=(2, 6))
|
||
if tid:
|
||
tk.Label(btn_row, text=tid, font=(FONT, 8), bg=bg, fg="#aaa").pack(side="left")
|
||
|
||
btn_offen = tk.Button(
|
||
btn_row, text="Offen", font=(FONT, 8), width=8,
|
||
relief="groove", bd=1, bg="#e0e0e0", fg=FG,
|
||
state="disabled" if cur_status == "offen" else "normal",
|
||
command=lambda t=tid: self._set_todo_status(t, "offen"),
|
||
)
|
||
btn_offen.pack(side="right", padx=(3, 0))
|
||
btn_done = tk.Button(
|
||
btn_row, text="Erledigt", font=(FONT, 8), width=8,
|
||
relief="groove", bd=1, bg="#c8e6c9", fg="#1b5e20",
|
||
state="disabled" if is_done else "normal",
|
||
command=lambda t=tid: self._set_todo_status(t, "erledigt"),
|
||
)
|
||
btn_done.pack(side="right", padx=(3, 0))
|
||
btn_active = tk.Button(
|
||
btn_row, text="In Arbeit", font=(FONT, 8), width=8,
|
||
relief="groove", bd=1, bg="#fff3e0", fg="#e65100",
|
||
state="disabled" if cur_status == "in Arbeit" else "normal",
|
||
command=lambda t=tid: self._set_todo_status(t, "in Arbeit"),
|
||
)
|
||
btn_active.pack(side="right", padx=(3, 0))
|
||
|
||
# ── Roadmap (project_roadmap.json) ──────────────────────────────────────
|
||
|
||
def _load_roadmap(self):
|
||
for w in self._road_inner.winfo_children():
|
||
w.destroy()
|
||
self._roadmap_combos = []
|
||
data = _read_json(_ROADMAP_FILE)
|
||
if data is None:
|
||
tk.Label(
|
||
self._road_inner, text="project_roadmap.json nicht lesbar oder nicht vorhanden.",
|
||
font=(FONT, 10), bg=BG, fg=TODO_HIGH, wraplength=500,
|
||
).pack(pady=20)
|
||
return
|
||
self._roadmap_data = data
|
||
phases = data.get("phases", [])
|
||
if not phases:
|
||
tk.Label(self._road_inner, text="Keine Phasen in project_roadmap.json.", font=(FONT, 10), bg=BG, fg=FG).pack(pady=20)
|
||
return
|
||
|
||
status_options = ["open", "in_progress", "done"]
|
||
for pi, ph in enumerate(phases):
|
||
ph_status = ph.get("status", "open").lower()
|
||
border_color = DONE_ACCENT if ph_status == "done" else (TODO_MED if ph_status == "in_progress" else ACCENT)
|
||
frame = tk.Frame(self._road_inner, bg=ROAD_BG, bd=2, relief="groove")
|
||
frame.pack(fill="x", pady=4)
|
||
|
||
head = tk.Frame(frame, bg=border_color, height=28)
|
||
head.pack(fill="x")
|
||
head.pack_propagate(False)
|
||
sym = "✓" if ph_status == "done" else ("▶" if ph_status == "in_progress" else "○")
|
||
tk.Label(head, text=f" {sym} {ph.get('name', '')}", font=(FONT, 10, "bold"), bg=border_color, fg="white").pack(side="left")
|
||
|
||
total = len(ph.get("milestones", []))
|
||
done_count = sum(1 for m in ph.get("milestones", []) if m.get("status", "").lower() == "done")
|
||
if total > 0:
|
||
pct = int(done_count / total * 100)
|
||
tk.Label(head, text=f"{done_count}/{total} ({pct}%)", font=(FONT, 8), bg=border_color, fg="#e0e0e0").pack(side="right", padx=8)
|
||
|
||
for mi, ms in enumerate(ph.get("milestones", [])):
|
||
ms_status = ms.get("status", "open").lower()
|
||
ms_color = DONE_ACCENT if ms_status == "done" else (TODO_MED if ms_status == "in_progress" else "#888")
|
||
ms_sym = "✓" if ms_status == "done" else ("▶" if ms_status == "in_progress" else "○")
|
||
row = tk.Frame(frame, bg=ROAD_BG)
|
||
row.pack(fill="x", padx=12, pady=2)
|
||
tk.Label(row, text=f" {ms_sym}", font=(FONT, 9), bg=ROAD_BG, fg=ms_color).pack(side="left")
|
||
tk.Label(row, text=ms.get("name", ""), font=(FONT, 9), bg=ROAD_BG, fg=FG).pack(side="left", padx=(4, 0))
|
||
var = tk.StringVar(value=ms.get("status", "open"))
|
||
combo = ttk.Combobox(row, textvariable=var, values=status_options, state="readonly", width=12, font=(FONT, 8))
|
||
combo.pack(side="right", padx=(4, 0))
|
||
self._roadmap_combos.append((pi, mi, var))
|
||
|
||
def _save_roadmap(self):
|
||
data = getattr(self, "_roadmap_data", None) or _read_json(_ROADMAP_FILE)
|
||
if data is None:
|
||
self._toast("Fehler: project_roadmap.json nicht lesbar", TODO_HIGH)
|
||
return
|
||
for pi, mi, var in getattr(self, "_roadmap_combos", []):
|
||
try:
|
||
phases = data.get("phases", [])
|
||
if pi < len(phases):
|
||
milestones = phases[pi].get("milestones", [])
|
||
if mi < len(milestones):
|
||
milestones[mi]["status"] = var.get()
|
||
except Exception:
|
||
pass
|
||
for ph in data.get("phases", []):
|
||
statuses = [m.get("status", "open").lower() for m in ph.get("milestones", [])]
|
||
if all(s == "done" for s in statuses) and statuses:
|
||
ph["status"] = "done"
|
||
elif any(s == "in_progress" for s in statuses):
|
||
ph["status"] = "in_progress"
|
||
elif any(s == "done" for s in statuses):
|
||
ph["status"] = "in_progress"
|
||
data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||
if not _save_json_atomic(_ROADMAP_FILE, data):
|
||
self._toast("Fehler beim Speichern", TODO_HIGH)
|
||
return
|
||
self._toast("Roadmap gespeichert")
|
||
self._load_roadmap()
|
||
|
||
# ── Projektnotizen ────────────────────────────────────────────────────
|
||
|
||
def _load_notizen(self):
|
||
"""Laedt projekt_status.md und changelog.md aus dem Projekt-Notizen-Ordner."""
|
||
parts = []
|
||
for label, full_path in [
|
||
("Projektstatus", _NOTIZEN_PROJEKT),
|
||
("Changelog", _NOTIZEN_CHANGELOG),
|
||
]:
|
||
content = ""
|
||
if full_path.is_file():
|
||
try:
|
||
with open(str(full_path), "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
except Exception:
|
||
content = "(Fehler beim Lesen)"
|
||
else:
|
||
content = f"(Datei noch nicht vorhanden. Im Projekt-Notizen-Fenster den Tab \"{label}\" oeffnen und Inhalt anlegen.)"
|
||
parts.append((label, content))
|
||
|
||
combined = ""
|
||
for label, content in parts:
|
||
combined += f"\n## {label}\n\n{content.strip()}\n\n"
|
||
|
||
self._notizen_text.config(state="normal")
|
||
self._notizen_text.delete("1.0", "end")
|
||
if not combined.strip():
|
||
self._notizen_text.insert("end", "Keine Projektnotizen vorhanden.\n\nOeffnen Sie das Projekt-Notizen-Fenster und legen Sie projekt_status.md oder changelog.md an.")
|
||
else:
|
||
in_code = False
|
||
for line in combined.split("\n"):
|
||
if line.strip().startswith("```"):
|
||
in_code = not in_code
|
||
continue
|
||
if in_code:
|
||
self._notizen_text.insert("end", line + "\n", "code")
|
||
elif line.startswith("# "):
|
||
self._notizen_text.insert("end", line[2:] + "\n", "h1")
|
||
elif line.startswith("## "):
|
||
self._notizen_text.insert("end", line[3:] + "\n", "h2")
|
||
elif line.startswith("- ") or line.startswith("* "):
|
||
self._notizen_text.insert("end", " " + line + "\n")
|
||
else:
|
||
self._notizen_text.insert("end", line + "\n")
|
||
self._notizen_text.config(state="disabled")
|
||
|
||
# ── Handover actions ──────────────────────────────────────────────────
|
||
|
||
def _save_handover(self):
|
||
try:
|
||
content = self._hand_text.get("1.0", "end-1c")
|
||
with open(str(_PROJECT_HANDOVER_FILE), "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
self._hand_path_lbl.config(text=str(_PROJECT_HANDOVER_FILE))
|
||
self._toast("Handover gespeichert")
|
||
except Exception as exc:
|
||
self._toast(f"Fehler: {exc}", TODO_HIGH)
|
||
|
||
def _handover_copy(self):
|
||
try:
|
||
content = self._hand_text.get("1.0", "end-1c")
|
||
self.clipboard_clear()
|
||
self.clipboard_append(content)
|
||
self.update()
|
||
self._toast("Handover in Zwischenablage kopiert")
|
||
except Exception as exc:
|
||
self._toast(f"Fehler: {exc}", TODO_HIGH)
|
||
|
||
# ── Handover (project_handover.md) ──────────────────────────────────────
|
||
|
||
def _load_handover(self):
|
||
content = ""
|
||
if _PROJECT_HANDOVER_FILE.is_file():
|
||
try:
|
||
with open(str(_PROJECT_HANDOVER_FILE), "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
try:
|
||
self._hand_path_lbl.config(text=str(_PROJECT_HANDOVER_FILE))
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
content = _HANDOVER_DEFAULT
|
||
try:
|
||
self._hand_path_lbl.config(text="(Fehler beim Lesen)")
|
||
except Exception:
|
||
pass
|
||
else:
|
||
content = _HANDOVER_DEFAULT
|
||
try:
|
||
self._hand_path_lbl.config(text="(Default - Speichern erstellt Datei)")
|
||
except Exception:
|
||
pass
|
||
|
||
self._hand_text.config(state="normal")
|
||
self._hand_text.delete("1.0", "end")
|
||
self._hand_text.insert("1.0", content)
|