"""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 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, _ = 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)) 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) if not input_files: st.warning("Keine .xlsx-Dateien im input/-Ordner gefunden.") selected_input = 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( '
' '' '❌ Validierung fehlgeschlagen
', unsafe_allow_html=True, ) for err in val_result.errors: st.error(f"🔴 {err}") st.session_state.input_validated = False else: if val_result.warnings: st.markdown( '
' '' '⚠️ Hinweise
', unsafe_allow_html=True, ) for warn in val_result.warnings: st.warning(warn) # Zeige Zusammenfassung aus gecachten Records records = st.session_state.parsed_records employees = get_unique_employees(records) antragsnummern = get_unique_antragsnummern(records) st.success(f"✅ {len(records)} Einträge in {val_result.info.get('sheets_with_data', 0)} Monats-Sheets") pills = " ".join( f'{an}' for an in antragsnummern ) st.markdown(f"**Antragsnummern:**
{pills}", unsafe_allow_html=True) with col2: st.subheader("2. Template") template_files = get_available_templates(app_config) if not template_files: st.warning("Keine .pdf-Dateien im template/-Ordner gefunden.") selected_template = None else: # Lock-Files herausfiltern real_templates = [t for t in template_files if not t.startswith("~")] if not real_templates: real_templates = template_files # Bei nur einem Template: automatisch selektieren (keine Leerauswahl) if len(real_templates) == 1: selected_template = st.selectbox( "Template wählen:", options=real_templates, ) else: selected_template = st.selectbox( "Template wählen:", options=[""] + real_templates, format_func=lambda x: "– Bitte wählen –" if x == "" else x, ) if selected_template: # Nur validieren wenn sich das Template geändert hat if selected_template != st.session_state.last_template_file: template_path = Path(app_config.template_dir) / selected_template with st.spinner("Validiere Template…"): tpl_result = validate_template(template_path, app_config.output_mapping) st.session_state.cached_tpl_result = tpl_result st.session_state.last_template_file = selected_template st.session_state.template_validated = tpl_result.is_valid else: tpl_result = st.session_state.cached_tpl_result if not tpl_result.is_valid: st.markdown( '
' '' '❌ Validierung fehlgeschlagen
', unsafe_allow_html=True, ) for err in tpl_result.errors: st.error(f"🔴 {err}") st.session_state.template_validated = False else: st.session_state.template_validated = True found = tpl_result.info.get("found_fields", 0) total = tpl_result.info.get("total_fields", 0) st.success(f"✅ {found}/{total} Platzhalter-Felder gefunden") if tpl_result.warnings: st.markdown( '
' '' '⚠️ Hinweise
', unsafe_allow_html=True, ) for warn in tpl_result.warnings: st.warning(warn) # --- Mitarbeiter-Auswahl --- st.subheader("3. Mitarbeiter auswählen") if st.session_state.input_validated and st.session_state.parsed_records: employees = get_unique_employees(st.session_state.parsed_records) st.caption(f"{len(employees)} Mitarbeiter gefunden – wähle aus, für wen Tätigkeitsnachweise erstellt werden sollen:") # Monatsbezeichnungen für die kompakte Anzeige hinter dem Namen month_labels = { "01": "Jan", "02": "Feb", "03": "Mär", "04": "Apr", "05": "Mai", "06": "Jun", "07": "Jul", "08": "Aug", "09": "Sep", "10": "Okt", "11": "Nov", "12": "Dez", } all_groups = group_by_employee(st.session_state.parsed_records) available_months = {} for emp in employees: months = sorted({ record.monat.split(".", 1)[0].zfill(2) for record in all_groups.get(emp, []) if record.monat and record.monat.split(".", 1)[0].isdigit() }, key=lambda value: int(value)) available_months[emp] = months if emp not in st.session_state.employee_months: st.session_state.employee_months[emp] = set(months[-2:]) # Mitarbeiter- und Monatsliste teilen sich die verfügbare Breite. employee_col, month_col = st.columns(2) with employee_col: employee_btn_col1, employee_btn_col2 = st.columns(2) if employee_btn_col1.button("☑ Alle auswählen", use_container_width=True): for emp in employees: st.session_state[f"sel_{emp}"] = True if employee_btn_col2.button("☐ Keine auswählen", use_container_width=True): for emp in employees: st.session_state[f"sel_{emp}"] = False with st.container(border=True): selected_employees = [] for emp in employees: key = f"sel_{emp}" if key not in st.session_state: st.session_state[key] = False selected_months = st.session_state.employee_months.get(emp, set()) short_months = ", ".join(month_labels[m] for m in available_months[emp] if m in selected_months) label = f"{emp} ({short_months})" if short_months else f"{emp} (**keine Monate**)" if st.checkbox( label, key=key, on_change=set_active_employee, args=(emp,), ): selected_employees.append(emp) with month_col: selected_for_months = st.session_state.get("active_month_employee") if selected_for_months not in employees: selected_for_months = None month_btn_col1, month_btn_col2 = st.columns(2) month_btn_col1.button( "☑ Alle Monate", use_container_width=True, disabled=not selected_for_months, on_click=set_all_employee_months, args=(selected_for_months, available_months[selected_for_months], True) if selected_for_months else ("", [], True), ) month_btn_col2.button( "☐ Keine Monate", use_container_width=True, disabled=not selected_for_months, on_click=set_all_employee_months, args=(selected_for_months, available_months[selected_for_months], False) if selected_for_months else ("", [], False), ) with st.container(border=True): st.caption("Monate für den angeklickten Mitarbeiter") if selected_for_months: st.write(selected_for_months) month_values = {} for month in available_months[selected_for_months]: month_key = f"month_{selected_for_months}_{month}" if month_key not in st.session_state: st.session_state[month_key] = month in st.session_state.employee_months[selected_for_months] month_values[month] = st.checkbox( f"{month_labels[month]} ({month})", key=month_key, on_change=sync_employee_months, args=(selected_for_months, available_months[selected_for_months]), ) st.session_state.employee_months[selected_for_months] = { month for month, checked in month_values.items() if checked } else: st.info("Einen Mitarbeiter links auswählen, um seine Monate festzulegen.") st.caption(f"{len(selected_employees)} von {len(employees)} Mitarbeitern ausgewählt") else: selected_employees = [] if not st.session_state.input_validated: st.info("Bitte zuerst eine gültige Input-Datei auswählen.") # --- Hilfsfunktion zum Öffnen von Dateien/Ordnern --- def open_path(path_str: str): """Öffnet eine Datei mit dem Standardprogramm oder einen Ordner im Dateimanager. Funktioniert plattformübergreifend: macOS, Windows, Linux.""" if sys.platform == "win32": os.startfile(path_str) elif sys.platform == "darwin": subprocess.run(["open", path_str]) else: subprocess.run(["xdg-open", path_str]) # --- Generate Button --- st.divider() gen_col1, gen_col2, gen_col3 = st.columns([1, 1, 2]) with gen_col1: generate_clicked = st.button( "🚀 Tätigkeitsnachweise generieren", type="primary", disabled=not (st.session_state.input_validated and st.session_state.template_validated and selected_employees), ) if generate_clicked: if not selected_employees: st.error("Keine Mitarbeiter ausgewählt.") else: # Filtere Records auf selektierte Mitarbeiter all_groups = group_by_employee(st.session_state.parsed_records) selected_groups = { name: [ record for record in all_groups[name] if record.monat.split(".", 1)[0].zfill(2) in st.session_state.employee_months.get(name, set()) ] for name in selected_employees if name in all_groups } selected_groups = {name: records for name, records in selected_groups.items() if records} if not selected_groups: st.error("Für die ausgewählten Mitarbeiter sind keine Monate ausgewählt.") st.stop() template_path = Path(app_config.template_dir) / selected_template output_dir = Path(app_config.output_dir) with st.spinner(f"Generiere {len(selected_groups)} Tätigkeitsnachweise…"): try: generated = generate_activity_reports( employee_records=selected_groups, template_path=template_path, mapping=app_config.output_mapping, output_dir=output_dir, filename_pattern=app_config.filename_pattern, fontsize=app_config.fontsize, ) st.session_state.generated_files = generated except Exception as e: st.error(f"Fehler bei der Generierung: {e}") # --- Generierte Dateien anzeigen (aus Session State, übersteht Reruns) --- if "generated_files" in st.session_state and st.session_state.generated_files: generated = st.session_state.generated_files st.success(f"✅ {len(generated)} Tätigkeitsnachweise erstellt:") for i, fpath in enumerate(generated): col_file, col_open, col_folder = st.columns([0.78, 0.11, 0.11]) with col_file: st.markdown(f"📄 `{fpath.name}`") with col_open: st.button( "🔗 Öffnen", key=f"open_file_{i}", on_click=open_path, args=(str(fpath),), help="Datei mit Standardprogramm öffnen", use_container_width=True, ) with col_folder: st.button( "📂", key=f"open_folder_{i}", on_click=open_path, args=(str(fpath.parent),), help="Ordner im Finder öffnen", use_container_width=True, ) if current_version_text: st.markdown( f'
' f'Programmversion {current_version_text}
', unsafe_allow_html=True, )