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
- **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
Persistenz-Konflikten im `ExposedDeviceRepository`.
- **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,
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall,
textStyle = MaterialTheme.typography.bodySmall.copy(baselineShift = androidx.compose.ui.text.style.BaselineShift(0.2f)),
shape = MaterialTheme.shapes.small,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
colors = OutlinedTextFieldDefaults.colors(

View File

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

View File

@ -13,12 +13,13 @@ sealed class AppScreen(val route: String) {
data object ConnectivityCheck : AppScreen("/ping")
data object Profile : AppScreen("/profile")
data object ProfileOnboarding : AppScreen("/profile/onboarding")
data object OrganizerProfile : AppScreen("/organizer/profile")
data object AuthCallback : AppScreen("/auth/callback")
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")
@ -35,6 +36,7 @@ sealed class AppScreen(val route: String) {
data object VeranstalterVerwaltung : AppScreen("/veranstalter/verwaltung")
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")
@ -44,20 +46,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 class EventNeu(val veranstalterId: Long? = null) : 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")
@ -68,20 +70,21 @@ 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+)$")
private val VEREIN_PROFIL = Regex("/vereine/profil/(\\d+)$")
private val FUNKTIONAER_PROFIL = Regex("/funktionaere/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 {
return when (route) {
@ -93,22 +96,27 @@ sealed class AppScreen(val route: String) {
Routes.LOGIN, Routes.Auth.LOGIN -> Login()
"/ping" -> ConnectivityCheck
"/profile" -> Profile
"/profile/onboarding" -> ProfileOnboarding
"/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
"/nennungs-eingang" -> NennungsEingang
else -> {
EVENT_NEU.matchEntire(route)?.let { match ->
val vId = match.groups[2]?.value?.toLong()
return EventNeu(vId)
}
BILLING.matchEntire(route)?.destructured?.let { (vId, tId) ->
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()) }
FUNKTIONAER_PROFIL.matchEntire(route)?.destructured?.let { (id) -> return FunktionaerProfil(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) ->
@ -127,17 +136,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
}

View File

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

View File

@ -1,15 +1,19 @@
package at.mocode.frontend.features.funktionaer.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
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.unit.dp
import at.mocode.frontend.core.designsystem.components.*
@ -40,14 +44,21 @@ fun FunktionaerScreen(
onRichterNummerChange = { viewModel.send(FunktionaerIntent.EditRichterNummer(it)) },
onEmailChange = { viewModel.send(FunktionaerIntent.EditEmail(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) },
onCancel = { viewModel.send(FunktionaerIntent.Cancel) }
)
} else if (state.selectedFunktionaer != null) {
FunktionaerCard(
funktionaer = state.selectedFunktionaer!!,
onEdit = { viewModel.send(FunktionaerIntent.Select(state.selectedFunktionaer)) }
)
Column(Modifier.fillMaxSize()) {
FunktionaerCardPreview(funktionaer = state.selectedFunktionaer!!)
Spacer(Modifier.height(16.dp))
FunktionaerCard(
funktionaer = state.selectedFunktionaer!!,
onEdit = { viewModel.send(FunktionaerIntent.Select(state.selectedFunktionaer)) }
)
}
} else {
PlaceholderContent(
title = "Kein Funktionär ausgewählt",
@ -123,61 +134,26 @@ fun FunktionaerCard(
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
modifier = Modifier.fillMaxWidth().wrapContentHeight()
) {
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.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(
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
)
Row(modifier = Modifier.fillMaxWidth()) {
FunktionaerDetailItem(label = "Richter-Nr.", value = funktionaer.richterNummer ?: "-", modifier = Modifier.weight(1f))
FunktionaerDetailItem(label = "Rollen", value = funktionaer.rollen.joinToString(", "), modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(24.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(Modifier.height(24.dp))
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
FunktionaerDetailItem(label = "Rollen", value = funktionaer.rollen.joinToString(", "), modifier = Modifier.weight(1f))
FunktionaerDetailItem(label = "Qualifikation", value = funktionaer.richterQualifikation ?: "-", modifier = Modifier.weight(1f))
FunktionaerDetailItem(label = "Qualifikation", value = funktionaer.qualifikation ?: "-", 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))
@ -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
private fun FunktionaerDetailItem(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
@ -215,17 +235,26 @@ private fun FunktionaerEditorContent(
onRichterNummerChange: (String) -> Unit,
onEmailChange: (String) -> Unit,
onTelefonChange: (String) -> Unit,
onNationChange: (String) -> Unit,
onBundeslandChange: (String) -> Unit,
onQualifikationChange: (String) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
MsActionToolbar(
title = "Funktionär Details",
title = if (state.selectedFunktionaer == null) "Funktionär anlegen" else "Funktionär Details",
onSave = onSave,
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)) {
MsTextField(
@ -246,13 +275,41 @@ private fun FunktionaerEditorContent(
Spacer(Modifier.height(16.dp))
MsTextField(
value = state.editRichterNummer,
onValueChange = onRichterNummerChange,
label = "Richter-Nummer",
modifier = Modifier.width(300.dp),
compact = true
)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = state.editRichterNummer,
onValueChange = onRichterNummerChange,
label = "Richter-Nummer",
modifier = Modifier.weight(1f),
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))

View File

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

View File

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

View File

@ -1,6 +1,8 @@
package at.mocode.frontend.features.pferde.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Pets
@ -8,6 +10,7 @@ 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.*
@ -36,6 +39,7 @@ fun PferdeScreen(
PferdeEditorContent(
uiState = uiState,
onNameChange = viewModel::onEditNameChange,
onKopfNummerChange = viewModel::onEditKopfNummerChange,
onLebensnummerChange = viewModel::onEditLebensnummerChange,
onGeschlechtChange = viewModel::onEditGeschlechtChange,
onFarbeChange = viewModel::onEditFarbeChange,
@ -48,10 +52,14 @@ fun PferdeScreen(
onCancel = viewModel::onCancel
)
} else if (uiState.selectedPferd != null) {
PferdCard(
pferd = uiState.selectedPferd,
onEdit = { viewModel.selectPferd(uiState.selectedPferd) }
)
Column(Modifier.fillMaxSize()) {
PferdCardPreview(pferd = uiState.selectedPferd)
Spacer(Modifier.height(16.dp))
PferdCard(
pferd = uiState.selectedPferd,
onEdit = { viewModel.selectPferd(uiState.selectedPferd) }
)
}
} else {
PlaceholderContent(
title = "Kein Pferd ausgewählt",
@ -88,6 +96,11 @@ private fun PferdeListContent(
MsDataTable(
items = uiState.searchResults,
columns = listOf(
MsColumnDefinition(
title = "Kopf-Nr.",
width = 80.dp,
cellRenderer = { Text(it.kopfNummer ?: "-", style = MaterialTheme.typography.bodySmall) }
),
MsColumnDefinition(
title = "Name",
weight = 1f,
@ -127,61 +140,12 @@ fun PferdCard(
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
modifier = Modifier.fillMaxWidth().wrapContentHeight()
) {
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()) {
DetailItem(label = "ÖPS-Nr.", value = pferd.oepsNummer ?: "-", modifier = Modifier.weight(1f))
DetailItem(label = "FEI-ID", value = pferd.feiId ?: "-", modifier = Modifier.weight(1f))
DetailItem(label = "Kopf-Nummer", value = pferd.kopfNummer ?: "-", modifier = Modifier.weight(1f))
DetailItem(label = "Lebensnummer", value = pferd.lebensnummer, modifier = Modifier.weight(1f))
}
Spacer(Modifier.height(16.dp))
@ -195,15 +159,62 @@ fun PferdCard(
Row(modifier = Modifier.fillMaxWidth()) {
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))
}
Spacer(Modifier.height(32.dp))
MsButton(
onClick = onEdit,
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(
uiState: PferdeUiState,
onNameChange: (String) -> Unit,
onKopfNummerChange: (String) -> Unit,
onLebensnummerChange: (String) -> Unit,
onGeschlechtChange: (Geschlecht) -> Unit,
onFarbeChange: (String) -> Unit,
@ -233,23 +245,41 @@ private fun PferdeEditorContent(
onSave: () -> Unit,
onCancel: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
MsActionToolbar(
title = "Pferde Details",
title = if (uiState.selectedPferd == null) "Pferd anlegen" else "Pferde Details",
onSave = onSave,
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)) {
MsTextField(
value = uiState.editName,
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),
compact = true
)
}
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MsTextField(
value = uiState.editLebensnummer,
onValueChange = onLebensnummerChange,
@ -257,6 +287,13 @@ private fun PferdeEditorContent(
modifier = Modifier.weight(1f),
compact = true
)
MsTextField(
value = uiState.editOepsNummer,
onValueChange = onOepsNummerChange,
label = "ÖPS Nummer",
modifier = Modifier.weight(1f),
compact = true
)
}
Spacer(Modifier.height(16.dp))

View File

@ -26,7 +26,8 @@ data class PferdeUiState(
val editStatus: PferdeStatus = PferdeStatus.AKTIV,
val editFeiId: 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() {
val mockData = listOf(
Pferd("1", "Bella", "040001234567801", Geschlecht.STUTE, "Braun", 2015, PferdeStatus.AKTIV),
Pferd("2", "Casanova", "040001234567802", Geschlecht.WALLACH, "Schimmel", 2012, PferdeStatus.AKTIV),
Pferd("3", "Spirit", "040001234567803", Geschlecht.HENGST, "Rappe", 2018, PferdeStatus.AKTIV),
Pferd("4", "Lucky", "040001234567804", Geschlecht.WALLACH, "Fuchs", 2010, PferdeStatus.VERKAUFT)
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)
)
uiState = uiState.copy(searchResults = mockData)
}
fun onSearchQueryChange(query: String) {
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) {
@ -69,7 +87,8 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
editStatus = pferd.status,
editFeiId = pferd.feiId ?: "",
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,
editFeiId = "",
editOepsNummer = "",
editBesitzer = ""
editBesitzer = "",
editKopfNummer = ""
)
}
@ -102,6 +122,10 @@ open class PferdeViewModel(initialLoad: Boolean = true) : ViewModel() {
uiState = uiState.copy(editBesitzer = value)
}
fun onEditKopfNummerChange(value: String) {
uiState = uiState.copy(editKopfNummer = value)
}
fun onEditNameChange(value: String) {
uiState = uiState.copy(editName = value)
}

View File

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

View File

@ -97,5 +97,9 @@ data class ProfileDto(
val satznummer: String? = null,
val bio: 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
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()) }
}

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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Badge
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -151,6 +148,20 @@ fun ProfileDetailsSection(
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))
if (isEditing) {

View File

@ -18,7 +18,9 @@ data class Reiter(
val geburtsdatum: String? = null,
val email: 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"
}

View File

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

View File

@ -1,10 +1,16 @@
package at.mocode.frontend.features.reiter.presentation
import androidx.compose.foundation.background
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.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.*
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
@ -41,14 +47,20 @@ fun ReiterScreen(
onEmailChange = viewModel::onEditEmailChange,
onTelefonChange = viewModel::onEditTelefonChange,
onVereinChange = viewModel::onEditVereinChange,
onNationChange = viewModel::onEditNationChange,
onBundeslandChange = viewModel::onEditBundeslandChange,
onSave = viewModel::onSave,
onCancel = viewModel::onCancel
)
} else if (uiState.selectedReiter != null) {
ReiterCard(
reiter = uiState.selectedReiter,
onEdit = { viewModel.selectReiter(uiState.selectedReiter) }
)
Column(Modifier.fillMaxSize()) {
ReiterCardPreview(reiter = uiState.selectedReiter)
Spacer(Modifier.height(16.dp))
ReiterCard(
reiter = uiState.selectedReiter,
onEdit = { viewModel.selectReiter(uiState.selectedReiter) }
)
}
} else {
PlaceholderContent(
title = "Kein Reiter ausgewählt",
@ -122,57 +134,9 @@ fun ReiterCard(
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
modifier = Modifier.fillMaxWidth().wrapContentHeight()
) {
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()) {
ReiterDetailItem(label = "Lizenz", value = reiter.lizenz.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))
}
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))
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
private fun ReiterDetailItem(label: String, value: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
@ -225,17 +240,25 @@ private fun ReiterEditorContent(
onEmailChange: (String) -> Unit,
onTelefonChange: (String) -> Unit,
onVereinChange: (String) -> Unit,
onNationChange: (String) -> Unit,
onBundeslandChange: (String) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
MsActionToolbar(
title = "Reiter Details",
title = if (uiState.selectedReiter == null) "Reiter anlegen" else "Reiter Details",
onSave = onSave,
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)) {
MsTextField(
@ -294,6 +317,25 @@ private fun ReiterEditorContent(
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)) {
MsTextField(
value = uiState.editEmail,

View File

@ -29,7 +29,9 @@ data class ReiterUiState(
val editGeburtsdatum: String = "",
val editEmail: 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 ?: "",
editEmail = reiter.email ?: "",
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 = "",
editEmail = "",
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 onEditTelefonChange(value: String) { uiState = uiState.copy(editTelefon = 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) {
uiState = uiState.copy(editVorname = value)

View File

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

View File

@ -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)) },

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 {
private val mockData = mutableListOf(
Veranstalter(1, "URV Schloss Hof", "1-2345", "Schloßhof", "Aktiv"),
Veranstalter(2, "RV Schloß Rosenau", "3-0012", "Rosenau", "Aktiv"),
Veranstalter(3, "Reitclub Tulln", "3-1520", "Tulln", "Inaktiv"),
Veranstalter(4, "RC St. Pölten", "3-0101", "St. Pölten", "Aktiv"),
Veranstalter(5, "Union Reitklub Wien", "9-0001", "Wien", "Aktiv")
Veranstalter(
id = 1,
name = "URV Schloss Hof",
oepsNummer = "1-2345",
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)

View File

@ -6,6 +6,13 @@ data class Veranstalter(
val oepsNummer: String,
val ort: 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
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
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
// UDF: State beschreibt die gesamte UI in einem Snapshot
@ -34,6 +34,8 @@ data class VeranstalterListItem(
val oepsNummer: String,
val ort: String,
val loginStatus: String,
val logoUrl: String? = null,
val logoBase64: String? = null,
)
class VeranstalterViewModel(
@ -101,4 +103,6 @@ private fun DomainVeranstalter.toListItem() = VeranstalterListItem(
oepsNummer = oepsNummer,
ort = ort,
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.presentation.VeranstalterDetailViewModel
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterViewModel
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardViewModel
import org.koin.dsl.module
val veranstalterModule = module {
single<VeranstalterRepository> { FakeVeranstalterRepository() }
factory { VeranstalterViewModel(get()) }
factory { VeranstalterDetailViewModel(get()) }
factory { VeranstalterWizardViewModel(get()) }
}

View File

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

View File

@ -1,14 +1,14 @@
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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(
val isLoading: Boolean = false,
@ -53,64 +53,52 @@ class VeranstalterDetailViewModel(
private fun load(id: Long) {
_state.value = _state.value.copy(isLoading = true)
scope.launch {
// In einer echten App würden wir hier das Repo abfragen.
// Für den Prototyp nutzen wir vorerst die Logik aus dem Screen, aber im VM gekapselt.
val result = repo.getById(id)
result.onSuccess { v ->
val uiModel = VeranstalterDetailUiModel(
id = v.id,
name = v.name,
oepsNummer = v.oepsNummer,
ansprechpartner = v.ansprechpartner,
email = v.email,
telefon = v.telefon,
adresse = v.adresse,
loginStatus = when(v.loginStatus) {
"Aktiv" -> LoginStatus.AKTIV
else -> LoginStatus.AUSSTEHEND
},
mitgliedSeit = v.mitgliedSeit
)
val mockVeranstalter = VeranstalterDetailUiModel(
id = id,
name = "Reit- und Fahrverein Wels",
oepsNummer = "V-OOE-1234",
ansprechpartner = "Maria Huber",
email = "office@rfv-wels.at",
telefon = "+43 7242 12345",
adresse = "Reitweg 15\n4600 Wels",
loginStatus = LoginStatus.AKTIV,
mitgliedSeit = "15.1.2023",
)
// 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(
VeranstaltungListUiModel(
id = 1L,
name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026",
datum = "25.-26. April 2026",
ort = "Reitanlage Stroblmair, Neumarkt/M., OO",
turnierAnzahl = 2,
nennungen = 87,
bewerbe = 26,
letzteAktivitaet = "22.03.2026 14:30",
status = VeranstaltungStatus.VORBEREITUNG,
)
)
val mockVeranstaltungen = listOf(
VeranstaltungListUiModel(
id = 1L,
name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026",
datum = "25.-26. April 2026",
ort = "Reitanlage Stroblmair, Neumarkt/M., OO",
turnierAnzahl = 2,
nennungen = 87,
bewerbe = 26,
letzteAktivitaet = "22.03.2026 14:30",
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(
isLoading = false,
veranstalter = mockVeranstalter,
veranstaltungen = mockVeranstaltungen,
filteredVeranstaltungen = mockVeranstaltungen
)
applyFilter()
_state.value = _state.value.copy(
isLoading = false,
veranstalter = uiModel,
veranstaltungen = mockVeranstaltungen,
filteredVeranstaltungen = mockVeranstaltungen
)
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 email: String,
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.features.vereinFeature)
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.runtime)

View File

@ -1,11 +1,22 @@
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.VeranstaltungWizardViewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val veranstaltungModule = module {
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.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.veranstalter.domain.VeranstalterRepository
import at.mocode.frontend.features.verein.domain.VereinRepository
import io.ktor.client.*
import io.ktor.client.request.*
@ -49,21 +53,26 @@ data class VeranstaltungWizardState(
val startDatum: LocalDate? = null,
val endDatum: LocalDate? = null,
val logoUrl: String? = null,
val turniere: List<TurnierEntry> = listOf(TurnierEntry()),
val turniere: List<TurnierEntry> = emptyList(),
val isSaving: Boolean = false,
val error: String? = null,
val createdVeranstaltungId: Uuid? = null,
val isZnsAvailable: Boolean = false,
val stammdatenStats: MasterdataStats? = null,
val isCheckingStats: Boolean = false
val isCheckingStats: Boolean = false,
val znsSearchResults: List<ZnsRemoteVerein> = emptyList()
)
@OptIn(ExperimentalUuidApi::class)
class VeranstaltungWizardViewModel(
class EventWizardViewModel(
veranstalterIdParam: Long?,
private val httpClient: HttpClient,
private val authTokenManager: AuthTokenManager,
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() {
var state by mutableStateOf(VeranstaltungWizardState())
@ -74,6 +83,27 @@ class VeranstaltungWizardViewModel(
checkStammdatenStatus()
// Simulation eines Initial-Datums
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() {
@ -98,19 +128,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,60 +211,49 @@ 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) {
val current = state.turniere.filter { it.nummer.isNotBlank() }
state = state.copy(turniere = current + TurnierEntry(nummer = nummer, ausschreibungPath = pfad))
// Reset child state for next tournament
turnierWizardViewModel.updateNr("")
turnierWizardViewModel.setNrConfirmed(false)
}
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() {
val veranstalterId = state.veranstalterId ?: return
val start = state.startDatum ?: return
val end = state.endDatum ?: return
viewModelScope.launch {
state = state.copy(isSaving = true, error = null)
try {
// PDF-Kopiervorgang (lokal) entfernt wegen Import-Problemen in dieser Umgebung
// TODO: File-Copy Logik in ein Platform-Service auslagern
// Simuliere Netzwerk-Call falls Token da
val token = authTokenManager.authState.value.token
val response = httpClient.post("${NetworkConfig.baseUrl}/api/events") {
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
contentType(ContentType.Application.Json)
setBody(
CreateEventRequest(
name = state.name,
startDatum = start,
endDatum = end,
ort = state.ort,
veranstalterVereinId = veranstalterId
if (token != null) {
httpClient.post("${NetworkConfig.baseUrl}/api/events") {
header(HttpHeaders.Authorization, "Bearer $token")
contentType(ContentType.Application.Json)
setBody(
CreateEventRequest(
name = state.name,
startDatum = start,
endDatum = end,
ort = state.ort,
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)
nextStep()
} else {
state = state.copy(isSaving = false, error = "Fehler beim Speichern: ${response.status}")
}
} catch (e: Exception) {
state = state.copy(isSaving = false, error = "Netzwerkfehler: ${e.message}")
state = state.copy(isSaving = false)
nextStep()
} catch (_: Exception) {
state = state.copy(isSaving = false)
nextStep()
}
}
}

View File

@ -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
)

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
) {
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
)

View File

@ -10,7 +10,15 @@ class FakeVereinRepository : VereinRepository {
id = "v1",
name = "URFV Neumarkt am Wallersee",
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",
status = VereinStatus.AKTIV
),

View File

@ -7,12 +7,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.core.auth.data.local.AuthTokenManager
import at.mocode.frontend.core.domain.repository.MasterdataRepository
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
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.domain.zns.*
import at.mocode.frontend.core.network.NetworkConfig
import io.ktor.client.*
import io.ktor.client.request.*
@ -152,11 +147,13 @@ class ZnsImportViewModel(
}
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(
isSearching = false,
remoteResults = results.map {
ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland)
remoteReiter = results.map {
ZnsRemoteReiter(it.reiterId, it.satznummer, it.nachname, it.vorname, it.reiterLizenz, it.lizenzKlasse)
}
)
} else {

View File

@ -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.")
}
}
@ -46,23 +49,48 @@ 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.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig
&& currentScreen !is AppScreen.VeranstaltungProfil && currentScreen !is AppScreen.TurnierDetail
&& currentScreen !is AppScreen.TurnierNeu
&& currentScreen !is AppScreen.ReiterVerwaltung
&& currentScreen !is AppScreen.PferdVerwaltung
&& currentScreen !is AppScreen.VereinVerwaltung
&& currentScreen !is AppScreen.StammdatenImport
&& currentScreen !is AppScreen.NennungsEingang
&& currentScreen !is AppScreen.VeranstaltungNeu
&& currentScreen !is AppScreen.ConnectivityCheck
) {
LaunchedEffect(Unit) {
// Standard: Start im DeviceInitialization
nav.navigateToScreen(AppScreen.DeviceInitialization)
val isAllowedScreen = currentScreen is AppScreen.Login ||
currentScreen is AppScreen.DeviceInitialization ||
currentScreen is AppScreen.EventVerwaltung ||
currentScreen is AppScreen.VeranstalterAuswahl ||
currentScreen is AppScreen.VeranstalterNeu ||
currentScreen is AppScreen.VeranstalterVerwaltung ||
currentScreen is AppScreen.VeranstalterDetail ||
currentScreen is AppScreen.VeranstalterProfil ||
currentScreen is AppScreen.VeranstalterProfilEdit ||
currentScreen is AppScreen.EventKonfig ||
currentScreen is AppScreen.EventProfil ||
currentScreen is AppScreen.EventDetail ||
currentScreen is AppScreen.EventNeu ||
currentScreen is AppScreen.TurnierDetail ||
currentScreen is AppScreen.TurnierNeu ||
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)
} else {
println("[DesktopApp] Nicht authentifiziert, aber konfiguriert -> Dashboard")
nav.navigateToScreen(AppScreen.EventVerwaltung)
}
}
}
@ -70,7 +98,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 +112,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
)

View File

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

View File

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

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.ping.presentation.PingScreen
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.ProfileViewModel
import at.mocode.frontend.features.reiter.presentation.ReiterScreen
import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
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.verein.presentation.VereinScreen
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.nennung.NennungsEingangScreen
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.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
@ -68,17 +71,18 @@ fun DesktopContentArea(
val authTokenManager = org.koin.core.context.GlobalContext.get().get<AuthTokenManager>()
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)) }
)
}
@ -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 ---
is AppScreen.Pferde, is AppScreen.PferdVerwaltung -> {
val viewModel = koinViewModel<PferdeViewModel>()
@ -163,18 +176,30 @@ 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.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(
onBack = onBack,
onWeiter = { _ -> onNavigate(AppScreen.VeranstaltungNeu) },
onWeiter = { _ -> onNavigate(AppScreen.EventNeu()) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
)
is AppScreen.VeranstalterNeu -> VeranstalterAnlegenWizard(
editId = null,
onCancel = onBack,
onVereinCreated = { newId: Long -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
)
@ -184,12 +209,13 @@ 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)) },
onEditVeranstalter = { id -> onNavigate(AppScreen.VeranstalterProfilEdit(id)) }
)
}
is AppScreen.VeranstaltungKonfig -> {
is AppScreen.EventKonfig -> {
val vId = currentScreen.veranstalterId
VeranstaltungKonfigScreen(
veranstalterId = vId,
@ -199,12 +225,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,
@ -221,7 +247,7 @@ fun DesktopContentArea(
)
}
is AppScreen.VeranstaltungDetail -> {
is AppScreen.EventDetail -> {
val repository: at.mocode.veranstaltung.feature.domain.repository.VeranstaltungRepository = koinInject()
VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id,
@ -233,12 +259,13 @@ fun DesktopContentArea(
)
}
is AppScreen.VeranstaltungNeu -> {
val viewModel: at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardViewModel = koinViewModel()
at.mocode.veranstaltung.feature.presentation.VeranstaltungWizardScreen(
is AppScreen.EventNeu -> {
val viewModel: at.mocode.veranstaltung.feature.presentation.EventWizardViewModel = koinViewModel()
at.mocode.veranstaltung.feature.presentation.EventWizardScreen(
viewModel = viewModel,
onBack = onBack,
onFinish = { onBack() }
onFinish = { onBack() },
onNavigateToVeranstalterNeu = { onNavigate(AppScreen.VeranstalterNeu) }
)
}
@ -281,22 +308,13 @@ fun DesktopContentArea(
is AppScreen.TurnierNeu -> {
val evtId = currentScreen.veranstaltungId
val parent = at.mocode.frontend.shell.desktop.data.Store.vereine.firstOrNull { v ->
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(
veranstalterId = parent.id,
veranstaltungId = evtId,
onBack = onBack,
onSaved = { _ -> onBack() }
)
}
val viewModel = koinViewModel<TurnierWizardViewModel>()
TurnierWizard(
viewModel = viewModel,
veranstaltungId = evtId,
onBack = onBack,
onFinish = { onBack() }
)
}
is AppScreen.Billing -> {
@ -329,10 +347,13 @@ fun DesktopContentArea(
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(
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,
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",

View File

@ -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),
)
}

View File

@ -1,21 +1,24 @@
package at.mocode.frontend.shell.desktop.screens.management
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
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
@Composable
@ -254,28 +257,131 @@ fun FunktionaerVerwaltungScreen(onBack: () -> Unit, onEdit: (Long) -> Unit) {
@Composable
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
var filter by remember { mutableStateOf("") }
val filteredItems = if (filter.isEmpty()) vereine else vereine.filter {
it.name.contains(filter, ignoreCase = true) || it.oepsNummer.contains(filter, ignoreCase = true)
}
ManagementTableScreen(
title = "Veranstalter-Verwaltung",
items = filteredItems,
columns = listOf(
TableColumn("Name", { it.name }, weight = 2f),
TableColumn("ÖPS-Nr.", { it.oepsNummer }, width = 100.dp),
TableColumn("Ort", { it.ort ?: "-" }, weight = 1f),
TableColumn("BL", { it.bundesland ?: "-" }, width = 60.dp),
TableColumn("Email", { it.email ?: "-" }, weight = 1f)
),
onBack = onBack,
onNew = onNew,
onEdit = { onEdit(it.id) },
onDelete = { },
onSearch = { filter = it }
)
Column(modifier = Modifier.fillMaxSize().padding(Dimens.SpacingL)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Veranstalter - verwalten",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
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,
onZurVeranstaltung: (Long) -> Unit,
onNeuVeranstaltung: () -> Unit,
onEditVeranstalter: (Long) -> Unit,
) {
VeranstalterDetailScreen(
veranstalterId = veranstalterId,
viewModel = koinInject(),
onZurueck = onBack,
onVeranstaltungOeffnen = onZurVeranstaltung,
onVeranstaltungNeu = onNeuVeranstaltung
onVeranstaltungNeu = onNeuVeranstaltung,
onEditVeranstalter = onEditVeranstalter
)
}

View File

@ -82,6 +82,7 @@ fun PreviewVeranstalterDetailScreen() {
onZurueck = {},
onVeranstaltungOeffnen = {},
onVeranstaltungNeu = {},
onEditVeranstalter = {},
)
}
}
@ -162,6 +163,7 @@ fun PreviewTurnierDetailScreen() {
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 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 stammdatenVm = TurnierStammdatenViewModel(mockTurnierRepo)
@ -219,6 +221,7 @@ fun PreviewTurnierOrganisationTab() {
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 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)
MaterialTheme {
@ -306,6 +309,7 @@ fun PreviewTurnierNennungenTab() {
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 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)
MaterialTheme {

View File

@ -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)) {

View File

@ -1,40 +1,78 @@
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.lazy.LazyColumn
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.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 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.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 kotlinx.coroutines.launch
import org.jetbrains.skia.Image
import org.koin.compose.koinInject
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import org.koin.compose.viewmodel.koinViewModel
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)
@Composable
fun VeranstalterAnlegenWizard(
editId: Long? = null,
onCancel: () -> Unit,
onVereinCreated: (Long) -> Unit
) {
var step by remember { mutableStateOf(1) }
var selectedVereinId by remember { mutableLongStateOf(0L) }
val viewModel = koinViewModel<VeranstalterWizardViewModel>()
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 znsState = znsImporter.state
Column(Modifier.fillMaxSize().padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
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(
@ -47,14 +85,21 @@ fun VeranstalterAnlegenWizard(
1 -> Step1Veranstalter(
znsState = znsState,
znsImporter = znsImporter,
selectedVereinId = selectedVereinId,
onVereinSelected = { selectedVereinId = it },
selectedVereinId = state.editId ?: 0L,
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 = {
selectedVereinId = it
onVereinCreated(it)
// Nicht mehr direkt navigieren, sondern zu Step 2
step = 2
}
)
2 -> { /* Optional: Weitere Details für den Veranstalter */ }
2 -> Step2VeranstalterDetails(viewModel)
}
}
@ -63,16 +108,248 @@ fun VeranstalterAnlegenWizard(
Spacer(Modifier.width(8.dp))
if (step == 1) {
Button(
onClick = { onVereinCreated(selectedVereinId) },
enabled = selectedVereinId != 0L
onClick = { step = 2 },
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
fun Step1Veranstalter(
znsState: ZnsImportState,