# -*- coding: utf-8 -*- """ AzaArbeitsplanMixin – Arbeitsplan & Ferienplanung (Add-on). Kalenderansicht mit Drag-Select, Mitarbeiterverwaltung, Abwesenheitsplanung. Daten werden lokal als JSON gespeichert. """ import os import csv import json import calendar import datetime import threading import tkinter as tk from tkinter import ttk, messagebox, filedialog import requests as _requests from aza_ui_helpers import ( center_window, add_resize_grip, RoundedButton, load_toplevel_geometry, save_toplevel_geometry, ) _DATA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "kg_diktat_arbeitsplan.json") _ABSENCE_TYPES = { "urlaub": {"label": "Urlaub", "color": "#7EC8E3", "fg": "#fff", "counts": True}, "unbezahlt": {"label": "Unbezahlter Urlaub","color": "#E8C87E", "fg": "#fff", "counts": False}, "krank": {"label": "Krank", "color": "#F5A3A3", "fg": "#fff", "counts": False}, "buero": {"label": "Bürozeit", "color": "#A8D5BA", "fg": "#fff", "counts": False}, "weiterbildung": {"label": "Weiterbildung", "color": "#B8C8F0", "fg": "#fff", "counts": False}, "sonstiges": {"label": "Sonstiges", "color": "#D4C5F9", "fg": "#fff", "counts": False}, } _WEEKDAYS_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] _MONTHS_DE = [ "", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember", ] _MONTHS_SHORT = ["", "Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"] def _load_data() -> dict: try: if os.path.isfile(_DATA_FILE): with open(_DATA_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: pass return {"employees": [], "absences": []} def _save_data(data: dict): try: with open(_DATA_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) except Exception: pass def _parse_date(s: str): try: return datetime.date.fromisoformat(s) except Exception: return None def _business_days(start, end) -> float: count = 0 d = start while d <= end: if d.weekday() < 5: count += 1 d += datetime.timedelta(days=1) return count def _generate_ics(absences, employees): lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//AZA Arbeitsplan//DE", "CALSCALE:GREGORIAN"] for ab in absences: emp = employees.get(ab.get("employee_id", ""), {}) name = emp.get("name", "Mitarbeiter") atype = _ABSENCE_TYPES.get(ab.get("type", ""), {}).get("label", ab.get("type", "")) d_start = _parse_date(ab.get("start", "")) d_end = _parse_date(ab.get("end", "")) if not d_start or not d_end: continue d_end_ics = d_end + datetime.timedelta(days=1) lines += [ "BEGIN:VEVENT", f"DTSTART;VALUE=DATE:{d_start.strftime('%Y%m%d')}", f"DTEND;VALUE=DATE:{d_end_ics.strftime('%Y%m%d')}", f"SUMMARY:{atype} – {name}", f"DESCRIPTION:{ab.get('reason', '')}".strip(), "END:VEVENT", ] lines.append("END:VCALENDAR") return "\r\n".join(lines) class AzaArbeitsplanMixin: """Mixin für das Arbeitsplan-Fenster.""" def _open_arbeitsplan_window(self): win = tk.Toplevel(self) win.title("Arbeitsplan & Ferienplanung") win.minsize(960, 620) win.configure(bg="#E8F4FA") win.attributes("-topmost", True) self._register_window(win) saved_geom = load_toplevel_geometry("arbeitsplan") if saved_geom: win.geometry(saved_geom) else: win.geometry("1150x750") center_window(win, 1150, 750) _geom_after = [None] def _save_geom(e=None): if e and e.widget is not win: return if _geom_after[0]: win.after_cancel(_geom_after[0]) _geom_after[0] = win.after(400, lambda: save_toplevel_geometry("arbeitsplan", win.geometry())) win.bind("", _save_geom) def _on_close(): try: save_toplevel_geometry("arbeitsplan", win.geometry()) except Exception: pass if hasattr(self, "_aza_windows"): self._aza_windows.discard(win) win.destroy() win.protocol("WM_DELETE_WINDOW", _on_close) data = [_load_data()] emp_map = [{e["id"]: e for e in data[0].get("employees", [])}] today = datetime.date.today() cur_year = [today.year] cur_month = [today.month] cur_week_start = [today - datetime.timedelta(days=today.weekday())] view_mode = ["month"] # "week", "month", "year" cal_scale = [1.0] font_scale = [1.0] # ─── Minimize logic ─── _ap_minimized = [False] _ap_geom_before = [None] _ap_restoring = [False] def _restore_ap(): if not _ap_minimized[0]: return _ap_restoring[0] = True content_frame.pack(fill="both", expand=True) btn_mini.configure(text="—") _ap_minimized[0] = False win.minsize(960, 620) win.after(200, lambda: _ap_restoring.__setitem__(0, False)) def _toggle_minimize(): if _ap_minimized[0]: _restore_ap() if _ap_geom_before[0]: try: win.geometry(_ap_geom_before[0]) except Exception: pass else: _ap_geom_before[0] = win.geometry() content_frame.pack_forget() btn_mini.configure(text="□") _ap_minimized[0] = True win.minsize(300, 44) win.geometry(f"{win.winfo_width()}x44") def _on_ap_configure(e): if e.widget is not win: return if _ap_minimized[0] and not _ap_restoring[0] and e.height > 70: _restore_ap() win.bind("", _on_ap_configure, add="+") # ─── Header ─── header = tk.Frame(win, bg="#B9ECFA", padx=6, pady=3) header.pack(fill="x") tk.Label(header, text="📅 Arbeitsplan & Ferienplanung", font=("Segoe UI", 10, "bold"), bg="#B9ECFA", fg="#1a4d6d").pack(side="left") btn_mini = tk.Label(header, text="—", font=("Segoe UI", 10, "bold"), bg="#B9ECFA", fg="#5A90B0", cursor="hand2", padx=4) btn_mini.pack(side="right", padx=(0, 4)) btn_mini.bind("", lambda e: _toggle_minimize()) btn_mini.bind("", lambda e: btn_mini.configure(fg="#1a4d6d")) btn_mini.bind("", lambda e: btn_mini.configure(fg="#5A90B0")) win._aza_minimize = _toggle_minimize win._aza_is_minimized = lambda: _ap_minimized[0] if hasattr(self, "_aza_windows"): self._aza_windows.add(win) # Font size control _fs_fg = "#8AAFC0" _fs_hover = "#1a4d6d" fs_frame = tk.Frame(header, bg="#B9ECFA") fs_frame.pack(side="right", padx=(0, 8)) tk.Label(fs_frame, text="Aa", font=("Segoe UI", 8), bg="#B9ECFA", fg=_fs_fg).pack(side="left") fs_lbl = tk.Label(fs_frame, text="100%", font=("Segoe UI", 8), bg="#B9ECFA", fg=_fs_fg) fs_lbl.pack(side="left", padx=(2, 2)) def _fs_up(): font_scale[0] = min(font_scale[0] + 0.1, 2.0) fs_lbl.configure(text=f"{int(font_scale[0]*100)}%") _rebuild_current() def _fs_down(): font_scale[0] = max(font_scale[0] - 0.1, 0.6) fs_lbl.configure(text=f"{int(font_scale[0]*100)}%") _rebuild_current() fs_up = tk.Label(fs_frame, text="▲", font=("Segoe UI", 7), bg="#B9ECFA", fg=_fs_fg, cursor="hand2") fs_up.pack(side="left") fs_up.bind("", lambda e: _fs_up()) fs_up.bind("", lambda e: fs_up.configure(fg=_fs_hover)) fs_up.bind("", lambda e: fs_up.configure(fg=_fs_fg)) fs_dn = tk.Label(fs_frame, text="▼", font=("Segoe UI", 7), bg="#B9ECFA", fg=_fs_fg, cursor="hand2") fs_dn.pack(side="left") fs_dn.bind("", lambda e: _fs_down()) fs_dn.bind("", lambda e: fs_dn.configure(fg=_fs_hover)) fs_dn.bind("", lambda e: fs_dn.configure(fg=_fs_fg)) # Zoom control zoom_frame = tk.Frame(header, bg="#B9ECFA") zoom_frame.pack(side="right", padx=(0, 12)) tk.Label(zoom_frame, text="🔍", font=("Segoe UI", 9), bg="#B9ECFA").pack(side="left") zoom_lbl = tk.Label(zoom_frame, text="100%", font=("Segoe UI", 8), bg="#B9ECFA", fg=_fs_fg) zoom_lbl.pack(side="left", padx=(2, 2)) def _zoom_in(): cal_scale[0] = min(cal_scale[0] + 0.15, 2.5) zoom_lbl.configure(text=f"{int(cal_scale[0]*100)}%") _rebuild_current() def _zoom_out(): cal_scale[0] = max(cal_scale[0] - 0.15, 0.5) zoom_lbl.configure(text=f"{int(cal_scale[0]*100)}%") _rebuild_current() z_up = tk.Label(zoom_frame, text="▲", font=("Segoe UI", 7), bg="#B9ECFA", fg=_fs_fg, cursor="hand2") z_up.pack(side="left") z_up.bind("", lambda e: _zoom_in()) z_up.bind("", lambda e: z_up.configure(fg=_fs_hover)) z_up.bind("", lambda e: z_up.configure(fg=_fs_fg)) z_dn = tk.Label(zoom_frame, text="▼", font=("Segoe UI", 7), bg="#B9ECFA", fg=_fs_fg, cursor="hand2") z_dn.pack(side="left") z_dn.bind("", lambda e: _zoom_out()) z_dn.bind("", lambda e: z_dn.configure(fg=_fs_hover)) z_dn.bind("", lambda e: z_dn.configure(fg=_fs_fg)) RoundedButton(header, ".ics Exp", command=lambda: _export_ics(), width=72, height=22, canvas_bg="#B9ECFA", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="right", padx=4) # ─── Content frame (minimizable) ─── content_frame = tk.Frame(win, bg="#E8F4FA") content_frame.pack(fill="both", expand=True) # ─── Tabs ─── tab_bar = tk.Frame(content_frame, bg="#A8D8E8") tab_bar.pack(fill="x") _active_tab = ["calendar"] _ts_a = {"font": ("Segoe UI", 9, "bold"), "bg": "#E8F4FA", "fg": "#1a4d6d"} _ts_i = {"font": ("Segoe UI", 9), "bg": "#C4E4F0", "fg": "#5A90B0"} tab_btn_cal = tk.Label(tab_bar, text="📅 Kalender", cursor="hand2", padx=10, pady=3, **_ts_a) tab_btn_cal.pack(side="left") tab_btn_emp = tk.Label(tab_bar, text="👥 Mitarbeiter", cursor="hand2", padx=10, pady=3, **_ts_i) tab_btn_emp.pack(side="left") tab_btn_stats = tk.Label(tab_bar, text="📊 Übersicht", cursor="hand2", padx=10, pady=3, **_ts_i) tab_btn_stats.pack(side="left") tab_btn_plan = tk.Label(tab_bar, text="🌐 Planung", cursor="hand2", padx=10, pady=3, **_ts_i) tab_btn_plan.pack(side="left") _all_tab_btns = {"calendar": tab_btn_cal, "employees": tab_btn_emp, "stats": tab_btn_stats, "planung": tab_btn_plan} cal_page = tk.Frame(content_frame, bg="#E8F4FA") emp_page = tk.Frame(content_frame, bg="#E8F4FA") stats_page = tk.Frame(content_frame, bg="#E8F4FA") plan_page = tk.Frame(content_frame, bg="#E8F4FA") _all_pages = {"calendar": cal_page, "employees": emp_page, "stats": stats_page, "planung": plan_page} def _switch_tab(name): _active_tab[0] = name for k, b in _all_tab_btns.items(): b.configure(**(_ts_a if k == name else _ts_i)) for k, p in _all_pages.items(): p.pack_forget() _all_pages[name].pack(fill="both", expand=True) _rebuild_current() def _rebuild_current(): t = _active_tab[0] if t == "calendar": _rebuild_calendar() elif t == "employees": _rebuild_employees() elif t == "stats": _rebuild_stats() elif t == "planung": pass tab_btn_cal.bind("", lambda e: _switch_tab("calendar")) tab_btn_emp.bind("", lambda e: _switch_tab("employees")) tab_btn_stats.bind("", lambda e: _switch_tab("stats")) tab_btn_plan.bind("", lambda e: _switch_tab("planung")) # ═══════════════════════════════════════════ # KALENDER # ═══════════════════════════════════════════ cal_toolbar = tk.Frame(cal_page, bg="#D4EEF7", padx=6, pady=2) cal_toolbar.pack(fill="x") # View mode buttons def _set_view(v): view_mode[0] = v for vk, vb in view_btns.items(): vb.configure(bg="#5B8DB3" if vk == v else "#9CC0D4", fg="white" if vk == v else "#1a4d6d") _rebuild_calendar() view_btns = {} for vk, vl in [("week", "Woche"), ("month", "Monat"), ("year", "Jahr")]: b = tk.Label(cal_toolbar, text=vl, font=("Segoe UI", 8, "bold"), bg="#5B8DB3" if vk == "month" else "#9CC0D4", fg="white" if vk == "month" else "#1a4d6d", padx=7, pady=2, cursor="hand2") b.pack(side="left", padx=(0, 2)) view_btns[vk] = b b.bind("", lambda e, v=vk: _set_view(v)) def _nav_prev(): if view_mode[0] == "week": cur_week_start[0] -= datetime.timedelta(weeks=1) elif view_mode[0] == "month": if cur_month[0] == 1: cur_month[0] = 12; cur_year[0] -= 1 else: cur_month[0] -= 1 else: cur_year[0] -= 1 _rebuild_calendar() def _nav_next(): if view_mode[0] == "week": cur_week_start[0] += datetime.timedelta(weeks=1) elif view_mode[0] == "month": if cur_month[0] == 12: cur_month[0] = 1; cur_year[0] += 1 else: cur_month[0] += 1 else: cur_year[0] += 1 _rebuild_calendar() def _nav_today(): t = datetime.date.today() cur_year[0] = t.year; cur_month[0] = t.month cur_week_start[0] = t - datetime.timedelta(days=t.weekday()) _rebuild_calendar() tk.Label(cal_toolbar, text=" ", bg="#D4EEF7").pack(side="left", padx=2) RoundedButton(cal_toolbar, "◀", command=_nav_prev, width=26, height=22, canvas_bg="#D4EEF7", bg="#7EC8E3", fg="white", active_bg="#6CB8D3").pack(side="left") nav_label = tk.Label(cal_toolbar, text="", font=("Segoe UI", 9, "bold"), bg="#D4EEF7", fg="#1a4d6d", width=18, anchor="center") nav_label.pack(side="left", padx=4) RoundedButton(cal_toolbar, "▶", command=_nav_next, width=26, height=22, canvas_bg="#D4EEF7", bg="#7EC8E3", fg="white", active_bg="#6CB8D3").pack(side="left") RoundedButton(cal_toolbar, "Heute", command=_nav_today, width=48, height=22, canvas_bg="#D4EEF7", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="left", padx=(6, 0)) RoundedButton(cal_toolbar, "+ Mitarb.", command=lambda: (_switch_tab("employees"), _add_employee_dialog()), width=85, height=22, canvas_bg="#D4EEF7", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="right") # Calendar content area cal_body = tk.Frame(cal_page, bg="#E8F4FA") cal_body.pack(fill="both", expand=True) cal_canvas = tk.Canvas(cal_body, bg="#E8F4FA", highlightthickness=0) cal_vscroll = ttk.Scrollbar(cal_body, orient="vertical", command=cal_canvas.yview) cal_hscroll = ttk.Scrollbar(cal_body, orient="horizontal", command=cal_canvas.xview) cal_inner = tk.Frame(cal_canvas, bg="#E8F4FA") cal_inner.bind("", lambda e: cal_canvas.configure(scrollregion=cal_canvas.bbox("all"))) cal_canvas.create_window((0, 0), window=cal_inner, anchor="nw") cal_canvas.configure(yscrollcommand=cal_vscroll.set, xscrollcommand=cal_hscroll.set) cal_hscroll.pack(side="bottom", fill="x") cal_vscroll.pack(side="right", fill="y") cal_canvas.pack(side="left", fill="both", expand=True) def _on_mwheel(e): cal_canvas.yview_scroll(int(-1 * (e.delta / 120)), "units") cal_canvas.bind("", _on_mwheel) # Drag state drag_state = {"active": False, "emp_id": None, "start_date": None, "cells": {}} def _cell_press(emp_id, dt, cell): drag_state["active"] = True drag_state["emp_id"] = emp_id drag_state["start_date"] = dt cell.configure(bg="#B8D8E8") def _cell_enter(emp_id, dt, cell): if not drag_state["active"] or drag_state["emp_id"] != emp_id: return cell.configure(bg="#B8D8E8") def _cell_release(emp_id, dt): if not drag_state["active"] or drag_state["emp_id"] != emp_id: drag_state["active"] = False return drag_state["active"] = False d1 = drag_state["start_date"] d2 = dt if d1 > d2: d1, d2 = d2, d1 bd = _business_days(d1, d2) emp = emp_map[0].get(emp_id, {}) emp_name = emp.get("name", "?") vac_days = emp.get("vacation_days", 0) year_of = d1.year used = _used_vac(emp_id, year_of) carry = emp.get("carry_over", {}).get(str(year_of), 0) total_avail = vac_days + carry rest = total_avail - used dlg_result = [None] rdlg = tk.Toplevel(win) rdlg.title("Abwesenheit eintragen") rdlg.configure(bg="#E8F4FA") rdlg.geometry("410x400") center_window(rdlg, 410, 400) rdlg.transient(win) rdlg.grab_set() rdlg.attributes("-topmost", True) self._register_window(rdlg) tk.Label(rdlg, text=emp_name, font=("Segoe UI", 11, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(pady=(10, 2)) tk.Label(rdlg, text=f"{d1.strftime('%d.%m.%Y')} – {d2.strftime('%d.%m.%Y')}", font=("Segoe UI", 10), bg="#E8F4FA", fg="#5A90B0").pack() tk.Label(rdlg, text=f"{bd:.0f} Arbeitstage", font=("Segoe UI", 9), bg="#E8F4FA", fg="#888").pack(pady=(0, 6)) tk.Label(rdlg, text="Art der Abwesenheit:", font=("Segoe UI", 9, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", padx=16) type_var = tk.StringVar(value="urlaub") type_frame = tk.Frame(rdlg, bg="#E8F4FA") type_frame.pack(fill="x", padx=16, pady=(2, 6)) col = 0 for k, v in _ABSENCE_TYPES.items(): rb = tk.Radiobutton(type_frame, text=v["label"], variable=type_var, value=k, font=("Segoe UI", 9), bg="#E8F4FA", fg="#1a4d6d", activebackground="#E8F4FA", selectcolor="#D4EEF7") rb.grid(row=col // 2, column=col % 2, sticky="w", padx=(0, 12)) col += 1 tk.Frame(rdlg, bg="#C4E4F0", height=1).pack(fill="x", padx=16, pady=(4, 4)) stats_f = tk.Frame(rdlg, bg="#E8F4FA") stats_f.pack(fill="x", padx=16) tk.Label(stats_f, text=f"Ferientage {year_of}:", font=("Segoe UI", 9, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w") if vac_days: tk.Label(stats_f, text=f"Anspruch: {total_avail} | Genommen: {used:.0f} | Rest: {rest:.0f}", font=("Segoe UI", 9), bg="#E8F4FA", fg="#1a4d6d" if rest >= 0 else "#D04040").pack(anchor="w") else: tk.Label(stats_f, text="(Keine Ferientage konfiguriert)", font=("Segoe UI", 9), bg="#E8F4FA", fg="#999").pack(anchor="w") tk.Frame(rdlg, bg="#C4E4F0", height=1).pack(fill="x", padx=16, pady=(4, 6)) tk.Label(rdlg, text="Grund (optional):", font=("Segoe UI", 9), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", padx=16, pady=(0, 2)) reason_var = tk.StringVar() ttk.Entry(rdlg, textvariable=reason_var, width=40).pack(padx=16) def _ok(): dlg_result[0] = {"type": type_var.get(), "reason": reason_var.get().strip()} rdlg.destroy() def _cancel(): dlg_result[0] = None rdlg.destroy() bf = tk.Frame(rdlg, bg="#E8F4FA") bf.pack(pady=10) RoundedButton(bf, "OK", command=_ok, width=70, height=26, canvas_bg="#E8F4FA", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="left", padx=4) RoundedButton(bf, "Abbrechen", command=_cancel, width=90, height=26, canvas_bg="#E8F4FA", bg="#B0C4D0", fg="#1a4d6d", active_bg="#A0B4C0").pack(side="left", padx=4) win.wait_window(rdlg) if dlg_result[0] is None: _rebuild_calendar() return atype = dlg_result[0]["type"] absence = { "id": f"{emp_id}_{d1.isoformat()}_{atype}_{id(d1)}", "employee_id": emp_id, "type": atype, "start": d1.isoformat(), "end": d2.isoformat(), "reason": dlg_result[0]["reason"], } data[0].setdefault("absences", []).append(absence) _save_data(data[0]) _rebuild_calendar() def _delete_absence_at(emp_id, dt): absences = data[0].get("absences", []) match = None for ab in absences: if ab.get("employee_id") != emp_id: continue s = _parse_date(ab.get("start", "")) en = _parse_date(ab.get("end", "")) if s and en and s <= dt <= en: match = ab break if not match: return emp = emp_map[0].get(emp_id, {}) atype = _ABSENCE_TYPES.get(match.get("type", ""), {}).get("label", "") if messagebox.askyesno("Löschen", f"{emp.get('name', '?')}\n{atype}: {match['start']} – {match['end']}" f"\n{match.get('reason', '')}\n\nLöschen?", parent=win): data[0]["absences"] = [a for a in absences if a.get("id") != match.get("id")] _save_data(data[0]) _rebuild_calendar() def _rebuild_calendar(): for w in cal_inner.winfo_children(): w.destroy() sc = cal_scale[0] fs = font_scale[0] employees = data[0].get("employees", []) absences = data[0].get("absences", []) try: if view_mode[0] == "month": _build_month_view(employees, absences, sc, fs) elif view_mode[0] == "week": _build_week_view(employees, absences, sc, fs) elif view_mode[0] == "year": _build_year_view(employees, absences, sc, fs) except Exception as exc: import traceback traceback.print_exc() tk.Label(cal_inner, text=f"Fehler beim Kalender-Aufbau:\n{exc}", font=("Segoe UI", 10), bg="#E8F4FA", fg="#D04040", wraplength=600, justify="left", pady=20).pack() def _build_month_view(employees, absences, sc, fs): start_y, start_m = cur_year[0], cur_month[0] nav_label.configure(text=f"ab {_MONTHS_DE[start_m]} {start_y}") name_w = max(10, int(12 * sc)) day_w = max(20, int(26 * sc)) row_h = max(18, int(22 * sc)) hdr_font = ("Segoe UI", max(7, int(8 * fs)), "bold") wd_font = ("Segoe UI", max(6, int(7 * fs))) name_font = ("Segoe UI", max(7, int(9 * fs))) init_font = ("Segoe UI", max(6, int(8 * fs)), "bold") month_title_font = ("Segoe UI", max(8, int(9 * fs)), "bold") num_months = 12 if employees else 1 for month_offset in range(num_months): m = ((start_m - 1 + month_offset) % 12) + 1 y = start_y + ((start_m - 1 + month_offset) // 12) _, days_in = calendar.monthrange(y, m) month_sep = tk.Frame(cal_inner, bg="#B0D8E8", padx=4, pady=1) month_sep.pack(fill="x", padx=2, pady=(6 if month_offset > 0 else 2, 0)) tk.Label(month_sep, text=f"{_MONTHS_DE[m]} {y}", font=month_title_font, bg="#B0D8E8", fg="#1a4d6d").pack(side="left") hdr = tk.Frame(cal_inner, bg="#E8F4FA") hdr.pack(fill="x", padx=2, pady=(1, 0)) tk.Label(hdr, text="Mitarb.", font=hdr_font, bg="#E8F4FA", fg="#1a4d6d", width=name_w, anchor="w").pack(side="left", padx=(2, 0)) for d in range(1, days_in + 1): dt = datetime.date(y, m, d) is_we = dt.weekday() >= 5 is_today = dt == today bg_c = "#7EC8E3" if is_today else ("#D0D0D0" if is_we else "#D4EEF7") fg_c = "#fff" if is_today else ("#888" if is_we else "#1a4d6d") tk.Label(hdr, text=str(d), font=hdr_font, bg=bg_c, fg=fg_c, width=max(2, int(2 * sc)), anchor="center", padx=0, pady=0 ).pack(side="left", padx=0) wd_row = tk.Frame(cal_inner, bg="#E8F4FA") wd_row.pack(fill="x", padx=2) tk.Label(wd_row, text="", bg="#E8F4FA", width=name_w).pack(side="left", padx=(2, 0)) for d in range(1, days_in + 1): dt = datetime.date(y, m, d) fg_c = "#aaa" if dt.weekday() >= 5 else "#5A90B0" tk.Label(wd_row, text=_WEEKDAYS_DE[dt.weekday()], font=wd_font, bg="#E8F4FA", fg=fg_c, width=max(2, int(2 * sc)), anchor="center").pack(side="left", padx=0) if not employees: tk.Label(cal_inner, text="Keine Mitarbeiter. Bitte unter «Mitarbeiter» hinzufügen.", font=("Segoe UI", 9), bg="#E8F4FA", fg="#888", pady=20).pack() continue for emp in employees: eid = emp["id"] row = tk.Frame(cal_inner, bg="#F5FAFC", highlightbackground="#D4EEF7", highlightthickness=1, pady=0) row.pack(fill="x", padx=2, pady=0) name_f = tk.Frame(row, bg="#F5FAFC", width=int(name_w * 7)) name_f.pack(side="left", padx=(1, 0)) name_f.pack_propagate(False) initials = "".join(w[0].upper() for w in emp["name"].split() if w)[:2] av = tk.Frame(name_f, bg="#5B8DB3", width=int(16 * sc), height=int(16 * sc)) av.pack(side="left", padx=(1, 2), pady=0) av.pack_propagate(False) tk.Label(av, text=initials, font=init_font, bg="#5B8DB3", fg="white").pack(expand=True) vac = emp.get("vacation_days", 0) used_y = _used_vac(eid, y) carry = emp.get("carry_over", {}).get(str(y), 0) rest = vac + carry - used_y nm_text = emp["name"] if vac: nm_text += f" ({rest:.0f})" tk.Label(name_f, text=nm_text, font=name_font, bg="#F5FAFC", fg="#1a4d6d", anchor="w").pack(side="left", fill="x", expand=True) emp_abs = [a for a in absences if a.get("employee_id") == eid] for d in range(1, days_in + 1): dt = datetime.date(y, m, d) is_we = dt.weekday() >= 5 cell_bg = "#ECECEC" if is_we else "#FFFFFF" ab_match = None for ab in emp_abs: s = _parse_date(ab.get("start", "")) en = _parse_date(ab.get("end", "")) if s and en and s <= dt <= en: ab_match = ab break if ab_match: cell_bg = _ABSENCE_TYPES.get(ab_match.get("type", ""), {}).get("color", "#ccc") cell = tk.Frame(row, bg=cell_bg, width=day_w, height=row_h) cell.pack(side="left", padx=0, pady=0) cell.pack_propagate(False) cell.bind("", lambda e, eid_=eid, dt_=dt: _cell_release(eid_, dt_)) if ab_match: cell.configure(cursor="hand2") cell.bind("", lambda e, eid_=eid, dt_=dt: _delete_absence_at(eid_, dt_)) else: cell.bind("", lambda e, eid_=eid, dt_=dt, c=cell: _cell_press(eid_, dt_, c)) cell.bind("", lambda e, eid_=eid, dt_=dt, c=cell: _cell_enter(eid_, dt_, c)) _build_legend() def _build_week_view(employees, absences, sc, fs): ws = cur_week_start[0] we = ws + datetime.timedelta(days=6) kw = ws.isocalendar()[1] nav_label.configure(text=f"KW {kw} · {ws.strftime('%d.%m.')} – {we.strftime('%d.%m.%Y')}") name_w = max(12, int(14 * sc)) day_w = max(50, int(65 * sc)) row_h = max(26, int(34 * sc)) hdr_font = ("Segoe UI", max(8, int(9 * fs)), "bold") name_font = ("Segoe UI", max(8, int(9 * fs))) init_font = ("Segoe UI", max(7, int(8 * fs)), "bold") cell_font = ("Segoe UI", max(7, int(8 * fs))) hdr = tk.Frame(cal_inner, bg="#E8F4FA") hdr.pack(fill="x", padx=2, pady=(4, 0)) tk.Label(hdr, text="Mitarb.", font=hdr_font, bg="#E8F4FA", fg="#1a4d6d", width=name_w, anchor="w").pack(side="left", padx=(2, 0)) for i in range(7): dt = ws + datetime.timedelta(days=i) is_we = dt.weekday() >= 5 is_today = dt == today bg_c = "#7EC8E3" if is_today else ("#D0D0D0" if is_we else "#D4EEF7") fg_c = "#fff" if is_today else ("#888" if is_we else "#1a4d6d") txt = f"{_WEEKDAYS_DE[dt.weekday()]} {dt.day}.{dt.month}." tk.Label(hdr, text=txt, font=hdr_font, bg=bg_c, fg=fg_c, width=max(7, int(8 * sc)), anchor="center", padx=1, pady=1).pack(side="left", padx=1) if not employees: tk.Label(cal_inner, text="Keine Mitarbeiter.", font=("Segoe UI", 10), bg="#E8F4FA", fg="#888", pady=30).pack() return for emp in employees: eid = emp["id"] row = tk.Frame(cal_inner, bg="#F5FAFC", highlightbackground="#D4EEF7", highlightthickness=1, pady=1) row.pack(fill="x", padx=2, pady=1) name_f = tk.Frame(row, bg="#F5FAFC", width=int(name_w * 8)) name_f.pack(side="left", padx=(2, 0)) name_f.pack_propagate(False) initials = "".join(w[0].upper() for w in emp["name"].split() if w)[:2] av = tk.Frame(name_f, bg="#5B8DB3", width=int(22 * sc), height=int(22 * sc)) av.pack(side="left", padx=(2, 3), pady=1) av.pack_propagate(False) tk.Label(av, text=initials, font=init_font, bg="#5B8DB3", fg="white").pack(expand=True) tk.Label(name_f, text=emp["name"], font=name_font, bg="#F5FAFC", fg="#1a4d6d", anchor="w").pack(side="left", fill="x", expand=True) emp_abs = [a for a in absences if a.get("employee_id") == eid] for i in range(7): dt = ws + datetime.timedelta(days=i) is_we = dt.weekday() >= 5 cell_bg = "#ECECEC" if is_we else "#FFFFFF" ab_match = None for ab in emp_abs: s = _parse_date(ab.get("start", "")) en = _parse_date(ab.get("end", "")) if s and en and s <= dt <= en: ab_match = ab break if ab_match: atype = ab_match.get("type", "") cell_bg = _ABSENCE_TYPES.get(atype, {}).get("color", "#ccc") cell = tk.Frame(row, bg=cell_bg, width=day_w, height=row_h) cell.pack(side="left", padx=1, pady=0) cell.pack_propagate(False) if ab_match: lbl_txt = _ABSENCE_TYPES.get(ab_match.get("type", ""), {}).get("label", "")[:6] tk.Label(cell, text=lbl_txt, font=cell_font, bg=cell_bg, fg="#fff").pack(expand=True) cell.configure(cursor="hand2") cell.bind("", lambda e, eid_=eid, dt_=dt: _delete_absence_at(eid_, dt_)) else: cell.bind("", lambda e, eid_=eid, dt_=dt, c=cell: _cell_press(eid_, dt_, c)) cell.bind("", lambda e, eid_=eid, dt_=dt, c=cell: _cell_enter(eid_, dt_, c)) cell.bind("", lambda e, eid_=eid, dt_=dt: _cell_release(eid_, dt_)) _build_legend() def _build_year_view(employees, absences, sc, fs): y = cur_year[0] nav_label.configure(text=str(y)) name_font = ("Segoe UI", max(9, int(11 * fs)), "bold") month_font = ("Segoe UI", max(7, int(8 * fs)), "bold") day_font = ("Segoe UI", max(6, int(7 * fs))) cell_sz = max(8, int(11 * sc)) if not employees: tk.Label(cal_inner, text="Keine Mitarbeiter.", font=("Segoe UI", 10), bg="#E8F4FA", fg="#888", pady=30).pack() return for emp in employees: eid = emp["id"] emp_frame = tk.Frame(cal_inner, bg="#F5FAFC", highlightbackground="#D4EEF7", highlightthickness=1, padx=4, pady=4) emp_frame.pack(fill="x", padx=4, pady=2) vac = emp.get("vacation_days", 0) used = _used_vac(eid, y) carry = emp.get("carry_over", {}).get(str(y), 0) rest = vac + carry - used title = emp["name"] if vac: title += f" (Anspruch: {vac + carry}, genommen: {used:.0f}, Rest: {rest:.0f})" tk.Label(emp_frame, text=title, font=name_font, bg="#F5FAFC", fg="#1a4d6d").pack(anchor="w") months_frame = tk.Frame(emp_frame, bg="#F5FAFC") months_frame.pack(fill="x") emp_abs = [a for a in absences if a.get("employee_id") == eid] for m in range(1, 13): mf = tk.Frame(months_frame, bg="#F5FAFC", padx=2, pady=2) mf.pack(side="left", anchor="nw") tk.Label(mf, text=_MONTHS_SHORT[m], font=month_font, bg="#F5FAFC", fg="#5A90B0").pack() _, dim = calendar.monthrange(y, m) for d in range(1, dim + 1): dt = datetime.date(y, m, d) is_we = dt.weekday() >= 5 c = "#D8D8D8" if is_we else "#E8F4FA" for ab in emp_abs: s = _parse_date(ab.get("start", "")) en = _parse_date(ab.get("end", "")) if s and en and s <= dt <= en: c = _ABSENCE_TYPES.get(ab.get("type", ""), {}).get("color", "#ccc") break dc = tk.Frame(mf, bg=c, width=cell_sz, height=cell_sz) dc.pack(pady=0) _build_legend() def _build_legend(): legend = tk.Frame(cal_inner, bg="#E8F4FA", pady=6) legend.pack(fill="x", padx=8) for _, info in _ABSENCE_TYPES.items(): lf = tk.Frame(legend, bg="#E8F4FA") lf.pack(side="left", padx=(0, 14)) tk.Frame(lf, bg=info["color"], width=12, height=12).pack(side="left", padx=(0, 3)) tk.Label(lf, text=info["label"], font=("Segoe UI", 8), bg="#E8F4FA", fg="#1a4d6d").pack(side="left") tk.Label(legend, text="(Rechtsklick = löschen, Links-Drag = eintragen)", font=("Segoe UI", 7), bg="#E8F4FA", fg="#999").pack(side="right") def _used_vac(emp_id, year): total = 0.0 for ab in data[0].get("absences", []): if ab.get("employee_id") != emp_id: continue atype = ab.get("type", "") if not _ABSENCE_TYPES.get(atype, {}).get("counts", False): continue s = _parse_date(ab.get("start", "")) en = _parse_date(ab.get("end", "")) if not s or not en: continue ys = max(s, datetime.date(year, 1, 1)) ye = min(en, datetime.date(year, 12, 31)) if ys <= ye: total += _business_days(ys, ye) return total # ═══════════════════════════════════════════ # MITARBEITER # ═══════════════════════════════════════════ emp_list_frame = tk.Frame(emp_page, bg="#E8F4FA") def _rebuild_employees(): for w in emp_list_frame.winfo_children(): w.destroy() emp_list_frame.pack(fill="both", expand=True, padx=12, pady=8) top = tk.Frame(emp_list_frame, bg="#E8F4FA") top.pack(fill="x", pady=(0, 8)) tk.Label(top, text="Mitarbeiter verwalten", font=("Segoe UI", 12, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(side="left") RoundedButton(top, "+ Mitarbeiter", command=_add_employee_dialog, width=145, height=30, canvas_bg="#E8F4FA", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="right") hdr = tk.Frame(emp_list_frame, bg="#D4EEF7", padx=8, pady=4) hdr.pack(fill="x") for txt, w in [("Name", 18), ("Rolle", 14), ("Ferien/J.", 10), ("Übertrag", 8), ("Genommen", 8), ("Rest", 7)]: tk.Label(hdr, text=txt, font=("Segoe UI", 9, "bold"), bg="#D4EEF7", fg="#1a4d6d", width=w, anchor="w").pack(side="left") y = cur_year[0] for emp in data[0].get("employees", []): row = tk.Frame(emp_list_frame, bg="#F5FAFC", highlightbackground="#D4EEF7", highlightthickness=1, padx=8, pady=5) row.pack(fill="x", pady=1) initials = "".join(w[0].upper() for w in emp["name"].split() if w)[:2] av = tk.Frame(row, bg="#5B8DB3", width=28, height=28) av.pack(side="left", padx=(0, 6)) av.pack_propagate(False) tk.Label(av, text=initials, font=("Segoe UI", 9, "bold"), bg="#5B8DB3", fg="white").pack(expand=True) vac = emp.get("vacation_days", 0) carry = emp.get("carry_over", {}).get(str(y), 0) used = _used_vac(emp["id"], y) rest = vac + carry - used tk.Label(row, text=emp["name"], font=("Segoe UI", 9), bg="#F5FAFC", fg="#1a4d6d", width=16, anchor="w").pack(side="left") tk.Label(row, text=emp.get("role", "–"), font=("Segoe UI", 9), bg="#F5FAFC", fg="#5A90B0", width=13, anchor="w").pack(side="left") tk.Label(row, text=str(vac) if vac else "–", font=("Segoe UI", 9), bg="#F5FAFC", fg="#1a4d6d", width=9, anchor="w").pack(side="left") tk.Label(row, text=str(carry) if carry else "0", font=("Segoe UI", 9), bg="#F5FAFC", fg="#5A90B0", width=7, anchor="w").pack(side="left") tk.Label(row, text=f"{used:.0f}", font=("Segoe UI", 9), bg="#F5FAFC", fg="#1a4d6d", width=7, anchor="w").pack(side="left") rest_fg = "#1a4d6d" if rest >= 0 else "#D04040" tk.Label(row, text=f"{rest:.0f}" if vac else "–", font=("Segoe UI", 9, "bold"), bg="#F5FAFC", fg=rest_fg, width=6, anchor="w").pack(side="left") def _mk_edit(e=emp): return lambda: _edit_employee_dialog(e) def _mk_carry(e=emp): return lambda: _carry_over_dialog(e) def _mk_del(e=emp): return lambda: _delete_employee(e) RoundedButton(row, "✏", command=_mk_edit(), width=30, height=26, canvas_bg="#F5FAFC", bg="#7EC8E3", fg="white", active_bg="#6CB8D3").pack(side="right", padx=2) RoundedButton(row, "🗑", command=_mk_del(), width=30, height=26, canvas_bg="#F5FAFC", bg="#F5A3A3", fg="white", active_bg="#E09090").pack(side="right", padx=2) RoundedButton(row, "↪ Übertrag", command=_mk_carry(), width=110, height=26, canvas_bg="#F5FAFC", bg="#A8D5BA", fg="#1a4d6d", active_bg="#90C5A5").pack(side="right", padx=2) if not data[0].get("employees"): tk.Label(emp_list_frame, text="Noch keine Mitarbeiter erfasst.", font=("Segoe UI", 11), bg="#E8F4FA", fg="#888", pady=30).pack() def _emp_dialog(title, emp=None): dlg = tk.Toplevel(win) dlg.title(title) dlg.configure(bg="#E8F4FA") dlg.geometry("380x280") center_window(dlg, 380, 280) dlg.transient(win) dlg.grab_set() dlg.attributes("-topmost", True) self._register_window(dlg) pad = {"padx": 12, "pady": 4} tk.Label(dlg, text=title, font=("Segoe UI", 12, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(pady=(12, 8)) tk.Label(dlg, text="Name:", font=("Segoe UI", 10), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", **pad) name_var = tk.StringVar(value=emp["name"] if emp else "") ttk.Entry(dlg, textvariable=name_var, width=35).pack(**pad) tk.Label(dlg, text="Rolle / Abteilung:", font=("Segoe UI", 10), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", **pad) role_var = tk.StringVar(value=emp.get("role", "") if emp else "") ttk.Entry(dlg, textvariable=role_var, width=35).pack(**pad) tk.Label(dlg, text="Ferientage pro Jahr:", font=("Segoe UI", 10), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", **pad) vac_var = tk.StringVar(value=str(emp.get("vacation_days", 25)) if emp else "25") ttk.Entry(dlg, textvariable=vac_var, width=35).pack(**pad) result = [None] def _save(): n = name_var.get().strip() if not n: messagebox.showerror("Fehler", "Bitte Name eingeben.", parent=dlg) return try: v = int(vac_var.get().strip()) if vac_var.get().strip() else 0 except ValueError: v = 0 result[0] = {"name": n, "role": role_var.get().strip(), "vacation_days": v} dlg.destroy() bf = tk.Frame(dlg, bg="#E8F4FA") bf.pack(pady=12) RoundedButton(bf, "Speichern", command=_save, width=90, height=28, canvas_bg="#E8F4FA", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="left", padx=4) RoundedButton(bf, "Abbrechen", command=dlg.destroy, width=90, height=28, canvas_bg="#E8F4FA", bg="#B0C4D0", fg="#1a4d6d", active_bg="#A0B4C0").pack(side="left", padx=4) win.wait_window(dlg) return result[0] def _add_employee_dialog(): r = _emp_dialog("Mitarbeiter hinzufügen") if not r: return eid = r["name"].lower().replace(" ", "_") + f"_{len(data[0].get('employees', []))}" emp = {"id": eid, **r, "carry_over": {}} data[0].setdefault("employees", []).append(emp) emp_map[0][eid] = emp _save_data(data[0]) _rebuild_employees() def _edit_employee_dialog(emp): r = _emp_dialog("Mitarbeiter bearbeiten", emp) if not r: return emp["name"] = r["name"] emp["role"] = r["role"] emp["vacation_days"] = r["vacation_days"] emp_map[0][emp["id"]] = emp _save_data(data[0]) _rebuild_employees() def _carry_over_dialog(emp): dlg = tk.Toplevel(win) dlg.title("Ferientage übertragen") dlg.configure(bg="#E8F4FA") dlg.geometry("340x200") center_window(dlg, 340, 200) dlg.transient(win) dlg.grab_set() dlg.attributes("-topmost", True) self._register_window(dlg) y = cur_year[0] vac = emp.get("vacation_days", 0) used = _used_vac(emp["id"], y) rest = vac - used cur_carry = emp.get("carry_over", {}).get(str(y + 1), 0) tk.Label(dlg, text=f"Übertrag für {emp['name']}", font=("Segoe UI", 11, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(pady=(12, 4)) tk.Label(dlg, text=f"Jahr {y}: Rest = {rest:.0f} Tage", font=("Segoe UI", 10), bg="#E8F4FA", fg="#5A90B0").pack(pady=2) tk.Label(dlg, text=f"Übertrag nach {y + 1}:", font=("Segoe UI", 10), bg="#E8F4FA", fg="#1a4d6d").pack(pady=(8, 2)) carry_var = tk.StringVar(value=str(cur_carry) if cur_carry else str(max(0, int(rest)))) ttk.Entry(dlg, textvariable=carry_var, width=10).pack() def _save(): try: c = int(carry_var.get().strip()) except ValueError: c = 0 emp.setdefault("carry_over", {})[str(y + 1)] = c _save_data(data[0]) dlg.destroy() _rebuild_employees() bf = tk.Frame(dlg, bg="#E8F4FA") bf.pack(pady=10) RoundedButton(bf, "Speichern", command=_save, width=90, height=28, canvas_bg="#E8F4FA", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="left", padx=4) RoundedButton(bf, "Abbrechen", command=dlg.destroy, width=90, height=28, canvas_bg="#E8F4FA", bg="#B0C4D0", fg="#1a4d6d", active_bg="#A0B4C0").pack(side="left", padx=4) def _delete_employee(emp): if not messagebox.askyesno("Löschen", f"«{emp['name']}» und alle Abwesenheiten löschen?", parent=win): return data[0]["employees"] = [e for e in data[0].get("employees", []) if e["id"] != emp["id"]] data[0]["absences"] = [a for a in data[0].get("absences", []) if a.get("employee_id") != emp["id"]] emp_map[0].pop(emp["id"], None) _save_data(data[0]) _rebuild_employees() # ═══════════════════════════════════════════ # ÜBERSICHT # ═══════════════════════════════════════════ stats_inner = tk.Frame(stats_page, bg="#E8F4FA") def _rebuild_stats(): for w in stats_inner.winfo_children(): w.destroy() stats_inner.pack(fill="both", expand=True, padx=12, pady=8) tk.Label(stats_inner, text="Übersicht Ferien & Abwesenheiten", font=("Segoe UI", 12, "bold"), bg="#E8F4FA", fg="#1a4d6d").pack(anchor="w", pady=(0, 8)) yr_f = tk.Frame(stats_inner, bg="#E8F4FA") yr_f.pack(fill="x", pady=(0, 8)) stat_year = [cur_year[0]] yr_lbl = tk.Label(yr_f, text=str(stat_year[0]), font=("Segoe UI", 11, "bold"), bg="#E8F4FA", fg="#1a4d6d") def _yp(): stat_year[0] -= 1; yr_lbl.configure(text=str(stat_year[0])); _fill() def _yn(): stat_year[0] += 1; yr_lbl.configure(text=str(stat_year[0])); _fill() RoundedButton(yr_f, "◀", command=_yp, width=28, height=22, canvas_bg="#E8F4FA", bg="#7EC8E3", fg="white", active_bg="#6CB8D3").pack(side="left") yr_lbl.pack(side="left", padx=8) RoundedButton(yr_f, "▶", command=_yn, width=28, height=22, canvas_bg="#E8F4FA", bg="#7EC8E3", fg="white", active_bg="#6CB8D3").pack(side="left") st_table = tk.Frame(stats_inner, bg="#E8F4FA") st_table.pack(fill="both", expand=True) def _fill(): for w in st_table.winfo_children(): w.destroy() y = stat_year[0] hdr = tk.Frame(st_table, bg="#D4EEF7", padx=8, pady=4) hdr.pack(fill="x") for txt, w in [("Mitarbeiter", 18), ("Anspruch", 9), ("Übertrag", 8), ("Genommen", 8), ("Rest", 7), ("Krank", 7), ("Unbez.", 7), ("Sonst.", 7)]: tk.Label(hdr, text=txt, font=("Segoe UI", 9, "bold"), bg="#D4EEF7", fg="#1a4d6d", width=w, anchor="center").pack(side="left") for emp in data[0].get("employees", []): row = tk.Frame(st_table, bg="#F5FAFC", highlightbackground="#D4EEF7", highlightthickness=1, padx=8, pady=4) row.pack(fill="x", pady=1) vac = emp.get("vacation_days", 0) carry = emp.get("carry_over", {}).get(str(y), 0) used = _used_vac(emp["id"], y) rest = vac + carry - used counts = {} for ab in data[0].get("absences", []): if ab.get("employee_id") != emp["id"]: continue s = _parse_date(ab.get("start", "")) en = _parse_date(ab.get("end", "")) if not s or not en: continue ys = max(s, datetime.date(y, 1, 1)) ye = min(en, datetime.date(y, 12, 31)) if ys <= ye: t = ab.get("type", "sonstiges") counts[t] = counts.get(t, 0) + _business_days(ys, ye) tk.Label(row, text=emp["name"], font=("Segoe UI", 9), bg="#F5FAFC", fg="#1a4d6d", width=16, anchor="w").pack(side="left") tk.Label(row, text=str(vac) if vac else "–", font=("Segoe UI", 9), bg="#F5FAFC", fg="#1a4d6d", width=8, anchor="center").pack(side="left") tk.Label(row, text=str(carry) if carry else "0", font=("Segoe UI", 9), bg="#F5FAFC", fg="#5A90B0", width=7, anchor="center").pack(side="left") tk.Label(row, text=f"{used:.0f}", font=("Segoe UI", 9), bg="#F5FAFC", fg="#1a4d6d", width=7, anchor="center").pack(side="left") rfg = "#1a4d6d" if rest >= 0 else "#D04040" tk.Label(row, text=f"{rest:.0f}" if vac else "–", font=("Segoe UI", 9, "bold"), bg="#F5FAFC", fg=rfg, width=6, anchor="center").pack(side="left") tk.Label(row, text=f"{counts.get('krank', 0):.0f}", font=("Segoe UI", 9), bg="#F5FAFC", fg="#D04040", width=6, anchor="center").pack(side="left") tk.Label(row, text=f"{counts.get('unbezahlt', 0):.0f}", font=("Segoe UI", 9), bg="#F5FAFC", fg="#C8A040", width=6, anchor="center").pack(side="left") other = sum(v for k, v in counts.items() if k not in ("urlaub", "krank", "unbezahlt")) tk.Label(row, text=f"{other:.0f}", font=("Segoe UI", 9), bg="#F5FAFC", fg="#8A6DBB", width=6, anchor="center").pack(side="left") _fill() # ═══════════════════════════════════════════ # PLANUNG (Backend-API) # ═══════════════════════════════════════════ plan_toolbar = tk.Frame(plan_page, bg="#D4EEF7", padx=6, pady=4) plan_toolbar.pack(fill="x") tk.Label(plan_toolbar, text="Backend-Planung", font=("Segoe UI", 10, "bold"), bg="#D4EEF7", fg="#1a4d6d").pack(side="left") tk.Label(plan_toolbar, text=" User:", font=("Segoe UI", 8), bg="#D4EEF7", fg="#1a4d6d").pack(side="left") plan_user_var = tk.StringVar(value=os.getenv("MEDWORK_USER", "default")) ttk.Entry(plan_toolbar, textvariable=plan_user_var, width=12).pack(side="left", padx=(2, 8)) plan_status = tk.Label(plan_toolbar, text="", font=("Segoe UI", 8), bg="#D4EEF7", fg="#5A90B0") plan_status.pack(side="right", padx=(0, 8)) RoundedButton(plan_toolbar, "Aktualisieren", command=lambda: _fetch_plan(), width=110, height=24, canvas_bg="#D4EEF7", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="right", padx=4) # ── Filterzeile ── filter_frame = tk.Frame(plan_page, bg="#DDE9F0", padx=8, pady=3) filter_frame.pack(fill="x") _fl_s = {"font": ("Segoe UI", 8), "bg": "#DDE9F0", "fg": "#1a4d6d"} tk.Label(filter_frame, text="Filter:", font=("Segoe UI", 8, "bold"), bg="#DDE9F0", fg="#1a4d6d").pack(side="left") tk.Label(filter_frame, text="Mitarbeiter:", **_fl_s).pack(side="left", padx=(6, 0)) flt_emp_var = tk.StringVar() ttk.Entry(filter_frame, textvariable=flt_emp_var, width=14).pack(side="left", padx=(2, 6)) tk.Label(filter_frame, text="Von:", **_fl_s).pack(side="left") flt_from_var = tk.StringVar() ttk.Entry(filter_frame, textvariable=flt_from_var, width=11).pack(side="left", padx=(2, 6)) tk.Label(filter_frame, text="Bis:", **_fl_s).pack(side="left") flt_to_var = tk.StringVar() ttk.Entry(filter_frame, textvariable=flt_to_var, width=11).pack(side="left", padx=(2, 6)) def _clear_filters(): flt_emp_var.set("") flt_from_var.set("") flt_to_var.set("") _fetch_plan() RoundedButton(filter_frame, "Filtern", command=lambda: _fetch_plan(), width=65, height=22, canvas_bg="#DDE9F0", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="left", padx=4) RoundedButton(filter_frame, "Alle", command=_clear_filters, width=45, height=22, canvas_bg="#DDE9F0", bg="#9CC0D4", fg="#1a4d6d", active_bg="#88B0C4").pack(side="left", padx=2) # ── Eingabezeile ── input_frame = tk.Frame(plan_page, bg="#E8F4FA", padx=8, pady=4) input_frame.pack(fill="x") _lbl_s = {"font": ("Segoe UI", 8), "bg": "#E8F4FA", "fg": "#1a4d6d"} _ent_w = 16 tk.Label(input_frame, text="Mitarbeiter:", **_lbl_s).pack(side="left") plan_emp_var = tk.StringVar() ttk.Entry(input_frame, textvariable=plan_emp_var, width=_ent_w).pack(side="left", padx=(2, 8)) tk.Label(input_frame, text="Datum:", **_lbl_s).pack(side="left") plan_date_var = tk.StringVar() ttk.Entry(input_frame, textvariable=plan_date_var, width=12).pack(side="left", padx=(2, 8)) tk.Label(input_frame, text="Typ:", **_lbl_s).pack(side="left") plan_type_var = tk.StringVar(value="work") type_cb = ttk.Combobox(input_frame, textvariable=plan_type_var, width=9, values=["work", "vacation", "sick"], state="readonly") type_cb.pack(side="left", padx=(2, 8)) tk.Label(input_frame, text="Notiz:", **_lbl_s).pack(side="left") plan_note_var = tk.StringVar() ttk.Entry(input_frame, textvariable=plan_note_var, width=_ent_w).pack(side="left", padx=(2, 8)) RoundedButton(input_frame, "Hinzufügen", command=lambda: _plan_add(), width=95, height=24, canvas_bg="#E8F4FA", bg="#2E8B57", fg="white", active_bg="#267A4A").pack(side="left", padx=4) RoundedButton(input_frame, "Ändern", command=lambda: _plan_update_day(), width=75, height=24, canvas_bg="#E8F4FA", bg="#E8A317", fg="white", active_bg="#C88D12").pack(side="left", padx=4) RoundedButton(input_frame, "Löschen (Tag)", command=lambda: _plan_delete_day(), width=110, height=24, canvas_bg="#E8F4FA", bg="#D04040", fg="white", active_bg="#B83030").pack(side="left", padx=4) RoundedButton(input_frame, "CSV exportieren", command=lambda: _plan_csv_export(), width=110, height=24, canvas_bg="#E8F4FA", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="right", padx=4) RoundedButton(input_frame, "Backup exportieren", command=lambda: _plan_backup(), width=130, height=24, canvas_bg="#E8F4FA", bg="#7B68A0", fg="white", active_bg="#695A8C").pack(side="right", padx=4) restore_dry_var = tk.BooleanVar(value=True) ttk.Checkbutton(input_frame, text="Dry Run", variable=restore_dry_var).pack(side="right", padx=(0, 2)) RoundedButton(input_frame, "Backup wiederherstellen", command=lambda: _plan_restore(), width=165, height=24, canvas_bg="#E8F4FA", bg="#7B68A0", fg="white", active_bg="#695A8C").pack(side="right", padx=4) # ── PanedWindow für Items + Monatsansicht ── plan_pane = ttk.PanedWindow(plan_page, orient="vertical") plan_pane.pack(fill="both", expand=True, padx=8, pady=6) # ── Treeview (Items) ── tree_frame = tk.Frame(plan_pane, bg="#E8F4FA") plan_cols = ("employee", "date", "type", "note") plan_tree = ttk.Treeview(tree_frame, columns=plan_cols, show="headings", height=10) plan_tree.heading("employee", text="Mitarbeiter") plan_tree.heading("date", text="Datum") plan_tree.heading("type", text="Typ") plan_tree.heading("note", text="Notiz") plan_tree.column("employee", width=160) plan_tree.column("date", width=110) plan_tree.column("type", width=90) plan_tree.column("note", width=220) tree_scroll = ttk.Scrollbar(tree_frame, orient="vertical", command=plan_tree.yview) plan_tree.configure(yscrollcommand=tree_scroll.set) tree_scroll.pack(side="right", fill="y") plan_tree.pack(fill="both", expand=True) plan_pane.add(tree_frame, weight=1) # ── Monatsansicht ── _WDAY_DE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] month_frame = tk.Frame(plan_pane, bg="#E8F4FA") month_bar = tk.Frame(month_frame, bg="#D4EEF7", padx=6, pady=3) month_bar.pack(fill="x") tk.Label(month_bar, text="Monatsansicht", font=("Segoe UI", 9, "bold"), bg="#D4EEF7", fg="#1a4d6d").pack(side="left") _mfl = {"font": ("Segoe UI", 8), "bg": "#D4EEF7", "fg": "#1a4d6d"} tk.Label(month_bar, text="Jahr:", **_mfl).pack(side="left", padx=(10, 0)) mv_year_var = tk.StringVar(value=str(datetime.date.today().year)) ttk.Entry(month_bar, textvariable=mv_year_var, width=6).pack(side="left", padx=(2, 6)) tk.Label(month_bar, text="Monat:", **_mfl).pack(side="left") mv_month_var = tk.StringVar(value=str(datetime.date.today().month)) mv_month_cb = ttk.Combobox(month_bar, textvariable=mv_month_var, width=4, values=[str(i) for i in range(1, 13)], state="readonly") mv_month_cb.pack(side="left", padx=(2, 6)) RoundedButton(month_bar, "Monat anzeigen", command=lambda: _build_month_view(), width=115, height=22, canvas_bg="#D4EEF7", bg="#5B8DB3", fg="white", active_bg="#4A7A9E").pack(side="left", padx=4) mv_status = tk.Label(month_bar, text="", font=("Segoe UI", 8), bg="#D4EEF7", fg="#5A90B0") mv_status.pack(side="right") mv_tree_frame = tk.Frame(month_frame, bg="#E8F4FA") mv_tree_frame.pack(fill="both", expand=True) mv_cols = ("date", "weekday", "entries") mv_tree = ttk.Treeview(mv_tree_frame, columns=mv_cols, show="headings", height=8) mv_tree.heading("date", text="Datum") mv_tree.heading("weekday", text="Tag") mv_tree.heading("entries", text="Einträge") mv_tree.column("date", width=100) mv_tree.column("weekday", width=50) mv_tree.column("entries", width=500) mv_scroll = ttk.Scrollbar(mv_tree_frame, orient="vertical", command=mv_tree.yview) mv_tree.configure(yscrollcommand=mv_scroll.set) mv_scroll.pack(side="right", fill="y") mv_tree.pack(fill="both", expand=True) plan_pane.add(month_frame, weight=1) _cached_items = [] def _build_month_view(employees=None, absences=None, sc=None, fs=None): try: yr = int(mv_year_var.get().strip()) mo = int(mv_month_var.get().strip()) if mo < 1 or mo > 12: raise ValueError except (ValueError, TypeError): messagebox.showwarning("Eingabe", "Bitte gültiges Jahr und Monat eingeben.", parent=win) return items = _cached_items if not items: mv_status.configure(text="Lade…", fg="#5A90B0") win.update_idletasks() def _worker(): try: r = _requests.get(f"{_plan_backend_url()}/v1/schedule", headers=_plan_headers(), timeout=8) r.raise_for_status() fetched = r.json().get("items", []) _cached_items.clear() _cached_items.extend(fetched) win.after(0, _build_month_view) except Exception as e: win.after(0, lambda err=e: mv_status.configure(text=(f"Fehler: {err}" if err else ""), fg="#D04040")) threading.Thread(target=_worker, daemon=True).start() return prefix = f"{yr:04d}-{mo:02d}-" month_items = [it for it in items if it.get("date", "").startswith(prefix)] by_date = {} for it in month_items: d = it["date"] entry = it.get("employee", "") entry += f": {it.get('type', '')}" note = it.get("note", "") if note: entry += f" ({note})" by_date.setdefault(d, []).append(entry) _, days_in = calendar.monthrange(yr, mo) for row in mv_tree.get_children(): mv_tree.delete(row) for d in range(1, days_in + 1): ds = f"{yr:04d}-{mo:02d}-{d:02d}" dt = datetime.date(yr, mo, d) wd = _WDAY_DE[dt.weekday()] entries = " | ".join(by_date.get(ds, [])) mv_tree.insert("", "end", values=(ds, wd, entries)) mv_status.configure(text=f"{len(month_items)} Einträge im Monat", fg="#2E8B57") def _on_tree_select(event): sel = plan_tree.selection() if not sel: return vals = plan_tree.item(sel[0], "values") if vals and len(vals) >= 4: plan_emp_var.set(vals[0]) plan_date_var.set(vals[1]) plan_type_var.set(vals[2]) plan_note_var.set(vals[3]) plan_tree.bind("<>", _on_tree_select) def _plan_backend_url(): return getattr(self, "BACKEND_URL", os.getenv("MEDWORK_BACKEND_URL", "http://127.0.0.1:8001")) def _plan_headers(): token = getattr(self, "_BACKEND_API_TOKEN", os.getenv("MEDWORK_API_TOKEN", "")) h = {"Content-Type": "application/json"} if token: h["X-Api-Token"] = token u = plan_user_var.get().strip() if u: h["X-User"] = u return h def _check_user(): u = plan_user_var.get().strip() if not u: messagebox.showwarning("User", "Bitte User-Feld ausfüllen.", parent=win) return False return True def _fetch_plan(): if not _check_user(): return plan_status.configure(text="Lade…", fg="#5A90B0") win.update_idletasks() params = {} v = flt_emp_var.get().strip() if v: params["employee"] = v v = flt_from_var.get().strip() if v: params["from"] = v v = flt_to_var.get().strip() if v: params["to"] = v def _worker(): try: r = _requests.get(f"{_plan_backend_url()}/v1/schedule", headers=_plan_headers(), params=params, timeout=8) r.raise_for_status() items = r.json().get("items", []) win.after(0, lambda: _show_plan_items(items)) except Exception as e: win.after(0, lambda: plan_status.configure(text=f"Fehler: {e}", fg="#D04040")) threading.Thread(target=_worker, daemon=True).start() def _show_plan_items(items): _cached_items.clear() _cached_items.extend(items) for row in plan_tree.get_children(): plan_tree.delete(row) for item in sorted(items, key=lambda x: (x.get("date", ""), x.get("employee", ""))): plan_tree.insert("", "end", values=( item.get("employee", ""), item.get("date", ""), item.get("type", ""), item.get("note", ""), )) plan_status.configure(text=f"{len(items)} Einträge geladen", fg="#2E8B57") def _plan_add(): if not _check_user(): return emp = plan_emp_var.get().strip() dt = plan_date_var.get().strip() tp = plan_type_var.get().strip() if not emp: messagebox.showwarning("Eingabe", "Bitte Mitarbeiter eingeben.", parent=win) return if not dt or len(dt) != 10 or "-" not in dt: messagebox.showwarning("Eingabe", "Bitte Datum im Format YYYY-MM-DD eingeben.", parent=win) return plan_status.configure(text="Speichere…", fg="#5A90B0") win.update_idletasks() def _worker(): try: body = {"employee": emp, "date": dt, "type": tp, "note": plan_note_var.get().strip()} r = _requests.post(f"{_plan_backend_url()}/v1/schedule/item", headers=_plan_headers(), json=body, timeout=8) if r.status_code // 100 != 2: win.after(0, lambda: messagebox.showerror("Fehler", r.text, parent=win)) win.after(0, lambda: plan_status.configure(text="Fehler", fg="#D04040")) return win.after(0, _fetch_plan) except Exception as e: win.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=win)) win.after(0, lambda: plan_status.configure(text=f"Fehler: {e}", fg="#D04040")) threading.Thread(target=_worker, daemon=True).start() def _plan_delete_day(): if not _check_user(): return emp = plan_emp_var.get().strip() dt = plan_date_var.get().strip() if not emp: messagebox.showwarning("Eingabe", "Bitte Mitarbeiter eingeben.", parent=win) return if not dt or len(dt) != 10 or "-" not in dt: messagebox.showwarning("Eingabe", "Bitte Datum im Format YYYY-MM-DD eingeben.", parent=win) return plan_status.configure(text="Lösche…", fg="#5A90B0") win.update_idletasks() def _worker(): try: body = {"employee": emp, "date": dt} r = _requests.delete(f"{_plan_backend_url()}/v1/schedule/item/by_day", headers=_plan_headers(), json=body, timeout=8) if r.status_code // 100 != 2: win.after(0, lambda: messagebox.showerror("Fehler", r.text, parent=win)) win.after(0, lambda: plan_status.configure(text="Fehler", fg="#D04040")) return win.after(0, _fetch_plan) except Exception as e: win.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=win)) win.after(0, lambda: plan_status.configure(text=f"Fehler: {e}", fg="#D04040")) threading.Thread(target=_worker, daemon=True).start() def _plan_update_day(): if not _check_user(): return emp = plan_emp_var.get().strip() dt = plan_date_var.get().strip() tp = plan_type_var.get().strip() if not emp: messagebox.showwarning("Eingabe", "Bitte Mitarbeiter eingeben.", parent=win) return if not dt or len(dt) != 10 or "-" not in dt: messagebox.showwarning("Eingabe", "Bitte Datum im Format YYYY-MM-DD eingeben.", parent=win) return plan_status.configure(text="Ändere…", fg="#5A90B0") win.update_idletasks() def _worker(): try: body = {"employee": emp, "date": dt, "type": tp, "note": plan_note_var.get().strip()} r = _requests.put(f"{_plan_backend_url()}/v1/schedule/item/by_day", headers=_plan_headers(), json=body, timeout=8) if r.status_code // 100 != 2: win.after(0, lambda: messagebox.showerror("Fehler", r.text, parent=win)) win.after(0, lambda: plan_status.configure(text="Fehler", fg="#D04040")) return win.after(0, _fetch_plan) except Exception as e: win.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=win)) win.after(0, lambda: plan_status.configure(text=f"Fehler: {e}", fg="#D04040")) threading.Thread(target=_worker, daemon=True).start() def _plan_backup(): if not _check_user(): return plan_status.configure(text="Backup…", fg="#5A90B0") win.update_idletasks() def _worker(): try: r = _requests.get(f"{_plan_backend_url()}/v1/backup", headers=_plan_headers(), timeout=15) if r.status_code // 100 != 2: win.after(0, lambda: messagebox.showerror("Fehler", r.text, parent=win)) win.after(0, lambda: plan_status.configure(text="Fehler", fg="#D04040")) return content = r.content win.after(0, lambda: _save_backup(content)) except Exception as e: win.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=win)) win.after(0, lambda: plan_status.configure(text=f"Fehler: {e}", fg="#D04040")) threading.Thread(target=_worker, daemon=True).start() def _save_backup(content_bytes): path = filedialog.asksaveasfilename( parent=win, title="Backup speichern", defaultextension=".json", filetypes=[("JSON", "*.json"), ("Alle", "*.*")], initialfile="medwork_backup.json", ) if not path: plan_status.configure(text="Abgebrochen", fg="#888") return try: with open(path, "wb") as f: f.write(content_bytes) messagebox.showinfo("Backup", f"Backup gespeichert:\n{path}", parent=win) plan_status.configure(text="Backup gespeichert", fg="#2E8B57") except Exception as e: messagebox.showerror("Fehler", str(e), parent=win) plan_status.configure(text=f"Fehler: {e}", fg="#D04040") def _plan_restore(): if not _check_user(): return path = filedialog.askopenfilename( parent=win, title="Backup-Datei auswählen", filetypes=[("JSON", "*.json"), ("Alle", "*.*")], ) if not path: return try: with open(path, "rb") as f: file_bytes = f.read() except Exception as e: messagebox.showerror("Fehler", f"Datei lesen fehlgeschlagen:\n{e}", parent=win) return dry = restore_dry_var.get() plan_status.configure(text="Restore (dry_run={})…".format(dry), fg="#5A90B0") win.update_idletasks() def _worker(): try: url = f"{_plan_backend_url()}/v1/restore?dry_run={'true' if dry else 'false'}" r = _requests.post(url, headers=_plan_headers(), files={"file": ("backup.json", file_bytes, "application/json")}, timeout=15) if r.status_code // 100 != 2: win.after(0, lambda: messagebox.showerror("Fehler", r.text, parent=win)) win.after(0, lambda: plan_status.configure(text="Fehler", fg="#D04040")) return resp = r.json() n = resp.get("import_items", "?") if dry: win.after(0, lambda: messagebox.showinfo( "Dry Run", f"Validierung OK.\n{n} Items würden importiert.", parent=win)) win.after(0, lambda: plan_status.configure( text=f"Dry Run OK ({n} Items)", fg="#2E8B57")) else: win.after(0, lambda: messagebox.showinfo( "Restore", f"Restore erfolgreich.\n{n} Items importiert.", parent=win)) win.after(0, _fetch_plan) except Exception as e: win.after(0, lambda: messagebox.showerror("Fehler", str(e), parent=win)) win.after(0, lambda: plan_status.configure(text=f"Fehler: {e}", fg="#D04040")) threading.Thread(target=_worker, daemon=True).start() def _plan_csv_export(): rows = plan_tree.get_children() if not rows: messagebox.showwarning("CSV Export", "Keine Daten zum Export.", parent=win) return path = filedialog.asksaveasfilename( parent=win, title="CSV speichern", defaultextension=".csv", filetypes=[("CSV", "*.csv"), ("Alle", "*.*")], initialfile="medwork_schedule.csv", ) if not path: return try: with open(path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) writer.writerow(["employee", "date", "type", "note"]) for rid in rows: writer.writerow(plan_tree.item(rid, "values")) messagebox.showinfo("CSV Export", f"CSV gespeichert:\n{path}", parent=win) plan_status.configure(text="CSV gespeichert", fg="#2E8B57") except Exception as e: messagebox.showerror("Fehler", str(e), parent=win) plan_status.configure(text=f"Fehler: {e}", fg="#D04040") # ═══════════════════════════════════════════ # ICS EXPORT # ═══════════════════════════════════════════ def _export_ics(): ics_text = _generate_ics(data[0].get("absences", []), emp_map[0]) path = filedialog.asksaveasfilename( parent=win, title="Kalender exportieren", defaultextension=".ics", filetypes=[("iCalendar", "*.ics"), ("Alle", "*.*")], initialfile="arbeitsplan_ferien.ics" ) if path: try: with open(path, "w", encoding="utf-8") as f: f.write(ics_text) messagebox.showinfo("Export", f"Exportiert nach:\n{path}\n\n" "Importierbar in Google Calendar, Outlook, Apple Kalender.", parent=win) except Exception as e: messagebox.showerror("Fehler", str(e), parent=win) # Resize-Grip ganz am Ende, damit er über allem liegt add_resize_grip(win, 960, 620) # Initial cal_page.pack(fill="both", expand=True) _rebuild_calendar()