diff --git a/docs/99_Journal/2026-04-21_Curator_Session_Summary.md b/docs/99_Journal/2026-04-21_Curator_Session_Summary.md index 4ef80ad1..fdab9c97 100644 --- a/docs/99_Journal/2026-04-21_Curator_Session_Summary.md +++ b/docs/99_Journal/2026-04-21_Curator_Session_Summary.md @@ -1,48 +1,49 @@ -# Journal: 21. April 2026 - Abschluss der Morgen-Session (Curator) +# Journal: 21. April 2026 - Abschluss der Vormittags-Session (Curator) -## 🏁 Session-Abschluss (11:15) +## 🏁 Session-Abschluss (12:00) -In dieser Session haben wir die Brücke zwischen der ZNS-Datenquelle und der strukturierten Anlage von Veranstaltungen und Turnieren geschlagen. Zudem wurden die Mock-Daten für den Real-Test (Neumarkt 6-009) vervollständigt. +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. **ZNS-Guard & Integration (SCS: Organizer):** - * Der `VeranstaltungWizardScreen` prüft nun zwingend auf vorhandene Stammdaten. - * Fehlen Daten, wird der `StammdatenImportScreen` direkt im Wizard eingebettet. - * Modul-Abhängigkeit zu `zns-import-feature` in `veranstaltung-feature` hergestellt. +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. **Mock-Daten & Real-Integration:** - * Erweiterung des `FakeVereinRepository` um den Verein **"Reitclub Neumarkt" (6-009)**. - * Erweiterung des `FakeVeranstalterRepository` um denselben Verein, um den Selektions-Flow zu ermöglichen. - * Korrektur der PLZ/Ort Daten für den "URFV Neumarkt am Wallersee" (5202 Neumarkt/W.). +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. **Navigation & UX-Verbesserung:** - * Aktivierung des Buttons "Diesen Verein als neuen Veranstalter anlegen" im `VeranstaltungWizard`. - * Integration der Navigation zum `VeranstalterAnlegenWizard` via Callback-Hoisting in der `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. **User-Identity & Onboarding (SCS: Identity):** - * Neuer `ProfileOnboardingWizard` zur Verknüpfung des lokalen Users mit einer ZNS-Satznummer. - * Integration des Onboarding-Flows in die Desktop-Shell (`ContentArea.kt`). - * Erweiterung der `AppScreen` Navigation um `/profile/onboarding`. +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. -3. **Turnier-Wizard Refactoring (SCS: Tournament):** - * Vollständiges Refactoring des `TurnierWizard` nach ADR-0024. - * Einführung des `TurnierWizardViewModel` zur Entkoppelung von UI und Persistenz. - * Integration des 3-stufigen Wizards (Basics, Sparten, Branding) in den `VeranstaltungWizard`. - -4. **Architektur & Build:** - * Korrektur von Modul-Abhängigkeiten in den `build.gradle.kts` Dateien. - * Konsolidierung der SCS-Grenzen zwischen Organizer, Tournament und Identity. - -### 🔧 Korrekturen & Optimierungen -* **Koin-Integration:** In `VeranstaltungWizardScreen` wurde `koinViewModel` durch `koinInject` ersetzt, um Auflösungsprobleme zu beheben. -* **Code-Cleanup:** Im `TurnierWizardViewModel` wurden ungenutzte Properties (`sponsoren`, `znsDataLoaded`, `typ`, `kategorie`) und Funktionen entfernt. -* **Bugfix:** Der Warnhinweis bezüglich ungenutzter Parameter (`veranstaltungId`) und Properties (`repository`) im `TurnierWizardViewModel` wurde behoben. +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:** Ergänzt um "ZNS-Guard" und "Profile-Onboarding". Der Punkt "Veranstaltungs-Wizard" wurde von einer UI-Hülle zu einem funktionalen Workflow (Wiring mit Turnier-Wizard) aufgewertet. +* **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. -### 🚀 Ausblick -Die Grundlage für eine saubere Datenkette ist gelegt. In der nächsten Session kann der Fokus auf die **Bewerbs-Anlage (§ 39 ÖTO)** und die **Echtdaten-Validierung** beim Import gelegt werden, da nun die Identitäten und Stammdaten-Guards aktiv sind. +### 🚀 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. diff --git a/docs/temp/Veranstaltungs_Flow.drawio b/docs/temp/Veranstaltungs_Flow.drawio new file mode 100644 index 00000000..bafa8bdb --- /dev/null +++ b/docs/temp/Veranstaltungs_Flow.drawio @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt index 799bb852..38480db3 100644 --- a/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt +++ b/frontend/core/navigation/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt @@ -19,7 +19,7 @@ sealed class AppScreen(val route: String) { data object EntryManagement : AppScreen("/nennung") // --- Desktop-Navigation (Vision_03) --- - data object VeranstaltungVerwaltung : AppScreen("/verwaltung") // Gesamtübersicht + data object EventVerwaltung : AppScreen("/event/verwaltung") // Gesamtübersicht // Profile data object PferdVerwaltung : AppScreen("/pferde/verwaltung") @@ -45,20 +45,20 @@ sealed class AppScreen(val route: String) { data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId") // Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail oder direkt aus Cockpit) - data class VeranstaltungKonfig(val veranstalterId: Long = 0) : - AppScreen("/veranstalter/$veranstalterId/veranstaltung/neu") + data class EventKonfig(val veranstalterId: Long = 0) : + AppScreen("/veranstalter/$veranstalterId/event/neu") - data class VeranstaltungProfil(val veranstalterId: Long, val veranstaltungId: Long) : - AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId") + data class EventProfil(val veranstalterId: Long, val veranstaltungId: Long) : + AppScreen("/veranstalter/$veranstalterId/event/$veranstaltungId") - data class VeranstaltungDetail(val id: Long) : AppScreen("/veranstaltung/$id") - data object VeranstaltungNeu : AppScreen("/veranstaltung/neu") + data class EventDetail(val id: Long) : AppScreen("/event/$id") + data object EventNeu : AppScreen("/event/neu") 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) : - AppScreen("/veranstaltung/$veranstaltungId/turnier/$turnierId/billing") + AppScreen("/event/$veranstaltungId/turnier/$turnierId/billing") data object Reiter : AppScreen("/reiter") data object Pferde : AppScreen("/pferde") @@ -69,13 +69,13 @@ sealed class AppScreen(val route: String) { data object NennungsEingang : AppScreen("/nennungs-eingang") companion object { - private val VERANSTALTUNG_DETAIL = Regex("/veranstaltung/(\\d+)$") - private val TURNIER_DETAIL = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)$") - private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$") - private val BILLING = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)/billing$") + private val EVENT_DETAIL = Regex("/event/(\\d+)$") + private val TURNIER_DETAIL = Regex("/event/(\\d+)/turnier/(\\d+)$") + private val TURNIER_NEU = Regex("/event/(\\d+)/turnier/neu$") + private val BILLING = Regex("/event/(\\d+)/turnier/(\\d+)/billing$") private val VERANSTALTER_DETAIL = Regex("/veranstalter/(\\d+)$") - private val VERANSTALTUNG_KONFIG = Regex("/veranstalter/(\\d+)/veranstaltung/neu$") - private val VERANSTALTUNG_PROFIL = Regex("/veranstalter/(\\d+)/veranstaltung/(\\d+)$") + private val EVENT_KONFIG = Regex("/veranstalter/(\\d+)/event/neu$") + private val EVENT_PROFIL = Regex("/veranstalter/(\\d+)/event/(\\d+)$") private val PFERD_PROFIL = Regex("/pferde/profil/(\\d+)$") private val REITER_PROFIL = Regex("/reiter/profil/(\\d+)$") @@ -98,14 +98,14 @@ sealed class AppScreen(val route: String) { "/organizer/profile" -> OrganizerProfile "/auth/callback" -> AuthCallback "/nennung" -> EntryManagement - "/verwaltung" -> VeranstaltungVerwaltung + "/event/verwaltung" -> EventVerwaltung "/pferde/verwaltung" -> PferdVerwaltung "/reiter/verwaltung" -> ReiterVerwaltung "/vereine/verwaltung" -> VereinVerwaltung "/funktionaere/verwaltung" -> FunktionaerVerwaltung "/veranstalter/verwaltung" -> VeranstalterVerwaltung "/veranstalter/auswahl" -> VeranstalterAuswahl - "/veranstaltung/neu" -> VeranstaltungNeu + "/event/neu" -> EventNeu "/meisterschaften" -> Meisterschaften "/cups" -> Cups "/stammdaten/import" -> StammdatenImport @@ -120,7 +120,7 @@ sealed class AppScreen(val route: String) { FUNKTIONAER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return FunktionaerProfil(id.toLong()) } VERANSTALTER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return VeranstalterProfil(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) -> @@ -129,17 +129,17 @@ sealed class AppScreen(val route: String) { TURNIER_NEU.matchEntire(route)?.destructured?.let { (vId) -> return TurnierNeu(vId.toLong()) } - VERANSTALTUNG_DETAIL.matchEntire(route)?.destructured?.let { (id) -> - return VeranstaltungDetail(id.toLong()) + EVENT_DETAIL.matchEntire(route)?.destructured?.let { (id) -> + return EventDetail(id.toLong()) } VERANSTALTER_DETAIL.matchEntire(route)?.destructured?.let { (vId) -> return VeranstalterDetail(vId.toLong()) } - VERANSTALTUNG_KONFIG.matchEntire(route)?.destructured?.let { (vId) -> - return VeranstaltungKonfig(vId.toLong()) + EVENT_KONFIG.matchEntire(route)?.destructured?.let { (vId) -> + return EventKonfig(vId.toLong()) } - VERANSTALTUNG_PROFIL.matchEntire(route)?.destructured?.let { (verId, vId) -> - return VeranstaltungProfil(verId.toLong(), vId.toLong()) + EVENT_PROFIL.matchEntire(route)?.destructured?.let { (verId, vId) -> + return EventProfil(verId.toLong(), vId.toLong()) } PortalDashboard // Default fallback } diff --git a/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/di/ProfileModule.kt b/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/di/ProfileModule.kt index c4b7946d..a737705f 100644 --- a/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/di/ProfileModule.kt +++ b/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/di/ProfileModule.kt @@ -1,6 +1,7 @@ package at.mocode.frontend.features.profile.di 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 org.koin.core.qualifier.named import org.koin.dsl.module @@ -8,4 +9,5 @@ import org.koin.dsl.module val profileModule = module { single { ProfileApiClient(get(named("apiClient")), get()) } single { ProfileViewModel(get()) } + factory { ProfileOnboardingViewModel(get(), get()) } } diff --git a/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/presentation/ProfileOnboardingScreen.kt b/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/presentation/ProfileOnboardingScreen.kt new file mode 100644 index 00000000..6d24b653 --- /dev/null +++ b/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/presentation/ProfileOnboardingScreen.kt @@ -0,0 +1,169 @@ +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( + "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") + } + } +} diff --git a/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/presentation/ProfileOnboardingViewModel.kt b/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/presentation/ProfileOnboardingViewModel.kt new file mode 100644 index 00000000..f263801f --- /dev/null +++ b/frontend/features/profile-feature/src/commonMain/kotlin/at/mocode/frontend/features/profile/presentation/ProfileOnboardingViewModel.kt @@ -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 = 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 + } + ) + } +} diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt index 8c0a64e1..e374124c 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/frontend/features/turnier/presentation/CreateBewerbWizardScreen.kt @@ -231,7 +231,7 @@ private fun StepOrtZeit(state: CreateBewerbWizardState, onStateChange: (CreateBe @Composable private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (CreateBewerbWizardState) -> Unit) { 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() if (warnTb) { Box( @@ -240,6 +240,25 @@ private fun StepRichterTeilung(state: CreateBewerbWizardState, onStateChange: (C 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( value = state.teilungsTyp, onValueChange = { onStateChange(state.copy(teilungsTyp = it)) }, diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/di/VeranstaltungModule.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/di/VeranstaltungModule.kt index 25154613..e1028ab5 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/di/VeranstaltungModule.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/di/VeranstaltungModule.kt @@ -1,5 +1,6 @@ package at.mocode.veranstaltung.feature.di +import at.mocode.frontend.core.domain.zns.ZnsImportProvider import at.mocode.veranstaltung.feature.presentation.VeranstaltungManagementViewModel import at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardViewModel import org.koin.core.qualifier.named @@ -7,5 +8,5 @@ import org.koin.dsl.module val veranstaltungModule = module { factory { VeranstaltungManagementViewModel(get()) } - factory { VeranstaltungWizardViewModel(get(named("apiClient")), get(), get(), get()) } + factory { VeranstaltungWizardViewModel(get(named("apiClient")), get(), get(), get(), get(), get()) } } diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungDetailScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungDetailScreen.kt index 7977763b..c982613e 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungDetailScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungDetailScreen.kt @@ -49,7 +49,7 @@ fun VeranstaltungDetailScreen( val event = veranstaltung if (event == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Veranstaltung #$veranstaltungId nicht gefunden.") + Text("Event #$veranstaltungId nicht gefunden.") } return } @@ -95,7 +95,7 @@ fun VeranstaltungDetailScreen( } Text( - text = "Turniere in dieser Veranstaltung", + text = "Turniere in diesem Event", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardScreen.kt index 0a06cbda..8320b5e9 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardScreen.kt @@ -18,9 +18,7 @@ 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 at.mocode.frontend.features.turnier.presentation.TurnierWizard -import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel import at.mocode.frontend.features.zns.import.presentation.StammdatenImportScreen -import org.koin.compose.koinInject import kotlin.uuid.ExperimentalUuidApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalUuidApi::class) @@ -37,7 +35,7 @@ fun VeranstaltungWizardScreen( topBar = { Column { TopAppBar( - title = { Text("Neue Veranstaltung anlegen") }, + title = { Text("Neues Event anlegen") }, navigationIcon = { IconButton(onClick = { if (state.currentStep == WizardStep.ZNS_CHECK) onBack() @@ -108,7 +106,7 @@ private fun VorschauCard(state: VeranstaltungWizardState) { Column(modifier = Modifier.weight(1f)) { Text( - text = state.name.ifBlank { "Neue Veranstaltung" }, + text = state.name.ifBlank { "Neues Event" }, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -275,7 +273,31 @@ private fun VeranstalterSelectionStep( } } } - } else { + } + + if (viewModel.state.znsSearchResults.isNotEmpty()) { + Text("Gefundene Vereine in den Stammdaten:", style = MaterialTheme.typography.labelMedium) + viewModel.state.znsSearchResults.forEach { znsVerein -> + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { viewModel.selectZnsVerein(znsVerein) } + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(Icons.Default.Add, null) + Column { + Text(znsVerein.name, fontWeight = FontWeight.Medium) + Text("OEPS-Nr: ${znsVerein.oepsNummer} | ${znsVerein.ort ?: ""}", style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + + if (viewModel.state.veranstalterId == null && viewModel.state.znsSearchResults.isEmpty()) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -400,13 +422,13 @@ private fun MetaDataStep(viewModel: VeranstaltungWizardViewModel) { @Composable private fun TurnierAnlageStep(viewModel: VeranstaltungWizardViewModel) { val state = viewModel.state + val turnierViewModel = viewModel.turnierWizardViewModel var showWizard by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text("Schritt 5: Turniere & Ausschreibung", style = MaterialTheme.typography.titleLarge) if (showWizard) { - val turnierViewModel = koinInject() Card(modifier = Modifier.fillMaxWidth().height(500.dp)) { TurnierWizard( viewModel = turnierViewModel, @@ -414,7 +436,7 @@ private fun TurnierAnlageStep(viewModel: VeranstaltungWizardViewModel) { onBack = { showWizard = false }, onFinish = { showWizard = false - viewModel.addTurnier() // Dummy zum Hinzufügen im Haupt-Wizard + viewModel.addTurnier(turnierViewModel.state.turnierNr, "") } ) } diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardViewModel.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardViewModel.kt index cfa088cd..50684a3f 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardViewModel.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungWizardViewModel.kt @@ -9,7 +9,10 @@ import at.mocode.core.domain.serialization.UuidSerializer import at.mocode.frontend.core.auth.data.local.AuthTokenManager import at.mocode.frontend.core.domain.repository.MasterdataRepository 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.features.turnier.presentation.TurnierWizardViewModel import at.mocode.frontend.features.verein.domain.VereinRepository import io.ktor.client.* import io.ktor.client.request.* @@ -55,7 +58,8 @@ data class VeranstaltungWizardState( val createdVeranstaltungId: Uuid? = null, val isZnsAvailable: Boolean = false, val stammdatenStats: MasterdataStats? = null, - val isCheckingStats: Boolean = false + val isCheckingStats: Boolean = false, + val znsSearchResults: List = emptyList() ) @OptIn(ExperimentalUuidApi::class) @@ -63,7 +67,9 @@ class VeranstaltungWizardViewModel( private val httpClient: HttpClient, private val authTokenManager: AuthTokenManager, private val vereinRepository: VereinRepository, - private val masterdataRepository: MasterdataRepository + private val masterdataRepository: MasterdataRepository, + private val znsImportProvider: ZnsImportProvider, + val turnierWizardViewModel: TurnierWizardViewModel // Injected Child-ViewModel ) : ViewModel() { var state by mutableStateOf(VeranstaltungWizardState()) @@ -98,19 +104,45 @@ class VeranstaltungWizardViewModel( fun searchVeranstalterByOepsNr(oepsNr: String) { viewModelScope.launch { - val verein = vereinRepository.findByOepsNr(oepsNr) - if (verein != null) { - setVeranstalter( - id = Uuid.parse(verein.id), - nummer = verein.oepsNr ?: "", - name = verein.name, - standardOrt = "${verein.plz ?: ""} ${verein.ort ?: ""}".trim(), - logo = null // Hier könnte später ein Logo-Service greifen - ) + try { + val verein = vereinRepository.findByOepsNr(oepsNr) + 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( + id = uuid, + nummer = verein.oepsNr ?: "", + name = verein.name, + standardOrt = "${verein.plz ?: ""} ${verein.ort ?: ""}".trim(), + 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() { state = state.copy( currentStep = when (state.currentStep) { @@ -155,23 +187,13 @@ class VeranstaltungWizardViewModel( state = state.copy(name = name, ort = ort, startDatum = start, endDatum = end, logoUrl = logo) } - fun updateTurnier(index: Int, nummer: String, path: String?) { - val newList = state.turniere.toMutableList() - if (index in newList.indices) { - newList[index] = newList[index].copy(nummer = nummer, ausschreibungPath = path) - state = state.copy(turniere = newList) - } - } - - fun addTurnier() { - state = state.copy(turniere = state.turniere + TurnierEntry()) + fun addTurnier(nummer: String = "", pfad: String? = null) { + state = state.copy(turniere = state.turniere + TurnierEntry(nummer = nummer, ausschreibungPath = pfad)) } fun removeTurnier(index: Int) { - if (state.turniere.size > 1) { - val newList = state.turniere.toMutableList().apply { removeAt(index) } - state = state.copy(turniere = newList) - } + val newList = state.turniere.toMutableList().apply { removeAt(index) } + state = state.copy(turniere = newList) } fun saveVeranstaltung() { diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt index ea63dc05..f821ee69 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt @@ -43,12 +43,12 @@ fun VeranstaltungenScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Veranstaltungen - verwalten", + text = "Events - verwalten", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold ) MsButton( - text = "Neue Veranstaltung", + text = "Neues Event", onClick = onVeranstaltungNeu ) } @@ -119,7 +119,7 @@ fun VeranstaltungenScreen( ) Spacer(Modifier.height(Dimens.SpacingM)) Text( - "Keine Veranstaltungen gefunden.", + "Keine Events gefunden.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/DesktopApp.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/DesktopApp.kt index 36452e48..ff2889be 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/DesktopApp.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/DesktopApp.kt @@ -38,7 +38,10 @@ fun DesktopApp() { // DeviceInitialization-Check beim Start LaunchedEffect(Unit) { if (!DeviceInitializationSettingsManager.isConfigured()) { + println("[DesktopApp] Setup fehlt -> Umleitung zum DeviceInitialization") nav.navigateToScreen(AppScreen.DeviceInitialization) + } else { + println("[DesktopApp] Setup vorhanden.") } } @@ -47,22 +50,32 @@ fun DesktopApp() { // Login-Gate: Nicht-authentifizierte Screens → Login, außer DeviceInitialization ist erlaubt // Vision_03 Update: Wir starten mit DeviceInitialization if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.DeviceInitialization - && currentScreen !is AppScreen.VeranstaltungVerwaltung + && currentScreen !is AppScreen.EventVerwaltung && currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu - && currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig - && currentScreen !is AppScreen.VeranstaltungProfil && currentScreen !is AppScreen.TurnierDetail + && currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.EventKonfig + && currentScreen !is AppScreen.EventProfil && currentScreen !is AppScreen.TurnierDetail && currentScreen !is AppScreen.TurnierNeu - && currentScreen !is AppScreen.ReiterVerwaltung - && currentScreen !is AppScreen.PferdVerwaltung - && currentScreen !is AppScreen.VereinVerwaltung + && currentScreen !is AppScreen.ReiterVerwaltung && currentScreen !is AppScreen.Reiter + && currentScreen !is AppScreen.PferdVerwaltung && currentScreen !is AppScreen.Pferde + && currentScreen !is AppScreen.VereinVerwaltung && currentScreen !is AppScreen.Vereine + && currentScreen !is AppScreen.FunktionaerVerwaltung && currentScreen !is AppScreen.FunktionaerProfil + && currentScreen !is AppScreen.ReiterProfil + && currentScreen !is AppScreen.PferdProfil + && currentScreen !is AppScreen.VereinProfil && currentScreen !is AppScreen.StammdatenImport && currentScreen !is AppScreen.NennungsEingang - && currentScreen !is AppScreen.VeranstaltungNeu + && currentScreen !is AppScreen.EventNeu && currentScreen !is AppScreen.ConnectivityCheck + && currentScreen !is AppScreen.Dashboard ) { - LaunchedEffect(Unit) { - // Standard: Start im DeviceInitialization - nav.navigateToScreen(AppScreen.DeviceInitialization) + LaunchedEffect(currentScreen) { + if (!DeviceInitializationSettingsManager.isConfigured()) { + println("[DesktopApp] Nicht authentifiziert & nicht konfiguriert -> Setup") + nav.navigateToScreen(AppScreen.DeviceInitialization) + } else { + println("[DesktopApp] Nicht authentifiziert, aber konfiguriert -> Dashboard") + nav.navigateToScreen(AppScreen.EventVerwaltung) + } } } @@ -70,7 +83,7 @@ fun DesktopApp() { is AppScreen.Login -> LoginScreen( viewModel = loginViewModel, onLoginSuccess = { - val returnTo = screen.returnTo ?: AppScreen.VeranstaltungVerwaltung + val returnTo = screen.returnTo ?: AppScreen.EventVerwaltung nav.navigateToScreen(returnTo) }, onBack = { nav.navigateBack() }, @@ -84,7 +97,7 @@ fun DesktopApp() { onBack = { nav.navigateBack() }, onLogout = { authTokenManager.clearToken() - nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.VeranstaltungVerwaltung)) + nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.EventVerwaltung)) }, isAuthenticated = authState.isAuthenticated ) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt index f9fd1d6d..a3712e6b 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/DesktopMainLayout.kt @@ -45,13 +45,15 @@ fun DesktopMainLayout( } // Automatische Umleitung zum DeviceInitialization, wenn Setup fehlt (außer wir sind bereits dort) - LaunchedEffect(onboardingSettings) { + LaunchedEffect(currentScreen) { if (!onboardingSettings.isConfigured && currentScreen !is AppScreen.DeviceInitialization) { println("[DesktopNav] Setup fehlt -> Umleitung zum DeviceInitialization") onNavigate(AppScreen.DeviceInitialization) } else if (onboardingSettings.isConfigured && currentScreen is AppScreen.DeviceInitialization) { - println("[DesktopNav] Setup abgeschlossen -> Wechsel zum Dashboard") - onNavigate(AppScreen.VeranstaltungVerwaltung) + // Falls wir konfiguriert sind, aber im Setup-Screen landen (z.B. durch manuellen Nav-Call), + // erlauben wir den Aufenthalt dort (für Edit), aber forcieren keinen Redirect zum Dashboard hier, + // da dies der Wizard am Ende selbst macht. + println("[DesktopNav] Setup vorhanden und im Setup-Screen.") } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/ContentArea.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/ContentArea.kt index 9f5536e7..667652e5 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/ContentArea.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/ContentArea.kt @@ -27,7 +27,8 @@ import at.mocode.frontend.features.pferde.presentation.PferdeScreen import at.mocode.frontend.features.pferde.presentation.PferdeViewModel import at.mocode.frontend.features.ping.presentation.PingScreen import at.mocode.frontend.features.ping.presentation.PingViewModel -import at.mocode.frontend.features.profile.presentation.ProfileOnboardingWizard +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.ProfileViewModel import at.mocode.frontend.features.reiter.presentation.ReiterScreen @@ -70,17 +71,18 @@ fun DesktopContentArea( val authTokenManager = org.koin.core.context.GlobalContext.get().get() authTokenManager.setToken(finalSettings.sharedKey) onSettingsChange(finalSettings) - onNavigate(AppScreen.VeranstaltungVerwaltung) + // nav.navigateToScreen(...) wird hier nicht direkt gerufen, sondern onNavigate + onNavigate(AppScreen.EventVerwaltung) }) } DeviceInitializationScreen(viewModel = viewModel) } - // Haupt-Zentrale: Veranstaltung-Verwaltung - is AppScreen.VeranstaltungVerwaltung -> { + // Haupt-Zentrale: Event-Verwaltung + is AppScreen.EventVerwaltung -> { VeranstaltungenScreen( - onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) }, - onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) } + onVeranstaltungNeu = { onNavigate(AppScreen.EventNeu) }, + onVeranstaltungOeffnen = { vId: Long, eId: Long -> onNavigate(AppScreen.EventProfil(vId, eId)) } ) } @@ -91,6 +93,15 @@ fun DesktopContentArea( ) } + // --- Profile Onboarding --- + is AppScreen.ProfileOnboarding -> { + val viewModel = koinViewModel() + ProfileOnboardingScreen( + viewModel = viewModel, + onFinish = { onNavigate(AppScreen.EventVerwaltung) } + ) + } + // --- Pferde-Verwaltung & Profil --- is AppScreen.Pferde, is AppScreen.PferdVerwaltung -> { val viewModel = koinViewModel() @@ -165,14 +176,14 @@ fun DesktopContentArea( is AppScreen.VeranstalterProfil -> VeranstalterDetail( veranstalterId = currentScreen.id, onBack = onBack, - onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.VeranstaltungProfil(currentScreen.id, evtId)) }, - onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungNeu) }, + onZurVeranstaltung = { evtId: Long -> onNavigate(AppScreen.EventProfil(currentScreen.id, evtId)) }, + onNeuVeranstaltung = { onNavigate(AppScreen.EventNeu) }, ) - // Neuer Flow: Veranstalter auswählen → Veranstaltung-Wizard + // Neuer Flow: Veranstalter auswählen → Event-Wizard is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahl( onBack = onBack, - onWeiter = { _ -> onNavigate(AppScreen.VeranstaltungNeu) }, + onWeiter = { _ -> onNavigate(AppScreen.EventNeu) }, onNeu = { onNavigate(AppScreen.VeranstalterNeu) }, ) @@ -186,12 +197,12 @@ fun DesktopContentArea( VeranstalterDetail( veranstalterId = vId, onBack = onBack, - onZurVeranstaltung = { evtId -> onNavigate(AppScreen.VeranstaltungProfil(vId, evtId)) }, - onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) }, + onZurVeranstaltung = { evtId -> onNavigate(AppScreen.EventProfil(vId, evtId)) }, + onNeuVeranstaltung = { onNavigate(AppScreen.EventKonfig(vId)) }, ) } - is AppScreen.VeranstaltungKonfig -> { + is AppScreen.EventKonfig -> { val vId = currentScreen.veranstalterId VeranstaltungKonfigScreen( veranstalterId = vId, @@ -201,12 +212,12 @@ fun DesktopContentArea( // val allEvents = Store.allEvents() // 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( veranstalterId = currentScreen.veranstalterId, veranstaltungId = currentScreen.veranstaltungId, @@ -223,7 +234,7 @@ fun DesktopContentArea( ) } - is AppScreen.VeranstaltungDetail -> { + is AppScreen.EventDetail -> { val repository: at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository = koinInject() VeranstaltungDetailScreen( veranstaltungId = currentScreen.id, @@ -235,7 +246,7 @@ fun DesktopContentArea( ) } - is AppScreen.VeranstaltungNeu -> { + is AppScreen.EventNeu -> { val viewModel: at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardViewModel = koinViewModel() at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardScreen( viewModel = viewModel, @@ -323,18 +334,13 @@ fun DesktopContentArea( ProfileScreen(viewModel = viewModel) } - is AppScreen.ProfileOnboarding -> { - val viewModel = koinViewModel() - ProfileOnboardingWizard( - viewModel = viewModel, - onFinish = { onNavigate(AppScreen.Dashboard) } - ) - } - 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( onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) }, - onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) } + onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.EventDetail(id)) } ) } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/NavRail.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/NavRail.kt index 996b11e3..8598b74a 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/NavRail.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/NavRail.kt @@ -35,21 +35,13 @@ fun DesktopNavRail( icon = Icons.Default.Adjust, label = "Logo", selected = false, - onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }, + onClick = { onNavigate(AppScreen.EventVerwaltung) }, enabled = isConfigured ) Spacer(Modifier.height(Dimens.SpacingL)) // 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( icon = Icons.Default.CloudDownload, label = "ZNS-Import", @@ -101,7 +93,7 @@ fun DesktopNavRail( leadingIcon = { Icon(Icons.Default.Pets, contentDescription = null) } ) DropdownMenuItem( - text = { Text("Richter") }, + text = { Text("Funktionäre") }, onClick = { showStammdatenMenu = false 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( icon = Icons.Default.Email, label = "Mails", diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt index 7f366af1..72f102d9 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/layout/components/TopHeader.kt @@ -43,7 +43,7 @@ fun DesktopTopHeader( ) { Row(verticalAlignment = Alignment.CenterVertically) { // 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( onClick = { // Verhindere Rücksprung zum Setup, wenn konfiguriert @@ -65,7 +65,7 @@ fun DesktopTopHeader( // Home Icon als Anker IconButton( - onClick = { onNavigate(AppScreen.VeranstaltungVerwaltung) }, + onClick = { onNavigate(AppScreen.EventVerwaltung) }, modifier = Modifier.size(Dimens.IconSizeM), enabled = isConfigured ) { @@ -207,7 +207,7 @@ private fun BreadcrumbContent( ) } - is AppScreen.VeranstaltungProfil -> { + is AppScreen.EventProfil -> { BreadcrumbSeparator() Text( text = "Veranstalter-Verwaltung", @@ -224,43 +224,43 @@ private fun BreadcrumbContent( ) BreadcrumbSeparator() Text( - text = "Veranstaltung #${currentScreen.veranstaltungId}", + text = "Event #${currentScreen.veranstaltungId}", style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), ) } - is AppScreen.VeranstaltungVerwaltung -> { + is AppScreen.EventVerwaltung -> { BreadcrumbSeparator() Text( - text = "Veranstaltungs-Verwaltung", + text = "Event-Verwaltung", style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), ) } - is AppScreen.VeranstaltungDetail -> { + is AppScreen.EventDetail -> { BreadcrumbSeparator() Text( - text = "Veranstaltungs-Verwaltung", + text = "Event-Verwaltung", style = textStyle.copy(color = clickableColor), - modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) }, + modifier = Modifier.clickable { onNavigate(AppScreen.EventVerwaltung) }, ) BreadcrumbSeparator() Text( - text = "Veranstaltung #${currentScreen.id}", + text = "Event #${currentScreen.id}", style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), ) } - is AppScreen.VeranstaltungNeu -> { + is AppScreen.EventNeu -> { BreadcrumbSeparator() Text( - text = "Veranstaltungs-Verwaltung", + text = "Event-Verwaltung", style = textStyle.copy(color = clickableColor), - modifier = Modifier.clickable { onNavigate(AppScreen.VeranstaltungVerwaltung) }, + modifier = Modifier.clickable { onNavigate(AppScreen.EventVerwaltung) }, ) BreadcrumbSeparator() Text( - text = "Neue Veranstaltung", + text = "Neues Event", style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), ) } @@ -268,10 +268,10 @@ private fun BreadcrumbContent( is AppScreen.TurnierDetail -> { BreadcrumbSeparator() Text( - text = "Veranstaltung #${currentScreen.veranstaltungId}", + text = "Event #${currentScreen.veranstaltungId}", style = textStyle.copy(color = clickableColor), modifier = Modifier.clickable { - onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) + onNavigate(AppScreen.EventDetail(currentScreen.veranstaltungId)) }, ) BreadcrumbSeparator() @@ -284,10 +284,10 @@ private fun BreadcrumbContent( is AppScreen.TurnierNeu -> { BreadcrumbSeparator() Text( - text = "Veranstaltung #${currentScreen.veranstaltungId}", + text = "Event #${currentScreen.veranstaltungId}", style = textStyle.copy(color = clickableColor), modifier = Modifier.clickable { - onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) + onNavigate(AppScreen.EventDetail(currentScreen.veranstaltungId)) }, ) BreadcrumbSeparator() @@ -300,10 +300,10 @@ private fun BreadcrumbContent( is AppScreen.Billing -> { BreadcrumbSeparator() Text( - text = "Veranstaltung #${currentScreen.veranstaltungId}", + text = "Event #${currentScreen.veranstaltungId}", style = textStyle.copy(color = clickableColor), modifier = Modifier.clickable { - onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) + onNavigate(AppScreen.EventDetail(currentScreen.veranstaltungId)) }, ) BreadcrumbSeparator() @@ -356,7 +356,7 @@ private fun BreadcrumbContent( is AppScreen.FunktionaerVerwaltung -> { BreadcrumbSeparator() Text( - text = "Richter-Verwaltung", + text = "Funktionär-Verwaltung", style = textStyle.copy(fontWeight = FontWeight.SemiBold, color = activeColor), ) } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/veranstaltung/details/VeranstaltungDetails.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/veranstaltung/details/VeranstaltungDetails.kt index 99fbf8c4..be5c8626 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/veranstaltung/details/VeranstaltungDetails.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/frontend/shell/desktop/screens/veranstaltung/details/VeranstaltungDetails.kt @@ -4,7 +4,10 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.runtime.Composable import androidx.compose.ui.Alignment @@ -33,7 +36,7 @@ fun VeranstaltungProfilScreen( val turniere = TurnierStore.list(veranstaltungId) 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 } @@ -65,7 +68,7 @@ fun VeranstaltungProfilScreen( 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()) { Card(Modifier.fillMaxWidth()) { 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)) { Card { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {