Files
aza/AzA march 2026/aza_arbeitsplan_mixin.py
2026-03-25 22:03:39 +01:00

1682 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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("<Configure>", _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("<Configure>", _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("<Button-1>", lambda e: _toggle_minimize())
btn_mini.bind("<Enter>", lambda e: btn_mini.configure(fg="#1a4d6d"))
btn_mini.bind("<Leave>", 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("<Button-1>", lambda e: _fs_up())
fs_up.bind("<Enter>", lambda e: fs_up.configure(fg=_fs_hover))
fs_up.bind("<Leave>", 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("<Button-1>", lambda e: _fs_down())
fs_dn.bind("<Enter>", lambda e: fs_dn.configure(fg=_fs_hover))
fs_dn.bind("<Leave>", 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("<Button-1>", lambda e: _zoom_in())
z_up.bind("<Enter>", lambda e: z_up.configure(fg=_fs_hover))
z_up.bind("<Leave>", 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("<Button-1>", lambda e: _zoom_out())
z_dn.bind("<Enter>", lambda e: z_dn.configure(fg=_fs_hover))
z_dn.bind("<Leave>", 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("<Button-1>", lambda e: _switch_tab("calendar"))
tab_btn_emp.bind("<Button-1>", lambda e: _switch_tab("employees"))
tab_btn_stats.bind("<Button-1>", lambda e: _switch_tab("stats"))
tab_btn_plan.bind("<Button-1>", 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("<Button-1>", 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("<Configure>", 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("<MouseWheel>", _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("<ButtonRelease-1>", lambda e, eid_=eid, dt_=dt: _cell_release(eid_, dt_))
if ab_match:
cell.configure(cursor="hand2")
cell.bind("<Button-3>", lambda e, eid_=eid, dt_=dt: _delete_absence_at(eid_, dt_))
else:
cell.bind("<ButtonPress-1>", lambda e, eid_=eid, dt_=dt, c=cell: _cell_press(eid_, dt_, c))
cell.bind("<Enter>", 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("<Button-3>", lambda e, eid_=eid, dt_=dt: _delete_absence_at(eid_, dt_))
else:
cell.bind("<ButtonPress-1>", lambda e, eid_=eid, dt_=dt, c=cell: _cell_press(eid_, dt_, c))
cell.bind("<Enter>", lambda e, eid_=eid, dt_=dt, c=cell: _cell_enter(eid_, dt_, c))
cell.bind("<ButtonRelease-1>", 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("<<TreeviewSelect>>", _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()