Compare commits

...

11 Commits

Author SHA1 Message Date
0ab1807235 chore: vereinheitliche Imports und ersetze androidx.compose.foundation.Image durch Image im Veranstalter-Wizard
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 15:18:04 +02:00
7cfdd06d1e chore: integriere Logo-Upload und Vorschau in Veranstalter-Wizard, verbessere Navigationslogik und erweitere Datenmodelle
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 15:16:05 +02:00
544fbf792c chore: erweitere Veranstalter-Wizard um Bearbeitungsmodus, füge Kontaktdaten und Step-Logik hinzu
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 14:37:43 +02:00
18e619abfc chore: erweitere Datenmodelle um neue Felder, verbessere Styling und aktualisiere Veranstalter-UI
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 14:24:54 +02:00
5eeff24b3a chore: refaktoriere Veranstaltungs-Wizard zu Event-Wizard, entferne überflüssige Komponenten und passe DI-Konfiguration an
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 14:00:04 +02:00
f13c2eb35b chore: erweitere Datenmodelle um Nation und Bundesland, verbessere UI im Profil- und Veranstaltungs-Wizard
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 13:56:08 +02:00
2662d4e82e chore: erweitere Pferd-, Funktionär- und Reiter-Modelle um neue Felder, verbessere UI und Suche
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 13:47:37 +02:00
574f8c470c chore: refaktoriere Veranstaltungs-UI zu Events, implementiere ZNS-Suche und verbessere Navigationslogik
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 13:42:45 +02:00
9b4af2bb56 chore: füge Navigation zum Veranstalter-Wizard hinzu, erweitere Mock-Daten und verbessere Veranstaltungs-Flow
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 10:57:30 +02:00
1a295c18c8 chore: integriere Turnier-Wizard und ZNS-Importer in Veranstaltungsscreen, implementiere Profil-Onboarding und aktualisiere Modulabhängigkeiten
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 10:42:47 +02:00
01bf440f21 chore: behebe Kompilierungsfehler in ScreenPreviews.kt durch Implementierung von getStats() in Mock-Repos
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-21 09:41:57 +02:00
55 changed files with 2323 additions and 1016 deletions

View File

@ -34,6 +34,8 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
### Behoben ### Behoben
- **Frontend (Desktop):** Behebung von Kompilierungsfehlern in `ScreenPreviews.kt` durch Implementierung der fehlenden
`getStats()` Methode in den `MasterdataRepository`-Mocks.
- **Identity-Modul:** Umstellung auf `kotlin.time.Instant` zur Vermeidung von Deprecation-Warnungen und Behebung von - **Identity-Modul:** Umstellung auf `kotlin.time.Instant` zur Vermeidung von Deprecation-Warnungen und Behebung von
Persistenz-Konflikten im `ExposedDeviceRepository`. Persistenz-Konflikten im `ExposedDeviceRepository`.
- **Koin DI:** Korrektur von Typ-Inferenz-Fehlern beim `HttpClient` im `nennung-feature` durch explizite Qualifier. - **Koin DI:** Korrektur von Typ-Inferenz-Fehlern beim `HttpClient` im `nennung-feature` durch explizite Qualifier.

View File

@ -0,0 +1,83 @@
# Journal: 21. April 2026 - Abschluss der Vormittags-Session (Curator)
## 🏁 Session-Abschluss (12:00)
In dieser Session haben wir den Navigations-Flow massiv professionalisiert und die geforderte fachliche Tiefe in die Veranstaltungsanlage integriert. Weg von reinen "Fake-Daten", hin zu einem robusten, ZNS-gestützten Workflow.
### ✅ Erreichte Meilensteine
1. **Hybrid-Suche & ZNS-Fallback (SCS: Organizer):**
* Der `VeranstaltungWizard` durchsucht nun nicht mehr nur die lokale Datenbank, sondern bietet bei fehlenden Treffern einen automatischen Fallback auf die **ZNS-Stammdaten** an.
* Gefundene Vereine aus den Stammdaten können mit einem Klick als neuer Veranstalter in den Workflow übernommen werden.
2. **Profile-Onboarding Wizard (SCS: Identity):**
* Realisierung des `ProfileOnboardingWizard` (3 Steps: Suchen → Bestätigen → Verknüpfen).
* Dieser Wizard klärt die Identität des Benutzers (Satznummern-Check) vor der ersten Pferdesportlochen-Aktion.
* Nahtlose Integration in die Desktop-Shell (`ContentArea.kt`).
3. **Tiefe Turnier-Integration (SCS: Tournament):**
* Der `TurnierWizard` wurde vollständig nach ADR-0024 refactored und als Komponente in Schritt 5 des `VeranstaltungWizard` eingebettet.
* Die Child-ViewModel Injektion ermöglicht den konsistenten Datentransfer vom Turnier-Wizard zurück in die Veranstaltungsliste.
4. **Fachliche Validierung (§ 39 ÖTO) (SCS: Competition):**
* Implementierung einer dynamischen **Abteilungs-Vorschau** im Bewerbs-Wizard.
* Das System zeigt nun proaktiv die Schwellenwerte für Abteilungstrennungen (z. B. ab 35 Nennungen in Klasse S) an, basierend auf der gewählten Klasse.
5. **Stabilisierung & Robustheit:**
* Einführung von robustem UUID-Parsing mit Try-Catch Fallbacks für Mock-IDs ("v1", "v2").
* Beseitigung von "Dead-Ends" in der Navigation durch konsistentes Callback-Hoisting.
* **Navigations-Stabilisierung:** Behebung eines Fehlers in `DesktopApp.kt`, der Benutzer trotz vorhandener Konfiguration fälschlicherweise zum `DeviceInitialization`-Wizard umleitete.
* **Daten-Integrität:** Ergänzung der `settings.json` um Pflichtfelder (`syncInterval`), um die Validierung im `DeviceInitializationValidator` erfolgreich zu bestehen.
* **Logging-Transparenz:** Erweiterung der Navigations-Logs in `DesktopApp.kt` und `DesktopMainLayout.kt` zur besseren Rückverfolgbarkeit von Redirect-Entscheidungen.
* **Identity-Integration:** Hinzufügen des `Dashboard` Screens zur Ausnahmeliste des Authentifizierungs-Gates.
### 📋 Status der MASTER_ROADMAP
* **PHASE 13 (Erweitert):** Der "Veranstaltungs-Wizard" ist nun keine Wunschvorstellung mehr, sondern ein integrierter Prozess vom ZNS-Import über das Benutzer-Profil bis zur fachlich validierten Bewerbs-Anlage.
### 🚀 Nächste Schritte
Die Pferdesportliche Logik (§ 39) ist nun im Wizard sichtbar. Der nächste Schritt ist die **Live-Koppelung mit dem Nennungseingang**, um die Abteilungen basierend auf Realdaten (Nennungen) automatisch vorzuschlagen.
*Dokumentiert durch den Curator.*
### 🔧 Hotfix: Build-Stabilisierung & Navigations-Fix (12:15)
- Behebung von Kompilierungsfehlern im `ProfileOnboardingScreen.kt`:
- Korrektur der `MsTextField` `leadingIcon` Syntax (ImageVector statt Lambda).
- Auflösung von `firstName`/`lastName` Referenzfehlern durch Nutzung der ZNS-Reiterdaten (`vorname`/`nachname`).
- **Navigations-Fix:**
- Korrektur der `LaunchedEffect`-Logik in `DesktopMainLayout.kt` zur Vermeidung von automatischen Umleitungen zur `VeranstaltungVerwaltung`, die Stammdaten-Screens (Vereine, Reiter, etc.) blockierten.
- Erweiterung des Login-Gates in `DesktopApp.kt` um alle relevanten Stammdaten-Screens (`Vereine`, `Reiter`, `Pferde`, `Funktionäre` sowie deren Profil-Ansichten), um unerwünschte Redirects im Offline-Modus zu verhindern.
- Erfolgreiche Verifizierung durch Kompilierung des Desktop-Moduls.
### 🍱 Stammdaten-Refinement: Vorschau-Cards & Erweiterungen (13:50)
- **Modell-Erweiterungen:**
- `Reiter` & `Funktionaer`: Ergänzung der Felder `Nation` und `Bundesland`.
- `Pferd`: Einführung der `Kopfnummer`.
- `Funktionaer`: Konsolidierung der `Qualifikation`.
- **UI-Modernisierung:**
- Implementierung von `ReiterCardPreview`, `PferdCardPreview` und `FunktionaerCardPreview` zur schnellen Identifikation in der Detailansicht.
- Integration dieser Vorschau-Cards in die jeweiligen Screens (`Master-Detail-Layout`) sowie als Orientierungshilfe in den Editoren.
- Anpassung der Pferde-Liste um die Spalte `Kopf-Nr.`.
- **Suche & Performance:**
- Erweiterung der Pferde-Suche um die Kopfnummer im `PferdeViewModel`.
- Bereinigung von Compiler-Warnungen (unnecessary non-null assertions) in den neuen Screen-Komponenten.
- Erfolgreiche Verifizierung durch Kompilierung des Desktop-Moduls (`BUILD SUCCESSFUL`).
### 2026-04-21 15:00 - [Frontend Expert] & [Lead Architect]
* **Aktion:** Professionalisierung des Veranstalter-Wizards und Profil-Bearbeitung.
* **Ergebnis:**
* **Veranstalter-Domäne:** Erweiterung um Kontaktdaten (Telefon, E-Mail, Ansprechpartner, Adresse).
* **Veranstalter-Wizard:** Refactoring des Wizards zu einer vollwertigen Edit/Create-Komponente inklusive Detail-Erfassung (Step 2).
* **Repository-Update:** `FakeVeranstalterRepository` liefert nun vollständige Datensätze für alle Mock-Veranstalter.
* **UI-Integration:** "Bearbeiten"-Button im Veranstalter-Profil ist nun mit dem Wizard verknüpft; Änderungen werden via Repository persistiert.
* **Architektur:** Umstellung `VeranstalterDetailViewModel` auf das `VeranstalterRepository` (Eliminierung von In-View-Mocks).
* **Navigation:** Einführung der Route `VeranstalterProfilEdit` für gezielte Bearbeitungs-Sprints.
* **Vorschau & Logo:** Integration einer `VeranstalterCardPreview` und der `LogoUploadZone` im Veranstalter-Wizard zur optischen Verifikation und Branding-Unterstützung.
* **Status:** Der "Veranstalter-Wizard" ist nun fachlich fertiggestellt und ermöglicht die vollständige Verwaltung der Veranstalter-Stammdaten inkl. Logos.
### 2026-04-21 15:30 - [Lead Architect] & [Frontend Expert] - Navigations-Hotfix
* **Problem:** Unerwünschte Redirects im Offline-Modus (nicht authentifiziert) führten dazu, dass bei Klick auf "Veranstalter" oder andere Screens immer die "EventVerwaltung" (Dashboard) erzwungen wurde.
* **Lösung:**
* **DesktopApp.kt:** Erweiterung der Whitelist für erlaubte Screens im Offline-Modus um `VeranstalterVerwaltung`, `VeranstalterProfil`, `VeranstalterProfilEdit` und weitere. Die Whitelist wurde zudem in eine übersichtlichere Variable `isAllowedScreen` extrahiert.
* **DesktopMainLayout.kt:** Entfernung redundanter und störender Redirect-Logik im `LaunchedEffect`, die Screen-Wechsel fälschlicherweise als "Setup-Erfolg" interpretierte und zurück zum Dashboard sprang.
* **Ergebnis:** Die Sidebar-Navigation funktioniert nun konsistent; Benutzer landen auf dem Screen, den sie ausgewählt haben.
* **Verifizierung:** Erfolgreicher Build des Desktop-Moduls.

View File

@ -0,0 +1,112 @@
<mxfile host="Electron" modified="2026-04-21T11:20:00.000Z" agent="5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/21.6.8 Chrome/114.0.5735.289 Electron/25.5.0 Safari/537.36" version="21.6.8" type="device">
<diagram id="meldestelle-flow" name="Veranstaltungs-Flow">
<mxGraphModel dx="1422" dy="798" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- Screens (Main Container) -->
<mxCell id="screen_init" value="&lt;b&gt;DeviceInitializationScreen&lt;/b&gt;&lt;br/&gt;(Shell: Desktop)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="180" height="60" as="geometry" />
</mxCell>
<mxCell id="screen_cockpit" value="&lt;b&gt;VeranstaltungenScreen&lt;/b&gt;&lt;br/&gt;(Feature: Veranstaltung)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="180" height="60" as="geometry" />
</mxCell>
<mxCell id="screen_auswahl" value="&lt;b&gt;VeranstalterAuswahlScreen&lt;/b&gt;&lt;br/&gt;(Feature: Veranstalter)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="520" y="40" width="180" height="60" as="geometry" />
</mxCell>
<mxCell id="screen_wizard" value="&lt;b&gt;VeranstaltungWizardScreen&lt;/b&gt;&lt;br/&gt;(Feature: Veranstaltung)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;verticalAlign=top;align=left;spacingLeft=5;" vertex="1" parent="1">
<mxGeometry x="280" y="180" width="420" height="340" as="geometry" />
</mxCell>
<!-- Wizard Steps -->
<mxCell id="step_zns" value="1. ZnsCheckStep" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="300" y="220" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="step_selection" value="2. VeranstalterSelection" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="300" y="270" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="step_person" value="3. Ansprechperson" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="300" y="320" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="step_meta" value="4. MetaData" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="300" y="370" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="step_turnier" value="5. TurnierAnlage" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="300" y="420" width="120" height="40" as="geometry" />
</mxCell>
<mxCell id="step_summary" value="6. Summary" style="rounded=0;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="300" y="470" width="120" height="40" as="geometry" />
</mxCell>
<!-- Components & Sub-Wizards -->
<mxCell id="comp_turnier_wiz" value="&lt;b&gt;TurnierWizard&lt;/b&gt;&lt;br/&gt;(Injected UI)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="500" y="420" width="140" height="40" as="geometry" />
</mxCell>
<mxCell id="screen_v_neu" value="&lt;b&gt;VeranstalterAnlegenWizard&lt;/b&gt;&lt;br/&gt;(Feature: Veranstalter)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;" vertex="1" parent="1">
<mxGeometry x="760" y="270" width="180" height="60" as="geometry" />
</mxCell>
<!-- ViewModels & Logic -->
<mxCell id="vm_wizard" value="&lt;b&gt;VeranstaltungWizardViewModel&lt;/b&gt;&lt;br/&gt;State: WizardStep, veranstalterId, turniere&lt;br/&gt;Check: ZnsAvailability" style="ellipse;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;align=center;" vertex="1" parent="1">
<mxGeometry x="40" y="300" width="220" height="100" as="geometry" />
</mxCell>
<!-- Connectors -->
<mxCell id="edge1" value="Start" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" source="screen_init" target="screen_cockpit" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry" />
</mxCell>
<mxCell id="edge2" value="Neu" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" source="screen_cockpit" target="screen_auswahl" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry" />
</mxCell>
<mxCell id="edge3" value="ID vorhanden" style="endArrow=classic;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.75;entryY=0;entryDx=0;entryDy=0;" edge="1" source="screen_auswahl" target="screen_wizard" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry" />
</mxCell>
<mxCell id="edge4" value="Anlegen" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" source="screen_auswahl" target="screen_v_neu" parent="1">
<mxGeometry x="-0.3333" y="10" width="50" height="50" relative="1" as="geometry">
<mxPoint as="offset" />
</mxGeometry>
</mxCell>
<mxCell id="edge5" value="Hoisted Component" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;dashed=1;" edge="1" source="step_turnier" target="comp_turnier_wiz" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry" />
</mxCell>
<mxCell id="edge6" value="State Management" style="endArrow=classic;startArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" source="vm_wizard" target="screen_wizard" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry" />
</mxCell>
<mxCell id="edge7" value="Diesen Verein als neuen... (onClick)" style="endArrow=classic;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontColor=#FF0000;" edge="1" source="step_selection" target="screen_v_neu" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry" />
</mxCell>
<mxCell id="edge8" value="Created (Callback)" style="endArrow=classic;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" edge="1" source="screen_v_neu" target="step_selection" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<Array as="points">
<mxPoint x="850" y="380" />
<mxPoint x="480" y="380" />
<mxPoint x="480" y="290" />
</Array>
</mxGeometry>
</mxCell>
<!-- Info Labels -->
<mxCell id="info1" value="&lt;b&gt;CRASH-POINT:&lt;/b&gt;&lt;br/&gt;Uuid.parse() mit Fake-ID 'v1'&lt;br/&gt;(FIXED: FakeRepo nutzt jetzt UUIDs)" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=#b85450;fillColor=#f8cecc;fontColor=#FF0000;" vertex="1" parent="1">
<mxGeometry x="500" y="100" width="210" height="50" as="geometry" />
</mxCell>
<mxCell id="info2" value="&lt;b&gt;LOGGING:&lt;/b&gt;&lt;br/&gt;ContentArea loggt jetzt&lt;br/&gt;jeden Screen-Wechsel" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=#6c8ebf;fillColor=#dae8fc;" vertex="1" parent="1">
<mxGeometry x="40" y="120" width="160" height="50" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@ -62,7 +62,7 @@ fun MsFilterBar(
} }
} else null, } else null,
singleLine = true, singleLine = true,
textStyle = MaterialTheme.typography.bodySmall, textStyle = MaterialTheme.typography.bodySmall.copy(baselineShift = androidx.compose.ui.text.style.BaselineShift(0.2f)),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(

View File

@ -11,6 +11,7 @@ data class ZnsImportState(
val errorMessage: String? = null, val errorMessage: String? = null,
val isFinished: Boolean = false, val isFinished: Boolean = false,
val remoteResults: List<ZnsRemoteVerein> = emptyList(), val remoteResults: List<ZnsRemoteVerein> = emptyList(),
val remoteReiter: List<ZnsRemoteReiter> = emptyList(),
val isSearching: Boolean = false, val isSearching: Boolean = false,
val lastSyncVersion: String? = null, val lastSyncVersion: String? = null,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
@ -31,6 +32,8 @@ data class ZnsRemoteReiter(
val vorname: String, val vorname: String,
val lizenz: String?, val lizenz: String?,
val lizenzKlasse: String, val lizenzKlasse: String,
val nation: String? = "AUT",
val bundesland: String? = null,
) )
data class ZnsRemotePferd( data class ZnsRemotePferd(
@ -47,6 +50,8 @@ data class ZnsRemoteFunktionaer(
val satzNummer: Int, val satzNummer: Int,
val name: String?, val name: String?,
val qualifikationen: List<String>, val qualifikationen: List<String>,
val nation: String? = "AUT",
val bundesland: String? = null,
) )
interface ZnsImportProvider { interface ZnsImportProvider {

View File

@ -13,12 +13,13 @@ sealed class AppScreen(val route: String) {
data object ConnectivityCheck : AppScreen("/ping") data object ConnectivityCheck : AppScreen("/ping")
data object Profile : AppScreen("/profile") data object Profile : AppScreen("/profile")
data object ProfileOnboarding : AppScreen("/profile/onboarding")
data object OrganizerProfile : AppScreen("/organizer/profile") data object OrganizerProfile : AppScreen("/organizer/profile")
data object AuthCallback : AppScreen("/auth/callback") data object AuthCallback : AppScreen("/auth/callback")
data object EntryManagement : AppScreen("/nennung") data object EntryManagement : AppScreen("/nennung")
// --- Desktop-Navigation (Vision_03) --- // --- Desktop-Navigation (Vision_03) ---
data object VeranstaltungVerwaltung : AppScreen("/verwaltung") // Gesamtübersicht data object EventVerwaltung : AppScreen("/event/verwaltung") // Gesamtübersicht
// Profile // Profile
data object PferdVerwaltung : AppScreen("/pferde/verwaltung") data object PferdVerwaltung : AppScreen("/pferde/verwaltung")
@ -35,6 +36,7 @@ sealed class AppScreen(val route: String) {
data object VeranstalterVerwaltung : AppScreen("/veranstalter/verwaltung") data object VeranstalterVerwaltung : AppScreen("/veranstalter/verwaltung")
data class VeranstalterProfil(val id: Long) : AppScreen("/veranstalter/profil/$id") data class VeranstalterProfil(val id: Long) : AppScreen("/veranstalter/profil/$id")
data class VeranstalterProfilEdit(val id: Long) : AppScreen("/veranstalter/profil/$id/edit")
// data class VeranstaltungProfil(val id: Long) : AppScreen("/veranstaltung/profil/$id") // data class VeranstaltungProfil(val id: Long) : AppScreen("/veranstaltung/profil/$id")
@ -44,20 +46,20 @@ sealed class AppScreen(val route: String) {
data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId") data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId")
// Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail oder direkt aus Cockpit) // Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail oder direkt aus Cockpit)
data class VeranstaltungKonfig(val veranstalterId: Long = 0) : data class EventKonfig(val veranstalterId: Long = 0) :
AppScreen("/veranstalter/$veranstalterId/veranstaltung/neu") AppScreen("/veranstalter/$veranstalterId/event/neu")
data class VeranstaltungProfil(val veranstalterId: Long, val veranstaltungId: Long) : data class EventProfil(val veranstalterId: Long, val veranstaltungId: Long) :
AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId") AppScreen("/veranstalter/$veranstalterId/event/$veranstaltungId")
data class VeranstaltungDetail(val id: Long) : AppScreen("/veranstaltung/$id") data class EventDetail(val id: Long) : AppScreen("/event/$id")
data object VeranstaltungNeu : AppScreen("/veranstaltung/neu") data class EventNeu(val veranstalterId: Long? = null) : AppScreen("/event/neu")
data class TurnierDetail(val veranstaltungId: Long, val turnierId: Long) : data class TurnierDetail(val veranstaltungId: Long, val turnierId: Long) :
AppScreen("/veranstaltung/$veranstaltungId/turnier/$turnierId") AppScreen("/event/$veranstaltungId/turnier/$turnierId")
data class TurnierNeu(val veranstaltungId: Long) : AppScreen("/veranstaltung/$veranstaltungId/turnier/neu") data class TurnierNeu(val veranstaltungId: Long) : AppScreen("/event/$veranstaltungId/turnier/neu")
data class Billing(val veranstaltungId: Long, val turnierId: Long) : data class Billing(val veranstaltungId: Long, val turnierId: Long) :
AppScreen("/veranstaltung/$veranstaltungId/turnier/$turnierId/billing") AppScreen("/event/$veranstaltungId/turnier/$turnierId/billing")
data object Reiter : AppScreen("/reiter") data object Reiter : AppScreen("/reiter")
data object Pferde : AppScreen("/pferde") data object Pferde : AppScreen("/pferde")
@ -68,20 +70,21 @@ sealed class AppScreen(val route: String) {
data object NennungsEingang : AppScreen("/nennungs-eingang") data object NennungsEingang : AppScreen("/nennungs-eingang")
companion object { companion object {
private val VERANSTALTUNG_DETAIL = Regex("/veranstaltung/(\\d+)$") private val EVENT_DETAIL = Regex("/event/(\\d+)$")
private val TURNIER_DETAIL = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)$") private val TURNIER_DETAIL = Regex("/event/(\\d+)/turnier/(\\d+)$")
private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$") private val TURNIER_NEU = Regex("/event/(\\d+)/turnier/neu$")
private val BILLING = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)/billing$") private val BILLING = Regex("/event/(\\d+)/turnier/(\\d+)/billing$")
private val VERANSTALTER_DETAIL = Regex("/veranstalter/(\\d+)$") private val VERANSTALTER_DETAIL = Regex("/veranstalter/(\\d+)$")
private val VERANSTALTUNG_KONFIG = Regex("/veranstalter/(\\d+)/veranstaltung/neu$") private val EVENT_KONFIG = Regex("/veranstalter/(\\d+)/event/neu$")
private val VERANSTALTUNG_PROFIL = Regex("/veranstalter/(\\d+)/veranstaltung/(\\d+)$") private val EVENT_PROFIL = Regex("/veranstalter/(\\d+)/event/(\\d+)$")
private val PFERD_PROFIL = Regex("/pferde/profil/(\\d+)$") private val PFERD_PROFIL = Regex("/pferde/profil/(\\d+)$")
private val REITER_PROFIL = Regex("/reiter/profil/(\\d+)$") private val REITER_PROFIL = Regex("/reiter/profil/(\\d+)$")
private val VEREIN_PROFIL = Regex("/vereine/profil/(\\d+)$") private val VEREIN_PROFIL = Regex("/vereine/profil/(\\d+)$")
private val FUNKTIONAER_PROFIL = Regex("/funktionaere/profil/(\\d+)$") private val FUNKTIONAER_PROFIL = Regex("/funktionaere/profil/(\\d+)$")
private val VERANSTALTER_PROFIL = Regex("/veranstalter/profil/(\\d+)$") private val VERANSTALTER_PROFIL = Regex("/veranstalter/profil/(\\d+)$")
// private val VERANSTALTUNG_PROFIL_LEGACY = Regex("/veranstaltung/profil/(\\d+)$") private val VERANSTALTER_PROFIL_EDIT = Regex("/veranstalter/profil/(\\d+)/edit$")
private val EVENT_NEU = Regex("/event/neu(\\?veranstalterId=(\\d+))?$")
fun fromRoute(route: String): AppScreen { fun fromRoute(route: String): AppScreen {
return when (route) { return when (route) {
@ -93,22 +96,27 @@ sealed class AppScreen(val route: String) {
Routes.LOGIN, Routes.Auth.LOGIN -> Login() Routes.LOGIN, Routes.Auth.LOGIN -> Login()
"/ping" -> ConnectivityCheck "/ping" -> ConnectivityCheck
"/profile" -> Profile "/profile" -> Profile
"/profile/onboarding" -> ProfileOnboarding
"/organizer/profile" -> OrganizerProfile "/organizer/profile" -> OrganizerProfile
"/auth/callback" -> AuthCallback "/auth/callback" -> AuthCallback
"/nennung" -> EntryManagement "/nennung" -> EntryManagement
"/verwaltung" -> VeranstaltungVerwaltung "/event/verwaltung" -> EventVerwaltung
"/pferde/verwaltung" -> PferdVerwaltung "/pferde/verwaltung" -> PferdVerwaltung
"/reiter/verwaltung" -> ReiterVerwaltung "/reiter/verwaltung" -> ReiterVerwaltung
"/vereine/verwaltung" -> VereinVerwaltung "/vereine/verwaltung" -> VereinVerwaltung
"/funktionaere/verwaltung" -> FunktionaerVerwaltung "/funktionaere/verwaltung" -> FunktionaerVerwaltung
"/veranstalter/verwaltung" -> VeranstalterVerwaltung "/veranstalter/verwaltung" -> VeranstalterVerwaltung
"/veranstalter/auswahl" -> VeranstalterAuswahl "/veranstalter/auswahl" -> VeranstalterAuswahl
"/veranstaltung/neu" -> VeranstaltungNeu "/event/neu" -> EventNeu()
"/meisterschaften" -> Meisterschaften "/meisterschaften" -> Meisterschaften
"/cups" -> Cups "/cups" -> Cups
"/stammdaten/import" -> StammdatenImport "/stammdaten/import" -> StammdatenImport
"/nennungs-eingang" -> NennungsEingang "/nennungs-eingang" -> NennungsEingang
else -> { else -> {
EVENT_NEU.matchEntire(route)?.let { match ->
val vId = match.groups[2]?.value?.toLong()
return EventNeu(vId)
}
BILLING.matchEntire(route)?.destructured?.let { (vId, tId) -> BILLING.matchEntire(route)?.destructured?.let { (vId, tId) ->
return Billing(vId.toLong(), tId.toLong()) return Billing(vId.toLong(), tId.toLong())
} }
@ -117,8 +125,9 @@ sealed class AppScreen(val route: String) {
VEREIN_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VereinProfil(id.toLong()) } VEREIN_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VereinProfil(id.toLong()) }
FUNKTIONAER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return FunktionaerProfil(id.toLong()) } FUNKTIONAER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return FunktionaerProfil(id.toLong()) }
VERANSTALTER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VeranstalterProfil(id.toLong()) } VERANSTALTER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VeranstalterProfil(id.toLong()) }
VERANSTALTER_PROFIL_EDIT.matchEntire(route)?.destructured?.let { (id) -> return VeranstalterProfilEdit(id.toLong()) }
/* /*
VERANSTALTUNG_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VeranstaltungProfil(id.toLong()) } EVENT_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return EventProfil(id.toLong()) }
*/ */
TURNIER_DETAIL.matchEntire(route)?.destructured?.let { (vId, tId) -> TURNIER_DETAIL.matchEntire(route)?.destructured?.let { (vId, tId) ->
@ -127,17 +136,17 @@ sealed class AppScreen(val route: String) {
TURNIER_NEU.matchEntire(route)?.destructured?.let { (vId) -> TURNIER_NEU.matchEntire(route)?.destructured?.let { (vId) ->
return TurnierNeu(vId.toLong()) return TurnierNeu(vId.toLong())
} }
VERANSTALTUNG_DETAIL.matchEntire(route)?.destructured?.let { (id) -> EVENT_DETAIL.matchEntire(route)?.destructured?.let { (id) ->
return VeranstaltungDetail(id.toLong()) return EventDetail(id.toLong())
} }
VERANSTALTER_DETAIL.matchEntire(route)?.destructured?.let { (vId) -> VERANSTALTER_DETAIL.matchEntire(route)?.destructured?.let { (vId) ->
return VeranstalterDetail(vId.toLong()) return VeranstalterDetail(vId.toLong())
} }
VERANSTALTUNG_KONFIG.matchEntire(route)?.destructured?.let { (vId) -> EVENT_KONFIG.matchEntire(route)?.destructured?.let { (vId) ->
return VeranstaltungKonfig(vId.toLong()) return EventKonfig(vId.toLong())
} }
VERANSTALTUNG_PROFIL.matchEntire(route)?.destructured?.let { (verId, vId) -> EVENT_PROFIL.matchEntire(route)?.destructured?.let { (verId, vId) ->
return VeranstaltungProfil(verId.toLong(), vId.toLong()) return EventProfil(verId.toLong(), vId.toLong())
} }
PortalDashboard // Default fallback PortalDashboard // Default fallback
} }

View File

@ -11,5 +11,8 @@ data class Funktionaer(
val email: String? = null, val email: String? = null,
val telefon: String? = null, val telefon: String? = null,
val vereinsNummer: String? = null, val vereinsNummer: String? = null,
val nation: String? = "AUT",
val bundesland: String? = null,
val qualifikation: String? = null,
val istAktiv: Boolean = true val istAktiv: Boolean = true
) )

View File

@ -1,15 +1,19 @@
package at.mocode.frontend.features.funktionaer.presentation package at.mocode.frontend.features.funktionaer.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.* import at.mocode.frontend.core.designsystem.components.*
@ -40,14 +44,21 @@ fun FunktionaerScreen(
onRichterNummerChange = { viewModel.send(FunktionaerIntent.EditRichterNummer(it)) }, onRichterNummerChange = { viewModel.send(FunktionaerIntent.EditRichterNummer(it)) },
onEmailChange = { viewModel.send(FunktionaerIntent.EditEmail(it)) }, onEmailChange = { viewModel.send(FunktionaerIntent.EditEmail(it)) },
onTelefonChange = { viewModel.send(FunktionaerIntent.EditTelefon(it)) }, onTelefonChange = { viewModel.send(FunktionaerIntent.EditTelefon(it)) },
onNationChange = { viewModel.send(FunktionaerIntent.EditNation(it)) },
onBundeslandChange = { viewModel.send(FunktionaerIntent.EditBundesland(it)) },
onQualifikationChange = { viewModel.send(FunktionaerIntent.EditQualifikation(it)) },
onSave = { viewModel.send(FunktionaerIntent.Save) }, onSave = { viewModel.send(FunktionaerIntent.Save) },
onCancel = { viewModel.send(FunktionaerIntent.Cancel) } onCancel = { viewModel.send(FunktionaerIntent.Cancel) }
) )
} else if (state.selectedFunktionaer != null) { } else if (state.selectedFunktionaer != null) {
Column(Modifier.fillMaxSize()) {
FunktionaerCardPreview(funktionaer = state.selectedFunktionaer!!)
Spacer(Modifier.height(16.dp))
FunktionaerCard( FunktionaerCard(
funktionaer = state.selectedFunktionaer!!, funktionaer = state.selectedFunktionaer!!,
onEdit = { viewModel.send(FunktionaerIntent.Select(state.selectedFunktionaer)) } onEdit = { viewModel.send(FunktionaerIntent.Select(state.selectedFunktionaer)) }
) )
}
} else { } else {
PlaceholderContent( PlaceholderContent(
title = "Kein Funktionär ausgewählt", title = "Kein Funktionär ausgewählt",
@ -123,61 +134,26 @@ fun FunktionaerCard(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(), modifier = Modifier.fillMaxWidth().wrapContentHeight()
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) { ) {
Column(modifier = Modifier.padding(24.dp)) { Column(modifier = Modifier.padding(24.dp)) {
Row( Row(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(), FunktionaerDetailItem(label = "Richter-Nr.", value = funktionaer.richterNummer ?: "-", modifier = Modifier.weight(1f))
horizontalArrangement = Arrangement.SpaceBetween, FunktionaerDetailItem(label = "Rollen", value = funktionaer.rollen.joinToString(", "), modifier = Modifier.weight(1f))
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(48.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Gavel,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(Modifier.width(16.dp))
Column {
Text(
"${funktionaer.vorname} ${funktionaer.nachname}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
"Richter-Nr: ${funktionaer.richterNummer ?: "-"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
MsStatusBadge( Spacer(Modifier.height(16.dp))
text = if (funktionaer.istAktiv) "Aktiv" else "Inaktiv",
containerColor = (if (funktionaer.istAktiv) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error).copy(alpha = 0.1f),
contentColor = if (funktionaer.istAktiv) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
FunktionaerDetailItem(label = "Rollen", value = funktionaer.rollen.joinToString(", "), modifier = Modifier.weight(1f)) FunktionaerDetailItem(label = "Qualifikation", value = funktionaer.qualifikation ?: "-", modifier = Modifier.weight(1f))
FunktionaerDetailItem(label = "Qualifikation", value = funktionaer.richterQualifikation ?: "-", modifier = Modifier.weight(1f)) FunktionaerDetailItem(label = "Sparte(n)", value = funktionaer.qualifiziertFuerSparten.joinToString(", ").ifBlank { "-" }, modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
FunktionaerDetailItem(label = "Nation", value = funktionaer.nation ?: "-", modifier = Modifier.weight(1f))
FunktionaerDetailItem(label = "Bundesland", value = funktionaer.bundesland ?: "-", modifier = Modifier.weight(1f))
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@ -199,6 +175,50 @@ fun FunktionaerCard(
} }
} }
@Composable
fun FunktionaerCardPreview(funktionaer: Funktionaer) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Person, null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("${funktionaer.vorname} ${funktionaer.nachname}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
MsStatusBadge(
text = if (funktionaer.istAktiv) "Aktiv" else "Inaktiv",
containerColor = (if (funktionaer.istAktiv) Color(0xFF2E7D32) else Color.Gray).copy(alpha = 0.1f),
contentColor = if (funktionaer.istAktiv) Color(0xFF2E7D32) else Color.Gray
)
}
Text(
text = buildString {
append("Nr: ${funktionaer.richterNummer ?: "-"}")
if (!funktionaer.nation.isNullOrBlank()) append(" | ${funktionaer.nation}")
if (!funktionaer.bundesland.isNullOrBlank()) append(" (${funktionaer.bundesland})")
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable @Composable
private fun FunktionaerDetailItem(label: String, value: String, modifier: Modifier = Modifier) { private fun FunktionaerDetailItem(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -215,17 +235,26 @@ private fun FunktionaerEditorContent(
onRichterNummerChange: (String) -> Unit, onRichterNummerChange: (String) -> Unit,
onEmailChange: (String) -> Unit, onEmailChange: (String) -> Unit,
onTelefonChange: (String) -> Unit, onTelefonChange: (String) -> Unit,
onNationChange: (String) -> Unit,
onBundeslandChange: (String) -> Unit,
onQualifikationChange: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
MsActionToolbar( MsActionToolbar(
title = "Funktionär Details", title = if (state.selectedFunktionaer == null) "Funktionär anlegen" else "Funktionär Details",
onSave = onSave, onSave = onSave,
onCancel = onCancel onCancel = onCancel
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(16.dp))
// Preview in Editor
if (state.selectedFunktionaer != null) {
FunktionaerCardPreview(funktionaer = state.selectedFunktionaer)
Spacer(Modifier.height(16.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField( MsTextField(
@ -246,13 +275,41 @@ private fun FunktionaerEditorContent(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField( MsTextField(
value = state.editRichterNummer, value = state.editRichterNummer,
onValueChange = onRichterNummerChange, onValueChange = onRichterNummerChange,
label = "Richter-Nummer", label = "Richter-Nummer",
modifier = Modifier.width(300.dp), modifier = Modifier.weight(1f),
compact = true compact = true
) )
MsTextField(
value = state.editQualifikation,
onValueChange = onQualifikationChange,
label = "Qualifikation",
modifier = Modifier.weight(1f),
compact = true
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = state.editNation,
onValueChange = onNationChange,
label = "Nation",
modifier = Modifier.weight(1f),
compact = true
)
MsTextField(
value = state.editBundesland,
onValueChange = onBundeslandChange,
label = "Bundesland",
modifier = Modifier.weight(1f),
compact = true
)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))

View File

@ -1,8 +1,8 @@
package at.mocode.frontend.features.funktionaer.presentation package at.mocode.frontend.features.funktionaer.presentation
import at.mocode.frontend.features.funktionaer.domain.Funktionaer
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.funktionaer.domain.Funktionaer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -26,6 +26,9 @@ data class FunktionaerState(
val editRichterNummer: String = "", val editRichterNummer: String = "",
val editEmail: String = "", val editEmail: String = "",
val editTelefon: String = "", val editTelefon: String = "",
val editNation: String = "AUT",
val editBundesland: String = "",
val editQualifikation: String = "",
val errorMessage: String? = null, val errorMessage: String? = null,
) )
@ -40,6 +43,9 @@ sealed interface FunktionaerIntent {
data class EditRichterNummer(val value: String) : FunktionaerIntent data class EditRichterNummer(val value: String) : FunktionaerIntent
data class EditEmail(val value: String) : FunktionaerIntent data class EditEmail(val value: String) : FunktionaerIntent
data class EditTelefon(val value: String) : FunktionaerIntent data class EditTelefon(val value: String) : FunktionaerIntent
data class EditNation(val value: String) : FunktionaerIntent
data class EditBundesland(val value: String) : FunktionaerIntent
data class EditQualifikation(val value: String) : FunktionaerIntent
data object Save : FunktionaerIntent data object Save : FunktionaerIntent
data object Cancel : FunktionaerIntent data object Cancel : FunktionaerIntent
data object ClearError : FunktionaerIntent data object ClearError : FunktionaerIntent
@ -69,7 +75,10 @@ class FunktionaerViewModel(
editNachname = intent.funktionaer?.nachname ?: "", editNachname = intent.funktionaer?.nachname ?: "",
editRichterNummer = intent.funktionaer?.richterNummer ?: "", editRichterNummer = intent.funktionaer?.richterNummer ?: "",
editEmail = intent.funktionaer?.email ?: "", editEmail = intent.funktionaer?.email ?: "",
editTelefon = intent.funktionaer?.telefon ?: "" editTelefon = intent.funktionaer?.telefon ?: "",
editNation = intent.funktionaer?.nation ?: "AUT",
editBundesland = intent.funktionaer?.bundesland ?: "",
editQualifikation = intent.funktionaer?.qualifikation ?: ""
) )
} }
@ -81,7 +90,10 @@ class FunktionaerViewModel(
editNachname = "", editNachname = "",
editRichterNummer = "", editRichterNummer = "",
editEmail = "", editEmail = "",
editTelefon = "" editTelefon = "",
editNation = "AUT",
editBundesland = "",
editQualifikation = ""
) )
} }
@ -90,6 +102,9 @@ class FunktionaerViewModel(
is FunktionaerIntent.EditRichterNummer -> reduce { it.copy(editRichterNummer = intent.value) } is FunktionaerIntent.EditRichterNummer -> reduce { it.copy(editRichterNummer = intent.value) }
is FunktionaerIntent.EditEmail -> reduce { it.copy(editEmail = intent.value) } is FunktionaerIntent.EditEmail -> reduce { it.copy(editEmail = intent.value) }
is FunktionaerIntent.EditTelefon -> reduce { it.copy(editTelefon = intent.value) } is FunktionaerIntent.EditTelefon -> reduce { it.copy(editTelefon = intent.value) }
is FunktionaerIntent.EditNation -> reduce { it.copy(editNation = intent.value) }
is FunktionaerIntent.EditBundesland -> reduce { it.copy(editBundesland = intent.value) }
is FunktionaerIntent.EditQualifikation -> reduce { it.copy(editQualifikation = intent.value) }
is FunktionaerIntent.Save -> reduce { it.copy(isEditing = false) } is FunktionaerIntent.Save -> reduce { it.copy(isEditing = false) }
is FunktionaerIntent.Cancel -> reduce { it.copy(isEditing = false) } is FunktionaerIntent.Cancel -> reduce { it.copy(isEditing = false) }
is FunktionaerIntent.ClearError -> reduce { it.copy(errorMessage = null) } is FunktionaerIntent.ClearError -> reduce { it.copy(errorMessage = null) }

View File

@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.Color
data class Pferd( data class Pferd(
val id: String, val id: String,
val name: String, val name: String,
val kopfNummer: String? = null,
val lebensnummer: String, val lebensnummer: String,
val geschlecht: Geschlecht = Geschlecht.WALLACH, val geschlecht: Geschlecht = Geschlecht.WALLACH,
val farbe: String = "", val farbe: String = "",

View File

@ -1,6 +1,8 @@
package at.mocode.frontend.features.pferde.presentation package at.mocode.frontend.features.pferde.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Pets import androidx.compose.material.icons.filled.Pets
@ -8,6 +10,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.* import at.mocode.frontend.core.designsystem.components.*
@ -36,6 +39,7 @@ fun PferdeScreen(
PferdeEditorContent( PferdeEditorContent(
uiState = uiState, uiState = uiState,
onNameChange = viewModel::onEditNameChange, onNameChange = viewModel::onEditNameChange,
onKopfNummerChange = viewModel::onEditKopfNummerChange,
onLebensnummerChange = viewModel::onEditLebensnummerChange, onLebensnummerChange = viewModel::onEditLebensnummerChange,
onGeschlechtChange = viewModel::onEditGeschlechtChange, onGeschlechtChange = viewModel::onEditGeschlechtChange,
onFarbeChange = viewModel::onEditFarbeChange, onFarbeChange = viewModel::onEditFarbeChange,
@ -48,10 +52,14 @@ fun PferdeScreen(
onCancel = viewModel::onCancel onCancel = viewModel::onCancel
) )
} else if (uiState.selectedPferd != null) { } else if (uiState.selectedPferd != null) {
Column(Modifier.fillMaxSize()) {
PferdCardPreview(pferd = uiState.selectedPferd)
Spacer(Modifier.height(16.dp))
PferdCard( PferdCard(
pferd = uiState.selectedPferd, pferd = uiState.selectedPferd,
onEdit = { viewModel.selectPferd(uiState.selectedPferd) } onEdit = { viewModel.selectPferd(uiState.selectedPferd) }
) )
}
} else { } else {
PlaceholderContent( PlaceholderContent(
title = "Kein Pferd ausgewählt", title = "Kein Pferd ausgewählt",
@ -88,6 +96,11 @@ private fun PferdeListContent(
MsDataTable( MsDataTable(
items = uiState.searchResults, items = uiState.searchResults,
columns = listOf( columns = listOf(
MsColumnDefinition(
title = "Kopf-Nr.",
width = 80.dp,
cellRenderer = { Text(it.kopfNummer ?: "-", style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition( MsColumnDefinition(
title = "Name", title = "Name",
weight = 1f, weight = 1f,
@ -127,61 +140,12 @@ fun PferdCard(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(), modifier = Modifier.fillMaxWidth().wrapContentHeight()
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) { ) {
Column(modifier = Modifier.padding(24.dp)) { Column(modifier = Modifier.padding(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(48.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Pets,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(Modifier.width(16.dp))
Column {
Text(
pferd.name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
pferd.lebensnummer,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
MsStatusBadge(
text = pferd.status.label,
containerColor = pferd.status.color.copy(alpha = 0.1f),
contentColor = pferd.status.color
)
}
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
DetailItem(label = "ÖPS-Nr.", value = pferd.oepsNummer ?: "-", modifier = Modifier.weight(1f)) DetailItem(label = "Kopf-Nummer", value = pferd.kopfNummer ?: "-", modifier = Modifier.weight(1f))
DetailItem(label = "FEI-ID", value = pferd.feiId ?: "-", modifier = Modifier.weight(1f)) DetailItem(label = "Lebensnummer", value = pferd.lebensnummer, modifier = Modifier.weight(1f))
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@ -195,15 +159,62 @@ fun PferdCard(
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
DetailItem(label = "Geburtsjahr", value = pferd.geburtsjahr?.toString() ?: "-", modifier = Modifier.weight(1f)) DetailItem(label = "Geburtsjahr", value = pferd.geburtsjahr?.toString() ?: "-", modifier = Modifier.weight(1f))
DetailItem(label = "ÖPS-Nr.", value = pferd.oepsNummer ?: "-", modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
DetailItem(label = "FEI-ID", value = pferd.feiId ?: "-", modifier = Modifier.weight(1f))
DetailItem(label = "Besitzer", value = pferd.besitzer ?: "-", modifier = Modifier.weight(1f)) DetailItem(label = "Besitzer", value = pferd.besitzer ?: "-", modifier = Modifier.weight(1f))
} }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
MsButton( MsButton(
onClick = onEdit,
text = "Pferdedaten bearbeiten", text = "Pferdedaten bearbeiten",
modifier = Modifier.fillMaxWidth() onClick = onEdit,
fullWidth = true
)
}
}
}
}
@Composable
fun PferdCardPreview(pferd: Pferd) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Pets, null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(pferd.name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
MsStatusBadge(
text = pferd.status.label,
containerColor = pferd.status.color.copy(alpha = 0.1f),
contentColor = pferd.status.color
)
}
Text(
text = "Kopf-Nr: ${pferd.kopfNummer ?: "-"} | Lebensnummer: ${pferd.lebensnummer}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
@ -222,6 +233,7 @@ private fun DetailItem(label: String, value: String, modifier: Modifier = Modifi
private fun PferdeEditorContent( private fun PferdeEditorContent(
uiState: PferdeUiState, uiState: PferdeUiState,
onNameChange: (String) -> Unit, onNameChange: (String) -> Unit,
onKopfNummerChange: (String) -> Unit,
onLebensnummerChange: (String) -> Unit, onLebensnummerChange: (String) -> Unit,
onGeschlechtChange: (Geschlecht) -> Unit, onGeschlechtChange: (Geschlecht) -> Unit,
onFarbeChange: (String) -> Unit, onFarbeChange: (String) -> Unit,
@ -233,23 +245,41 @@ private fun PferdeEditorContent(
onSave: () -> Unit, onSave: () -> Unit,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
MsActionToolbar( MsActionToolbar(
title = "Pferde Details", title = if (uiState.selectedPferd == null) "Pferd anlegen" else "Pferde Details",
onSave = onSave, onSave = onSave,
onCancel = onCancel onCancel = onCancel
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(16.dp))
// Preview in Editor
if (uiState.selectedPferd != null) {
PferdCardPreview(pferd = uiState.selectedPferd)
Spacer(Modifier.height(16.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField( MsTextField(
value = uiState.editName, value = uiState.editName,
onValueChange = onNameChange, onValueChange = onNameChange,
label = "Name", label = "Pferdename",
modifier = Modifier.weight(1.5f),
compact = true
)
MsTextField(
value = uiState.editKopfNummer,
onValueChange = onKopfNummerChange,
label = "Kopf-Nummer",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
compact = true compact = true
) )
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField( MsTextField(
value = uiState.editLebensnummer, value = uiState.editLebensnummer,
onValueChange = onLebensnummerChange, onValueChange = onLebensnummerChange,
@ -257,6 +287,13 @@ private fun PferdeEditorContent(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
compact = true compact = true
) )
MsTextField(
value = uiState.editOepsNummer,
onValueChange = onOepsNummerChange,
label = "ÖPS Nummer",
modifier = Modifier.weight(1f),
compact = true
)
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))

View File

@ -26,7 +26,8 @@ data class PferdeUiState(
val editStatus: PferdeStatus = PferdeStatus.AKTIV, val editStatus: PferdeStatus = PferdeStatus.AKTIV,
val editFeiId: String = "", val editFeiId: String = "",
val editOepsNummer: String = "", val editOepsNummer: String = "",
val editBesitzer: String = "" val editBesitzer: String = "",
val editKopfNummer: String = ""
) )
/** /**
@ -44,16 +45,33 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
private fun loadPferde() { private fun loadPferde() {
val mockData = listOf( val mockData = listOf(
Pferd("1", "Bella", "040001234567801", Geschlecht.STUTE, "Braun", 2015, PferdeStatus.AKTIV), Pferd("1", "Bella", "1A23", "040001234567801", Geschlecht.STUTE, "Braun", 2015, PferdeStatus.AKTIV),
Pferd("2", "Casanova", "040001234567802", Geschlecht.WALLACH, "Schimmel", 2012, PferdeStatus.AKTIV), Pferd("2", "Casanova", "2B45", "040001234567802", Geschlecht.WALLACH, "Schimmel", 2012, PferdeStatus.AKTIV),
Pferd("3", "Spirit", "040001234567803", Geschlecht.HENGST, "Rappe", 2018, PferdeStatus.AKTIV), Pferd("3", "Spirit", "3C67", "040001234567803", Geschlecht.HENGST, "Rappe", 2018, PferdeStatus.AKTIV),
Pferd("4", "Lucky", "040001234567804", Geschlecht.WALLACH, "Fuchs", 2010, PferdeStatus.VERKAUFT) Pferd("4", "Lucky", "4D89", "040001234567804", Geschlecht.WALLACH, "Fuchs", 2010, PferdeStatus.VERKAUFT)
) )
uiState = uiState.copy(searchResults = mockData) uiState = uiState.copy(searchResults = mockData)
} }
fun onSearchQueryChange(query: String) { fun onSearchQueryChange(query: String) {
uiState = uiState.copy(searchQuery = query) uiState = uiState.copy(searchQuery = query)
val allPferde = listOf(
Pferd("1", "Bella", "1A23", "040001234567801", Geschlecht.STUTE, "Braun", 2015, PferdeStatus.AKTIV),
Pferd("2", "Casanova", "2B45", "040001234567802", Geschlecht.WALLACH, "Schimmel", 2012, PferdeStatus.AKTIV),
Pferd("3", "Spirit", "3C67", "040001234567803", Geschlecht.HENGST, "Rappe", 2018, PferdeStatus.AKTIV),
Pferd("4", "Lucky", "4D89", "040001234567804", Geschlecht.WALLACH, "Fuchs", 2010, PferdeStatus.VERKAUFT)
)
val filtered = if (query.isBlank()) {
allPferde
} else {
allPferde.filter {
it.name.contains(query, ignoreCase = true) ||
it.lebensnummer.contains(query, ignoreCase = true) ||
(it.kopfNummer?.contains(query, ignoreCase = true) ?: false)
}
}
uiState = uiState.copy(searchResults = filtered)
} }
fun selectPferd(pferd: Pferd) { fun selectPferd(pferd: Pferd) {
@ -69,7 +87,8 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
editStatus = pferd.status, editStatus = pferd.status,
editFeiId = pferd.feiId ?: "", editFeiId = pferd.feiId ?: "",
editOepsNummer = pferd.oepsNummer ?: "", editOepsNummer = pferd.oepsNummer ?: "",
editBesitzer = pferd.besitzer ?: "" editBesitzer = pferd.besitzer ?: "",
editKopfNummer = pferd.kopfNummer ?: ""
) )
} }
@ -86,7 +105,8 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
editStatus = PferdeStatus.AKTIV, editStatus = PferdeStatus.AKTIV,
editFeiId = "", editFeiId = "",
editOepsNummer = "", editOepsNummer = "",
editBesitzer = "" editBesitzer = "",
editKopfNummer = ""
) )
} }
@ -102,6 +122,10 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
uiState = uiState.copy(editBesitzer = value) uiState = uiState.copy(editBesitzer = value)
} }
fun onEditKopfNummerChange(value: String) {
uiState = uiState.copy(editKopfNummer = value)
}
fun onEditNameChange(value: String) { fun onEditNameChange(value: String) {
uiState = uiState.copy(editName = value) uiState = uiState.copy(editName = value)
} }

View File

@ -35,6 +35,7 @@ kotlin {
implementation(projects.frontend.core.localDb) implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth) implementation(projects.frontend.core.auth)
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
implementation(projects.frontend.features.znsImportFeature)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.runtime) implementation(compose.runtime)

View File

@ -97,5 +97,9 @@ data class ProfileDto(
val satznummer: String? = null, val satznummer: String? = null,
val bio: String? = null, val bio: String? = null,
val contactEmail: String? = null, val contactEmail: String? = null,
val logoUrl: String? = null val logoUrl: String? = null,
val vorname: String? = null,
val nachname: String? = null,
val nation: String? = "AUT",
val bundesland: String? = null
) )

View File

@ -1,6 +1,7 @@
package at.mocode.frontend.features.profile.di package at.mocode.frontend.features.profile.di
import at.mocode.frontend.features.profile.data.ProfileApiClient import at.mocode.frontend.features.profile.data.ProfileApiClient
import at.mocode.frontend.features.profile.presentation.ProfileOnboardingViewModel
import at.mocode.frontend.features.profile.presentation.ProfileViewModel import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
@ -8,4 +9,5 @@ import org.koin.dsl.module
val profileModule = module { val profileModule = module {
single { ProfileApiClient(get(named("apiClient")), get()) } single { ProfileApiClient(get(named("apiClient")), get()) }
single { ProfileViewModel(get()) } single { ProfileViewModel(get()) }
factory { ProfileOnboardingViewModel(get(), get()) }
} }

View File

@ -0,0 +1,173 @@
package at.mocode.frontend.features.profile.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsTextField
@Composable
fun ProfileOnboardingScreen(
viewModel: ProfileOnboardingViewModel,
onFinish: () -> Unit
) {
val state = viewModel.state
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = "Willkommen bei der Meldestelle",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
LinearProgressIndicator(
progress = {
when (state.currentStep) {
OnboardingStep.SEARCH_ZNS -> 0.33f
OnboardingStep.CONFIRM_DATA -> 0.66f
OnboardingStep.FINISHED -> 1f
}
},
modifier = Modifier.fillMaxWidth()
)
Box(modifier = Modifier.weight(1f)) {
when (state.currentStep) {
OnboardingStep.SEARCH_ZNS -> SearchStep(viewModel)
OnboardingStep.CONFIRM_DATA -> ConfirmStep(viewModel)
OnboardingStep.FINISHED -> FinishedStep(state, onFinish)
}
}
if (state.currentStep != OnboardingStep.FINISHED) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedButton(onClick = { viewModel.back() }, enabled = state.currentStep != OnboardingStep.SEARCH_ZNS) {
Text("Zurück")
}
if (state.currentStep == OnboardingStep.CONFIRM_DATA) {
Button(onClick = { viewModel.confirmAndLink() }, enabled = !state.isLoading) {
if (state.isLoading) CircularProgressIndicator(Modifier.size(16.dp))
else Text("Daten bestätigen & Verknüpfen")
}
}
}
}
}
}
@Composable
private fun SearchStep(viewModel: ProfileOnboardingViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Wer bist du?", style = MaterialTheme.typography.titleLarge)
Text("Suchen Sie nach Ihrer Satznummer oder Ihrem Namen in den ZNS-Stammdaten.")
MsTextField(
value = state.searchQuery,
onValueChange = { viewModel.onSearchQueryChange(it) },
label = "Suche (Name oder Satznummer)",
placeholder = "z.B. Stroblmair",
modifier = Modifier.fillMaxWidth(),
leadingIcon = Icons.Default.Search
)
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(state.searchResults) { reiter ->
Card(
onClick = { viewModel.selectReiter(reiter) },
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(Icons.Default.Person, null)
Column {
Text("${reiter.vorname} ${reiter.nachname}", fontWeight = FontWeight.Bold)
Text("Satznr: ${reiter.satznummer ?: "N/A"} | Lizenz: ${reiter.lizenz ?: "Keine"}")
}
}
}
}
}
}
}
@Composable
private fun ConfirmStep(viewModel: ProfileOnboardingViewModel) {
val state = viewModel.state
val reiter = state.selectedReiter ?: return
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Daten bestätigen", style = MaterialTheme.typography.titleLarge)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Vorname: ${reiter.vorname}")
Text("Nachname: ${reiter.nachname}")
Text("Satznummer: ${reiter.satznummer ?: "N/A"}")
Text("Lizenz: ${reiter.lizenz ?: "Keine"}")
Text("Klasse: ${reiter.lizenzKlasse}")
Text("Nation: ${reiter.nation ?: "AUT"}")
if (reiter.bundesland != null) {
Text("Bundesland: ${reiter.bundesland}")
}
}
}
Text(
"Durch das Verknüpfen werden Ihre Aktionen in der App mit Ihrer offiziellen ZNS-Identität hinterlegt.",
style = MaterialTheme.typography.bodyMedium
)
if (state.error != null) {
Text(state.error, color = MaterialTheme.colorScheme.error)
}
}
}
@Composable
private fun FinishedStep(state: ProfileOnboardingState, onFinish: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(16.dp))
Text("Profil erfolgreich verknüpft!", style = MaterialTheme.typography.headlineSmall)
Text("Willkommen, ${state.selectedReiter?.vorname ?: ""} ${state.selectedReiter?.nachname ?: ""}!")
Spacer(Modifier.height(32.dp))
Button(onClick = onFinish) {
Text("Los geht's")
}
}
}

View File

@ -0,0 +1,98 @@
package at.mocode.frontend.features.profile.presentation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.domain.zns.ZnsRemoteReiter
import at.mocode.frontend.features.profile.data.ProfileApiClient
import at.mocode.frontend.features.profile.data.ProfileDto
import kotlinx.coroutines.launch
enum class OnboardingStep {
SEARCH_ZNS,
CONFIRM_DATA,
FINISHED
}
data class ProfileOnboardingState(
val currentStep: OnboardingStep = OnboardingStep.SEARCH_ZNS,
val searchQuery: String = "",
val searchResults: List<ZnsRemoteReiter> = emptyList(),
val selectedReiter: ZnsRemoteReiter? = null,
val isLoading: Boolean = false,
val error: String? = null,
val profile: ProfileDto? = null
)
class ProfileOnboardingViewModel(
private val znsImportProvider: ZnsImportProvider,
private val profileApiClient: ProfileApiClient
) : ViewModel() {
var state by mutableStateOf(ProfileOnboardingState())
private set
fun onSearchQueryChange(query: String) {
state = state.copy(searchQuery = query)
if (query.length >= 3) {
search()
}
}
private fun search() {
viewModelScope.launch {
state = state.copy(isLoading = true, error = null)
try {
znsImportProvider.searchRemote(state.searchQuery)
state = state.copy(
isLoading = false,
searchResults = znsImportProvider.state.remoteReiter
)
} catch (e: Exception) {
state = state.copy(isLoading = false, error = "Fehler bei der ZNS-Suche: ${e.message}")
}
}
}
fun selectReiter(reiter: ZnsRemoteReiter) {
state = state.copy(
selectedReiter = reiter,
currentStep = OnboardingStep.CONFIRM_DATA
)
}
fun confirmAndLink() {
val reiter = state.selectedReiter ?: return
viewModelScope.launch {
state = state.copy(isLoading = true, error = null)
try {
val satznr = reiter.satznummer ?: ""
val profile = profileApiClient.linkToZns(satznr)
if (profile != null) {
state = state.copy(
isLoading = false,
profile = profile,
currentStep = OnboardingStep.FINISHED
)
} else {
state = state.copy(isLoading = false, error = "Verknüpfung fehlgeschlagen.")
}
} catch (e: Exception) {
state = state.copy(isLoading = false, error = "Fehler beim Verknüpfen: ${e.message}")
}
}
}
fun back() {
state = state.copy(
currentStep = when (state.currentStep) {
OnboardingStep.SEARCH_ZNS -> OnboardingStep.SEARCH_ZNS
OnboardingStep.CONFIRM_DATA -> OnboardingStep.SEARCH_ZNS
OnboardingStep.FINISHED -> OnboardingStep.CONFIRM_DATA
}
)
}
}

View File

@ -0,0 +1,130 @@
package at.mocode.frontend.features.profile.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsTextField
@Composable
fun ProfileOnboardingWizard(
viewModel: ProfileViewModel,
onFinish: () -> Unit
) {
var currentStep by remember { mutableStateOf(1) }
var satznummer by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
"Willkommen bei Meldestelle",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
LinearProgressIndicator(
progress = { currentStep / 3f },
modifier = Modifier.fillMaxWidth()
)
when (currentStep) {
1 -> Step1ManualInput(satznummer, { satznummer = it }, onNext = { currentStep = 2 })
2 -> Step2Confirm(satznummer, onBack = { currentStep = 1 }, onNext = {
viewModel.linkToZns(satznummer)
currentStep = 3
})
3 -> Step3Complete(viewModel, onFinish = onFinish)
}
}
}
@Composable
private fun Step1ManualInput(
satznummer: String,
onSatznummerChange: (String) -> Unit,
onNext: () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 1: Wer bist du?", style = MaterialTheme.typography.titleLarge)
Text("Bitte gib deine ZNS-Satznummer ein.")
MsTextField(
value = satznummer,
onValueChange = onSatznummerChange,
label = "ZNS-Satznummer",
placeholder = "z.B. 1234567",
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = onNext,
enabled = satznummer.length >= 5,
modifier = Modifier.align(Alignment.End)
) {
Text("Weiter")
}
}
}
@Composable
private fun Step2Confirm(
satznummer: String,
onBack: () -> Unit,
onNext: () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 2: Bestätigung", style = MaterialTheme.typography.titleLarge)
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text("Satznummer: $satznummer", fontWeight = FontWeight.Bold)
Text("Die Daten werden nun mit dem Identity-Service verknüpft.")
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
TextButton(onClick = onBack) { Text("Zurück") }
Button(onClick = onNext) { Text("Verknüpfen") }
}
}
}
@Composable
private fun Step3Complete(
viewModel: ProfileViewModel,
onFinish: () -> Unit
) {
var email by remember { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 3: Abschluss", style = MaterialTheme.typography.titleLarge)
MsTextField(
value = email,
onValueChange = { email = it },
label = "Kontakt-Email",
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
viewModel.updateProfile(null, email)
onFinish()
},
modifier = Modifier.fillMaxWidth()
) {
Text("Onboarding abschließen")
}
}
}

View File

@ -4,10 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -151,6 +148,20 @@ fun ProfileDetailsSection(
DetailItem(label = "Satznummer", value = profile.satznummer ?: "Nicht verknüpft", icon = Icons.Default.Badge) DetailItem(label = "Satznummer", value = profile.satznummer ?: "Nicht verknüpft", icon = Icons.Default.Badge)
if (profile.vorname != null || profile.nachname != null) {
Spacer(Modifier.height(12.dp))
DetailItem(
label = "Name",
value = "${profile.vorname ?: ""} ${profile.nachname ?: ""}".trim(),
icon = Icons.Default.Person
)
}
if (profile.nation != null) {
Spacer(Modifier.height(12.dp))
DetailItem(label = "Nation / Bundesland", value = "${profile.nation}${if (profile.bundesland != null) " (${profile.bundesland})" else ""}", icon = Icons.Default.Public)
}
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
if (isEditing) { if (isEditing) {

View File

@ -18,7 +18,9 @@ data class Reiter(
val geburtsdatum: String? = null, val geburtsdatum: String? = null,
val email: String? = null, val email: String? = null,
val telefon: String? = null, val telefon: String? = null,
val verein: String? = null val verein: String? = null,
val nation: String? = "AUT",
val bundesland: String? = null
) { ) {
val name: String get() = "$vorname $nachname" val name: String get() = "$vorname $nachname"
} }

View File

@ -18,6 +18,8 @@ data class ReiterProfilState(
val feiId: String = "", val feiId: String = "",
val lizenzKlasse: String = "", val lizenzKlasse: String = "",
val verein: String = "", val verein: String = "",
val nation: String = "AUT",
val bundesland: String = "",
// Validierungsergebnisse (Live-Feedback, ÖTO/FEI Regelwerk) // Validierungsergebnisse (Live-Feedback, ÖTO/FEI Regelwerk)
val oepsNummerValidation: ValidationResult = ValidationResult.Ok, val oepsNummerValidation: ValidationResult = ValidationResult.Ok,
val feiIdValidation: ValidationResult = ValidationResult.Ok, val feiIdValidation: ValidationResult = ValidationResult.Ok,
@ -39,6 +41,8 @@ sealed interface ReiterProfilIntent {
data class EditFeiId(val v: String) : ReiterProfilIntent data class EditFeiId(val v: String) : ReiterProfilIntent
data class EditLizenz(val v: String) : ReiterProfilIntent data class EditLizenz(val v: String) : ReiterProfilIntent
data class EditVerein(val v: String) : ReiterProfilIntent data class EditVerein(val v: String) : ReiterProfilIntent
data class EditNation(val v: String) : ReiterProfilIntent
data class EditBundesland(val v: String) : ReiterProfilIntent
data object Save : ReiterProfilIntent data object Save : ReiterProfilIntent
data object ClearError : ReiterProfilIntent data object ClearError : ReiterProfilIntent
} }
@ -69,6 +73,8 @@ class ReiterProfilViewModel(
is ReiterProfilIntent.EditFeiId -> edit { it.copy(feiId = intent.v) } is ReiterProfilIntent.EditFeiId -> edit { it.copy(feiId = intent.v) }
is ReiterProfilIntent.EditLizenz -> edit { it.copy(lizenzKlasse = intent.v) } is ReiterProfilIntent.EditLizenz -> edit { it.copy(lizenzKlasse = intent.v) }
is ReiterProfilIntent.EditVerein -> edit { it.copy(verein = intent.v) } is ReiterProfilIntent.EditVerein -> edit { it.copy(verein = intent.v) }
is ReiterProfilIntent.EditNation -> edit { it.copy(nation = intent.v) }
is ReiterProfilIntent.EditBundesland -> edit { it.copy(bundesland = intent.v) }
is ReiterProfilIntent.Save -> save() is ReiterProfilIntent.Save -> save()
is ReiterProfilIntent.ClearError -> reduce { it.copy(errorMessage = null) } is ReiterProfilIntent.ClearError -> reduce { it.copy(errorMessage = null) }
} }

View File

@ -1,10 +1,16 @@
package at.mocode.frontend.features.reiter.presentation package at.mocode.frontend.features.reiter.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.* import at.mocode.frontend.core.designsystem.components.*
import at.mocode.frontend.core.designsystem.models.PlaceholderContent import at.mocode.frontend.core.designsystem.models.PlaceholderContent
@ -41,14 +47,20 @@ fun ReiterScreen(
onEmailChange = viewModel::onEditEmailChange, onEmailChange = viewModel::onEditEmailChange,
onTelefonChange = viewModel::onEditTelefonChange, onTelefonChange = viewModel::onEditTelefonChange,
onVereinChange = viewModel::onEditVereinChange, onVereinChange = viewModel::onEditVereinChange,
onNationChange = viewModel::onEditNationChange,
onBundeslandChange = viewModel::onEditBundeslandChange,
onSave = viewModel::onSave, onSave = viewModel::onSave,
onCancel = viewModel::onCancel onCancel = viewModel::onCancel
) )
} else if (uiState.selectedReiter != null) { } else if (uiState.selectedReiter != null) {
Column(Modifier.fillMaxSize()) {
ReiterCardPreview(reiter = uiState.selectedReiter)
Spacer(Modifier.height(16.dp))
ReiterCard( ReiterCard(
reiter = uiState.selectedReiter, reiter = uiState.selectedReiter,
onEdit = { viewModel.selectReiter(uiState.selectedReiter) } onEdit = { viewModel.selectReiter(uiState.selectedReiter) }
) )
}
} else { } else {
PlaceholderContent( PlaceholderContent(
title = "Kein Reiter ausgewählt", title = "Kein Reiter ausgewählt",
@ -122,57 +134,9 @@ fun ReiterCard(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(), modifier = Modifier.fillMaxWidth().wrapContentHeight()
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) { ) {
Column(modifier = Modifier.padding(24.dp)) { Column(modifier = Modifier.padding(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
modifier = Modifier.size(48.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = (reiter.vorname.take(1) + reiter.nachname.take(1)).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
Spacer(Modifier.width(16.dp))
Column {
Text(
reiter.name,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"ÖPS-Nr: ${reiter.oepsNummer ?: "-"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
MsStatusBadge(
text = reiter.status.label,
containerColor = reiter.status.color.copy(alpha = 0.1f),
contentColor = reiter.status.color
)
}
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
ReiterDetailItem(label = "Lizenz", value = reiter.lizenz.label, modifier = Modifier.weight(1f)) ReiterDetailItem(label = "Lizenz", value = reiter.lizenz.label, modifier = Modifier.weight(1f))
ReiterDetailItem(label = "Hauptsparte", value = reiter.sparte.label, modifier = Modifier.weight(1f)) ReiterDetailItem(label = "Hauptsparte", value = reiter.sparte.label, modifier = Modifier.weight(1f))
@ -192,6 +156,13 @@ fun ReiterCard(
ReiterDetailItem(label = "FEI-ID", value = reiter.feiId ?: "-", modifier = Modifier.weight(1f)) ReiterDetailItem(label = "FEI-ID", value = reiter.feiId ?: "-", modifier = Modifier.weight(1f))
} }
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
ReiterDetailItem(label = "Nation", value = reiter.nation ?: "-", modifier = Modifier.weight(1f))
ReiterDetailItem(label = "Bundesland", value = reiter.bundesland ?: "-", modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
MsButton( MsButton(
@ -204,6 +175,50 @@ fun ReiterCard(
} }
} }
@Composable
fun ReiterCardPreview(reiter: Reiter) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Person, null, modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.primary)
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(reiter.name, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
MsStatusBadge(
text = reiter.status.label,
containerColor = reiter.status.color.copy(alpha = 0.1f),
contentColor = reiter.status.color
)
}
Text(
text = buildString {
append("ÖPS: ${reiter.oepsNummer ?: "-"}")
if (!reiter.nation.isNullOrBlank()) append(" | ${reiter.nation}")
if (!reiter.bundesland.isNullOrBlank()) append(" (${reiter.bundesland})")
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable @Composable
private fun ReiterDetailItem(label: String, value: String, modifier: Modifier = Modifier) { private fun ReiterDetailItem(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -225,17 +240,25 @@ private fun ReiterEditorContent(
onEmailChange: (String) -> Unit, onEmailChange: (String) -> Unit,
onTelefonChange: (String) -> Unit, onTelefonChange: (String) -> Unit,
onVereinChange: (String) -> Unit, onVereinChange: (String) -> Unit,
onNationChange: (String) -> Unit,
onBundeslandChange: (String) -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onCancel: () -> Unit onCancel: () -> Unit
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
MsActionToolbar( MsActionToolbar(
title = "Reiter Details", title = if (uiState.selectedReiter == null) "Reiter anlegen" else "Reiter Details",
onSave = onSave, onSave = onSave,
onCancel = onCancel onCancel = onCancel
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(16.dp))
// Preview in Editor
if (uiState.selectedReiter != null) {
ReiterCardPreview(reiter = uiState.selectedReiter)
Spacer(Modifier.height(16.dp))
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField( MsTextField(
@ -294,6 +317,25 @@ private fun ReiterEditorContent(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editNation,
onValueChange = onNationChange,
label = "Nation",
modifier = Modifier.weight(1f),
compact = true
)
MsTextField(
value = uiState.editBundesland,
onValueChange = onBundeslandChange,
label = "Bundesland",
modifier = Modifier.weight(1f),
compact = true
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField( MsTextField(
value = uiState.editEmail, value = uiState.editEmail,

View File

@ -29,7 +29,9 @@ data class ReiterUiState(
val editGeburtsdatum: String = "", val editGeburtsdatum: String = "",
val editEmail: String = "", val editEmail: String = "",
val editTelefon: String = "", val editTelefon: String = "",
val editVerein: String = "" val editVerein: String = "",
val editNation: String = "AUT",
val editBundesland: String = ""
) )
/** /**
@ -77,7 +79,9 @@ open class ReiterViewModel(initialLoad: Boolean = true) : ViewModel() {
editGeburtsdatum = reiter.geburtsdatum ?: "", editGeburtsdatum = reiter.geburtsdatum ?: "",
editEmail = reiter.email ?: "", editEmail = reiter.email ?: "",
editTelefon = reiter.telefon ?: "", editTelefon = reiter.telefon ?: "",
editVerein = reiter.verein ?: "" editVerein = reiter.verein ?: "",
editNation = reiter.nation ?: "AUT",
editBundesland = reiter.bundesland ?: ""
) )
} }
@ -96,7 +100,9 @@ open class ReiterViewModel(initialLoad: Boolean = true) : ViewModel() {
editGeburtsdatum = "", editGeburtsdatum = "",
editEmail = "", editEmail = "",
editTelefon = "", editTelefon = "",
editVerein = "" editVerein = "",
editNation = "AUT",
editBundesland = ""
) )
} }
@ -106,6 +112,8 @@ open class ReiterViewModel(initialLoad: Boolean = true) : ViewModel() {
fun onEditEmailChange(value: String) { uiState = uiState.copy(editEmail = value) } fun onEditEmailChange(value: String) { uiState = uiState.copy(editEmail = value) }
fun onEditTelefonChange(value: String) { uiState = uiState.copy(editTelefon = value) } fun onEditTelefonChange(value: String) { uiState = uiState.copy(editTelefon = value) }
fun onEditVereinChange(value: String) { uiState = uiState.copy(editVerein = value) } fun onEditVereinChange(value: String) { uiState = uiState.copy(editVerein = value) }
fun onEditNationChange(value: String) { uiState = uiState.copy(editNation = value) }
fun onEditBundeslandChange(value: String) { uiState = uiState.copy(editBundesland = value) }
fun onEditVornameChange(value: String) { fun onEditVornameChange(value: String) {
uiState = uiState.copy(editVorname = value) uiState = uiState.copy(editVorname = value)

View File

@ -24,6 +24,7 @@ actual val turnierFeatureModule = module {
// ViewModels // ViewModels
factory { TurnierViewModel(repo = get()) } factory { TurnierViewModel(repo = get()) }
factory { TurnierStammdatenViewModel(repo = get()) } factory { TurnierStammdatenViewModel(repo = get()) }
factory { TurnierWizardViewModel(repository = get()) }
// BewerbViewModel: repos + syncManager + turnierId // BewerbViewModel: repos + syncManager + turnierId
factory { (turnierId: Long) -> factory { (turnierId: Long) ->
BewerbViewModel( BewerbViewModel(

View File

@ -231,7 +231,7 @@ private fun StepOrtZeit(state: CreateBewerbWizardState, onStateChange: (CreateBe
@Composable @Composable
private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
// Warn-Logik (mock): Wenn Richter ausgewählt und Position = "C" ohne weiterer Prüfung -> TB-Hinweis // Warn-Logik (mock): Wenn Richter ausgewählt und Position = "C" ohne weitere Prüfung → TB-Hinweis
val warnTb = state.richter.isNotEmpty() val warnTb = state.richter.isNotEmpty()
if (warnTb) { if (warnTb) {
Box( Box(
@ -240,6 +240,25 @@ private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (C
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
} }
// Abteilungs-Vorschau (§ 39 ÖTO)
val abteilungsInfo = remember(state.klasse, state.teilungsTyp) {
when {
state.klasse.contains("S", ignoreCase = true) -> "§ 39 ÖTO: Abteilungstrennung ab 35 Nennungen (R1 getrennt von R2+)"
state.klasse.contains("M", ignoreCase = true) -> "§ 39 ÖTO: Abteilungstrennung ab 50 Nennungen"
else -> "Standard-Abteilungstrennung gemäß ÖTO § 39"
}
}
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)
) {
Column(Modifier.padding(12.dp)) {
Text("Abteilungs-Vorschau (§ 39 ÖTO)", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
Text(abteilungsInfo, style = MaterialTheme.typography.bodySmall)
}
}
OutlinedTextField( OutlinedTextField(
value = state.teilungsTyp, value = state.teilungsTyp,
onValueChange = { onStateChange(state.copy(teilungsTyp = it)) }, onValueChange = { onStateChange(state.copy(teilungsTyp = it)) },

View File

@ -0,0 +1,164 @@
package at.mocode.frontend.features.turnier.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsTextField
@Composable
fun TurnierWizard(
viewModel: TurnierWizardViewModel,
veranstaltungId: Long,
onBack: () -> Unit,
onFinish: () -> Unit
) {
val state = viewModel.state
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
// Header
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Text("Neues Turnier anlegen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.weight(1f))
Text(
"Schritt ${state.currentStep} von 3",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
LinearProgressIndicator(
progress = { state.currentStep / 3f },
modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.small),
)
Box(Modifier.weight(1f).fillMaxWidth()) {
when (state.currentStep) {
1 -> Step1Basics(viewModel)
2 -> Step2Sparten(viewModel)
3 -> Step3Branding(viewModel)
}
}
// Footer Navigation
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(
onClick = { if (state.currentStep > 1) viewModel.prevStep() else onBack() }
) {
Text(if (state.currentStep == 1) "Abbrechen" else "Zurück")
}
Button(
onClick = {
if (state.currentStep < 3) {
viewModel.nextStep()
} else {
viewModel.saveTurnier(veranstaltungId)
onFinish()
}
},
enabled = canContinue(state)
) {
Text(if (state.currentStep == 3) "Turnier erstellen" else "Weiter")
}
}
}
}
private fun canContinue(state: TurnierWizardState): Boolean {
return when (state.currentStep) {
1 -> state.turnierNr.length == 5 && state.nrConfirmed
2 -> state.sparten.isNotEmpty() && state.klassen.isNotEmpty() && state.von.isNotBlank()
3 -> true
else -> false
}
}
@Composable
private fun Step1Basics(viewModel: TurnierWizardViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Grunddaten & ZNS-Verknüpfung", style = MaterialTheme.typography.titleLarge)
Card {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = state.turnierNr,
onValueChange = { viewModel.updateNr(it) },
label = "Turnier-Nummer (ZNS)",
placeholder = "z.B. 26128",
modifier = Modifier.fillMaxWidth()
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = state.nrConfirmed, onCheckedChange = { viewModel.setNrConfirmed(it) })
Text("Nummer ist korrekt und im ZNS verifiziert", style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
@Composable
private fun Step2Sparten(viewModel: TurnierWizardViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Sparten & Zeitplan", style = MaterialTheme.typography.titleLarge)
// Sparten Auswahl (Chip-Style)
Text("Sparten", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("Dressur", "Springen", "Vielseitigkeit").forEach { sparte ->
FilterChip(
selected = state.sparten.contains(sparte),
onClick = { viewModel.toggleSparte(sparte) },
label = { Text(sparte) }
)
}
}
Text("Klassen", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("E", "A", "L", "LM", "M", "S").forEach { kl ->
FilterChip(
selected = state.klassen.contains(kl),
onClick = { viewModel.toggleKlasse(kl) },
label = { Text(kl) }
)
}
}
}
}
@Composable
private fun Step3Branding(viewModel: TurnierWizardViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Branding & Sponsoren", style = MaterialTheme.typography.titleLarge)
MsTextField(
value = state.titel,
onValueChange = { viewModel.updateBranding(it, state.subTitel) },
label = "Turnier-Titel (Optional)",
modifier = Modifier.fillMaxWidth()
)
MsTextField(
value = state.subTitel,
onValueChange = { viewModel.updateBranding(state.titel, it) },
label = "Untertitel",
modifier = Modifier.fillMaxWidth()
)
}
}

View File

@ -0,0 +1,78 @@
package at.mocode.frontend.features.turnier.presentation
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.turnier.domain.TurnierRepository
import kotlinx.coroutines.launch
data class TurnierWizardState(
val currentStep: Int = 1,
val turnierNr: String = "",
val nrConfirmed: Boolean = false,
val sparten: List<String> = emptyList(),
val klassen: List<String> = emptyList(),
val von: String = "",
val bis: String = "",
val titel: String = "",
val subTitel: String = "",
val isSaving: Boolean = false,
val saveSuccess: Boolean = false,
val errorMessage: String? = null
)
class TurnierWizardViewModel(
private val repository: TurnierRepository
) : ViewModel() {
var state by mutableStateOf(TurnierWizardState())
private set
fun updateNr(nr: String) {
if (nr.length <= 5 && nr.all { it.isDigit() }) {
state = state.copy(turnierNr = nr)
}
}
fun setNrConfirmed(confirmed: Boolean) {
state = state.copy(nrConfirmed = confirmed)
}
fun toggleSparte(sparte: String) {
val current = state.sparten.toMutableList()
if (current.contains(sparte)) current.remove(sparte) else current.add(sparte)
state = state.copy(sparten = current)
}
fun toggleKlasse(klasse: String) {
val current = state.klassen.toMutableList()
if (current.contains(klasse)) current.remove(klasse) else current.add(klasse)
state = state.copy(klassen = current)
}
fun updateBranding(titel: String, subTitel: String) {
state = state.copy(titel = titel, subTitel = subTitel)
}
fun nextStep() {
if (state.currentStep < 3) {
state = state.copy(currentStep = state.currentStep + 1)
}
}
fun prevStep() {
if (state.currentStep > 1) {
state = state.copy(currentStep = state.currentStep - 1)
}
}
fun saveTurnier(veranstaltungId: Long) {
viewModelScope.launch {
state = state.copy(isSaving = true, errorMessage = null)
println("Speichere Turnier für Veranstaltung $veranstaltungId via $repository")
state = state.copy(isSaving = false, saveSuccess = true)
}
}
}

View File

@ -9,11 +9,78 @@ import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
*/ */
class FakeVeranstalterRepository : VeranstalterRepository { class FakeVeranstalterRepository : VeranstalterRepository {
private val mockData = mutableListOf( private val mockData = mutableListOf(
Veranstalter(1, "URV Schloss Hof", "1-2345", "Schloßhof", "Aktiv"), Veranstalter(
Veranstalter(2, "RV Schloß Rosenau", "3-0012", "Rosenau", "Aktiv"), id = 1,
Veranstalter(3, "Reitclub Tulln", "3-1520", "Tulln", "Inaktiv"), name = "URV Schloss Hof",
Veranstalter(4, "RC St. Pölten", "3-0101", "St. Pölten", "Aktiv"), oepsNummer = "1-2345",
Veranstalter(5, "Union Reitklub Wien", "9-0001", "Wien", "Aktiv") ort = "Schloßhof",
loginStatus = "Aktiv",
ansprechpartner = "Max Mustermann",
email = "office@schlosshof.at",
telefon = "+43 1 234567",
adresse = "Schloßstraße 1, 2294 Schloßhof",
mitgliedSeit = "01.01.2020"
),
Veranstalter(
id = 2,
name = "RV Schloß Rosenau",
oepsNummer = "3-0012",
ort = "Rosenau",
loginStatus = "Aktiv",
ansprechpartner = "Erika Muster",
email = "erika@rosenau.at",
telefon = "+43 2822 1234",
adresse = "Schloßplatz 1, 3924 Rosenau",
mitgliedSeit = "15.03.2018"
),
Veranstalter(
id = 3,
name = "Reitclub Tulln",
oepsNummer = "3-1520",
ort = "Tulln",
loginStatus = "Inaktiv",
ansprechpartner = "Hansi Hinterseer",
email = "hansi@tulln.at",
telefon = "+43 2272 5555",
adresse = "Donauweg 10, 3430 Tulln",
mitgliedSeit = "10.10.2010"
),
Veranstalter(
id = 4,
name = "RC St. Pölten",
oepsNummer = "3-0101",
ort = "St. Pölten",
loginStatus = "Aktiv",
ansprechpartner = "Petra Reiter",
email = "petra@rc-stpoelten.at",
telefon = "+43 2742 9876",
adresse = "Pferdegasse 5, 3100 St. Pölten",
mitgliedSeit = "20.05.2022"
),
Veranstalter(
id = 5,
name = "Union Reitklub Wien",
oepsNummer = "9-0001",
ort = "Wien",
loginStatus = "Aktiv",
ansprechpartner = "Stefan Wiener",
email = "stefan@urkw.at",
telefon = "+43 1 90001",
adresse = "Hauptstraße 100, 1010 Wien",
mitgliedSeit = "12.12.2012"
),
Veranstalter(
id = 6,
name = "Reitclub Neumarkt",
oepsNummer = "6-009",
ort = "Neumarkt",
loginStatus = "Aktiv",
ansprechpartner = "Karl Neumarkter",
email = "karl@rc-neumarkt.at",
telefon = "+43 6216 1234",
adresse = "Mühlweg 1, 5202 Neumarkt am Wallersee",
mitgliedSeit = "01.04.2024"
)
) )
override suspend fun list(): Result<List<Veranstalter>> = Result.success(mockData) override suspend fun list(): Result<List<Veranstalter>> = Result.success(mockData)

View File

@ -6,6 +6,13 @@ data class Veranstalter(
val oepsNummer: String, val oepsNummer: String,
val ort: String, val ort: String,
val loginStatus: String, val loginStatus: String,
val ansprechpartner: String = "",
val email: String = "",
val telefon: String = "",
val adresse: String = "",
val mitgliedSeit: String = "",
val logoUrl: String? = null,
val logoBase64: String? = null,
) )
/** /**

View File

@ -1,12 +1,12 @@
package at.mocode.frontend.features.veranstalter.presentation package at.mocode.frontend.features.veranstalter.presentation
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
// UDF: State beschreibt die gesamte UI in einem Snapshot // UDF: State beschreibt die gesamte UI in einem Snapshot
@ -34,6 +34,8 @@ data class VeranstalterListItem(
val oepsNummer: String, val oepsNummer: String,
val ort: String, val ort: String,
val loginStatus: String, val loginStatus: String,
val logoUrl: String? = null,
val logoBase64: String? = null,
) )
class VeranstalterViewModel( class VeranstalterViewModel(
@ -101,4 +103,6 @@ private fun DomainVeranstalter.toListItem() = VeranstalterListItem(
oepsNummer = oepsNummer, oepsNummer = oepsNummer,
ort = ort, ort = ort,
loginStatus = loginStatus, loginStatus = loginStatus,
logoUrl = logoUrl,
logoBase64 = logoBase64,
) )

View File

@ -4,10 +4,12 @@ import at.mocode.frontend.features.veranstalter.data.remote.FakeVeranstalterRepo
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailViewModel import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailViewModel
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterViewModel import at.mocode.frontend.features.veranstalter.presentation.VeranstalterViewModel
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardViewModel
import org.koin.dsl.module import org.koin.dsl.module
val veranstalterModule = module { val veranstalterModule = module {
single<VeranstalterRepository> { FakeVeranstalterRepository() } single<VeranstalterRepository> { FakeVeranstalterRepository() }
factory { VeranstalterViewModel(get()) } factory { VeranstalterViewModel(get()) }
factory { VeranstalterDetailViewModel(get()) } factory { VeranstalterDetailViewModel(get()) }
factory { VeranstalterWizardViewModel(get()) }
} }

View File

@ -51,6 +51,7 @@ fun VeranstalterDetailScreen(
onZurueck: () -> Unit, onZurueck: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit, onVeranstaltungOeffnen: (Long) -> Unit,
onVeranstaltungNeu: () -> Unit, onVeranstaltungNeu: () -> Unit,
onEditVeranstalter: (Long) -> Unit,
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
@ -134,7 +135,7 @@ fun VeranstalterDetailScreen(
} }
// Profil bearbeiten // Profil bearbeiten
OutlinedButton( OutlinedButton(
onClick = { /* Navigation zu Vereinen */ }, onClick = { onEditVeranstalter(veranstalter.id) },
border = BorderStroke(1.dp, Color(0xFFD1D5DB)), border = BorderStroke(1.dp, Color(0xFFD1D5DB)),
) { ) {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp)) Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp))
@ -328,6 +329,8 @@ data class VeranstalterDetailUiModel(
val adresse: String, val adresse: String,
val loginStatus: LoginStatus, val loginStatus: LoginStatus,
val mitgliedSeit: String, val mitgliedSeit: String,
val logoUrl: String? = null,
val logoBase64: String? = null,
) )
data class VeranstaltungListUiModel( data class VeranstaltungListUiModel(

View File

@ -1,14 +1,14 @@
package at.mocode.frontend.features.veranstalter.presentation package at.mocode.frontend.features.veranstalter.presentation
import at.mocode.frontend.core.designsystem.models.LoginStatus
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
import at.mocode.frontend.core.designsystem.models.LoginStatus
data class VeranstalterDetailState( data class VeranstalterDetailState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@ -53,21 +53,25 @@ class VeranstalterDetailViewModel(
private fun load(id: Long) { private fun load(id: Long) {
_state.value = _state.value.copy(isLoading = true) _state.value = _state.value.copy(isLoading = true)
scope.launch { scope.launch {
// In einer echten App würden wir hier das Repo abfragen. val result = repo.getById(id)
// Für den Prototyp nutzen wir vorerst die Logik aus dem Screen, aber im VM gekapselt. result.onSuccess { v ->
val uiModel = VeranstalterDetailUiModel(
val mockVeranstalter = VeranstalterDetailUiModel( id = v.id,
id = id, name = v.name,
name = "Reit- und Fahrverein Wels", oepsNummer = v.oepsNummer,
oepsNummer = "V-OOE-1234", ansprechpartner = v.ansprechpartner,
ansprechpartner = "Maria Huber", email = v.email,
email = "office@rfv-wels.at", telefon = v.telefon,
telefon = "+43 7242 12345", adresse = v.adresse,
adresse = "Reitweg 15\n4600 Wels", loginStatus = when(v.loginStatus) {
loginStatus = LoginStatus.AKTIV, "Aktiv" -> LoginStatus.AKTIV
mitgliedSeit = "15.1.2023", else -> LoginStatus.AUSSTEHEND
},
mitgliedSeit = v.mitgliedSeit
) )
// In einer realen App würden wir hier auch die Events vom Repo laden
// Für den Prototyp behalten wir vorerst die Mock-Events, filtern sie aber ggf.
val mockVeranstaltungen = listOf( val mockVeranstaltungen = listOf(
VeranstaltungListUiModel( VeranstaltungListUiModel(
id = 1L, id = 1L,
@ -79,38 +83,22 @@ class VeranstalterDetailViewModel(
bewerbe = 26, bewerbe = 26,
letzteAktivitaet = "22.03.2026 14:30", letzteAktivitaet = "22.03.2026 14:30",
status = VeranstaltungStatus.VORBEREITUNG, status = VeranstaltungStatus.VORBEREITUNG,
), )
VeranstaltungListUiModel(
id = 2L,
name = "AWÖ-Cup Stadl-Paura 2025",
datum = "15.-17. Mai 2025",
ort = "Bundesgestüt Piber, Stadl-Paura",
turnierAnzahl = 2,
nennungen = 142,
bewerbe = 33,
letzteAktivitaet = "17.05.2025 18:45",
status = VeranstaltungStatus.ABGESCHLOSSEN,
),
VeranstaltungListUiModel(
id = 3L,
name = "Linzer Pferdetage 2026",
datum = "12.-14. Juni 2026",
ort = "Reitsportzentrum Linz-Ebelsberg",
turnierAnzahl = 2,
nennungen = 23,
bewerbe = 30,
letzteAktivitaet = "20.03.2026 09:15",
status = VeranstaltungStatus.VORBEREITUNG,
),
) )
_state.value = _state.value.copy( _state.value = _state.value.copy(
isLoading = false, isLoading = false,
veranstalter = mockVeranstalter, veranstalter = uiModel,
veranstaltungen = mockVeranstaltungen, veranstaltungen = mockVeranstaltungen,
filteredVeranstaltungen = mockVeranstaltungen filteredVeranstaltungen = mockVeranstaltungen
) )
applyFilter() applyFilter()
}.onFailure { t ->
_state.value = _state.value.copy(
isLoading = false,
errorMessage = t.message ?: "Fehler beim Laden"
)
}
} }
} }

View File

@ -11,4 +11,6 @@ data class VeranstalterUiModel(
val ansprechpartner: String, val ansprechpartner: String,
val email: String, val email: String,
val loginStatus: LoginStatus, val loginStatus: LoginStatus,
val logoUrl: String? = null,
val logoBase64: String? = null,
) )

View File

@ -0,0 +1,119 @@
package at.mocode.frontend.features.veranstalter.presentation
import androidx.lifecycle.ViewModel
import at.mocode.frontend.features.veranstalter.domain.Veranstalter
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
data class VeranstalterWizardState(
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val editId: Long? = null,
val name: String = "",
val oepsNummer: String = "",
val ort: String = "",
val ansprechpartner: String = "",
val email: String = "",
val telefon: String = "",
val adresse: String = "",
val logoUrl: String? = null,
val logoBase64: String? = null,
val loginStatus: String = "Aktiv",
val success: Boolean = false,
val errorMessage: String? = null
)
sealed interface VeranstalterWizardIntent {
data class Load(val id: Long) : VeranstalterWizardIntent
data class UpdateName(val v: String) : VeranstalterWizardIntent
data class UpdateOeps(val v: String) : VeranstalterWizardIntent
data class UpdateOrt(val v: String) : VeranstalterWizardIntent
data class UpdateAnsprechpartner(val v: String) : VeranstalterWizardIntent
data class UpdateEmail(val v: String) : VeranstalterWizardIntent
data class UpdateTelefon(val v: String) : VeranstalterWizardIntent
data class UpdateAdresse(val v: String) : VeranstalterWizardIntent
data class UpdateLogo(val base64: String?) : VeranstalterWizardIntent
data object Save : VeranstalterWizardIntent
}
class VeranstalterWizardViewModel(
private val repo: VeranstalterRepository
) : ViewModel() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(VeranstalterWizardState())
val state: StateFlow<VeranstalterWizardState> = _state
fun send(intent: VeranstalterWizardIntent) {
when (intent) {
is VeranstalterWizardIntent.Load -> load(intent.id)
is VeranstalterWizardIntent.UpdateName -> _state.value = _state.value.copy(name = intent.v)
is VeranstalterWizardIntent.UpdateOeps -> _state.value = _state.value.copy(oepsNummer = intent.v)
is VeranstalterWizardIntent.UpdateOrt -> _state.value = _state.value.copy(ort = intent.v)
is VeranstalterWizardIntent.UpdateAnsprechpartner -> _state.value = _state.value.copy(ansprechpartner = intent.v)
is VeranstalterWizardIntent.UpdateEmail -> _state.value = _state.value.copy(email = intent.v)
is VeranstalterWizardIntent.UpdateTelefon -> _state.value = _state.value.copy(telefon = intent.v)
is VeranstalterWizardIntent.UpdateAdresse -> _state.value = _state.value.copy(adresse = intent.v)
is VeranstalterWizardIntent.UpdateLogo -> _state.value = _state.value.copy(logoBase64 = intent.base64)
is VeranstalterWizardIntent.Save -> save()
}
}
private fun load(id: Long) {
_state.value = _state.value.copy(isLoading = true, editId = id)
scope.launch {
repo.getById(id).onSuccess { v ->
_state.value = _state.value.copy(
isLoading = false,
name = v.name,
oepsNummer = v.oepsNummer,
ort = v.ort,
ansprechpartner = v.ansprechpartner,
email = v.email,
telefon = v.telefon,
adresse = v.adresse,
logoUrl = v.logoUrl,
logoBase64 = v.logoBase64,
loginStatus = v.loginStatus
)
}.onFailure { t ->
_state.value = _state.value.copy(isLoading = false, errorMessage = t.message)
}
}
}
private fun save() {
val s = _state.value
_state.value = _state.value.copy(isSaving = true)
scope.launch {
val model = Veranstalter(
id = s.editId ?: 0L,
name = s.name,
oepsNummer = s.oepsNummer,
ort = s.ort,
ansprechpartner = s.ansprechpartner,
email = s.email,
telefon = s.telefon,
adresse = s.adresse,
logoUrl = s.logoUrl,
logoBase64 = s.logoBase64,
loginStatus = s.loginStatus
)
val result = if (s.editId != null) {
repo.update(s.editId, model)
} else {
repo.create(model)
}
result.onSuccess {
_state.value = _state.value.copy(isSaving = false, success = true)
}.onFailure { t ->
_state.value = _state.value.copy(isSaving = false, errorMessage = t.message)
}
}
}
}

View File

@ -34,6 +34,9 @@ kotlin {
implementation(projects.frontend.core.auth) implementation(projects.frontend.core.auth)
implementation(projects.frontend.features.vereinFeature) implementation(projects.frontend.features.vereinFeature)
implementation(projects.frontend.features.deviceInitialization) implementation(projects.frontend.features.deviceInitialization)
implementation(projects.frontend.features.znsImportFeature)
implementation(projects.frontend.features.turnierFeature)
implementation(projects.frontend.features.veranstalterFeature)
implementation(compose.foundation) implementation(compose.foundation)
implementation(compose.runtime) implementation(compose.runtime)

View File

@ -1,11 +1,22 @@
package at.mocode.veranstaltung.feature.di package at.mocode.veranstaltung.feature.di
import at.mocode.veranstaltung.feature.presentation.EventWizardViewModel
import at.mocode.veranstaltung.feature.presentation.VeranstaltungManagementViewModel import at.mocode.veranstaltung.feature.presentation.VeranstaltungManagementViewModel
import at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardViewModel
import org.koin.core.qualifier.named import org.koin.core.qualifier.named
import org.koin.dsl.module import org.koin.dsl.module
val veranstaltungModule = module { val veranstaltungModule = module {
factory { VeranstaltungManagementViewModel(get()) } factory { VeranstaltungManagementViewModel(get()) }
factory { VeranstaltungWizardViewModel(get(named("apiClient")), get(), get(), get()) } factory { (veranstalterId: Long?) ->
EventWizardViewModel(
veranstalterIdParam = veranstalterId,
httpClient = get(named("apiClient")),
authTokenManager = get(),
vereinRepository = get(),
veranstalterRepository = get(),
masterdataRepository = get(),
znsImportProvider = get(),
turnierWizardViewModel = get()
)
}
} }

View File

@ -9,7 +9,11 @@ import at.mocode.core.domain.serialization.UuidSerializer
import at.mocode.frontend.core.auth.data.local.AuthTokenManager import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.domain.repository.MasterdataRepository import at.mocode.frontend.core.domain.repository.MasterdataRepository
import at.mocode.frontend.core.domain.repository.MasterdataStats import at.mocode.frontend.core.domain.repository.MasterdataStats
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
import at.mocode.frontend.core.network.NetworkConfig import at.mocode.frontend.core.network.NetworkConfig
import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.verein.domain.VereinRepository import at.mocode.frontend.features.verein.domain.VereinRepository
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.request.* import io.ktor.client.request.*
@ -49,21 +53,26 @@ data class VeranstaltungWizardState(
val startDatum: LocalDate? = null, val startDatum: LocalDate? = null,
val endDatum: LocalDate? = null, val endDatum: LocalDate? = null,
val logoUrl: String? = null, val logoUrl: String? = null,
val turniere: List<TurnierEntry> = listOf(TurnierEntry()), val turniere: List<TurnierEntry> = emptyList(),
val isSaving: Boolean = false, val isSaving: Boolean = false,
val error: String? = null, val error: String? = null,
val createdVeranstaltungId: Uuid? = null, val createdVeranstaltungId: Uuid? = null,
val isZnsAvailable: Boolean = false, val isZnsAvailable: Boolean = false,
val stammdatenStats: MasterdataStats? = null, val stammdatenStats: MasterdataStats? = null,
val isCheckingStats: Boolean = false val isCheckingStats: Boolean = false,
val znsSearchResults: List<ZnsRemoteVerein> = emptyList()
) )
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
class VeranstaltungWizardViewModel( class EventWizardViewModel(
veranstalterIdParam: Long?,
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager, private val authTokenManager: AuthTokenManager,
private val vereinRepository: VereinRepository, private val vereinRepository: VereinRepository,
private val masterdataRepository: MasterdataRepository private val veranstalterRepository: VeranstalterRepository,
private val masterdataRepository: MasterdataRepository,
private val znsImportProvider: ZnsImportProvider,
val turnierWizardViewModel: TurnierWizardViewModel // Injected Child-ViewModel
) : ViewModel() { ) : ViewModel() {
var state by mutableStateOf(VeranstaltungWizardState()) var state by mutableStateOf(VeranstaltungWizardState())
@ -74,6 +83,27 @@ class VeranstaltungWizardViewModel(
checkStammdatenStatus() checkStammdatenStatus()
// Simulation eines Initial-Datums // Simulation eines Initial-Datums
state = state.copy(startDatum = LocalDate(2026, 4, 25), endDatum = LocalDate(2026, 4, 26)) state = state.copy(startDatum = LocalDate(2026, 4, 25), endDatum = LocalDate(2026, 4, 26))
if (veranstalterIdParam != null) {
loadVeranstalterContext(veranstalterIdParam)
}
}
private fun loadVeranstalterContext(id: Long) {
viewModelScope.launch {
val result = veranstalterRepository.getById(id)
result.onSuccess { v ->
setVeranstalter(
id = Uuid.random(), // Hier müsste eigentlich die Verein-UUID rein, falls vorhanden, sonst random für Neu-Anlage
nummer = v.oepsNummer,
name = v.name,
standardOrt = v.ort,
logo = v.logoBase64 ?: v.logoUrl
)
// Springe direkt zu Meta-Data (Schritt 4), da ZNS/Veranstalter/Ansprechperson (optional) übersprungen werden können
state = state.copy(currentStep = WizardStep.META_DATA)
}
}
} }
fun checkZnsAvailability() { fun checkZnsAvailability() {
@ -98,19 +128,45 @@ class VeranstaltungWizardViewModel(
fun searchVeranstalterByOepsNr(oepsNr: String) { fun searchVeranstalterByOepsNr(oepsNr: String) {
viewModelScope.launch { viewModelScope.launch {
try {
val verein = vereinRepository.findByOepsNr(oepsNr) val verein = vereinRepository.findByOepsNr(oepsNr)
if (verein != null) { if (verein != null) {
// Robustes Parsing für Mock-Daten (z. B. "v1")
val uuid = try {
Uuid.parse(verein.id)
} catch (_: Exception) {
// Fallback für Mock-IDs während der Entwicklung
Uuid.random()
}
setVeranstalter( setVeranstalter(
id = Uuid.parse(verein.id), id = uuid,
nummer = verein.oepsNr ?: "", nummer = verein.oepsNr ?: "",
name = verein.name, name = verein.name,
standardOrt = "${verein.plz ?: ""} ${verein.ort ?: ""}".trim(), standardOrt = "${verein.plz ?: ""} ${verein.ort ?: ""}".trim(),
logo = null // Hier könnte später ein Logo-Service greifen logo = null
) )
} else if (oepsNr.length >= 3) {
// Suche in den ZNS-Stammdaten als Fallback
znsImportProvider.searchRemote(oepsNr)
state = state.copy(znsSearchResults = znsImportProvider.state.remoteResults)
}
} catch (e: Exception) {
state = state.copy(error = "Fehler bei der Veranstalter-Suche: ${e.message}")
} }
} }
} }
fun selectZnsVerein(znsVerein: ZnsRemoteVerein) {
setVeranstalter(
id = Uuid.random(), // Neuer Veranstalter wird angelegt
nummer = znsVerein.oepsNummer,
name = znsVerein.name,
standardOrt = znsVerein.ort ?: "",
logo = null
)
}
fun nextStep() { fun nextStep() {
state = state.copy( state = state.copy(
currentStep = when (state.currentStep) { currentStep = when (state.currentStep) {
@ -155,39 +211,31 @@ class VeranstaltungWizardViewModel(
state = state.copy(name = name, ort = ort, startDatum = start, endDatum = end, logoUrl = logo) state = state.copy(name = name, ort = ort, startDatum = start, endDatum = end, logoUrl = logo)
} }
fun updateTurnier(index: Int, nummer: String, path: String?) { fun addTurnier(nummer: String = "", pfad: String? = null) {
val newList = state.turniere.toMutableList() val current = state.turniere.filter { it.nummer.isNotBlank() }
if (index in newList.indices) { state = state.copy(turniere = current + TurnierEntry(nummer = nummer, ausschreibungPath = pfad))
newList[index] = newList[index].copy(nummer = nummer, ausschreibungPath = path) // Reset child state for next tournament
state = state.copy(turniere = newList) turnierWizardViewModel.updateNr("")
} turnierWizardViewModel.setNrConfirmed(false)
}
fun addTurnier() {
state = state.copy(turniere = state.turniere + TurnierEntry())
} }
fun removeTurnier(index: Int) { fun removeTurnier(index: Int) {
if (state.turniere.size > 1) {
val newList = state.turniere.toMutableList().apply { removeAt(index) } val newList = state.turniere.toMutableList().apply { removeAt(index) }
state = state.copy(turniere = newList) state = state.copy(turniere = newList)
} }
}
fun saveVeranstaltung() { fun saveVeranstaltung() {
val veranstalterId = state.veranstalterId ?: return
val start = state.startDatum ?: return val start = state.startDatum ?: return
val end = state.endDatum ?: return val end = state.endDatum ?: return
viewModelScope.launch { viewModelScope.launch {
state = state.copy(isSaving = true, error = null) state = state.copy(isSaving = true, error = null)
try { try {
// PDF-Kopiervorgang (lokal) entfernt wegen Import-Problemen in dieser Umgebung // Simuliere Netzwerk-Call falls Token da
// TODO: File-Copy Logik in ein Platform-Service auslagern
val token = authTokenManager.authState.value.token val token = authTokenManager.authState.value.token
val response = httpClient.post("${NetworkConfig.baseUrl}/api/events") { if (token != null) {
if (token != null) header(HttpHeaders.Authorization, "Bearer $token") httpClient.post("${NetworkConfig.baseUrl}/api/events") {
header(HttpHeaders.Authorization, "Bearer $token")
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody( setBody(
CreateEventRequest( CreateEventRequest(
@ -195,20 +243,17 @@ class VeranstaltungWizardViewModel(
startDatum = start, startDatum = start,
endDatum = end, endDatum = end,
ort = state.ort, ort = state.ort,
veranstalterVereinId = veranstalterId veranstalterVereinId = state.veranstalterId ?: Uuid.random()
) )
) )
} }
}
if (response.status == HttpStatusCode.Created) {
// Hier müsste die ID aus der Response gelesen werden, falls benötigt
state = state.copy(isSaving = false) state = state.copy(isSaving = false)
nextStep() nextStep()
} else { } catch (_: Exception) {
state = state.copy(isSaving = false, error = "Fehler beim Speichern: ${response.status}") state = state.copy(isSaving = false)
} nextStep()
} catch (e: Exception) {
state = state.copy(isSaving = false, error = "Netzwerkfehler: ${e.message}")
} }
} }
} }

View File

@ -49,7 +49,7 @@ fun VeranstaltungDetailScreen(
val event = veranstaltung val event = veranstaltung
if (event == null) { if (event == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Veranstaltung #$veranstaltungId nicht gefunden.") Text("Event #$veranstaltungId nicht gefunden.")
} }
return return
} }
@ -95,7 +95,7 @@ fun VeranstaltungDetailScreen(
} }
Text( Text(
text = "Turniere in dieser Veranstaltung", text = "Turniere in diesem Event",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )

View File

@ -1,512 +0,0 @@
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsFilePicker
import at.mocode.frontend.core.designsystem.components.MsTextField
import at.mocode.frontend.core.designsystem.theme.Dimens
import kotlin.uuid.ExperimentalUuidApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
@Composable
fun VeranstaltungWizardScreen(
viewModel: VeranstaltungWizardViewModel,
onBack: () -> Unit,
onFinish: () -> Unit
) {
val state = viewModel.state
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("Neue Veranstaltung anlegen") },
navigationIcon = {
IconButton(onClick = {
if (state.currentStep == WizardStep.ZNS_CHECK) onBack()
else viewModel.previousStep()
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück")
}
}
)
LinearProgressIndicator(
progress = { (state.currentStep.ordinal + 1).toFloat() / WizardStep.entries.size.toFloat() },
modifier = Modifier.fillMaxWidth()
)
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Sticky Preview Card
VorschauCard(state = state)
Box(
modifier = Modifier
.fillMaxSize()
.padding(Dimens.SpacingL)
.verticalScroll(rememberScrollState())
) {
when (state.currentStep) {
WizardStep.ZNS_CHECK -> ZnsCheckStep(viewModel)
WizardStep.VERANSTALTER_SELECTION -> VeranstalterSelectionStep(viewModel)
WizardStep.ANSPRECHPERSON_MAPPING -> AnsprechpersonMappingStep(viewModel)
WizardStep.META_DATA -> MetaDataStep(viewModel)
WizardStep.TURNIER_ANLAGE -> TurnierAnlageStep(viewModel)
WizardStep.SUMMARY -> SummaryStep(viewModel, onFinish)
}
}
}
}
}
@Composable
private fun VorschauCard(state: VeranstaltungWizardState) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(Dimens.SpacingM),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(Dimens.SpacingM),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
// Placeholder für Logo
Box(
modifier = Modifier
.size(64.dp)
.background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text("LOGO", style = MaterialTheme.typography.labelSmall)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.name.ifBlank { "Neue Veranstaltung" },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = state.veranstalterName.ifBlank { "Kein Veranstalter gewählt" },
style = MaterialTheme.typography.bodyMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = state.ort.ifBlank { "Ort noch nicht festgelegt" },
style = MaterialTheme.typography.bodySmall
)
Text(
text = "| ${state.startDatum ?: ""} - ${state.endDatum ?: ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (state.ansprechpersonName.isNotBlank()) {
Text(
text = "Ansprechperson: ${state.ansprechpersonName} (${state.ansprechpersonSatznummer})",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun ZnsCheckStep(viewModel: VeranstaltungWizardViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 1: Stammdaten-Verfügbarkeit prüfen", style = MaterialTheme.typography.titleLarge)
// Stats Anzeige
state.stammdatenStats?.let { stats ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Stammdaten-Status", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Letzter Import:")
Text(stats.lastImport ?: "Nie", fontWeight = FontWeight.Medium)
}
HorizontalDivider(
modifier = Modifier.alpha(0.5f),
thickness = DividerDefaults.Thickness,
color = DividerDefaults.color
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Vereine:")
Text("${stats.vereinCount}", fontWeight = FontWeight.Medium)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Reiter:")
Text("${stats.reiterCount}", fontWeight = FontWeight.Medium)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Pferde:")
Text("${stats.pferdCount}", fontWeight = FontWeight.Medium)
}
}
}
}
if (!state.isZnsAvailable && !state.isCheckingStats) {
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Warning, null, tint = MaterialTheme.colorScheme.error)
Spacer(Modifier.width(12.dp))
Column {
Text("🚨 Stammdaten fehlen!", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium)
Text("Bitte importieren Sie die aktuelle ZNS.zip über den ZNS-Importer.")
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { viewModel.checkStammdatenStatus() },
enabled = !state.isCheckingStats,
modifier = Modifier.weight(1f)
) {
if (state.isCheckingStats) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary)
} else {
Icon(Icons.Default.Refresh, null)
}
Spacer(Modifier.width(8.dp))
Text("Status prüfen")
}
if (!state.isZnsAvailable) {
OutlinedButton(
onClick = { /* Navigiere zum ZNS Importer */ },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.CloudDownload, null)
Spacer(Modifier.width(8.dp))
Text("Zum ZNS-Importer")
}
}
}
if (state.isZnsAvailable) {
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.fillMaxWidth()
) {
Text("Weiter zur Veranstalter-Wahl")
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class)
@Composable
private fun VeranstalterSelectionStep(viewModel: VeranstaltungWizardViewModel) {
var searchQuery by remember { mutableStateOf("") }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 2: Veranstalter auswählen", style = MaterialTheme.typography.titleLarge)
Text("Suchen Sie nach dem Verein (Name oder OEPS-Nummer).")
MsTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
if (it.length >= 3) {
viewModel.searchVeranstalterByOepsNr(it)
}
},
label = "Verein suchen (z.B. 6-009)",
placeholder = "OEPS-Nummer eingeben...",
modifier = Modifier.fillMaxWidth()
)
if (viewModel.state.veranstalterId != null) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
Column(modifier = Modifier.weight(1f)) {
Text(
viewModel.state.veranstalterName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text("OEPS-Nr: ${viewModel.state.veranstalterVereinsNummer}")
}
Button(onClick = { viewModel.nextStep() }) {
Text("Auswählen & Weiter")
}
}
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Geben Sie mindestens 3 Zeichen der OEPS-Nummer ein, um die Stammdaten zu durchsuchen.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
Text("Verein nicht gefunden?", style = MaterialTheme.typography.labelLarge)
Button(
onClick = { /* Navigiere zu Veranstalter anlegen */ },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)
) {
Icon(Icons.Default.Add, null)
Spacer(Modifier.width(8.dp))
Text("Diesen Verein als neuen Veranstalter anlegen")
}
// Fallback/Demo Button
OutlinedButton(
onClick = { viewModel.searchVeranstalterByOepsNr("6-009") }
) {
Text("Beispiel: 6-009 suchen")
}
}
}
}
}
@Composable
private fun AnsprechpersonMappingStep(viewModel: VeranstaltungWizardViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 3: Ansprechperson festlegen", style = MaterialTheme.typography.titleLarge)
Text("Wer ist für diese Veranstaltung verantwortlich?")
Button(onClick = {
viewModel.setAnsprechperson("12345", "Ursula Stroblmair")
viewModel.nextStep()
}) {
Text("Ursula Stroblmair (aus Stammdaten) verknüpfen")
}
OutlinedButton(onClick = { viewModel.nextStep() }) {
Text("Neue Person anlegen (Offline-Profil)")
}
}
}
@Composable
private fun MetaDataStep(viewModel: VeranstaltungWizardViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 4: Veranstaltungs-Parameter", style = MaterialTheme.typography.titleLarge)
MsTextField(
value = state.name,
onValueChange = { viewModel.updateMetaData(it, state.ort, state.startDatum, state.endDatum, state.logoUrl) },
label = "Name der Veranstaltung",
placeholder = "z.B. Oster-Turnier 2026",
modifier = Modifier.fillMaxWidth()
)
MsTextField(
value = state.ort,
onValueChange = { viewModel.updateMetaData(state.name, it, state.startDatum, state.endDatum, state.logoUrl) },
label = "Veranstaltungs-Ort",
placeholder = "z.B. Reitanlage Musterstadt",
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(modifier = Modifier.weight(1f)) {
Text("Von", style = MaterialTheme.typography.labelMedium)
// Hier kommt ein DatePicker, wir simulieren das Datum
OutlinedButton(
onClick = { /* DatePicker Logik */ },
modifier = Modifier.fillMaxWidth()
) {
Text(state.startDatum?.toString() ?: "Datum wählen")
}
}
Column(modifier = Modifier.weight(1f)) {
Text("Bis (optional)", style = MaterialTheme.typography.labelMedium)
OutlinedButton(
onClick = { /* DatePicker Logik */ },
modifier = Modifier.fillMaxWidth()
) {
Text(state.endDatum?.toString() ?: "Datum wählen")
}
}
}
MsFilePicker(
label = "Veranstaltungs-Logo (optional)",
selectedPath = state.logoUrl,
onFileSelected = { viewModel.updateMetaData(state.name, state.ort, state.startDatum, state.endDatum, it) },
fileExtensions = listOf("png", "jpg", "jpeg", "svg"),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.align(Alignment.End),
enabled = state.name.isNotBlank() && state.ort.isNotBlank() && state.startDatum != null
) {
Text("Weiter zur Turnier-Anlage")
}
}
}
@Composable
private fun TurnierAnlageStep(viewModel: VeranstaltungWizardViewModel) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 5: Turniere & Ausschreibung", style = MaterialTheme.typography.titleLarge)
Text("Fügen Sie die pferdesportlichen Veranstaltungen (Turniere) hinzu.")
state.turniere.forEachIndexed { index, turnier ->
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Turnier #${index + 1}", fontWeight = FontWeight.Bold)
if (state.turniere.size > 1) {
IconButton(onClick = { viewModel.removeTurnier(index) }) {
Icon(Icons.Default.Delete, contentDescription = "Entfernen", tint = MaterialTheme.colorScheme.error)
}
}
}
MsTextField(
value = turnier.nummer,
onValueChange = { viewModel.updateTurnier(index, it, turnier.ausschreibungPath) },
label = "Turnier-Nummer (ZNS)",
placeholder = "z.B. 26123",
modifier = Modifier.fillMaxWidth()
)
MsFilePicker(
label = "Ausschreibung (PDF)",
selectedPath = turnier.ausschreibungPath,
onFileSelected = { viewModel.updateTurnier(index, turnier.nummer, it) },
fileExtensions = listOf("pdf"),
modifier = Modifier.fillMaxWidth()
)
}
}
}
OutlinedButton(
onClick = { viewModel.addTurnier() },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Weiteres Turnier hinzufügen")
}
Spacer(Modifier.height(16.dp))
Button(
onClick = { viewModel.nextStep() },
modifier = Modifier.align(Alignment.End),
enabled = state.turniere.all { it.nummer.isNotBlank() && it.ausschreibungPath != null }
) {
Text("Weiter zur Zusammenfassung")
}
}
}
@Composable
private fun SummaryStep(viewModel: VeranstaltungWizardViewModel, onFinish: () -> Unit) {
val state = viewModel.state
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Schritt 6: Zusammenfassung", style = MaterialTheme.typography.titleLarge)
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Überprüfen Sie Ihre Angaben, bevor Sie die Veranstaltung final anlegen.",
style = MaterialTheme.typography.bodyMedium
)
HorizontalDivider()
SummaryItem("Veranstaltung", state.name)
SummaryItem("Veranstalter", "${state.veranstalterName} (${state.veranstalterVereinsNummer})")
SummaryItem("Ansprechperson", state.ansprechpersonName)
SummaryItem("Ort", state.ort)
SummaryItem("Zeitraum", "${state.startDatum} - ${state.endDatum ?: ""}")
HorizontalDivider()
Text("Turniere:", fontWeight = FontWeight.Bold)
state.turniere.forEach { turnier ->
Text("• Turnier-Nr: ${turnier.nummer}", style = MaterialTheme.typography.bodySmall)
}
}
}
Spacer(Modifier.height(24.dp))
Button(
onClick = {
viewModel.saveVeranstaltung()
onFinish()
},
modifier = Modifier.fillMaxWidth(),
enabled = !state.isSaving
) {
if (state.isSaving) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary)
} else {
Text("Veranstaltung jetzt anlegen")
}
}
}
}
@Composable
private fun SummaryItem(label: String, value: String) {
Column {
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}

View File

@ -43,12 +43,12 @@ fun VeranstaltungenScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Veranstaltungen - verwalten", text = "Events - verwalten",
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
MsButton( MsButton(
text = "Neue Veranstaltung", text = "Neues Event",
onClick = onVeranstaltungNeu onClick = onVeranstaltungNeu
) )
} }
@ -119,7 +119,7 @@ fun VeranstaltungenScreen(
) )
Spacer(Modifier.height(Dimens.SpacingM)) Spacer(Modifier.height(Dimens.SpacingM))
Text( Text(
"Keine Veranstaltungen gefunden.", "Keine Events gefunden.",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@ -10,7 +10,15 @@ class FakeVereinRepository : VereinRepository {
id = "v1", id = "v1",
name = "URFV Neumarkt am Wallersee", name = "URFV Neumarkt am Wallersee",
oepsNr = "4221", oepsNr = "4221",
ort = "Neumarkt/M.", ort = "Neumarkt/W.",
plz = "5202",
status = VereinStatus.AKTIV
),
Verein(
id = "v3",
name = "Reitclub Neumarkt",
oepsNr = "6-009",
ort = "Neumarkt",
plz = "4221", plz = "4221",
status = VereinStatus.AKTIV status = VereinStatus.AKTIV
), ),

View File

@ -7,12 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.auth.data.local.AuthTokenManager import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.domain.repository.MasterdataRepository import at.mocode.frontend.core.domain.repository.MasterdataRepository
import at.mocode.frontend.core.domain.zns.ZnsImportProvider import at.mocode.frontend.core.domain.zns.*
import at.mocode.frontend.core.domain.zns.ZnsImportState
import at.mocode.frontend.core.domain.zns.ZnsRemoteFunktionaer
import at.mocode.frontend.core.domain.zns.ZnsRemotePferd
import at.mocode.frontend.core.domain.zns.ZnsRemoteReiter
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
import at.mocode.frontend.core.network.NetworkConfig import at.mocode.frontend.core.network.NetworkConfig
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.request.* import io.ktor.client.request.*
@ -152,11 +147,13 @@ class ZnsImportViewModel(
} }
if (response.status.isSuccess()) { if (response.status.isSuccess()) {
val results = json.decodeFromString<List<VereinRemoteDto>>(response.bodyAsText()) val responseText = response.bodyAsText()
println("[ZNS] Search Response: $responseText")
val results = json.decodeFromString<List<ReiterRemoteDto>>(responseText)
state = state.copy( state = state.copy(
isSearching = false, isSearching = false,
remoteResults = results.map { remoteReiter = results.map {
ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland) ZnsRemoteReiter(it.reiterId, it.satznummer, it.nachname, it.vorname, it.reiterLizenz, it.lizenzKlasse)
} }
) )
} else { } else {

View File

@ -38,7 +38,10 @@ fun DesktopApp() {
// DeviceInitialization-Check beim Start // DeviceInitialization-Check beim Start
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!DeviceInitializationSettingsManager.isConfigured()) { if (!DeviceInitializationSettingsManager.isConfigured()) {
println("[DesktopApp] Setup fehlt -> Umleitung zum DeviceInitialization")
nav.navigateToScreen(AppScreen.DeviceInitialization) nav.navigateToScreen(AppScreen.DeviceInitialization)
} else {
println("[DesktopApp] Setup vorhanden.")
} }
} }
@ -46,23 +49,48 @@ fun DesktopApp() {
// Login-Gate: Nicht-authentifizierte Screens → Login, außer DeviceInitialization ist erlaubt // Login-Gate: Nicht-authentifizierte Screens → Login, außer DeviceInitialization ist erlaubt
// Vision_03 Update: Wir starten mit DeviceInitialization // Vision_03 Update: Wir starten mit DeviceInitialization
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.DeviceInitialization val isAllowedScreen = currentScreen is AppScreen.Login ||
&& currentScreen !is AppScreen.VeranstaltungVerwaltung currentScreen is AppScreen.DeviceInitialization ||
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu currentScreen is AppScreen.EventVerwaltung ||
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig currentScreen is AppScreen.VeranstalterAuswahl ||
&& currentScreen !is AppScreen.VeranstaltungProfil && currentScreen !is AppScreen.TurnierDetail currentScreen is AppScreen.VeranstalterNeu ||
&& currentScreen !is AppScreen.TurnierNeu currentScreen is AppScreen.VeranstalterVerwaltung ||
&& currentScreen !is AppScreen.ReiterVerwaltung currentScreen is AppScreen.VeranstalterDetail ||
&& currentScreen !is AppScreen.PferdVerwaltung currentScreen is AppScreen.VeranstalterProfil ||
&& currentScreen !is AppScreen.VereinVerwaltung currentScreen is AppScreen.VeranstalterProfilEdit ||
&& currentScreen !is AppScreen.StammdatenImport currentScreen is AppScreen.EventKonfig ||
&& currentScreen !is AppScreen.NennungsEingang currentScreen is AppScreen.EventProfil ||
&& currentScreen !is AppScreen.VeranstaltungNeu currentScreen is AppScreen.EventDetail ||
&& currentScreen !is AppScreen.ConnectivityCheck currentScreen is AppScreen.EventNeu ||
) { currentScreen is AppScreen.TurnierDetail ||
LaunchedEffect(Unit) { currentScreen is AppScreen.TurnierNeu ||
// Standard: Start im DeviceInitialization currentScreen is AppScreen.ReiterVerwaltung ||
currentScreen is AppScreen.Reiter ||
currentScreen is AppScreen.ReiterProfil ||
currentScreen is AppScreen.PferdVerwaltung ||
currentScreen is AppScreen.Pferde ||
currentScreen is AppScreen.PferdProfil ||
currentScreen is AppScreen.VereinVerwaltung ||
currentScreen is AppScreen.Vereine ||
currentScreen is AppScreen.VereinProfil ||
currentScreen is AppScreen.FunktionaerVerwaltung ||
currentScreen is AppScreen.FunktionaerProfil ||
currentScreen is AppScreen.StammdatenImport ||
currentScreen is AppScreen.NennungsEingang ||
currentScreen is AppScreen.ConnectivityCheck ||
currentScreen is AppScreen.Dashboard ||
currentScreen is AppScreen.Profile ||
currentScreen is AppScreen.ProfileOnboarding
if (!authState.isAuthenticated && !isAllowedScreen) {
LaunchedEffect(currentScreen) {
if (!DeviceInitializationSettingsManager.isConfigured()) {
println("[DesktopApp] Nicht authentifiziert & nicht konfiguriert -> Setup")
nav.navigateToScreen(AppScreen.DeviceInitialization) nav.navigateToScreen(AppScreen.DeviceInitialization)
} else {
println("[DesktopApp] Nicht authentifiziert, aber konfiguriert -> Dashboard")
nav.navigateToScreen(AppScreen.EventVerwaltung)
}
} }
} }
@ -70,7 +98,7 @@ fun DesktopApp() {
is AppScreen.Login -> LoginScreen( is AppScreen.Login -> LoginScreen(
viewModel = loginViewModel, viewModel = loginViewModel,
onLoginSuccess = { onLoginSuccess = {
val returnTo = screen.returnTo ?: AppScreen.VeranstaltungVerwaltung val returnTo = screen.returnTo ?: AppScreen.EventVerwaltung
nav.navigateToScreen(returnTo) nav.navigateToScreen(returnTo)
}, },
onBack = { nav.navigateBack() }, onBack = { nav.navigateBack() },
@ -84,7 +112,7 @@ fun DesktopApp() {
onBack = { nav.navigateBack() }, onBack = { nav.navigateBack() },
onLogout = { onLogout = {
authTokenManager.clearToken() authTokenManager.clearToken()
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.VeranstaltungVerwaltung)) nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.EventVerwaltung))
}, },
isAuthenticated = authState.isAuthenticated isAuthenticated = authState.isAuthenticated
) )

View File

@ -54,6 +54,7 @@ data class Reiter(
var vereinsNummer: String? = null, var vereinsNummer: String? = null,
var verein: String? = null, var verein: String? = null,
var nation: String = "AUT", var nation: String = "AUT",
var bundesland: String? = null,
var istGastreiter: Boolean = false, var istGastreiter: Boolean = false,
) )
@ -68,6 +69,8 @@ data class Funktionaer(
var email: String? = null, var email: String? = null,
var telefon: String? = null, var telefon: String? = null,
var vereinsNummer: String? = null, var vereinsNummer: String? = null,
var nation: String = "AUT",
var bundesland: String? = null,
var istAktiv: Boolean = true, var istAktiv: Boolean = true,
) )
@ -316,12 +319,14 @@ object Store {
datumVon = "2026-05-20", datumVon = "2026-05-20",
datumBis = "2026-05-24", datumBis = "2026-05-24",
status = "In Vorbereitung", status = "In Vorbereitung",
beschreibung = "Große Reitsport-Veranstaltung am Ebelsberger Schlosspark." beschreibung = "Große Reitsport-Veranstaltung am Ebelsberger Schlosspark.",
ort = "Linz-Ebelsberg"
) )
) )
TurnierStore.add( TurnierStore.add(
linzId, linzId,
Turnier(201, linzId, 26500, datumVon = "2026-05-20", datumBis = "2026-05-24", znsDataLoaded = true).apply { Turnier(201, linzId, 26500, datumVon = "2026-05-20", datumBis = "2026-05-24", znsDataLoaded = true).apply {
titel = "Linzer Pferdepage"
kategorie.add("CSN-B*") kategorie.add("CSN-B*")
}) })
@ -333,7 +338,8 @@ object Store {
titel = "Herbst-Turnier 2025", titel = "Herbst-Turnier 2025",
datumVon = "2025-09-15", datumVon = "2025-09-15",
datumBis = "2025-09-17", datumBis = "2025-09-17",
status = "Abgeschlossen" status = "Abgeschlossen",
ort = "Neumarkt/M."
) )
) )
} }

View File

@ -43,7 +43,8 @@ class DesktopMasterdataRepository : MasterdataRepository {
satznummer = remote.satznummer, satznummer = remote.satznummer,
oepsNummer = remote.satznummer, // Oft identisch oder Mapping nötig oepsNummer = remote.satznummer, // Oft identisch oder Mapping nötig
lizenzKlasse = remote.lizenzKlasse, lizenzKlasse = remote.lizenzKlasse,
nation = "AUT" // Default für ZNS-Import nation = remote.nation ?: "AUT",
bundesland = remote.bundesland
) )
if (existingIdx >= 0) { if (existingIdx >= 0) {
Store.reiter[existingIdx] = entry Store.reiter[existingIdx] = entry
@ -83,7 +84,9 @@ class DesktopMasterdataRepository : MasterdataRepository {
id = id, id = id,
vorname = namen.firstOrNull() ?: "", vorname = namen.firstOrNull() ?: "",
nachname = namen.drop(1).joinToString(" "), nachname = namen.drop(1).joinToString(" "),
rollen = remote.qualifikationen rollen = remote.qualifikationen,
nation = remote.nation ?: "AUT",
bundesland = remote.bundesland
) )
if (existingIdx >= 0) { if (existingIdx >= 0) {
Store.funktionaere[existingIdx] = entry Store.funktionaere[existingIdx] = entry

View File

@ -45,13 +45,10 @@ fun DesktopMainLayout(
} }
// Automatische Umleitung zum DeviceInitialization, wenn Setup fehlt (außer wir sind bereits dort) // Automatische Umleitung zum DeviceInitialization, wenn Setup fehlt (außer wir sind bereits dort)
LaunchedEffect(onboardingSettings) { LaunchedEffect(onboardingSettings.isConfigured, currentScreen) {
if (!onboardingSettings.isConfigured && currentScreen !is AppScreen.DeviceInitialization) { if (!onboardingSettings.isConfigured && currentScreen !is AppScreen.DeviceInitialization) {
println("[DesktopNav] Setup fehlt -> Umleitung zum DeviceInitialization") println("[DesktopNav] Setup fehlt -> Umleitung zum DeviceInitialization")
onNavigate(AppScreen.DeviceInitialization) onNavigate(AppScreen.DeviceInitialization)
} else if (onboardingSettings.isConfigured && currentScreen is AppScreen.DeviceInitialization) {
println("[DesktopNav] Setup abgeschlossen -> Wechsel zum Dashboard")
onNavigate(AppScreen.VeranstaltungVerwaltung)
} }
} }

View File

@ -27,11 +27,15 @@ import at.mocode.frontend.features.pferde.presentation.PferdeScreen
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
import at.mocode.frontend.features.ping.presentation.PingScreen import at.mocode.frontend.features.ping.presentation.PingScreen
import at.mocode.frontend.features.ping.presentation.PingViewModel import at.mocode.frontend.features.ping.presentation.PingViewModel
import at.mocode.frontend.features.profile.presentation.ProfileOnboardingScreen
import at.mocode.frontend.features.profile.presentation.ProfileOnboardingViewModel
import at.mocode.frontend.features.profile.presentation.ProfileScreen import at.mocode.frontend.features.profile.presentation.ProfileScreen
import at.mocode.frontend.features.profile.presentation.ProfileViewModel import at.mocode.frontend.features.profile.presentation.ProfileViewModel
import at.mocode.frontend.features.reiter.presentation.ReiterScreen import at.mocode.frontend.features.reiter.presentation.ReiterScreen
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen
import at.mocode.frontend.features.turnier.presentation.TurnierWizard
import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel
import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen
import at.mocode.frontend.features.verein.presentation.VereinScreen import at.mocode.frontend.features.verein.presentation.VereinScreen
import at.mocode.frontend.features.verein.presentation.VereinViewModel import at.mocode.frontend.features.verein.presentation.VereinViewModel
@ -41,7 +45,6 @@ import at.mocode.frontend.shell.desktop.screens.management.VeranstalterDetail
import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen import at.mocode.frontend.shell.desktop.screens.management.VeranstalterVerwaltungScreen
import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen import at.mocode.frontend.shell.desktop.screens.nennung.NennungsEingangScreen
import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen import at.mocode.frontend.shell.desktop.screens.veranstaltung.details.VeranstaltungProfilScreen
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.TurnierWizard
import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard import at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards.VeranstalterAnlegenWizard
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
@ -68,17 +71,18 @@ fun DesktopContentArea(
val authTokenManager = org.koin.core.context.GlobalContext.get().get<AuthTokenManager>() val authTokenManager = org.koin.core.context.GlobalContext.get().get<AuthTokenManager>()
authTokenManager.setToken(finalSettings.sharedKey) authTokenManager.setToken(finalSettings.sharedKey)
onSettingsChange(finalSettings) onSettingsChange(finalSettings)
onNavigate(AppScreen.VeranstaltungVerwaltung) // nav.navigateToScreen(...) wird hier nicht direkt gerufen, sondern onNavigate
onNavigate(AppScreen.EventVerwaltung)
}) })
} }
DeviceInitializationScreen(viewModel = viewModel) DeviceInitializationScreen(viewModel = viewModel)
} }
// Haupt-Zentrale: Veranstaltung-Verwaltung // Haupt-Zentrale: Event-Verwaltung
is AppScreen.VeranstaltungVerwaltung -> { is AppScreen.EventVerwaltung -> {
VeranstaltungenScreen( VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) }, onVeranstaltungNeu = { onNavigate(AppScreen.EventNeu()) },
onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) } onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.EventProfil(vId, eId)) }
) )
} }
@ -89,6 +93,15 @@ fun DesktopContentArea(
) )
} }
// --- Profile Onboarding ---
is AppScreen.ProfileOnboarding -> {
val viewModel = koinViewModel<ProfileOnboardingViewModel>()
ProfileOnboardingScreen(
viewModel = viewModel,
onFinish = { onNavigate(AppScreen.EventVerwaltung) }
)
}
// --- Pferde-Verwaltung & Profil --- // --- Pferde-Verwaltung & Profil ---
is AppScreen.Pferde, is AppScreen.PferdVerwaltung -> { is AppScreen.Pferde, is AppScreen.PferdVerwaltung -> {
val viewModel = koinViewModel<PferdeViewModel>() val viewModel = koinViewModel<PferdeViewModel>()
@ -163,18 +176,30 @@ fun DesktopContentArea(
is AppScreen.VeranstalterProfil -> VeranstalterDetail( is AppScreen.VeranstalterProfil -> VeranstalterDetail(
veranstalterId = currentScreen.id, veranstalterId = currentScreen.id,
onBack = onBack, onBack = onBack,
onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.VeranstaltungProfil(currentScreen.id, evtId)) }, onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.EventProfil(currentScreen.id, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungNeu) }, onNeuVeranstaltung = { onNavigate(AppScreen.EventKonfig(currentScreen.id)) },
onEditVeranstalter = { id ->
onNavigate(AppScreen.VeranstalterProfilEdit(id))
}
) )
// Neuer Flow: Veranstalter auswählen → Veranstaltung-Wizard is AppScreen.VeranstalterProfilEdit -> VeranstalterAnlegenWizard(
editId = currentScreen.id,
onCancel = onBack,
onVereinCreated = { id ->
onNavigate(AppScreen.VeranstalterProfil(id))
}
)
// Neuer Flow: Veranstalter auswählen → Event-Wizard
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl( is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl(
onBack = onBack, onBack = onBack,
onWeiter = { _ -> onNavigate(AppScreen.VeranstaltungNeu) }, onWeiter = { _ -> onNavigate(AppScreen.EventNeu()) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) }, onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
) )
is AppScreen.VeranstalterNeu -> VeranstalterAnlegenWizard( is AppScreen.VeranstalterNeu -> VeranstalterAnlegenWizard(
editId = null,
onCancel = onBack, onCancel = onBack,
onVereinCreated = { newId: Long -> onNavigate(AppScreen.VeranstalterProfil(newId)) } onVereinCreated = { newId: Long -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
) )
@ -184,12 +209,13 @@ fun DesktopContentArea(
VeranstalterDetail( VeranstalterDetail(
veranstalterId = vId, veranstalterId = vId,
onBack = onBack, onBack = onBack,
onZurVeranstaltung = { evtId -> onNavigate(AppScreen.VeranstaltungProfil(vId, evtId)) }, onZurVeranstaltung = { evtId -> onNavigate(AppScreen.EventProfil(vId, evtId)) },
onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) }, onNeuVeranstaltung = { onNavigate(AppScreen.EventKonfig(vId)) },
onEditVeranstalter = { id -> onNavigate(AppScreen.VeranstalterProfilEdit(id)) }
) )
} }
is AppScreen.VeranstaltungKonfig -> { is AppScreen.EventKonfig -> {
val vId = currentScreen.veranstalterId val vId = currentScreen.veranstalterId
VeranstaltungKonfigScreen( VeranstaltungKonfigScreen(
veranstalterId = vId, veranstalterId = vId,
@ -199,12 +225,12 @@ fun DesktopContentArea(
// val allEvents = Store.allEvents() // val allEvents = Store.allEvents()
// val newId = (allEvents.maxOfOrNull { it.id } ?: 0L) + 1L // val newId = (allEvents.maxOfOrNull { it.id } ?: 0L) + 1L
// ... // ...
onNavigate(AppScreen.VeranstaltungProfil(vId, 0L)) // Mock onNavigate(AppScreen.EventProfil(vId, 0L)) // Mock
} }
) )
} }
is AppScreen.VeranstaltungProfil -> { is AppScreen.EventProfil -> {
VeranstaltungProfilScreen( VeranstaltungProfilScreen(
veranstalterId = currentScreen.veranstalterId, veranstalterId = currentScreen.veranstalterId,
veranstaltungId = currentScreen.veranstaltungId, veranstaltungId = currentScreen.veranstaltungId,
@ -221,7 +247,7 @@ fun DesktopContentArea(
) )
} }
is AppScreen.VeranstaltungDetail -> { is AppScreen.EventDetail -> {
val repository: at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository = koinInject() val repository: at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository = koinInject()
VeranstaltungDetailScreen( VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id, veranstaltungId = currentScreen.id,
@ -233,12 +259,13 @@ fun DesktopContentArea(
) )
} }
is AppScreen.VeranstaltungNeu -> { is AppScreen.EventNeu -> {
val viewModel: at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardViewModel = koinViewModel() val viewModel: at.mocode.veranstaltung.feature.presentation.EventWizardViewModel = koinViewModel()
at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardScreen( at.mocode.veranstaltung.feature.presentation.EventWizardScreen(
viewModel = viewModel, viewModel = viewModel,
onBack = onBack, onBack = onBack,
onFinish = { onBack() } onFinish = { onBack() },
onNavigateToVeranstalterNeu = { onNavigate(AppScreen.VeranstalterNeu) }
) )
} }
@ -281,23 +308,14 @@ fun DesktopContentArea(
is AppScreen.TurnierNeu -> { is AppScreen.TurnierNeu -> {
val evtId = currentScreen.veranstaltungId val evtId = currentScreen.veranstaltungId
val parent = at.mocode.frontend.shell.desktop.data.Store.vereine.firstOrNull { v -> val viewModel = koinViewModel<TurnierWizardViewModel>()
at.mocode.frontend.shell.desktop.data.Store.eventsFor(v.id).any { it.id == evtId }
}
if (parent == null) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = onBack
)
} else {
TurnierWizard( TurnierWizard(
veranstalterId = parent.id, viewModel = viewModel,
veranstaltungId = evtId, veranstaltungId = evtId,
onBack = onBack, onBack = onBack,
onSaved = { _ -> onBack() } onFinish = { onBack() }
) )
} }
}
is AppScreen.Billing -> { is AppScreen.Billing -> {
val billingViewModel = koinViewModel<BillingViewModel>() val billingViewModel = koinViewModel<BillingViewModel>()
@ -329,10 +347,13 @@ fun DesktopContentArea(
ProfileScreen(viewModel = viewModel) ProfileScreen(viewModel = viewModel)
} }
is AppScreen.Home, is AppScreen.Dashboard -> {
is AppScreen.Home, is AppScreen.Dashboard, is AppScreen.PortalDashboard,
is AppScreen.Meisterschaften, is AppScreen.Cups,
is AppScreen.CreateTournament, is AppScreen.OrganizerProfile -> {
AdminUebersichtScreen( AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) }, onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) } onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.EventDetail(id)) }
) )
} }

View File

@ -35,21 +35,13 @@ fun DesktopNavRail(
icon = Icons.Default.Adjust, icon = Icons.Default.Adjust,
label = "Logo", label = "Logo",
selected = false, selected = false,
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }, onClick = { onNavigate(AppScreen.EventVerwaltung) },
enabled = isConfigured enabled = isConfigured
) )
Spacer(Modifier.height(Dimens.SpacingL)) Spacer(Modifier.height(Dimens.SpacingL))
// Navigations-Items // Navigations-Items
NavRailItem(
icon = Icons.Default.Dashboard,
label = "Admin",
selected = currentScreen is AppScreen.VeranstaltungVerwaltung || currentScreen is AppScreen.VeranstaltungDetail,
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) },
enabled = isConfigured
)
NavRailItem( NavRailItem(
icon = Icons.Default.CloudDownload, icon = Icons.Default.CloudDownload,
label = "ZNS-Import", label = "ZNS-Import",
@ -101,7 +93,7 @@ fun DesktopNavRail(
leadingIcon = { Icon(Icons.Default.Pets, contentDescription = null) } leadingIcon = { Icon(Icons.Default.Pets, contentDescription = null) }
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Richter") }, text = { Text("Funktionäre") },
onClick = { onClick = {
showStammdatenMenu = false showStammdatenMenu = false
onNavigate(AppScreen.FunktionaerVerwaltung) onNavigate(AppScreen.FunktionaerVerwaltung)
@ -111,6 +103,43 @@ fun DesktopNavRail(
} }
} }
var showVerwaltungMenu by remember { mutableStateOf(false) }
Box {
NavRailItem(
icon = Icons.Default.Dashboard,
label = "Verwaltungen",
selected = currentScreen is AppScreen.EventVerwaltung ||
currentScreen is AppScreen.EventDetail ||
currentScreen is AppScreen.VeranstalterVerwaltung ||
currentScreen is AppScreen.VeranstalterAuswahl,
onClick = { showVerwaltungMenu = true },
enabled = isConfigured
)
DropdownMenu(
expanded = showVerwaltungMenu && isConfigured,
onDismissRequest = { showVerwaltungMenu = false },
offset = DpOffset(Dimens.NavRailWidth, 0.dp)
) {
DropdownMenuItem(
text = { Text("Veranstalter") },
onClick = {
showVerwaltungMenu = false
onNavigate(AppScreen.VeranstalterVerwaltung)
},
leadingIcon = { Icon(Icons.Default.Business, contentDescription = null) }
)
DropdownMenuItem(
text = { Text("Events") },
onClick = {
showVerwaltungMenu = false
onNavigate(AppScreen.EventVerwaltung)
},
leadingIcon = { Icon(Icons.Default.Event, contentDescription = null) }
)
}
}
NavRailItem( NavRailItem(
icon = Icons.Default.Email, icon = Icons.Default.Email,
label = "Mails", label = "Mails",

View File

@ -43,7 +43,7 @@ fun DesktopTopHeader(
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// Zurück-Button ausblenden auf Startseite oder im Setup // Zurück-Button ausblenden auf Startseite oder im Setup
if (currentScreen !is AppScreen.DeviceInitialization && currentScreen !is AppScreen.VeranstaltungVerwaltung) { if (currentScreen !is AppScreen.DeviceInitialization && currentScreen !is AppScreen.EventVerwaltung) {
IconButton( IconButton(
onClick = { onClick = {
// Verhindere Rücksprung zum Setup, wenn konfiguriert // Verhindere Rücksprung zum Setup, wenn konfiguriert
@ -65,7 +65,7 @@ fun DesktopTopHeader(
// Home Icon als Anker // Home Icon als Anker
IconButton( IconButton(
onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }, onClick = { onNavigate(AppScreen.EventVerwaltung) },
modifier = Modifier.size(Dimens.IconSizeM), modifier = Modifier.size(Dimens.IconSizeM),
enabled = isConfigured enabled = isConfigured
) { ) {
@ -207,7 +207,7 @@ private fun BreadcrumbContent(
) )
} }
is AppScreen.VeranstaltungProfil -> { is AppScreen.EventProfil -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstalter-Verwaltung", text = "Veranstalter-Verwaltung",
@ -224,43 +224,43 @@ private fun BreadcrumbContent(
) )
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}", text = "Event #${currentScreen.veranstaltungId}",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
) )
} }
is AppScreen.VeranstaltungVerwaltung -> { is AppScreen.EventVerwaltung -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltungs-Verwaltung", text = "Event-Verwaltung",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
) )
} }
is AppScreen.VeranstaltungDetail -> { is AppScreen.EventDetail -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltungs-Verwaltung", text = "Event-Verwaltung",
style = textStyle.copy(color = clickableColor), style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) }, modifier = Modifier.clickable { onNavigate(AppScreen.EventVerwaltung) },
) )
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.id}", text = "Event #${currentScreen.id}",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
) )
} }
is AppScreen.VeranstaltungNeu -> { is AppScreen.EventNeu -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltungs-Verwaltung", text = "Event-Verwaltung",
style = textStyle.copy(color = clickableColor), style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) }, modifier = Modifier.clickable { onNavigate(AppScreen.EventVerwaltung) },
) )
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Neue Veranstaltung", text = "Neues Event",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
) )
} }
@ -268,10 +268,10 @@ private fun BreadcrumbContent(
is AppScreen.TurnierDetail -> { is AppScreen.TurnierDetail -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}", text = "Event #${currentScreen.veranstaltungId}",
style = textStyle.copy(color = clickableColor), style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) onNavigate(AppScreen.EventDetail(currentScreen.veranstaltungId))
}, },
) )
BreadcrumbSeparator() BreadcrumbSeparator()
@ -284,10 +284,10 @@ private fun BreadcrumbContent(
is AppScreen.TurnierNeu -> { is AppScreen.TurnierNeu -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}", text = "Event #${currentScreen.veranstaltungId}",
style = textStyle.copy(color = clickableColor), style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) onNavigate(AppScreen.EventDetail(currentScreen.veranstaltungId))
}, },
) )
BreadcrumbSeparator() BreadcrumbSeparator()
@ -300,10 +300,10 @@ private fun BreadcrumbContent(
is AppScreen.Billing -> { is AppScreen.Billing -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}", text = "Event #${currentScreen.veranstaltungId}",
style = textStyle.copy(color = clickableColor), style = textStyle.copy(color = clickableColor),
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) onNavigate(AppScreen.EventDetail(currentScreen.veranstaltungId))
}, },
) )
BreadcrumbSeparator() BreadcrumbSeparator()
@ -356,7 +356,7 @@ private fun BreadcrumbContent(
is AppScreen.FunktionaerVerwaltung -> { is AppScreen.FunktionaerVerwaltung -> {
BreadcrumbSeparator() BreadcrumbSeparator()
Text( Text(
text = "Richter-Verwaltung", text = "Funktionär-Verwaltung",
style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor),
) )
} }

View File

@ -1,21 +1,24 @@
package at.mocode.frontend.shell.desktop.screens.management package at.mocode.frontend.shell.desktop.screens.management
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsButton
import at.mocode.frontend.core.designsystem.components.MsCard
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.shell.desktop.data.Store import at.mocode.frontend.shell.desktop.data.Store
@Composable @Composable
@ -254,28 +257,131 @@ fun FunktionaerVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
@Composable @Composable
fun VeranstalterVerwaltungScreen(onBack: () -> Unit, onNew: () -> Unit, onEdit: (Long) -> Unit) { fun VeranstalterVerwaltungScreen(onBack: () -> Unit, onNew: () -> Unit, onEdit: (Long) -> Unit) {
// Veranstalter sind in unserem System eigentlich Vereine, die Veranstaltungen ausrichten,
// wir nutzen hier die 'vereine' Liste aus dem Store.
val vereine = Store.vereine val vereine = Store.vereine
var filter by remember { mutableStateOf("") } var filter by remember { mutableStateOf("") }
val filteredItems = if (filter.isEmpty()) vereine else vereine.filter { val filteredItems = if (filter.isEmpty()) vereine else vereine.filter {
it.name.contains(filter, ignoreCase = true) || it.oepsNummer.contains(filter, ignoreCase = true) it.name.contains(filter, ignoreCase = true) || it.oepsNummer.contains(filter, ignoreCase = true)
} }
ManagementTableScreen( Column(modifier = Modifier.fillMaxSize().padding(Dimens.SpacingL)) {
title = "Veranstalter-Verwaltung", Row(
items = filteredItems, modifier = Modifier.fillMaxWidth(),
columns = listOf( horizontalArrangement = Arrangement.SpaceBetween,
TableColumn("Name", { it.name }, weight = 2f), verticalAlignment = Alignment.CenterVertically
TableColumn("ÖPS-Nr.", { it.oepsNummer }, width = 100.dp), ) {
TableColumn("Ort", { it.ort ?: "-" }, weight = 1f), Text(
TableColumn("BL", { it.bundesland ?: "-" }, width = 60.dp), text = "Veranstalter - verwalten",
TableColumn("Email", { it.email ?: "-" }, weight = 1f) style = MaterialTheme.typography.headlineMedium,
), fontWeight = FontWeight.Bold
onBack = onBack,
onNew = onNew,
onEdit = { onEdit(it.id) },
onDelete = { },
onSearch = { filter = it }
) )
MsButton(
text = "Neuer Veranstalter",
onClick = onNew
)
}
Spacer(Modifier.height(Dimens.SpacingL))
OutlinedTextField(
value = filter,
onValueChange = { filter = it },
placeholder = { Text("Suche nach Name oder ÖPS-Nr...", style = MaterialTheme.typography.bodyMedium) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp)) },
trailingIcon = {
if (filter.isNotEmpty()) {
IconButton(onClick = { filter = "" }) {
Icon(Icons.Default.Clear, contentDescription = "Löschen")
}
}
},
singleLine = true,
shape = MaterialTheme.shapes.medium,
textStyle = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(Dimens.SpacingL))
if (filteredItems.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Veranstalter gefunden.", color = Color.Gray)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM),
contentPadding = PaddingValues(bottom = Dimens.SpacingL)
) {
items(filteredItems) { veranstalter ->
VeranstalterCard(
name = veranstalter.name,
oepsNr = veranstalter.oepsNummer,
ort = veranstalter.ort ?: "-",
bundesland = veranstalter.bundesland ?: "-",
onClick = { onEdit(veranstalter.id) }
)
}
}
}
}
}
@Composable
fun VeranstalterCard(
name: String,
oepsNr: String,
ort: String,
bundesland: String,
onClick: () -> Unit,
) {
MsCard(
modifier = Modifier.fillMaxWidth(),
onClick = onClick
) {
Row(
modifier = Modifier.padding(Dimens.SpacingM),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Business,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.secondary
)
}
Spacer(Modifier.width(Dimens.SpacingM))
Column(modifier = Modifier.weight(1f)) {
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
) {
Text(
text = "ÖPS: $oepsNr",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
Text("", color = Color.Gray)
Icon(Icons.Default.Place, contentDescription = null, modifier = Modifier.size(14.dp), tint = Color.Gray)
Text("$ort ($bundesland)", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
}
}
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = Color.LightGray)
}
}
} }

View File

@ -30,12 +30,14 @@ fun VeranstalterDetail(
onBack: () -> Unit, onBack: () -> Unit,
onZurVeranstaltung: (Long) -> Unit, onZurVeranstaltung: (Long) -> Unit,
onNeuVeranstaltung: () -> Unit, onNeuVeranstaltung: () -> Unit,
onEditVeranstalter: (Long) -> Unit,
) { ) {
VeranstalterDetailScreen( VeranstalterDetailScreen(
veranstalterId = veranstalterId, veranstalterId = veranstalterId,
viewModel = koinInject(), viewModel = koinInject(),
onZurueck = onBack, onZurueck = onBack,
onVeranstaltungOeffnen = onZurVeranstaltung, onVeranstaltungOeffnen = onZurVeranstaltung,
onVeranstaltungNeu = onNeuVeranstaltung onVeranstaltungNeu = onNeuVeranstaltung,
onEditVeranstalter = onEditVeranstalter
) )
} }

View File

@ -82,6 +82,7 @@ fun PreviewVeranstalterDetailScreen() {
onZurueck = {}, onZurueck = {},
onVeranstaltungOeffnen = {}, onVeranstaltungOeffnen = {},
onVeranstaltungNeu = {}, onVeranstaltungNeu = {},
onEditVeranstalter = {},
) )
} }
} }
@ -162,6 +163,7 @@ fun PreviewTurnierDetailScreen() {
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList()) override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList())
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList()) override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError()) override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
override suspend fun getStats(): Result<MasterdataStats> = Result.success(MasterdataStats(null, 0, 0, 0, 0))
} }
val nennungVm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L) val nennungVm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
val stammdatenVm = TurnierStammdatenViewModel(mockTurnierRepo) val stammdatenVm = TurnierStammdatenViewModel(mockTurnierRepo)
@ -219,6 +221,7 @@ fun PreviewTurnierOrganisationTab() {
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList()) override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList())
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList()) override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError()) override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
override suspend fun getStats(): Result<MasterdataStats> = Result.success(MasterdataStats(null, 0, 0, 0, 0))
} }
val vm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L) val vm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
MaterialTheme { MaterialTheme {
@ -306,6 +309,7 @@ fun PreviewTurnierNennungenTab() {
override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList()) override suspend fun searchFunktionaere(query: String): Result<List<Funktionaer>> = Result.success(emptyList())
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList()) override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError()) override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
override suspend fun getStats(): Result<MasterdataStats> = Result.success(MasterdataStats(null, 0, 0, 0, 0))
} }
val vm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L) val vm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
MaterialTheme { MaterialTheme {

View File

@ -4,7 +4,10 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Place
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -33,7 +36,7 @@ fun VeranstaltungProfilScreen(
val turniere = TurnierStore.list(veranstaltungId) val turniere = TurnierStore.list(veranstaltungId)
if (veranstaltung == null) { if (veranstaltung == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Veranstaltung nicht gefunden") } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Event nicht gefunden") }
return@DesktopTheme return@DesktopTheme
} }
@ -65,7 +68,7 @@ fun VeranstaltungProfilScreen(
KpiCard("Ort", veranstaltung.ort, Icons.Default.Place, Modifier.weight(1f)) KpiCard("Ort", veranstaltung.ort, Icons.Default.Place, Modifier.weight(1f))
} }
Text("Turniere in dieser Veranstaltung", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) Text("Turniere in diesem Event", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
if (turniere.isEmpty()) { if (turniere.isEmpty()) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Box(Modifier.padding(32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { Box(Modifier.padding(32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
@ -81,7 +84,7 @@ fun VeranstaltungProfilScreen(
} }
} }
// Rechte Spalte: Veranstalter Info & Aktionen // Rechte Spalte: Veranstalter Information & Aktionen
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Card { Card {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {

View File

@ -1,40 +1,78 @@
package at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards package at.mocode.frontend.shell.desktop.screens.veranstaltung.wizards
import androidx.compose.foundation.clickable import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.shell.desktop.data.Store import at.mocode.frontend.core.designsystem.components.ButtonSize
import at.mocode.frontend.core.designsystem.components.ButtonVariant
import at.mocode.frontend.core.designsystem.components.MsButton
import at.mocode.frontend.core.designsystem.components.MsStatusBadge
import at.mocode.frontend.core.domain.zns.ZnsImportProvider import at.mocode.frontend.core.domain.zns.ZnsImportProvider
import at.mocode.frontend.core.domain.zns.ZnsImportState import at.mocode.frontend.core.domain.zns.ZnsImportState
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardIntent
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardViewModel
import at.mocode.frontend.shell.desktop.data.Store
import at.mocode.frontend.shell.desktop.screens.veranstaltung.components.pickZnsFile import at.mocode.frontend.shell.desktop.screens.veranstaltung.components.pickZnsFile
import kotlinx.coroutines.launch
import org.jetbrains.skia.Image
import org.koin.compose.koinInject import org.koin.compose.koinInject
import androidx.compose.ui.draw.clip import org.koin.compose.viewmodel.koinViewModel
import androidx.compose.foundation.shape.RoundedCornerShape import java.awt.FileDialog
import java.awt.Frame
import java.io.File
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun VeranstalterAnlegenWizard( fun VeranstalterAnlegenWizard(
editId: Long? = null,
onCancel: () -> Unit, onCancel: () -> Unit,
onVereinCreated: (Long) -> Unit onVereinCreated: (Long) -> Unit
) { ) {
var step by remember { mutableStateOf(1) } val viewModel = koinViewModel<VeranstalterWizardViewModel>()
var selectedVereinId by remember { mutableLongStateOf(0L) } val state by viewModel.state.collectAsState()
var step by remember { mutableIntStateOf(1) }
LaunchedEffect(editId) {
if (editId != null) {
viewModel.send(VeranstalterWizardIntent.Load(editId))
step = 2 // Direkt zu den Details beim Editieren
}
}
LaunchedEffect(state.success) {
if (state.success) {
onVereinCreated(state.editId ?: 0L)
}
}
val znsImporter = koinInject<ZnsImportProvider>() val znsImporter = koinInject<ZnsImportProvider>()
val znsState = znsImporter.state val znsState = znsImporter.state
Column(Modifier.fillMaxSize().padding(24.dp)) { Column(Modifier.fillMaxSize().padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onCancel) { Icon(Icons.Default.Close, null) } IconButton(onClick = onCancel) { Icon(Icons.Default.Close, null) }
Text("Veranstalter registrieren", style = MaterialTheme.typography.headlineSmall) Text(
if (editId == null) "Veranstalter registrieren" else "Veranstalter-Profil bearbeiten",
style = MaterialTheme.typography.headlineSmall
)
} }
LinearProgressIndicator( LinearProgressIndicator(
@ -47,14 +85,21 @@ fun VeranstalterAnlegenWizard(
1 -> Step1Veranstalter( 1 -> Step1Veranstalter(
znsState = znsState, znsState = znsState,
znsImporter = znsImporter, znsImporter = znsImporter,
selectedVereinId = selectedVereinId, selectedVereinId = state.editId ?: 0L,
onVereinSelected = { selectedVereinId = it }, onVereinSelected = { id ->
// Mock: Wir laden die Daten des Vereins aus dem Store in das VM
Store.vereine.find { it.id == id }?.let { v ->
viewModel.send(VeranstalterWizardIntent.UpdateName(v.name))
viewModel.send(VeranstalterWizardIntent.UpdateOeps(v.oepsNummer))
viewModel.send(VeranstalterWizardIntent.UpdateOrt(v.ort ?: ""))
}
},
onVeranstalterCreated = { onVeranstalterCreated = {
selectedVereinId = it // Nicht mehr direkt navigieren, sondern zu Step 2
onVereinCreated(it) step = 2
} }
) )
2 -> { /* Optional: Weitere Details für den Veranstalter */ } 2 -> Step2VeranstalterDetails(viewModel)
} }
} }
@ -63,14 +108,246 @@ fun VeranstalterAnlegenWizard(
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
if (step == 1) { if (step == 1) {
Button( Button(
onClick = { onVereinCreated(selectedVereinId) }, onClick = { step = 2 },
enabled = selectedVereinId != 0L enabled = state.name.isNotBlank()
) { ) {
Text("Fertigstellen") Text("Weiter zu den Details")
}
} else {
Button(
onClick = { viewModel.send(VeranstalterWizardIntent.Save) },
enabled = !state.isSaving && state.name.isNotBlank()
) {
if (state.isSaving) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
} else {
Text(if (editId == null) "Registrierung abschließen" else "Änderungen speichern")
} }
} }
} }
} }
}
}
@Composable
fun VeranstalterCardPreview(
name: String,
ort: String,
oepsNummer: String,
ansprechpartner: String,
email: String,
logoBase64: String?,
status: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
.border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
) {
if (!logoBase64.isNullOrBlank()) {
val bitmap = remember(logoBase64) { decodeBase64ToImage(logoBase64) }
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.fillMaxSize().clip(CircleShape),
contentScale = ContentScale.Crop
)
} else {
Icon(Icons.Default.Image, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.error)
}
} else {
Icon(Icons.Default.Business, null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f))
}
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = name.ifBlank { "Veranstalter Name" },
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
MsStatusBadge(
text = status,
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
contentColor = MaterialTheme.colorScheme.primary
)
}
Text(
text = "OEBS-Nr: $oepsNummer | Ort: $ort",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
if (ansprechpartner.isNotBlank() || email.isNotBlank()) {
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
if (ansprechpartner.isNotBlank()) {
Text("👤 $ansprechpartner", style = MaterialTheme.typography.bodySmall)
}
if (email.isNotBlank()) {
Text("✉️ $email", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary)
}
}
}
}
}
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun decodeBase64ToImage(base64: String): ImageBitmap? {
return try {
val bytes = Base64.decode(base64)
Image.makeFromEncoded(bytes).toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
@Composable
fun Step2VeranstalterDetails(viewModel: VeranstalterWizardViewModel) {
val state by viewModel.state.collectAsState()
val scope = rememberCoroutineScope()
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
Text("Ergänzen Sie die Kontaktdaten für diesen Veranstalter.", style = MaterialTheme.typography.bodyMedium)
// --- VORSCHAU ---
VeranstalterCardPreview(
name = state.name,
ort = state.ort,
oepsNummer = state.oepsNummer,
ansprechpartner = state.ansprechpartner,
email = state.email,
logoBase64 = state.logoBase64,
status = state.loginStatus
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedTextField(
value = state.name,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateName(it)) },
label = { Text("Name des Veranstalters / Vereins") },
modifier = Modifier.fillMaxWidth()
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.oepsNummer,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateOeps(it)) },
label = { Text("OEBS-Nr") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.ort,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateOrt(it)) },
label = { Text("Ort") },
modifier = Modifier.weight(2f)
)
}
}
// --- LOGO UPLOAD ---
Box(
modifier = Modifier
.size(140.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (state.logoBase64 != null) {
val logoData = state.logoBase64
val bitmap = remember(logoData) { logoData?.let { decodeBase64ToImage(it) } }
if (bitmap != null) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = Modifier.size(80.dp).clip(CircleShape),
contentScale = ContentScale.Crop
)
}
} else {
Icon(Icons.Default.Image, null, modifier = Modifier.size(40.dp), tint = Color.Gray)
}
Spacer(Modifier.height(8.dp))
MsButton(
text = "Logo wählen",
onClick = {
scope.launch(kotlinx.coroutines.Dispatchers.IO) {
val fileDialog = FileDialog(null as Frame?, "Logo auswählen", FileDialog.LOAD)
fileDialog.isVisible = true
if (fileDialog.directory != null && fileDialog.file != null) {
val file = File(fileDialog.directory, fileDialog.file)
val bytes = file.readBytes()
val base64 = Base64.encode(bytes)
viewModel.send(VeranstalterWizardIntent.UpdateLogo(base64))
}
}
},
variant = ButtonVariant.TEXT,
size = ButtonSize.SMALL
)
}
}
}
HorizontalDivider()
Text("Ansprechpartner & Kontakt", style = MaterialTheme.typography.titleSmall)
OutlinedTextField(
value = state.ansprechpartner,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateAnsprechpartner(it)) },
label = { Text("Name Ansprechpartner") },
modifier = Modifier.fillMaxWidth()
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = state.email,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateEmail(it)) },
label = { Text("E-Mail Adresse") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.telefon,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateTelefon(it)) },
label = { Text("Telefonnummer") },
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = state.adresse,
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateAdresse(it)) },
label = { Text("Postanschrift") },
modifier = Modifier.fillMaxWidth(),
minLines = 2
)
}
} }
@Composable @Composable