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()
|