"""Tätigkeitsnachweis-Generator – Streamlit App.""" import os import subprocess import sys from pathlib import Path import streamlit as st # Projekt-Root zum Python-Path hinzufügen # Im gebundelten Modus (PyInstaller) zeigt APP_BASE_DIR auf das Verzeichnis der .exe, # wo config/, input/, template/, output/ liegen. Im Entwicklungsmodus bleibt es None # und PROJECT_ROOT wird aus __file__ abgeleitet. _BASE_DIR = os.environ.get("APP_BASE_DIR") if _BASE_DIR: PROJECT_ROOT = Path(_BASE_DIR).resolve() else: PROJECT_ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(PROJECT_ROOT)) from src.config import ( load_settings, load_input_schema, load_output_mapping, get_available_input_files, get_available_templates, ) from src.models import AppConfig from src.parser import parse_input_file, group_by_employee, get_unique_antragsnummern, get_unique_employees from src.validator import validate_input_file, validate_template from src.writer import generate_activity_reports from version_check import UPDATE_DIR, get_latest_version from update_installer import can_replace_application, install_update, start_application def open_input_folder(directory: Path): directory.mkdir(parents=True, exist_ok=True) if sys.platform == "win32": os.startfile(str(directory)) else: subprocess.run(["open" if sys.platform == "darwin" else "xdg-open", str(directory)], check=True) def set_active_employee(employee_name: str): """Merkt die zuletzt angeklickte Person für die Monatsliste.""" st.session_state.active_month_employee = employee_name def sync_employee_months(employee_name: str, months: list[str]): """Übernimmt Monats-Checkboxen vor dem nächsten UI-Rendern.""" st.session_state.employee_months[employee_name] = { month for month in months if st.session_state.get(f"month_{employee_name}_{month}", False) } def set_all_employee_months(employee_name: str, months: list[str], selected: bool): """Setzt alle Monats-Checkboxen einer Person gesammelt.""" st.session_state.employee_months[employee_name] = set(months) if selected else set() for month in months: st.session_state[f"month_{employee_name}_{month}"] = selected # --- Seite konfigurieren --- st.set_page_config( page_title="Tätigkeitsnachweis-Generator", page_icon="📋", layout="wide", ) st.title("📋 Tätigkeitsnachweis-Generator") st.markdown("Erstellt Tätigkeitsnachweise aus Input-Daten und einem Excel-Template.") # --- Konfiguration laden --- CONFIG_DIR = PROJECT_ROOT / "config" @st.cache_data def load_app_config(): config = load_settings(CONFIG_DIR, PROJECT_ROOT) config.input_schema = load_input_schema(CONFIG_DIR) config.output_mapping = load_output_mapping(CONFIG_DIR) return config try: app_config = load_app_config() except Exception as e: st.error(f"Fehler beim Laden der Konfiguration: {e}") st.stop() # --- Versionsinformation für die dezente Anzeige am Seitenende --- current_version_text = "" try: local_version, latest_version = get_latest_version() current_version_text = f"v{'.'.join(map(str, local_version))}" if latest_version: available_version, update_path = latest_version version_text = ".".join(map(str, available_version)) st.html( f''' ''' ) with st.container(key="update_notice_open"): if st.button("Update Ordner öffnen 📁", key="open_update_directory", help="Update-Ordner öffnen"): if sys.platform == "win32": os.startfile(str(UPDATE_DIR)) replace_supported = can_replace_application() replace_clicked = st.button( "Anwendung ersetzen 🔄", key="replace_application", disabled=not replace_supported or st.session_state.get("update_started", False), help=("Neue Version neben der aktuellen einrichten, input/ und output/ übernehmen " "und starten. Die aktuelle Version bleibt als Ordner erhalten." if replace_supported else "Nur in der installierten Windows-Anwendung verfügbar.")) if replace_clicked: try: with st.spinner(f"Version v{version_text} wird eingerichtet …"): result = install_update(update_path, PROJECT_ROOT) start_application(result.target_dir) except Exception as exc: # Der umgebende Block verschluckt sonst jeden Fehler. st.error(f"Update fehlgeschlagen: {exc}\n\n" "Alternativ das Update manuell aus dem Update-Ordner einrichten.") else: st.session_state.update_started = True details = [f"Neuer Programmordner: `{result.target_dir}`"] if result.already_installed: details.append("Die Version war dort bereits eingerichtet und wurde nur gestartet.") else: details.append("Die Ordner input/ und output/ wurden übernommen.") if result.previous_settings: details.append(f"Die bisherige settings.ini wurde als `{result.previous_settings.name}` aufbewahrt.") details.append("Bitte im folgenden Fenster bestätigen, dass die alte Version beendet wird. " "Desktop-Verknüpfungen zeigen noch auf den alten Ordner.") st.success(f"Version v{version_text} wird gestartet.\n\n" + "\n\n".join(details)) except Exception: pass # --- Session State initialisieren --- if "parsed_records" not in st.session_state: st.session_state.parsed_records = [] if "input_validated" not in st.session_state: st.session_state.input_validated = False if "template_validated" not in st.session_state: st.session_state.template_validated = False # Caches für Validierung/Parsing – nur bei Dateiwechsel neu ausführen if "last_input_file" not in st.session_state: st.session_state.last_input_file = None if "last_template_file" not in st.session_state: st.session_state.last_template_file = None if "cached_val_result" not in st.session_state: st.session_state.cached_val_result = None if "cached_tpl_result" not in st.session_state: st.session_state.cached_tpl_result = None if "employee_months" not in st.session_state: st.session_state.employee_months = {} # --- Layout: zwei Spalten --- col1, col2 = st.columns(2) with col1: st.subheader("1. Input-Datei") input_files = get_available_input_files(app_config) for message in st.session_state.pop("input_upload_messages", []): st.success(message) for message in st.session_state.pop("input_upload_errors", []): st.error(message) if not input_files: with st.container(border=True, key="input_file_panel"): st.markdown("**Excel-Datei hinzufügen**") st.caption("Keine .xlsx-Dateien im Input/-Ordner gefunden.") input_directory = Path(app_config.input_dir) uploaded_files = st.file_uploader( "Excel-Dateien hierher ziehen oder auswählen", type=["xlsx"], accept_multiple_files=True, key=f"input_upload_{st.session_state.get('input_upload_generation', 0)}", label_visibility="collapsed", ) if uploaded_files: saved, errors = [], [] for uploaded_file in uploaded_files: try: name = uploaded_file.name if (Path(name).name != name or "/" in name or "\\" in name or Path(name).suffix.lower() != ".xlsx" or name.startswith("~")): raise ValueError("Bitte eine .xlsx-Datei mit gültigem Dateinamen auswählen.") input_directory.mkdir(parents=True, exist_ok=True) destination = input_directory / name counter = 1 while True: try: with destination.open("xb") as output: output.write(uploaded_file.getbuffer()) break except FileExistsError: counter += 1 destination = input_directory / f"{Path(name).stem} ({counter}).xlsx" saved.append(f"{destination.name} wurde in den Input-Ordner kopiert.") except (OSError, ValueError) as exc: errors.append(f"{uploaded_file.name} konnte nicht übernommen werden: {exc}") st.session_state.input_upload_messages = saved st.session_state.input_upload_errors = errors st.session_state.input_upload_generation = st.session_state.get("input_upload_generation", 0) + 1 st.session_state.last_input_file = None st.rerun() st.caption("Dateien hierher ziehen oder auswählen. Sie werden automatisch im Input-Ordner gespeichert.") folder_action, refresh_action = st.columns(2) with folder_action: if st.button("Ordner öffnen", icon="📁", key="open_input_directory", use_container_width=True): try: open_input_folder(input_directory) except (OSError, subprocess.SubprocessError) as exc: st.error(f"Der Input-Ordner konnte nicht geöffnet werden: {exc}") with refresh_action: if st.button("Neu einlesen", icon="🔄", key="refresh_input_directory", use_container_width=True, help="Dateiliste aktualisieren, nachdem Dateien im Explorer hinzugefügt wurden."): st.rerun() selected_input = None st.session_state.parsed_records = [] st.session_state.input_validated = False st.session_state.last_input_file = None else: # Bei nur einer Datei: automatisch selektieren (keine Leerauswahl) if len(input_files) == 1: selected_input = st.selectbox( "Input-Datei wählen:", options=input_files, ) else: selected_input = st.selectbox( "Input-Datei wählen:", options=[""] + input_files, format_func=lambda x: "– Bitte wählen –" if x == "" else x, ) if selected_input: input_path = Path(app_config.input_dir) / selected_input # Nur validieren/parsen wenn sich die Datei geändert hat if selected_input != st.session_state.last_input_file: with st.spinner("Validiere Input-Datei…"): val_result = validate_input_file(input_path, app_config.input_schema, app_config.month_sheet_pattern) if val_result.is_valid: records = parse_input_file( input_path, app_config.input_schema, app_config.month_sheet_pattern, app_config.skip_empty_name, app_config.skip_empty_antragsnummer, ) st.session_state.parsed_records = records st.session_state.input_validated = True else: st.session_state.parsed_records = [] st.session_state.input_validated = False st.session_state.cached_val_result = val_result st.session_state.last_input_file = selected_input else: val_result = st.session_state.cached_val_result if not val_result.is_valid: st.markdown( '