Multiple changes
- Excel from network folder - multiple input Excel via dowpdown, - free creation date, - Output folder path tooltip
This commit is contained in:
1 parent
6e9d865a9d
commit
9d0390f8fd
4 files changed
+273
-47
No files matched your search
+2
-1
@@ -31,5 +31,6 @@
|
|||||||
"pdf_pauschale_prefix": "pauschale_5",
|
"pdf_pauschale_prefix": "pauschale_5",
|
||||||
"pauschale_present_value": "1",
|
"pauschale_present_value": "1",
|
||||||
"pdf_use_display_values": true,
|
"pdf_use_display_values": true,
|
||||||
"pdf_need_appearances": false
|
"pdf_need_appearances": false,
|
||||||
|
"excel_network_source": "J:\\Fachbereiche\\BO\\Produktionsschule\\Maßnahmeunterlagen\\30_Teilnehmende\\01_Anwesenheit\\Anwesenheitsliste\\Anwesenheitsliste 2026.xlsx"
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,15 @@
|
|||||||
# Build-Artefakte: build/ (temporaer, nicht committen)
|
# Build-Artefakte: build/ (temporaer, nicht committen)
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
import tkinter as tk
|
||||||
from tkinter import messagebox, filedialog
|
from tkinter import messagebox, filedialog
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
@@ -42,13 +44,42 @@ def _runtime_dir() -> Path:
|
|||||||
return Path(__file__).parent
|
return Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
def _scan_input_folder() -> Optional[Path]:
|
def _input_dir() -> Path:
|
||||||
"""Return the single .xlsx in input/, or None if 0 or >1 found."""
|
"""Return the input directory and ensure it exists."""
|
||||||
folder = _runtime_dir() / "input"
|
folder = _runtime_dir() / "input"
|
||||||
if not folder.exists():
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
return None
|
return folder
|
||||||
files = list(folder.glob("*.xlsx"))
|
|
||||||
return files[0] if len(files) == 1 else None
|
|
||||||
|
def _list_input_files() -> List[Path]:
|
||||||
|
"""Return all Excel files from input/ sorted by name."""
|
||||||
|
return sorted(_input_dir().glob("*.xlsx"), key=lambda path: path.name.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_excel_to_input(source: Path) -> Path:
|
||||||
|
"""Copy a selected Excel file into input/ and return the local file path."""
|
||||||
|
target = _input_dir() / source.name
|
||||||
|
if source.resolve() != target.resolve():
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_network_excel(config: Dict[str, Any]) -> tuple[Optional[Path], str]:
|
||||||
|
"""Copy a newer Excel file from the configured network path into input/."""
|
||||||
|
source_value = str(config.get("excel_network_source", "")).strip()
|
||||||
|
if not source_value:
|
||||||
|
return None, "Kein Netzlaufwerk-Pfad konfiguriert."
|
||||||
|
|
||||||
|
source = Path(source_value)
|
||||||
|
if not source.exists():
|
||||||
|
return None, f"Netzlaufwerk-Datei nicht gefunden: {source.name}"
|
||||||
|
|
||||||
|
target = _input_dir() / source.name
|
||||||
|
if not target.exists() or source.stat().st_mtime > target.stat().st_mtime:
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
return target, f"Excel-Datei aktualisiert: {source.name}"
|
||||||
|
|
||||||
|
return target, f"Lokale Excel-Datei ist aktuell: {source.name}"
|
||||||
|
|
||||||
|
|
||||||
def _scan_template_folder() -> Optional[Path]:
|
def _scan_template_folder() -> Optional[Path]:
|
||||||
@@ -79,6 +110,26 @@ def _build_month_keys(config: Dict[str, Any]) -> List[str]:
|
|||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_middle(text: str, max_length: int = 48) -> str:
|
||||||
|
"""Shorten long text by replacing the middle with '...' ."""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
keep = max_length - 3
|
||||||
|
left = keep // 2
|
||||||
|
right = keep - left
|
||||||
|
return f"{text[:left]}...{text[-right:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_creation_date(value: date) -> str:
|
||||||
|
"""Format a creation date for the GUI and PDF preview."""
|
||||||
|
return value.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_creation_date(value: str) -> date:
|
||||||
|
"""Parse a German-style date from the GUI entry."""
|
||||||
|
return datetime.strptime(value.strip(), "%d.%m.%Y").date()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Startup: load config once
|
# Startup: load config once
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -117,6 +168,10 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._excel_path: Optional[Path] = None
|
self._excel_path: Optional[Path] = None
|
||||||
self._template_path: Optional[Path] = None
|
self._template_path: Optional[Path] = None
|
||||||
self._out_dir: Optional[Path] = None
|
self._out_dir: Optional[Path] = None
|
||||||
|
self._input_files: List[Path] = []
|
||||||
|
self._excel_name_to_path: Dict[str, Path] = {}
|
||||||
|
self._tooltip_window: Optional[ctk.CTkToplevel] = None
|
||||||
|
self._tooltip_label: Optional[tk.Label] = None
|
||||||
|
|
||||||
# Build UI sections
|
# Build UI sections
|
||||||
self._build_input_section()
|
self._build_input_section()
|
||||||
@@ -124,8 +179,8 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._build_preview_section()
|
self._build_preview_section()
|
||||||
self._build_log_section()
|
self._build_log_section()
|
||||||
|
|
||||||
# Auto-scan after event loop starts
|
# Auto-sync after event loop starts
|
||||||
self.after(100, self._scan_and_validate)
|
self.after(100, self._start_initial_sync)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# UI builders
|
# UI builders
|
||||||
@@ -146,8 +201,16 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
||||||
command=self._browse_excel,
|
command=self._browse_excel,
|
||||||
).grid(row=0, column=1, padx=(0, 6), pady=6)
|
).grid(row=0, column=1, padx=(0, 6), pady=6)
|
||||||
self._lbl_excel = ctk.CTkLabel(frame, text="Suche\u2026", anchor="w")
|
self._excel_var = ctk.StringVar(value="")
|
||||||
self._lbl_excel.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="ew")
|
self._excel_menu = ctk.CTkOptionMenu(
|
||||||
|
frame,
|
||||||
|
values=["Keine Excel-Datei gefunden"],
|
||||||
|
variable=self._excel_var,
|
||||||
|
command=lambda _: self._on_excel_changed(),
|
||||||
|
width=320,
|
||||||
|
state="disabled",
|
||||||
|
)
|
||||||
|
self._excel_menu.grid(row=0, column=2, padx=(0, 12), pady=6, sticky="w")
|
||||||
|
|
||||||
# Row 1: Template
|
# Row 1: Template
|
||||||
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
|
ctk.CTkLabel(frame, text="Template:", width=110, anchor="w").grid(
|
||||||
@@ -174,22 +237,46 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
)
|
)
|
||||||
self._month_menu.grid(row=2, column=2, padx=(0, 12), pady=6, sticky="w")
|
self._month_menu.grid(row=2, column=2, padx=(0, 12), pady=6, sticky="w")
|
||||||
|
|
||||||
# Row 3: Output dir
|
# Row 3: Creation date
|
||||||
|
ctk.CTkLabel(frame, text="Erstellungsdatum:", width=110, anchor="w").grid(
|
||||||
|
row=3, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||||
|
)
|
||||||
|
self._creation_date_var = ctk.StringVar(value=_format_creation_date(date.today()))
|
||||||
|
self._entry_creation_date = ctk.CTkEntry(
|
||||||
|
frame,
|
||||||
|
textvariable=self._creation_date_var,
|
||||||
|
width=150,
|
||||||
|
)
|
||||||
|
self._entry_creation_date.grid(row=3, column=2, padx=(0, 12), pady=6, sticky="w")
|
||||||
|
self._entry_creation_date.bind("<FocusOut>", lambda _: self._on_creation_date_changed())
|
||||||
|
self._entry_creation_date.bind("<Return>", lambda _: self._on_creation_date_changed())
|
||||||
|
|
||||||
|
# Row 4: Output dir
|
||||||
out_label = str((_runtime_dir() / _CONFIG.get("output_dir", "output")).resolve())
|
out_label = str((_runtime_dir() / _CONFIG.get("output_dir", "output")).resolve())
|
||||||
ctk.CTkLabel(frame, text="Ausgabe:", width=110, anchor="w").grid(
|
ctk.CTkLabel(frame, text="Ausgabe:", width=110, anchor="w").grid(
|
||||||
row=3, column=0, padx=(12, 4), pady=6, sticky="w"
|
row=4, column=0, padx=(12, 4), pady=6, sticky="w"
|
||||||
)
|
)
|
||||||
ctk.CTkButton(
|
ctk.CTkButton(
|
||||||
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
frame, text="\U0001f4c2", width=BTN_W, height=28,
|
||||||
command=self._open_output_folder,
|
command=self._open_output_folder,
|
||||||
).grid(row=3, column=1, padx=(0, 6), pady=6)
|
).grid(row=4, column=1, padx=(0, 6), pady=6)
|
||||||
ctk.CTkLabel(frame, text=out_label, anchor="w").grid(
|
self._lbl_output_path = ctk.CTkLabel(
|
||||||
row=3, column=2, padx=(0, 12), pady=6, sticky="ew"
|
frame, text=_truncate_middle(out_label), anchor="w"
|
||||||
|
)
|
||||||
|
self._lbl_output_path.grid(
|
||||||
|
row=4, column=2, padx=(0, 12), pady=6, sticky="ew"
|
||||||
|
)
|
||||||
|
self._lbl_output_path.bind(
|
||||||
|
"<Enter>", lambda event: self._show_tooltip(event, out_label)
|
||||||
|
)
|
||||||
|
self._lbl_output_path.bind("<Leave>", lambda _: self._hide_tooltip())
|
||||||
|
self._lbl_output_path.bind(
|
||||||
|
"<Motion>", lambda event: self._move_tooltip(event, out_label)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Row 4: Output warning + "Ordner leeren" link (hidden when 0)
|
# Row 5: Output warning + "Ordner leeren" link (hidden when 0)
|
||||||
warning_row = ctk.CTkFrame(frame, fg_color="transparent")
|
warning_row = ctk.CTkFrame(frame, fg_color="transparent")
|
||||||
warning_row.grid(row=4, column=2, padx=(0, 12), pady=(0, 6), sticky="ew")
|
warning_row.grid(row=5, column=2, padx=(0, 12), pady=(0, 6), sticky="ew")
|
||||||
self._lbl_warning = ctk.CTkLabel(
|
self._lbl_warning = ctk.CTkLabel(
|
||||||
warning_row, text="", anchor="w", text_color="orange"
|
warning_row, text="", anchor="w", text_color="orange"
|
||||||
)
|
)
|
||||||
@@ -201,6 +288,13 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._lbl_clear_link.pack(side="left", padx=(6, 0))
|
self._lbl_clear_link.pack(side="left", padx=(6, 0))
|
||||||
self._lbl_clear_link.bind("<Button-1>", lambda _: self._confirm_clear_output())
|
self._lbl_clear_link.bind("<Button-1>", lambda _: self._confirm_clear_output())
|
||||||
|
|
||||||
|
self._lbl_download_status = ctk.CTkLabel(
|
||||||
|
frame, text="", anchor="w", text_color="#60A5FA"
|
||||||
|
)
|
||||||
|
self._lbl_download_status.grid(
|
||||||
|
row=6, column=2, padx=(0, 12), pady=(0, 8), sticky="ew"
|
||||||
|
)
|
||||||
|
|
||||||
def _build_button_section(self) -> None:
|
def _build_button_section(self) -> None:
|
||||||
frame = ctk.CTkFrame(self)
|
frame = ctk.CTkFrame(self)
|
||||||
frame.pack(fill="x", padx=16, pady=(8, 0))
|
frame.pack(fill="x", padx=16, pady=(8, 0))
|
||||||
@@ -228,7 +322,6 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
("Monat:", "_pv_monat"),
|
("Monat:", "_pv_monat"),
|
||||||
("Antragsnummer:", "_pv_antrag"),
|
("Antragsnummer:", "_pv_antrag"),
|
||||||
("Ort:", "_pv_ort"),
|
("Ort:", "_pv_ort"),
|
||||||
("Erstelldatum:", "_pv_datum"),
|
|
||||||
("Teilnehmer:", "_pv_teilnehmer"),
|
("Teilnehmer:", "_pv_teilnehmer"),
|
||||||
("Fehler:", "_pv_fehler"),
|
("Fehler:", "_pv_fehler"),
|
||||||
]
|
]
|
||||||
@@ -290,6 +383,51 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._log_box.delete("1.0", "end")
|
self._log_box.delete("1.0", "end")
|
||||||
self._log_box.configure(state="disabled")
|
self._log_box.configure(state="disabled")
|
||||||
|
|
||||||
|
def _show_tooltip(self, event, text: str) -> None:
|
||||||
|
if self._tooltip_window is None:
|
||||||
|
self._tooltip_window = tk.Toplevel(self)
|
||||||
|
self._tooltip_window.wm_overrideredirect(True)
|
||||||
|
self._tooltip_window.attributes("-topmost", True)
|
||||||
|
self._tooltip_label = tk.Label(
|
||||||
|
self._tooltip_window,
|
||||||
|
background="#F8FAFC",
|
||||||
|
foreground="#111827",
|
||||||
|
relief="solid",
|
||||||
|
borderwidth=1,
|
||||||
|
padx=10,
|
||||||
|
pady=6,
|
||||||
|
font=("Segoe UI", 9),
|
||||||
|
)
|
||||||
|
self._tooltip_label.pack()
|
||||||
|
|
||||||
|
if self._tooltip_label is not None:
|
||||||
|
self._tooltip_label.configure(text=text)
|
||||||
|
|
||||||
|
self._tooltip_window.geometry(f"+{event.x_root + 14}+{event.y_root + 10}")
|
||||||
|
|
||||||
|
def _move_tooltip(self, event, text: str) -> None:
|
||||||
|
if self._tooltip_window is None:
|
||||||
|
self._show_tooltip(event, text)
|
||||||
|
return
|
||||||
|
self._tooltip_window.geometry(f"+{event.x_root + 14}+{event.y_root + 10}")
|
||||||
|
|
||||||
|
def _hide_tooltip(self) -> None:
|
||||||
|
if self._tooltip_window is not None:
|
||||||
|
self._tooltip_window.destroy()
|
||||||
|
self._tooltip_window = None
|
||||||
|
self._tooltip_label = None
|
||||||
|
|
||||||
|
def _set_excel_loading_state(self, text: str) -> None:
|
||||||
|
self._excel_menu.configure(values=[text], state="disabled")
|
||||||
|
self._excel_var.set(text)
|
||||||
|
|
||||||
|
def _set_download_status(self, text: str, color: str = "#60A5FA") -> None:
|
||||||
|
self._lbl_download_status.configure(text=text, text_color=color)
|
||||||
|
|
||||||
|
def _get_creation_date(self) -> date:
|
||||||
|
"""Return the parsed creation date from the GUI entry."""
|
||||||
|
return _parse_creation_date(self._creation_date_var.get())
|
||||||
|
|
||||||
def _on_log_click(self, event) -> None:
|
def _on_log_click(self, event) -> None:
|
||||||
tb = self._log_box._textbox
|
tb = self._log_box._textbox
|
||||||
idx = tb.index(f"@{event.x},{event.y}")
|
idx = tb.index(f"@{event.x},{event.y}")
|
||||||
@@ -328,7 +466,7 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
def _clear_preview(self) -> None:
|
def _clear_preview(self) -> None:
|
||||||
for attr in (
|
for attr in (
|
||||||
"_pv_datei", "_pv_jahr", "_pv_monat", "_pv_antrag",
|
"_pv_datei", "_pv_jahr", "_pv_monat", "_pv_antrag",
|
||||||
"_pv_ort", "_pv_datum", "_pv_teilnehmer", "_pv_fehler",
|
"_pv_ort", "_pv_teilnehmer", "_pv_fehler",
|
||||||
):
|
):
|
||||||
getattr(self, attr).configure(text="–", text_color=("gray10", "gray90"))
|
getattr(self, attr).configure(text="–", text_color=("gray10", "gray90"))
|
||||||
|
|
||||||
@@ -377,13 +515,15 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
)
|
)
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
self._excel_path = Path(path)
|
self._excel_path = _copy_excel_to_input(Path(path))
|
||||||
self._lbl_excel.configure(
|
self._refresh_excel_dropdown(selected=self._excel_path)
|
||||||
text=self._excel_path.name, text_color=("gray10", "gray90")
|
|
||||||
)
|
|
||||||
self._btn_generate.configure(state="disabled")
|
self._btn_generate.configure(state="disabled")
|
||||||
self._clear_preview()
|
self._clear_preview()
|
||||||
self._clear_log()
|
self._clear_log()
|
||||||
|
self._set_download_status(
|
||||||
|
f"Excel-Datei nach input kopiert: {self._excel_path.name}",
|
||||||
|
"#22C55E",
|
||||||
|
)
|
||||||
if self._template_path and self._month_var.get():
|
if self._template_path and self._month_var.get():
|
||||||
threading.Thread(target=self._run_validate, daemon=True).start()
|
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||||
|
|
||||||
@@ -404,6 +544,73 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
if self._excel_path and self._month_var.get():
|
if self._excel_path and self._month_var.get():
|
||||||
threading.Thread(target=self._run_validate, daemon=True).start()
|
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||||
|
|
||||||
|
def _refresh_excel_dropdown(self, selected: Optional[Path] = None) -> None:
|
||||||
|
self._input_files = _list_input_files()
|
||||||
|
self._excel_name_to_path = {path.name: path for path in self._input_files}
|
||||||
|
|
||||||
|
if not self._input_files:
|
||||||
|
self._excel_path = None
|
||||||
|
self._excel_menu.configure(values=["Keine Excel-Datei gefunden"], state="disabled")
|
||||||
|
self._excel_var.set("Keine Excel-Datei gefunden")
|
||||||
|
return
|
||||||
|
|
||||||
|
values = [path.name for path in self._input_files]
|
||||||
|
self._excel_menu.configure(values=values, state="normal")
|
||||||
|
|
||||||
|
if selected and selected.name in self._excel_name_to_path:
|
||||||
|
selected_name = selected.name
|
||||||
|
elif self._excel_var.get() in self._excel_name_to_path:
|
||||||
|
selected_name = self._excel_var.get()
|
||||||
|
else:
|
||||||
|
selected_name = values[0]
|
||||||
|
|
||||||
|
self._excel_var.set(selected_name)
|
||||||
|
self._excel_path = self._excel_name_to_path[selected_name]
|
||||||
|
|
||||||
|
def _start_initial_sync(self) -> None:
|
||||||
|
self._set_excel_loading_state("Lade Excel vom Netzlaufwerk ...")
|
||||||
|
self._set_download_status("Datei vom Netzlaufwerk wird geprueft ...")
|
||||||
|
threading.Thread(target=self._run_initial_sync, daemon=True).start()
|
||||||
|
|
||||||
|
def _run_initial_sync(self) -> None:
|
||||||
|
try:
|
||||||
|
synced_path, status = _sync_network_excel(_CONFIG)
|
||||||
|
self.after(0, self._finish_initial_sync, synced_path, status, None)
|
||||||
|
except Exception as exc:
|
||||||
|
self.after(0, self._finish_initial_sync, None, "", str(exc))
|
||||||
|
|
||||||
|
def _finish_initial_sync(
|
||||||
|
self,
|
||||||
|
synced_path: Optional[Path],
|
||||||
|
status: str,
|
||||||
|
error: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
if error:
|
||||||
|
self._set_download_status(
|
||||||
|
f"Netzlaufwerk-Pruefung fehlgeschlagen: {error}",
|
||||||
|
"orange",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
color = "#22C55E" if synced_path else "orange"
|
||||||
|
self._set_download_status(status, color)
|
||||||
|
self._scan_and_validate(selected_excel=synced_path)
|
||||||
|
|
||||||
|
def _on_excel_changed(self) -> None:
|
||||||
|
selected = self._excel_name_to_path.get(self._excel_var.get())
|
||||||
|
self._excel_path = selected
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
self._clear_preview()
|
||||||
|
self._clear_log()
|
||||||
|
if self._excel_path and self._template_path and self._month_var.get():
|
||||||
|
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_creation_date_changed(self) -> None:
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
self._clear_preview()
|
||||||
|
self._clear_log()
|
||||||
|
if self._excel_path and self._template_path and self._month_var.get():
|
||||||
|
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Event handlers
|
# Event handlers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -414,28 +621,12 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._clear_log()
|
self._clear_log()
|
||||||
self._scan_and_validate()
|
self._scan_and_validate()
|
||||||
|
|
||||||
def _scan_and_validate(self) -> None:
|
def _scan_and_validate(self, selected_excel: Optional[Path] = None) -> None:
|
||||||
if _CONFIG_ERROR:
|
if _CONFIG_ERROR:
|
||||||
self._log(f"[FEHLER] config.json: {_CONFIG_ERROR}")
|
self._log(f"[FEHLER] config.json: {_CONFIG_ERROR}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Scan input/
|
self._refresh_excel_dropdown(selected=selected_excel)
|
||||||
self._excel_path = _scan_input_folder()
|
|
||||||
input_folder = _runtime_dir() / "input"
|
|
||||||
if self._excel_path:
|
|
||||||
self._lbl_excel.configure(
|
|
||||||
text=self._excel_path.name, text_color=("gray10", "gray90")
|
|
||||||
)
|
|
||||||
elif not input_folder.exists() or not list(input_folder.glob("*.xlsx")):
|
|
||||||
self._lbl_excel.configure(
|
|
||||||
text="Keine .xlsx in input/ gefunden", text_color="red"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
n = len(list(input_folder.glob("*.xlsx")))
|
|
||||||
self._lbl_excel.configure(
|
|
||||||
text=f"Mehrere Dateien gefunden ({n}) — nur eine erlaubt",
|
|
||||||
text_color="red",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Scan template/
|
# Scan template/
|
||||||
self._template_path = _scan_template_folder()
|
self._template_path = _scan_template_folder()
|
||||||
@@ -461,6 +652,8 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
# Start validation thread if all inputs are ready
|
# Start validation thread if all inputs are ready
|
||||||
if self._excel_path and self._template_path and self._month_var.get():
|
if self._excel_path and self._template_path and self._month_var.get():
|
||||||
threading.Thread(target=self._run_validate, daemon=True).start()
|
threading.Thread(target=self._run_validate, daemon=True).start()
|
||||||
|
else:
|
||||||
|
self._btn_generate.configure(state="disabled")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Validation (background thread)
|
# Validation (background thread)
|
||||||
@@ -474,6 +667,7 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
"year": 0,
|
"year": 0,
|
||||||
"persons": [],
|
"persons": [],
|
||||||
"month": month,
|
"month": month,
|
||||||
|
"creation_date": None,
|
||||||
"exception": None,
|
"exception": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,6 +688,13 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result["errors"].append(f"Teilnehmer nicht lesbar: {exc}")
|
result["errors"].append(f"Teilnehmer nicht lesbar: {exc}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result["creation_date"] = self._get_creation_date()
|
||||||
|
except ValueError:
|
||||||
|
result["errors"].append(
|
||||||
|
"Erstellungsdatum ungueltig. Bitte Format DD.MM.YYYY verwenden."
|
||||||
|
)
|
||||||
|
|
||||||
self.after(0, self._on_validate_done, result)
|
self.after(0, self._on_validate_done, result)
|
||||||
|
|
||||||
def _on_validate_done(self, result: Dict[str, Any]) -> None:
|
def _on_validate_done(self, result: Dict[str, Any]) -> None:
|
||||||
@@ -506,6 +707,7 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
year: int = result["year"]
|
year: int = result["year"]
|
||||||
persons = result["persons"]
|
persons = result["persons"]
|
||||||
month: str = result["month"]
|
month: str = result["month"]
|
||||||
|
creation_date_value = result["creation_date"]
|
||||||
|
|
||||||
month_num = (_CONFIG.get("month_mapping") or {}).get(month, "??")
|
month_num = (_CONFIG.get("month_mapping") or {}).get(month, "??")
|
||||||
default_color = ("gray10", "gray90")
|
default_color = ("gray10", "gray90")
|
||||||
@@ -526,9 +728,6 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
self._pv_ort.configure(
|
self._pv_ort.configure(
|
||||||
text=_CONFIG.get("ort", "–"), text_color=default_color
|
text=_CONFIG.get("ort", "–"), text_color=default_color
|
||||||
)
|
)
|
||||||
self._pv_datum.configure(
|
|
||||||
text=date.today().strftime("%d.%m.%Y"), text_color=default_color
|
|
||||||
)
|
|
||||||
self._pv_teilnehmer.configure(
|
self._pv_teilnehmer.configure(
|
||||||
text=str(len(persons)), text_color=default_color
|
text=str(len(persons)), text_color=default_color
|
||||||
)
|
)
|
||||||
@@ -550,6 +749,17 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _start_generate(self) -> None:
|
def _start_generate(self) -> None:
|
||||||
|
try:
|
||||||
|
self._get_creation_date()
|
||||||
|
except ValueError:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Ung\u00fcltiges Datum",
|
||||||
|
"Bitte ein Erstellungsdatum im Format DD.MM.YYYY eingeben.",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
self._entry_creation_date.focus_set()
|
||||||
|
return
|
||||||
|
|
||||||
count = _count_output_pdfs(_CONFIG)
|
count = _count_output_pdfs(_CONFIG)
|
||||||
if count > 0:
|
if count > 0:
|
||||||
answer = messagebox.askyesnocancel(
|
answer = messagebox.askyesnocancel(
|
||||||
@@ -581,6 +791,7 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
try:
|
try:
|
||||||
year = extract_year_from_filename(excel_file, _CONFIG)
|
year = extract_year_from_filename(excel_file, _CONFIG)
|
||||||
persons = extract_persons(excel_file, month, _CONFIG)
|
persons = extract_persons(excel_file, month, _CONFIG)
|
||||||
|
creation_date_value = self._get_creation_date()
|
||||||
total = len(persons)
|
total = len(persons)
|
||||||
|
|
||||||
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
out_dir = _runtime_dir() / _CONFIG.get("output_dir", "output")
|
||||||
@@ -594,7 +805,13 @@ class AnwesenheitApp(ctk.CTk):
|
|||||||
)
|
)
|
||||||
output_filename = f"{person.filename_safe_name}.pdf"
|
output_filename = f"{person.filename_safe_name}.pdf"
|
||||||
output_path = out_dir / output_filename
|
output_path = out_dir / output_filename
|
||||||
fill_pdf(attendance, self._template_path, output_path, _CONFIG)
|
fill_pdf(
|
||||||
|
attendance,
|
||||||
|
self._template_path,
|
||||||
|
output_path,
|
||||||
|
_CONFIG,
|
||||||
|
creation_date=creation_date_value,
|
||||||
|
)
|
||||||
self.after(0, self._log_ok, f"[OK] {output_filename} ({i}/{total})")
|
self.after(0, self._log_ok, f"[OK] {output_filename} ({i}/{total})")
|
||||||
success_count += 1
|
success_count += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -90,6 +90,10 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
|
"description": "Steuert AcroForm /NeedAppearances beim Schreiben der PDF. false wird fuer stabile PyMuPDF-Render bevorzugt (Footer/Buttons sichtbar).",
|
||||||
"default": false
|
"default": false
|
||||||
|
},
|
||||||
|
"excel_network_source": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optionaler SMB- oder Dateipfad zur Excel-Datei, die beim GUI-Start nach input/ kopiert und bei Bedarf aktualisiert wird"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,10 @@ def load_config(config_path: Path | None = None) -> Dict[str, Any]:
|
|||||||
|
|
||||||
# Optional settings with backward-compatible defaults
|
# Optional settings with backward-compatible defaults
|
||||||
data.setdefault("pdf_need_appearances", False)
|
data.setdefault("pdf_need_appearances", False)
|
||||||
|
data.setdefault(
|
||||||
|
"excel_network_source",
|
||||||
|
r"J:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\30_Teilnehmende\01_Anwesenheit\Anwesenheitsliste\Anwesenheitsliste 2026.xlsx",
|
||||||
|
)
|
||||||
|
|
||||||
_config_cache = data
|
_config_cache = data
|
||||||
_config_path_cache = config_path
|
_config_path_cache = config_path
|
||||||
|
|||||||
Reference in new issue
Block a user