1682 lines
79 KiB
Python
1682 lines
79 KiB
Python
|
|
# -*- 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()
|