From 41bab34d04a00c380dd698cda9ac6a0e716d5dd4 Mon Sep 17 00:00:00 2001 From: shahondin1624 Date: Thu, 9 Apr 2026 21:52:42 +0200 Subject: [PATCH] fix: improve warning box contrast and add XLSX converter script Warning summary boxes in the import wizard had white text on a light warning background, making them unreadable. Switch to a light-yellow background with dark text instead. Also adds convert_xlsx.py for converting the LvS member Excel spreadsheet into CSV files for ZIP bundle import, and documents the mandatory version bump rule in CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + appinfo/info.xml | 2 +- convert_xlsx.py | 418 +++++++++++++++++++++++++++++++++++++++++++ src/main.js | 2 +- src/views/Import.vue | 9 +- webpack.config.js | 2 +- 6 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 convert_xlsx.py diff --git a/CLAUDE.md b/CLAUDE.md index b7a76d0..a607b94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ These caused most bugs in this project. Every contributor must know them: - `make redeploy` = rebuild JS + copy into running container - After redeploy without version bump, restart container (`docker compose restart nextcloud`) and hard-refresh browser - Version bump (`appinfo/info.xml` + `webpack.config.js` DefinePlugin + `main.js` provide) + `occ upgrade` is the reliable way to bust all caches +- **Always bump the version when redeploying UI changes.** Without a version bump, Nextcloud serves cached JS/CSS and changes won't reach the browser — even with hard-refresh. Bump all three locations together. ## Gitea diff --git a/appinfo/info.xml b/appinfo/info.xml index 78b3f05..f71fe73 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ Mitgliederverwaltung Mitgliederverwaltung für Pfadfindervereine - 0.1.0 + 0.1.2 agpl shahondin1624 Mitgliederverwaltung diff --git a/convert_xlsx.py b/convert_xlsx.py new file mode 100644 index 0000000..ff9e7a1 --- /dev/null +++ b/convert_xlsx.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +""" +Convert 'Mitglieder LvS 25.xlsx' into CSV files suitable for ZIP bundle import +into the Mitgliederverwaltung Nextcloud plugin. + +Usage: + python3 convert_xlsx.py "/path/to/Mitglieder LvS 25.xlsx" [output_dir] + +Output (in output_dir, default ./import_bundle): + Stufen.csv, Familien.csv, Mitglieder.csv, Adressen.csv, + Telefonnummern.csv, E-Mails.csv, import_bundle.zip +""" + +import csv +import os +import re +import sys +import zipfile +from collections import defaultdict +from datetime import datetime + +import openpyxl + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +STUFEN_MAP = { + "W": "Wölflinge", + "P": "Pfadfinder", + "R": "Rover", +} + +STUFEN_META = { + "Wölflinge": {"sort_order": 1, "age_min": 7, "age_max": 10, "color": "#ff9800"}, + "Pfadfinder": {"sort_order": 2, "age_min": 11, "age_max": 16, "color": "#4caf50"}, + "Rover": {"sort_order": 3, "age_min": 16, "age_max": 25, "color": "#2196f3"}, +} + +CSV_DELIMITER = ";" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def fmt_date(val): + """Convert an Excel date value to YYYY-MM-DD string.""" + if val is None: + return "" + if isinstance(val, datetime): + return val.strftime("%Y-%m-%d") + s = str(val).strip() + if not s: + return "" + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y"): + try: + return datetime.strptime(s, fmt).strftime("%Y-%m-%d") + except ValueError: + continue + return s + + +def parse_address(raw): + """ + Parse 'Straße Nr, PLZ Ort' into (strasse, plz, ort). + Handles incomplete addresses gracefully. + """ + if not raw or str(raw).strip().lower() == "none": + return "", "", "" + raw = str(raw).strip() + # Try: "Street, PLZ City" + m = re.match(r"^(.+?),\s*(\d{5})\s+(.+)$", raw) + if m: + return m.group(1).strip(), m.group(2), m.group(3).strip() + # Try: "Street, City" (no PLZ) + m = re.match(r"^(.+?),\s*(.+)$", raw) + if m: + return m.group(1).strip(), "", m.group(2).strip() + return raw, "", "" + + +def clean(val): + """Stringify a cell value, stripping None.""" + if val is None: + return "" + return str(val).strip() + + +def write_csv(path, headers, rows): + """Write a semicolon-delimited UTF-8 CSV with BOM.""" + with open(path, "w", newline="", encoding="utf-8-sig") as f: + w = csv.writer(f, delimiter=CSV_DELIMITER) + w.writerow(headers) + w.writerows(rows) + print(f" {os.path.basename(path)}: {len(rows)} rows") + + +# --------------------------------------------------------------------------- +# Readers +# --------------------------------------------------------------------------- +def read_mitglieder_stamm(ws): + """ + Parse the 'Mitglieder Stamm' sheet. + Returns list of dicts with keys matching column semantics. + """ + members = [] + for row in ws.iter_rows(min_row=3, max_row=ws.max_row, values_only=True): + nachname = clean(row[1]) + if not nachname: + continue + members.append({ + "nachname": nachname, + "vorname": clean(row[2]), + "geburtsdatum": fmt_date(row[3]), + "stufe_code": clean(row[5]), # W / P / R + "adresse_raw": clean(row[6]), + "email": clean(row[7]), + "telefon": clean(row[8]), + "aktiv": clean(row[9]).lower() == "x", + "beitrag": clean(row[10]), + }) + return members + + +def read_kontaktdaten(ws): + """ + Parse a 'Kontaktdaten …' sheet. + Returns dict keyed by (nachname, vorname) → health info dict. + """ + info = {} + # Find header row (contains "Name") + header_row = None + for r in range(1, min(10, ws.max_row + 1)): + cell = ws.cell(r, 2).value + if cell and str(cell).strip().lower() == "name": + header_row = r + break + if header_row is None: + return info + + for row in ws.iter_rows(min_row=header_row + 1, max_row=ws.max_row, values_only=True): + nachname = clean(row[1]) + vorname = clean(row[2]) + if not nachname: + continue + allergien = clean(row[6]) + if allergien.upper() == "NEIN": + allergien = "" + krankheiten = clean(row[5]) + if krankheiten.upper() == "NEIN": + krankheiten = "" + medis = clean(row[7]) + if medis.upper() == "NEIN": + medis = "" + + notes_parts = [] + schwimmer = clean(row[4]) + if schwimmer.upper() == "JA": + notes_parts.append("Schwimmer: Ja") + elif schwimmer: + notes_parts.append(f"Schwimmer: {schwimmer}") + if krankheiten: + notes_parts.append(f"Krankheiten: {krankheiten}") + if medis: + notes_parts.append(f"Medikamente: {medis}") + + # Parent contact from right side of sheet (cols K-N, indices 11-13) + parent_name = "" + parent_phone = "" + if len(row) > 12: + parent_name = clean(row[12]) # col M: parent Vorname + if len(row) > 13: + parent_phone = clean(row[13]) # col N: parent Mobil + + info[(nachname, vorname)] = { + "allergien": allergien, + "notizen": "; ".join(notes_parts) if notes_parts else "", + "parent_nachname": clean(row[11]) if len(row) > 11 else "", + "parent_vorname": parent_name, + "parent_phone": parent_phone, + } + return info + + +def read_kontoliste(ws): + """ + Parse the 'Kontoliste' sheet. + Returns list of dicts: kontoinhaber, betrag, iban. + """ + accounts = [] + for row in ws.iter_rows(min_row=2, max_row=ws.max_row, values_only=True): + inhaber = clean(row[1]) + if not inhaber: + continue + accounts.append({ + "kontoinhaber": inhaber, + "betrag": clean(row[2]), + "iban": clean(row[3]), + }) + return accounts + + +# --------------------------------------------------------------------------- +# Family grouping +# --------------------------------------------------------------------------- +def build_families(members, accounts): + """ + Group members into families by shared last name + address. + Match to Kontoliste by last name. + Returns: + - families: list of {name, kontoinhaber, iban} + - member→family_name mapping + """ + # Group by (nachname, address) to handle same last name at different addresses + groups = defaultdict(list) + for m in members: + key = m["nachname"] + groups[key].append(m) + + # Build account lookup: last name → account + acct_by_name = {} + for a in accounts: + # Kontoinhaber is "Nachname Vorname" — extract last name + parts = a["kontoinhaber"].replace(",", " ").split() + if parts: + acct_by_name[parts[0].lower()] = a + + families = [] + member_family = {} # (nachname, vorname) → family name + + for nachname, group in sorted(groups.items()): + family_name = f"Familie {nachname}" + acct = acct_by_name.get(nachname.lower(), {}) + families.append({ + "name": family_name, + "kontoinhaber": acct.get("kontoinhaber", ""), + "iban": acct.get("iban", ""), + }) + for m in group: + member_family[(m["nachname"], m["vorname"])] = family_name + + return families, member_family + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + xlsx_path = sys.argv[1] + out_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.join(os.path.dirname(xlsx_path), "import_bundle") + os.makedirs(out_dir, exist_ok=True) + + print(f"Reading {xlsx_path} ...") + wb = openpyxl.load_workbook(xlsx_path, data_only=True) + + # --- Read source sheets --- + members = read_mitglieder_stamm(wb["Mitglieder Stamm"]) + print(f" {len(members)} members from 'Mitglieder Stamm'") + + kontakt_info = {} + for sheet_name in wb.sheetnames: + if sheet_name.startswith("Kontaktdaten"): + ki = read_kontaktdaten(wb[sheet_name]) + kontakt_info.update(ki) + print(f" {len(ki)} entries from '{sheet_name}'") + + accounts = read_kontoliste(wb["Kontoliste"]) + print(f" {len(accounts)} accounts from 'Kontoliste'") + + families, member_family = build_families(members, accounts) + print(f" {len(families)} families grouped") + + print(f"\nWriting CSVs to {out_dir} ...") + + # --- Stufen.csv --- + stufen_rows = [] + for code, name in sorted(STUFEN_MAP.items(), key=lambda x: STUFEN_META[x[1]]["sort_order"]): + meta = STUFEN_META[name] + stufen_rows.append([ + name, meta["sort_order"], meta["age_min"], meta["age_max"], meta["color"] + ]) + write_csv( + os.path.join(out_dir, "Stufen.csv"), + ["Name", "Sortierung", "Mindestalter", "Hoechstalter", "Farbe"], + stufen_rows, + ) + + # --- Familien.csv --- + fam_rows = [] + for f in families: + fam_rows.append([f["name"], f["kontoinhaber"], f["iban"]]) + write_csv( + os.path.join(out_dir, "Familien.csv"), + ["Name", "Kontoinhaber", "IBAN"], + fam_rows, + ) + + # --- Mitglieder.csv --- + mitglieder_rows = [] + for m in members: + key = (m["nachname"], m["vorname"]) + ki = kontakt_info.get(key, {}) + stufe_name = STUFEN_MAP.get(m["stufe_code"], "") + status = "aktiv" if m["aktiv"] else "inaktiv" + family_name = member_family.get(key, "") + + mitglieder_rows.append([ + m["vorname"], + m["nachname"], + m["geburtsdatum"], + "", # Geschlecht — not in source + "mitglied", # Rolle + stufe_name, + "2025-01-01", # Eintritt — not in source, use reasonable default + "", # Austritt + status, + ki.get("allergien", ""), + ki.get("notizen", ""), + "", # Zusaetzliche Notizen + "", # KV-Typ + "", # KV-Name + family_name, + m["beitrag"], # Eingefrorener Beitragssatz + ]) + write_csv( + os.path.join(out_dir, "Mitglieder.csv"), + [ + "Vorname", "Nachname", "Geburtsdatum", "Geschlecht", "Rolle", + "Stufenname", "Eintritt", "Austritt", "Status", "Allergien", + "Notizen", "Zusaetzliche Notizen", "KV-Typ", "KV-Name", + "Familienname", "Eingefrorener Beitragssatz", + ], + mitglieder_rows, + ) + + # --- Adressen.csv --- + addr_rows = [] + for m in members: + strasse, plz, ort = parse_address(m["adresse_raw"]) + if not strasse: + continue + mitgliedername = f"{m['vorname']} {m['nachname']}" + addr_rows.append([ + mitgliedername, "Hauptadresse", strasse, plz, ort, "Deutschland", "Ja" + ]) + write_csv( + os.path.join(out_dir, "Adressen.csv"), + ["Mitgliedername", "Label", "Strasse", "PLZ", "Ort", "Land", "Primaer"], + addr_rows, + ) + + # --- Telefonnummern.csv --- + phone_rows = [] + for m in members: + # Member's own phone + if m["telefon"]: + mitgliedername = f"{m['vorname']} {m['nachname']}" + phone_rows.append([mitgliedername, "Mobil", m["telefon"]]) + + # Parent phones from Kontaktdaten sheets + seen_parent_phones = set() + for (nachname, vorname), ki in kontakt_info.items(): + parent_phone = ki.get("parent_phone", "") + if not parent_phone: + continue + mitgliedername = f"{vorname} {nachname}" + # Split "number oder number" into separate entries + phones = [p.strip() for p in parent_phone.split("oder")] + for i, phone in enumerate(phones): + if not phone: + continue + dedup_key = (mitgliedername, phone) + if dedup_key in seen_parent_phones: + continue + seen_parent_phones.add(dedup_key) + label = f"Eltern {i+1}" if len(phones) > 1 else "Eltern" + phone_rows.append([mitgliedername, label, phone]) + + write_csv( + os.path.join(out_dir, "Telefonnummern.csv"), + ["Mitgliedername", "Label", "Nummer"], + phone_rows, + ) + + # --- E-Mails.csv --- + email_rows = [] + for m in members: + if m["email"]: + mitgliedername = f"{m['vorname']} {m['nachname']}" + email_rows.append([mitgliedername, "Privat", m["email"]]) + write_csv( + os.path.join(out_dir, "E-Mails.csv"), + ["Mitgliedername", "Label", "E-Mail-Adresse"], + email_rows, + ) + + # --- Create ZIP bundle --- + zip_path = os.path.join(out_dir, "import_bundle.zip") + csv_files = [ + "Stufen.csv", "Familien.csv", "Mitglieder.csv", + "Adressen.csv", "Telefonnummern.csv", "E-Mails.csv", + ] + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for name in csv_files: + fpath = os.path.join(out_dir, name) + if os.path.exists(fpath): + zf.write(fpath, name) + print(f"\n import_bundle.zip created ({len(csv_files)} files)") + + print(f"\nDone! Import via: Mitgliederverwaltung → Import → ZIP-Bundle hochladen") + print(f" {zip_path}") + + +if __name__ == "__main__": + main() diff --git a/src/main.js b/src/main.js index 1484469..5b71ea8 100644 --- a/src/main.js +++ b/src/main.js @@ -31,7 +31,7 @@ app.use(router) // @nextcloud/vue v9 reads appName/appVersion via Vue's inject(), // not via webpack DefinePlugin globals. app.provide('appName', 'mitgliederverwaltung') -app.provide('appVersion', '0.1.0') +app.provide('appVersion', '0.1.2') app.mount('#mitgliederverwaltung') diff --git a/src/views/Import.vue b/src/views/Import.vue index a020b6c..93a638f 100644 --- a/src/views/Import.vue +++ b/src/views/Import.vue @@ -947,17 +947,18 @@ async function onExecuteWithMerge() { } .import-wizard__summary-item--success { - background: var(--color-success); + background: var(--color-success, #2d7b41); color: white; } .import-wizard__summary-item--warning { - background: var(--color-warning); - color: white; + background: #fef3cd; + color: #664d03; + border: 1px solid #e9d78a; } .import-wizard__summary-item--error { - background: var(--color-error); + background: var(--color-error, #DB0606); color: white; } diff --git a/webpack.config.js b/webpack.config.js index ecdc68d..b74947a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -41,7 +41,7 @@ module.exports = { new VueLoaderPlugin(), new webpack.DefinePlugin({ appName: JSON.stringify('mitgliederverwaltung'), - appVersion: JSON.stringify('0.1.0'), + appVersion: JSON.stringify('0.1.2'), }), new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1,