5 Commits
Author SHA1 Message Date
Rob 34730276d7 v1.3 2026-08-11 09:44:51 +02:00
Rob c12b15e021 feat: add version check with message in app window 2026-08-11 09:37:55 +02:00
Rob d027bedcb8 Auswählbare Mitarbeitermonate in separater Multiselektliste hinzugefügt 2026-08-10 19:37:03 +02:00
Rob eb1dd45e57 setup README for dev and end user 2026-08-10 18:19:59 +02:00
Rob 6f1d807f3c copy readme into build 2026-06-30 21:35:48 +02:00
7 changed files with 370 additions and 67 deletions

No files matched your search

+25
View File
@@ -7,6 +7,31 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
---
## [1.3] – 2026-08-11
### Hinzugefügt
- **Monatsauswahl pro Mitarbeiter**: Tätigkeitsnachweise können nun für individuell gewählte Monate erzeugt werden.
- Die Monatsauswahl erscheint rechts neben der Mitarbeiterliste für die zuletzt angeklickte Person.
- Die Auswahl wird in Kurzform direkt hinter dem Namen angezeigt, z. B. `(Jan, Mär, Dez)`.
- Standardmäßig sind die letzten zwei vorhandenen Monate ausgewählt.
- Schaltflächen zum Auswählen bzw. Abwählen aller Monate ergänzen die Monatsliste.
- **Versionsprüfung**: Die App prüft beim Start im zentralen SMB-Verzeichnis auf neuere
`Tätigkeitsnachweis-Generator vX.Y.Z.zip`- und künftig auch `.exe`-Dateien.
- **Versionsanzeige**: Die aktuell installierte Programmversion wird dezent am unteren rechten Rand der App angezeigt.
### Geändert
- **Mitarbeiterauswahl**: Mitarbeiter sind nach dem Einlesen einer Input-Datei nicht mehr standardmäßig ausgewählt.
Die Buttons zum Auswählen bzw. Abwählen aller Mitarbeiter befinden sich direkt über der Mitarbeiterliste.
- **Update-Hinweis**: Bei einer neueren zentralen Version wird ein schwebender Hinweis oben rechts angezeigt,
einschließlich Schließen- und Ordner-Schaltfläche.
---
## [1.2.1] – 2026-06-30
### Behoben
+107
View File
@@ -0,0 +1,107 @@
# Tätigkeitsnachweis-Generator
Streamlit-App zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
## Setup
1. **Python 3.9+ installieren** (falls nicht vorhanden): https://python.org
2. **Virtuelle Umgebung erstellen und Abhängigkeiten installieren:**
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
```
3. **App starten:**
```bash
streamlit run app.py
```
Oder einfach `start.bat` (Windows) bzw. `./start.sh` (macOS/Linux) ausführen.
## Cold Run – Headless-Generierung
`coldrun.py` erzeugt ohne GUI einen Tätigkeitsnachweis für die erste Person –
automatisch mit der ersten Input- und Template-Datei. Ergebnis wird auf macOS
im Finder geöffnet.
```bash
# Kommandozeile
.venv/bin/python coldrun.py
# oder per Doppelklick (macOS)
coldrun.command
```
## Bedienung
1. **Input-Datei wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `input/`-Ordner
- Nach Auswahl wird die Datei sofort validiert
- Angezeigt werden: Anzahl Mitarbeiter, Antragsnummern, Einträge pro Monat
2. **Template wählen** – Dropdown zeigt alle `.xlsx`-Dateien aus dem `template/`-Ordner
- Nach Auswahl wird das Template sofort validiert
- Angezeigt wird: Anzahl gefundener Zielfelder
3. **Mitarbeiter auswählen** – Multiselect-Liste aller gefundenen Mitarbeiter
- Standardmäßig sind alle ausgewählt
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
4. **"Tätigkeitsnachweise generieren"** klicken
- Die generierten Dateien werden im `output/`-Ordner abgelegt
## Konfiguration
Alle Einstellungen liegen im `config/`-Ordner:
### `settings.ini`
- `[paths]` – Ordner für Input, Template, Output
- `[output]` – Dateinamen-Pattern (`{first_name}`, `{last_name}`)
- `[parsing]` – Monats-Sheet-Erkennung, Leerzeilen-Verhalten
### `input-fields.json`
Definiert die Spalten der Input-Tabelle:
- `column`: Spaltenbuchstabe (A, B, C…)
- `field_name`: Exakte Spaltenüberschrift aus der Excel-Datei
- `type`: `string` oder `number`
- `required`: `true` oder `false`
### `output-fields.json`
Mapping von Input-Feldern auf Template-Zellen:
- `source_field`: Exakte Spaltenüberschrift aus `input-fields.json`
- `target_cell`: Zelladresse im Template (z. B. `"C16"`)
- `format`: Optionaler Format-String (z. B. `"%.2f"` für Prozentwerte)
## Erwartetes Input-Format
- Eine `.xlsx`-Datei mit einem Sheet pro Monat (z. B. "Januar 2025", "Februar 2025", …)
- Jedes Sheet hat in Zeile 1 die Spaltenüberschriften (laut `input-fields.json`)
- Daten beginnen ab Zeile 2
- Jede Zeile entspricht einer Mitarbeiter-Funktion-Kombination für diesen Monat
## Output
- Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt
- Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
## Tests
End-to-End-Tests parsen die reale Input-Datei (`input/2025 JM.xlsx`) und das reale
Template (`template/Tätigkeitsnachweise Stellenförderung.xlsx`) und validieren
den gesamten Generierungs-Workflow.
```bash
# Test-Abhängigkeiten installieren
.venv/bin/pip install pytest
# Tests ausführen
.venv/bin/python -m pytest tests/test_e2e.py -v
```
Die Tests prüfen:
- Vorhandensein der Input- und Template-Dateien
- Korrekte Parsierung (168 Datensätze, 19 Personen)
- Vollständige Pipeline für die erste Person („Draßdo, Cornelia"): 12 Monats-Sheets
mit allen Zellinhalten (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat,
Funktion, Tätigkeit, Ort, Datum)
+7 -57
View File
@@ -1,40 +1,10 @@
# Tätigkeitsnachweis-Generator
Streamlit-App zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
Programm zur automatisierten Erstellung von Tätigkeitsnachweisen aus Excel-Daten.
Die aktuellesten Programmversion find sich immer hier:
"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren\"
## Setup
1. **Python 3.9+ installieren** (falls nicht vorhanden): https://python.org
2. **Virtuelle Umgebung erstellen und Abhängigkeiten installieren:**
```bash
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txt
```
3. **App starten:**
```bash
streamlit run app.py
```
Oder einfach `start.bat` (Windows) bzw. `./start.sh` (macOS/Linux) ausführen.
## Cold Run – Headless-Generierung
`coldrun.py` erzeugt ohne GUI einen Tätigkeitsnachweis für die erste Person –
automatisch mit der ersten Input- und Template-Datei. Ergebnis wird auf macOS
im Finder geöffnet.
```bash
# Kommandozeile
.venv/bin/python coldrun.py
# oder per Doppelklick (macOS)
coldrun.command
```
Um das Programm zu starten: Doppelklick auf `Tätigkeitsnachweis-Generator.exe`
## Bedienung
@@ -45,9 +15,10 @@ coldrun.command
- Nach Auswahl wird das Template sofort validiert
- Angezeigt wird: Anzahl gefundener Zielfelder
3. **Mitarbeiter auswählen** – Multiselect-Liste aller gefundenen Mitarbeiter
- Standardmäßig sind alle ausgewählt
- Standardmäßig ist keiner ausgewählt
- Nicht selektierte Mitarbeiter werden bei der Generierung übersprungen
4. **"Tätigkeitsnachweise generieren"** klicken
4. **Mitarbeitermonate auswählen** – Multiselect-Liste aller verfügbaren Monate im Dokument. Vorselektiert sind die aktuell letzten beiden Monate.
5. **"Tätigkeitsnachweise generieren"** klicken
- Die generierten Dateien werden im `output/`-Ordner abgelegt
## Konfiguration
@@ -84,24 +55,3 @@ Mapping von Input-Feldern auf Template-Zellen:
- Pro selektiertem Mitarbeiter wird eine `.xlsx`-Datei erstellt
- Jeder Monat wird als eigenes Sheet (Kopie des Templates) eingefügt
- Dateiname: `{Nachname}_{Vorname}_Tätigkeitsnachweis.xlsx`
## Tests
End-to-End-Tests parsen die reale Input-Datei (`input/2025 JM.xlsx`) und das reale
Template (`template/Tätigkeitsnachweise Stellenförderung.xlsx`) und validieren
den gesamten Generierungs-Workflow.
```bash
# Test-Abhängigkeiten installieren
.venv/bin/pip install pytest
# Tests ausführen
.venv/bin/python -m pytest tests/test_e2e.py -v
```
Die Tests prüfen:
- Vorhandensein der Input- und Template-Dateien
- Korrekte Parsierung (168 Datensätze, 19 Personen)
- Vollständige Pipeline für die erste Person („Draßdo, Cornelia"): 12 Monats-Sheets
mit allen Zellinhalten (Antragsnummer, Name, Stellenanteil, Qualifikation, Monat,
Funktion, Tätigkeit, Ort, Datum)
+175 -9
View File
@@ -29,6 +29,27 @@ 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(
@@ -56,6 +77,60 @@ 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'''<style>
.update-notice {{
position: fixed; top: 4rem; right: 1rem; z-index: 10000;
width: 290px; padding: 8px 12px; color: #5f4700;
background: #fff8e1; border: 1px solid #e0b84c; border-radius: 8px;
box-shadow: 0 3px 12px rgba(0, 0, 0, .18); font-size: .85rem;
}}
#update-notice-dismiss {{ display:none; }}
#update-notice-dismiss:checked + .update-notice {{ display:none; }}
body:has(#update-notice-dismiss:checked) div.st-key-update_notice_open {{ display:none!important; }}
.update-notice__actions {{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }}
.update-notice__actions--footer {{ min-height:26px; }}
.update-notice__button {{
display:inline-flex; align-items:center; justify-content:center;
width:26px; height:26px; color:#5f4700; background:#fffdf3;
border:1px solid #d9bd66; border-radius:5px; text-decoration:none;
font-size:1rem; line-height:1;
}}
.update-notice__button:hover {{ background:#f6e7b2; }}
div.st-key-update_notice_open {{
position:fixed; top:8.5rem; right:3.75rem; z-index:10001; width:26px;
}}
div.st-key-update_notice_open div[data-testid="stButton"] {{ margin:0; }}
div.st-key-update_notice_open div[data-testid="stButton"] button {{
min-width:200px; min-height:26px; height:26px; padding:0; color:#5f4700;
background:#fffdf3; border:1px solid #d9bd66; border-radius:5px;
}}
</style>
<input id="update-notice-dismiss" type="checkbox">
<aside class="update-notice" role="status">
<div class="update-notice__actions">
<label class="update-notice__button" for="update-notice-dismiss" title="Hinweis schließen">×</label>
</div>
<strong>Neue Version v{version_text} verfügbar</strong>
<div class="update-notice__actions update-notice__actions--footer">
</div>
</aside>'''
)
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 = []
@@ -72,7 +147,8 @@ 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)
@@ -230,25 +306,95 @@ 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:")
# "Alle" / "Keine" Buttons
btn_col1, btn_col2 = st.columns(2)
if btn_col1.button("☑ Alle auswählen", use_container_width=True):
# 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 btn_col2.button("☐ Keine auswählen", use_container_width=True):
if employee_btn_col2.button("☐ Keine auswählen", use_container_width=True):
for emp in employees:
st.session_state[f"sel_{emp}"] = False
# Mitarbeiter-Liste mit Rahmen
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] = True
if st.checkbox(emp, key=key):
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 = []
@@ -283,7 +429,20 @@ if generate_clicked:
else:
# Filtere Records auf selektierte Mitarbeiter
all_groups = group_by_employee(st.session_state.parsed_records)
selected_groups = {name: all_groups[name] for name in selected_employees if name in all_groups}
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)
@@ -328,3 +487,10 @@ if "generated_files" in st.session_state and st.session_state.generated_files:
help="Ordner im Finder öffnen",
use_container_width=True,
)
if current_version_text:
st.markdown(
f'<div style="text-align:right; color:#888; font-size:0.8em; margin-top:2em;">'
f'Programmversion {current_version_text}</div>',
unsafe_allow_html=True,
)
+49
View File
@@ -0,0 +1,49 @@
"""Prüft auf neuere Programmversionen im zentralen SMB-Verzeichnis."""
import re
from pathlib import Path
UPDATE_DIR = Path(r"j:\Fachbereiche\BO\Produktionsschule\Maßnahmeunterlagen\20_Personal\05_Tätigkeitsnachweise ESF\Nachweise-automatisiert-generieren")
VERSION_PATTERN = re.compile(r"Tätigkeitsnachweis-Generator v(\d+)\.(\d+)\.(\d+)(?:\.\d+)?\.(zip|exe)$", re.IGNORECASE)
CHANGELOG_PATTERN = re.compile(r"^## \[(\d+)\.(\d+)(?:\.(\d+))?\]", re.MULTILINE)
def get_latest_version():
match = CHANGELOG_PATTERN.search(Path(__file__).with_name("CHANGELOG.md").read_text(encoding="utf-8"))
current = tuple(int(part or 0) for part in match.groups()) if match else (0, 0, 0)
try:
available = UPDATE_DIR.exists()
except OSError:
available = False
if not available:
return current, None
newer = []
try:
paths = UPDATE_DIR.iterdir()
for path in paths:
found = VERSION_PATTERN.fullmatch(path.name)
if found:
version = tuple(int(found.group(i)) for i in range(1, 4))
if version > current:
newer.append((version, path))
except OSError:
return current, None
if newer:
return current, max(newer)
return current, None
def main() -> int:
current, latest = get_latest_version()
print(f"[INFO] Lokale Version: v{'.'.join(map(str, current))}")
if latest:
version, path = latest
print(f"[HINWEIS] Neuere Version verfügbar: v{'.'.join(map(str, version))}")
print(f"[HINWEIS] Datei: {path}")
else:
print("[INFO] Keine neuere Version gefunden oder Update-Verzeichnis nicht erreichbar.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -1 +1 @@
1.2.2
1.3
+6
View File
@@ -222,6 +222,12 @@ def _prepare_build_dir() -> None:
encoding="utf-8",
)
# README.md aus 0_application/ kopieren
readme_src = APP_DIR / "README.md"
if readme_src.exists():
shutil.copy2(readme_src, BUILD_OUTPUT_DIR / "README.md")
print(" ✓ README.md nach 2_build/ kopiert")
print(f" ✓ 2_build/ vorbereitet")