Compare commits

...

4 Commits

Author SHA1 Message Date
3da6643724 Add Chat-Verlauf documentation for March 27, 2026, highlighting session results, MVP scope, open points, and proposed next steps for Neumarkt project.
Some checks failed
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Failing after 3m16s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Failing after 3m8s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Failing after 1m11s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 2m1s
2026-03-28 02:08:39 +01:00
43d83e403a Implement Vision_03: Overhaul Veranstalter and Veranstaltung flow with new composables, centralized state management via StoreV2, and updated navigation logic. Add DesktopThemeV2 for consistent UI styling. 2026-03-28 02:08:14 +01:00
7a10d8bb18 Implement Veranstalter and Veranstaltung management: Add VeranstalterDetailScreen, seed FakeVeranstaltungStore, and enable deletion of Veranstaltungen. Extend onboarding with device name validation. Refine UI for VeranstalterKonfigScreen, add InvalidContextNotice, and centralize navigation checks. 2026-03-28 01:37:32 +01:00
b7e78bbab5 Rename and reorganize frontend screenshots, adding Profil-Veranstalter_entwurf-01.png. 2026-03-27 19:32:52 +01:00
30 changed files with 1065 additions and 291 deletions

View File

@ -1,61 +0,0 @@
**Add your own guidelines here**
<!--
System Guidelines
Use this file to provide the AI with rules and guidelines you want it to follow.
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
# General guidelines
Any general rules you want the AI to follow.
For example:
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
* Refactor code as you go to keep code clean
* Keep file sizes small and put helper functions and components in their own files.
--------------
# Design system guidelines
Rules for how the AI should make generations look like your company's design system
Additionally, if you select a design system to use in the prompt box, you can reference
your design system's components, tokens, variables and components.
For example:
* Use a base font-size of 14px
* Date formats should always be in the format “Jun 10”
* The bottom toolbar should only ever have a maximum of 4 items
* Never use the floating action button with the bottom toolbar
* Chips should always come in sets of 3 or more
* Don't use a dropdown if there are 2 or fewer options
You can also create sub sections and add more specific details
For example:
## Button
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
users through the application. It provides visual feedback and clear affordances to enhance user experience.
### Usage
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
### Variants
* Primary Button
* Purpose : Used for the main action in a section or page
* Visual Style : Bold, filled with the primary brand color
* Usage : One primary button per section to guide users toward the most important action
* Secondary Button
* Purpose : Used for alternative or supporting actions
* Visual Style : Outlined with the primary color, transparent background
* Usage : Can appear alongside a primary button for less important actions
* Tertiary Button
* Purpose : Used for the least important actions
* Visual Style : Text-only with no border, using primary color
* Usage : For actions that should be available but not emphasized
-->

View File

@ -1,61 +0,0 @@
**Add your own guidelines here**
<!--
System Guidelines
Use this file to provide the AI with rules and guidelines you want it to follow.
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
# General guidelines
Any general rules you want the AI to follow.
For example:
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
* Refactor code as you go to keep code clean
* Keep file sizes small and put helper functions and components in their own files.
--------------
# Design system guidelines
Rules for how the AI should make generations look like your company's design system
Additionally, if you select a design system to use in the prompt box, you can reference
your design system's components, tokens, variables and components.
For example:
* Use a base font-size of 14px
* Date formats should always be in the format “Jun 10”
* The bottom toolbar should only ever have a maximum of 4 items
* Never use the floating action button with the bottom toolbar
* Chips should always come in sets of 3 or more
* Don't use a dropdown if there are 2 or fewer options
You can also create sub sections and add more specific details
For example:
## Button
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
users through the application. It provides visual feedback and clear affordances to enhance user experience.
### Usage
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
### Variants
* Primary Button
* Purpose : Used for the main action in a section or page
* Visual Style : Bold, filled with the primary brand color
* Usage : One primary button per section to guide users toward the most important action
* Secondary Button
* Purpose : Used for alternative or supporting actions
* Visual Style : Outlined with the primary color, transparent background
* Usage : Can appear alongside a primary button for less important actions
* Tertiary Button
* Purpose : Used for the least important actions
* Visual Style : Text-only with no border, using primary color
* Usage : For actions that should be available but not emphasized
-->

View File

@ -1,61 +0,0 @@
**Add your own guidelines here**
<!--
System Guidelines
Use this file to provide the AI with rules and guidelines you want it to follow.
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
# General guidelines
Any general rules you want the AI to follow.
For example:
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
* Refactor code as you go to keep code clean
* Keep file sizes small and put helper functions and components in their own files.
--------------
# Design system guidelines
Rules for how the AI should make generations look like your company's design system
Additionally, if you select a design system to use in the prompt box, you can reference
your design system's components, tokens, variables and components.
For example:
* Use a base font-size of 14px
* Date formats should always be in the format “Jun 10”
* The bottom toolbar should only ever have a maximum of 4 items
* Never use the floating action button with the bottom toolbar
* Chips should always come in sets of 3 or more
* Don't use a dropdown if there are 2 or fewer options
You can also create sub sections and add more specific details
For example:
## Button
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
users through the application. It provides visual feedback and clear affordances to enhance user experience.
### Usage
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
### Variants
* Primary Button
* Purpose : Used for the main action in a section or page
* Visual Style : Bold, filled with the primary brand color
* Usage : One primary button per section to guide users toward the most important action
* Secondary Button
* Purpose : Used for alternative or supporting actions
* Visual Style : Outlined with the primary color, transparent background
* Usage : Can appear alongside a primary button for less important actions
* Tertiary Button
* Purpose : Used for the least important actions
* Visual Style : Text-only with no border, using primary color
* Usage : For actions that should be available but not emphasized
-->

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@ -0,0 +1,118 @@
Wir hatte heute Vormittag eine sehr Produktive Session.
Den gesamten Chat-Verlauf kannst du unter:
- "docs/temp/Caht-Verlauf_2026-03-27.md"
nachlesen.
Unser Zwischen-Ergebniss findest du unter:
- "docs/04_Agents/Sessions/2026-03-27_Neumarkt_Public_Web_und_Desktop_Curator_Log.md"
Analysiere dies und bereite dich darauf vor, dass wir hoffentlich lückenlos weiter arbeiten können.
Alle Dokumentationen, Reports usw. findest du in den docs.
Gib mir bescheid wenn du bereit bist.
---
🧹 [Curator]
### Zusammenfassung der heutigen Session
Ich habe den Chat-Verlauf (`docs/temp/Caht-Verlauf_2026-03-27.md`) und das Curator-Log (`docs/04_Agents/Sessions/2026-03-27_Neumarkt_Public_Web_und_Desktop_Curator_Log.md`) analysiert. Der aktuelle Stand ist konsistent dokumentiert: klare Trennung von fachlichen Kontexten und `TechOps`/`Ping-Service`, definierter MVP-Fokus bis Neumarkt sowie präzise nächste Schritte und offene Punkte.
#### Kernaussagen aus dem Chat-Verlauf
- Strikte Trennung: Fach-UI vs. TechOps-UI, `Ping-Service` bleibt fachneutral und dient nur als technische Schablone.
- KMP/Compose-Frontend-Aufteilung mit eigenständiger `techops-desktop`-Shell und entkoppelten `features/*`-Modulen.
- Backend-Trennung nach Domänen (`services/meldung`, `services/auth`) und Technik (`services/ping`), Observability zentral.
- MVP für Neumarkt: minimal tragfähige Features für Erfassung/Verwaltung/Sync/Auth im Fach-UI, Ping/Metriken im TechOps-UI.
- Roadmap skizziert (3 Sprints), mit zuerst Struktur/Leitplanken, dann MVP-Funktionalität und Observability.
#### Kernaussagen aus dem Curator-Log (Neumarkt Public Web + Desktop)
- Zieltermin: 2026-04-07. Web-App öffentlich (Landing, Turnier-Cards 26128/26129, Nennformular), Desktop-App offline-first für interne Steuerung.
- Desktop Onboarding: Gerätename, gemeinsamer Sicherheitsschlüssel, ZNS-Daten laden (online/LAN oder Offline-Import `ZNS.zip`).
- „Veranstalter Übersicht“ als nächster zentraler Screen; Turnier-Config mit `nennenEnabled`, `resultsEnabled`, `notice` und bewerbsabhängigen Pflichtfeldern.
- TechOps-UI separat mit Kernmetriken (`nennungen_submit_latency_p95`, `nennungen_submit_error_rate`, etc.).
- Offene Punkte klar gelistet (Glossar-Bestätigung, Pflichtfelder Nennformular, E-Mail-Bestätigung, Unterschiede 26128/26129, LAN-Pairing-Fallback, UI-Details Veranstalter-Übersicht).
- Konkrete nächste Schritte für Desktop, Backend und Web sind definiert; Go/No-Go-Checkliste vorhanden.
### Validierte Leitplanken (aus beiden Dokumenten)
- Modul- und Kontexttrennung ist „nicht verhandelbar“: keine Cross-Imports zwischen `features/ping-feature` und Fach-Features.
- Offline-First: Sync-Schicht eigenständig, Queue/Retry auf allen Netzwerkpfaden, klare UI-States.
- Observability: OTel/Traces, Schlüsselmetriken, korrelierbare Request-IDs, keine PII in Logs.
- Security (MVP): gemeinsamer LAN-Schlüssel; Erweiterbarkeit eingeplant.
### Abgleich MVP-Umfang
- Web: Landing, Turnier-Cards (26128/26129) mit konfigurierbaren Toggles/`notice`, Nennformular POST inkl. Validierung, PDFs ausliefern.
- Desktop: Onboarding-Checklist, Statusleiste (Internet/Peer), Veranstalter-Übersicht (Liste + „Neu anlegen“), lokale Persistenz + Sync.
- TechOps: Separates Shell mit `Ping`-Dashboard und Basis-Metriken.
### Offene Punkte, die in der nächsten Session zu klären sind
1) Glossar-Definitionen bestätigen (Veranstalter/Veranstaltung/Turnier/Bewerb/Abteilung), Quelle: `docs/03_Domain/*`.
2) Pflichtfelder für Nennformular finalisieren, inkl. bewerbsabhängiger Regeln und DSGVO-Texte (Input durch `📜 Rulebook Expert`).
3) E-Mail-Bestätigung vs. On-Screen-Bestätigung vorerst; Abhängigkeit Mailserver klären.
4) Unterschiede der Regeln zwischen Turnieren `26128` und `26129` definieren.
5) LAN-Pairing-Fallback: Reicht gemeinsamer Schlüssel oder IP/QR-Option aktivieren?
6) UI-Details „Veranstalter Übersicht“ (Suche/Filter, Minimalfelder bei Neuanlage).
### Vorschlag für den direkten nächsten Schritt (fokussiert auf 1 Aufgabe)
- Aufgabe: Desktop „Onboarding-Checklist-Komponente“ fertig definieren und in UI verankern.
- Akzeptanzkriterien:
- `Gerätename` erfassen und lokal persistieren.
- `Sicherheitsschlüssel` setzen/prüfen; UI-Status „gekoppelt“ sobald LAN-Heartbeat aktiv.
- `ZNS.zip` Import-Flow mit Integritäts-/Versionsprüfung; eindeutige Fehlermeldungen und Warnung bei veraltetem Stand.
- Statusleiste zeigt `Internet erreichbar` und `Peer verbunden` deterministisch (mit Retry/Backoff).
- Telemetrie: Events `onboarding_completed`, `zns_import_success|failure`, `lan_peer_heartbeat`.
### Bereit für die Fortsetzung
Ich bin bereit, nahtlos weiterzuarbeiten. Wenn du möchtest, starten wir mit der Klärung der offenen Punkte 1) und 2) oder gehen direkt in die Umsetzung der Onboarding-Checklist (inkl. UI-Skizze und API-Verträgen).
Sag mir bitte, welchen Punkt wir als Nächstes priorisieren sollen oder ob es Ergänzungen/Korrekturen zur obigen Zusammenfassung gibt.
---
Okay, ich habe die Desktop-App installiert, starte diese
- Gerätename: "Meldestelle" -> Pflicht
- Sicherheitsschlüssle: "Neumarkt2026" -> Pflicht
- ZNS-Daten: synchron -> Optional, oder nur anzeige
Wenn beide Pflichtfelder ausgefüllt sind wird der
- Button "Weiter zu den Veranstaltungen" aktiv, ein klick darauf führt mich zu
# "Verwaltung - Veranstaltungen"
Diese könnte so "docs/06_Frontend/Screenshots/Verwaltung-Veranstaltungen_entwurf-01.png" aussehen.
Der Button links oben
- "+ Neue Veranstaltung" leitet mich weiter zur
# "Veranstalter - Auswahl"
Diese könnte so "docs/06_Frontend/Screenshots/Veranstalter-Auswahl_entwurf-01.png" aussehen.
Wenn ich mit einem klick auf einen Veranstalter eine auswähl treffe, dies könnte so
"docs/06_Frontend/Screenshots/Veranstalter-Auswahl_markiert_entwurf-01.png" aussehen, dann wird der
- Button "Weiter zum Veranstalter" aktiv.
Dieser leitet mich zu "Veranstalter Übersicht", dies könnte so "docs/06_Frontend/Screenshots/Veranstalter-Übersicht_entwurf-01.png" aussehen.
- Button-"+ Neuer Veranstalter" klicke komme ich auf eine Seite in der Ich einen neuen Veranstalter anlegen kann. Dies lönnte in etwa so aussehen "docs/06_Frontend/Screenshots/Neuen-Veranstalter-anlegen_entwurf-01.png" hier brauchen wir eine Suchmaske um einen Verein aus den OEPS-Daten auszuwählen, diese Daten bekommen wir über die ZNS.zip, der ZNS-Importer bereitet diese Daten sauber für unsere Datenbank auf. Hier sollten wir auch das Logo des Vereins hizufügen können.
Wir gehen davon aus, dass ich auf der
# "Veranstalter Auswahl" einen Veranstalter ausgewählt habe, ich klicke auf den
- Button "Weiter zum Veranstalter" (dieser Button aktiviert sich sobald ich einen Veranstalter ausgewählt habe) -> ich komme auf
# "Veranstalter Übersicht" diese soll in etws so "docs/06_Frontend/Screenshots/Veranstalter-Übersicht_entwurf-01.png" aussehen.
Rechts oben in der Card des Veranstalters
- Button "Profil bearbeiten" -> leitet mich weiter zur "Profil - Veranstalter", dies könnte so "docs/06_Frontend/Screenshots/Profil-Veranstalter_entwurf-01.png" aussehen.
- Button "+ Neue Veranstaltung" hier sollte ich auf eine Seite kommen in der ich eine Art der "Reitsportlichen - Veranstaltung" auswhälen könnte. In unserem Fall gibt es derzeit nur "Turnier" zur Auswahl. Mit der Auswahl "Turnier" aktiviert sich ein
- Button "Turnier anlegen" hier beginnt unser Wizzard zum anlegen eines Turniers. Ich werde weitergeleitet zu
# "Turnier - Stammdaten" könnte so "docs/06_Frontend/Screenshots/Turnier-Stammdaten_01_entwurf-01.png", "docs/06_Frontend/Screenshots/Turnier-Stammdaten_02_entwurf01.png" aussehen.
Das aller wichtigste ist die Turnier-Nr.: Eingabefeld numerisch 5 stellig validieren und noch einmal bestätigen lassen, zuückfragen ob die Turnier-Nr. korrekt ist, denn diese ist die offizelle Genehmigungs-Nummer vom ÖPS/Verband. Mit dieser Nummer wollen wir die Datenbank initialisieren
Erst wenn die Turnier-Nr. erfolgreich angegeben wurde und damit die Turnier-Datenbank initialisiert ist, ist diese Nummer nicht mehr zu ändern.
Jetzt werden die restlichen Konfigurationen freigeschalten.
Hier gehen wir chronologisch vor.
- "Type" - ÖTO(national) FEI(international) // Wir haben derzeit nur national
- "ZNS-Daten" - btn-"aktualisieren" btn-"USB-Import" // kleine Anzeige ob ZNS-Daten geladen sind, "aktualisieren" um vom Backend die Daten zu sychronisieren, "USB-Import" offline Möglichkeit die Daten zu Importieren.
- "Sprache" - Deutsch Englisch // wir haben derzeit alles nur auf deutsch
- "Sparte" - Dressur Springen // wir bieten jetzt nur Dressur und Springen an, diese können einzeln oder aber auch kombiniert werden.
- "Klasse" C B A // wir machen jetzt nur die Turnier-Klasse "C"
- "Kategorie" // hier wollen wir die eine Auswahlmöglichkeit bieten welche vorgefiltert wurde aus Sparte und Klasse, in Neumarkt haben die Turniere 26129-"CDN-C-NEU CDNP-C-NEU" 26128-"CSN-C-NEU CSNP-C-NEU"
- "Datum" // darauf achten, dass das bis-Datum nicht vor dem von-Datum sein kann
- "Titel" // optional, wenn kein Titel agegeben wird, ist der Titel wie in der Ausschreibung "CDN-C-NEU CDNP-C-NEU NEUMARKT/M., OÖ"
- "Sub-Titel" // ebenfalls optional
- btn-"+ Sponsoren hinzufügen"

View File

@ -1,6 +1,8 @@
package at.mocode.frontend.core.navigation package at.mocode.frontend.core.navigation
sealed class AppScreen(val route: String) { sealed class AppScreen(val route: String) {
// Onboarding (Desktop: Gerätename/Schlüssel/ZNS)
data object Onboarding : AppScreen("/onboarding")
data object Landing : AppScreen(Routes.HOME) data object Landing : AppScreen(Routes.HOME)
data object Home : AppScreen("/home") data object Home : AppScreen("/home")
data object Dashboard : AppScreen("/dashboard") data object Dashboard : AppScreen("/dashboard")
@ -22,6 +24,8 @@ sealed class AppScreen(val route: String) {
data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl") data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl")
data object VeranstalterNeu : AppScreen("/veranstalter/neu") data object VeranstalterNeu : AppScreen("/veranstalter/neu")
data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId") data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId")
// Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail → "+ Neue Veranstaltung")
data class VeranstaltungKonfig(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId/veranstaltung/neu")
data class VeranstaltungUebersicht(val veranstalterId: Long, val veranstaltungId: Long) : data class VeranstaltungUebersicht(val veranstalterId: Long, val veranstaltungId: Long) :
AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId") AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId")
@ -43,10 +47,12 @@ sealed class AppScreen(val route: String) {
private val TURNIER_DETAIL = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)$") private val TURNIER_DETAIL = Regex("/veranstaltung/(\\d+)/turnier/(\\d+)$")
private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$") private val TURNIER_NEU = Regex("/veranstaltung/(\\d+)/turnier/neu$")
private val VERANSTALTER_DETAIL = Regex("/veranstalter/(\\d+)$") private val VERANSTALTER_DETAIL = Regex("/veranstalter/(\\d+)$")
private val VERANSTALTUNG_KONFIG = Regex("/veranstalter/(\\d+)/veranstaltung/neu$")
private val VERANSTALTUNG_UEBERSICHT = Regex("/veranstalter/(\\d+)/veranstaltung/(\\d+)$") private val VERANSTALTUNG_UEBERSICHT = Regex("/veranstalter/(\\d+)/veranstaltung/(\\d+)$")
fun fromRoute(route: String): AppScreen { fun fromRoute(route: String): AppScreen {
return when (route) { return when (route) {
"/onboarding" -> Onboarding
Routes.HOME -> Landing Routes.HOME -> Landing
"/home" -> Home "/home" -> Home
"/dashboard" -> Dashboard "/dashboard" -> Dashboard
@ -79,6 +85,9 @@ sealed class AppScreen(val route: String) {
VERANSTALTER_DETAIL.matchEntire(route)?.destructured?.let { (vId) -> VERANSTALTER_DETAIL.matchEntire(route)?.destructured?.let { (vId) ->
return VeranstalterDetail(vId.toLong()) return VeranstalterDetail(vId.toLong())
} }
VERANSTALTUNG_KONFIG.matchEntire(route)?.destructured?.let { (vId) ->
return VeranstaltungKonfig(vId.toLong())
}
VERANSTALTUNG_UEBERSICHT.matchEntire(route)?.destructured?.let { (verId, vId) -> VERANSTALTUNG_UEBERSICHT.matchEntire(route)?.destructured?.let { (verId, vId) ->
return VeranstaltungUebersicht(verId.toLong(), vId.toLong()) return VeranstaltungUebersicht(verId.toLong(), vId.toLong())
} }

View File

@ -0,0 +1,35 @@
package at.mocode.veranstalter.feature.presentation
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import at.mocode.frontend.core.designsystem.models.LoginStatus
/**
* Einfacher In-Memory-Store für Veranstalter (Vereine) zur Navigation/Validierung im Prototyp.
*/
object FakeVeranstalterStore {
private val list: SnapshotStateList<VeranstalterUiModel> = mutableStateListOf(
VeranstalterUiModel(
id = 1,
name = "Reitverein Neumarkt",
oepsNummer = "OEPS-12345",
ort = "4221 Neumarkt/M.",
ansprechpartner = "Max Mustermann",
email = "info@rv-neumarkt.at",
loginStatus = LoginStatus.AKTIV
),
VeranstalterUiModel(
id = 2,
name = "Union Reitclub Oberösterreich",
oepsNummer = "OEPS-67890",
ort = "4020 Linz",
ansprechpartner = "Anna Beispiel",
email = "kontakt@ur-ooe.at",
loginStatus = LoginStatus.AKTIV
)
)
fun all(): SnapshotStateList<VeranstalterUiModel> = list
fun exists(id: Long): Boolean = list.any { it.id == id }
}

View File

@ -0,0 +1,37 @@
package at.mocode.veranstalter.feature.presentation
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.mutableStateListOf
/**
* Einfacher In-Memory-Store für Veranstaltungen pro Veranstalter.
* Dient dem Prototyping ohne Backend/DB.
*/
object FakeVeranstaltungStore {
private val data: MutableMap<Long, SnapshotStateList<VeranstaltungListUiModel>> = mutableMapOf()
fun listFor(veranstalterId: Long): SnapshotStateList<VeranstaltungListUiModel> =
data.getOrPut(veranstalterId) { mutableStateListOf() }
fun addFirst(veranstalterId: Long, item: VeranstaltungListUiModel) {
val list = listFor(veranstalterId)
list.add(0, item)
}
fun seedIfEmpty(veranstalterId: Long, items: List<VeranstaltungListUiModel>) {
val list = listFor(veranstalterId)
if (list.isEmpty()) list.addAll(items)
}
fun exists(veranstaltungId: Long): Boolean =
data.values.any { list -> list.any { it.id == veranstaltungId } }
fun belongsTo(veranstalterId: Long, veranstaltungId: Long): Boolean =
data[veranstalterId]?.any { it.id == veranstaltungId } ?: false
fun remove(veranstalterId: Long, veranstaltungId: Long) {
val list = data[veranstalterId] ?: return
val idx = list.indexOfFirst { it.id == veranstaltungId }
if (idx >= 0) list.removeAt(idx)
}
}

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
@ -66,7 +67,12 @@ fun VeranstalterDetailScreen(
) )
} }
val veranstaltungen = remember(veranstalterId) { // Liste aus dem Fake-Store (pro Veranstalter). Falls leer, einmalig seeden.
val storeList = FakeVeranstaltungStore.listFor(veranstalterId)
LaunchedEffect(veranstalterId) {
if (storeList.isEmpty()) {
FakeVeranstaltungStore.seedIfEmpty(
veranstalterId,
listOf( listOf(
VeranstaltungListUiModel( VeranstaltungListUiModel(
id = 1L, id = 1L,
@ -102,9 +108,11 @@ fun VeranstalterDetailScreen(
status = VeranstaltungStatus.VORBEREITUNG, status = VeranstaltungStatus.VORBEREITUNG,
), ),
) )
)
}
} }
val gefiltert = veranstaltungen.filter { v -> val gefiltert = storeList.filter { v ->
val matchesStatus = when (statusFilter) { val matchesStatus = when (statusFilter) {
VeranstaltungStatusFilter.ALLE -> true VeranstaltungStatusFilter.ALLE -> true
VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG
@ -251,6 +259,9 @@ fun VeranstalterDetailScreen(
VeranstaltungListRow( VeranstaltungListRow(
veranstaltung = veranstaltung, veranstaltung = veranstaltung,
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) }, onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
onLoeschen = {
FakeVeranstaltungStore.remove(veranstalterId, veranstaltung.id)
}
) )
} }
} }
@ -269,6 +280,7 @@ private fun KontaktSpalte(label: String, wert: String) {
private fun VeranstaltungListRow( private fun VeranstaltungListRow(
veranstaltung: VeranstaltungListUiModel, veranstaltung: VeranstaltungListUiModel,
onOeffnen: () -> Unit, onOeffnen: () -> Unit,
onLoeschen: () -> Unit,
) { ) {
val statusColor = when (veranstaltung.status) { val statusColor = when (veranstaltung.status) {
VeranstaltungStatus.VORBEREITUNG -> StatusVorbereitungColor VeranstaltungStatus.VORBEREITUNG -> StatusVorbereitungColor
@ -276,8 +288,8 @@ private fun VeranstaltungListRow(
VeranstaltungStatus.ABGESCHLOSSEN -> StatusAbgeschlossenColor VeranstaltungStatus.ABGESCHLOSSEN -> StatusAbgeschlossenColor
} }
val statusText = when (veranstaltung.status) { val statusText = when (veranstaltung.status) {
VeranstaltungStatus.VORBEREITUNG -> "Vorbereitung" VeranstaltungStatus.VORBEREITUNG -> "In Vorbereitung"
VeranstaltungStatus.LIVE -> "Live" VeranstaltungStatus.LIVE -> "Aktiv"
VeranstaltungStatus.ABGESCHLOSSEN -> "Abgeschlossen" VeranstaltungStatus.ABGESCHLOSSEN -> "Abgeschlossen"
} }
@ -329,17 +341,23 @@ private fun VeranstaltungListRow(
StatSpalte("Letzte Aktivität", veranstaltung.letzteAktivitaet) StatSpalte("Letzte Aktivität", veranstaltung.letzteAktivitaet)
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
// Bearbeiten-Icon // Aktionen: Zur Veranstaltung & Löschen
IconButton(onClick = onOeffnen) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onOeffnen, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)) {
Text("Zur Veranstaltung")
}
// Roter Mülleimer
IconButton(onClick = onLoeschen) {
Icon( Icon(
Icons.Default.Edit, Icons.Default.Delete,
contentDescription = "Öffnen", contentDescription = "Löschen",
tint = Color(0xFF9CA3AF), tint = Color(0xFFDC2626),
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
) )
} }
} }
} }
}
} }
@Composable @Composable

View File

@ -0,0 +1,109 @@
package at.mocode.veranstalter.feature.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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Formular zum Anlegen einer neuen Veranstaltung (Titel + Datumspfad). Pflichtfelder: Titel, Datum von/bis.
* Beim Speichern wird über Callback die Initialisierung ausgelöst (ID-Generierung erfolgt im Aufrufer).
*/
@Composable
fun VeranstaltungKonfigScreen(
veranstalterId: Long,
onAbbrechen: () -> Unit,
onSpeichern: (titel: String, datumVon: String, datumBis: String) -> Unit,
) {
var titel by remember { mutableStateOf("") }
var datumVon by remember { mutableStateOf("") }
var datumBis by remember { mutableStateOf("") }
val datesPresent = datumVon.isNotBlank() && datumBis.isNotBlank()
// Einfache Validierung: YYYY-MM-DD Format erzwingen wir hier nicht strikt; wenn beide gesetzt, prüfen wir lexikografisch
val dateOrderOk = !datesPresent || datumBis >= datumVon
val valid = titel.isNotBlank() && datesPresent && dateOrderOk
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
// Header
Column(modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp)) {
Text(
text = "Neue Veranstaltung anlegen",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(6.dp))
Text(
text = "Erfassen Sie Titel und Zeitraum. Alle Turniere müssen innerhalb dieses Zeitraums stattfinden.",
fontSize = 13.sp,
color = Color(0xFF6B7280),
)
}
// Formular-Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Stammdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
label = { Text("Titel *") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
isError = titel.isBlank(),
)
Text("Zeitraum", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = datumVon,
onValueChange = { datumVon = it },
label = { Text("von (YYYY-MM-DD) *") },
singleLine = true,
modifier = Modifier.weight(1f),
isError = datumVon.isBlank(),
)
OutlinedTextField(
value = datumBis,
onValueChange = { datumBis = it },
label = { Text("bis (YYYY-MM-DD) *") },
singleLine = true,
modifier = Modifier.weight(1f),
isError = datumBis.isBlank() || (datesPresent && !dateOrderOk),
)
}
if (datesPresent && !dateOrderOk) {
Text("Das bis-Datum darf nicht vor dem von-Datum liegen.", color = MaterialTheme.colorScheme.error, fontSize = 12.sp)
}
}
}
Spacer(Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 40.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(onClick = onAbbrechen) { Text("Abbrechen") }
Button(onClick = { onSpeichern(titel.trim(), datumVon.trim(), datumBis.trim()) }, enabled = valid) {
Text("Speichern")
}
}
}
}

View File

@ -9,6 +9,8 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -38,8 +40,24 @@ fun AdminUebersichtScreen(
onVeranstaltungOeffnen: (Long) -> Unit, onVeranstaltungOeffnen: (Long) -> Unit,
onPingService: () -> Unit = {}, onPingService: () -> Unit = {},
) { ) {
// Placeholder-Daten für die UI-Struktur // Placeholder-Daten für die UI-Struktur (sichtbar als Cards)
val veranstaltungen = listOf<VeranstaltungUiModel>() // leer bis Backend angebunden val sample = listOf(
VeranstaltungUiModel(
id = 1001,
name = "NEUMARKT/M., OÖ",
ort = "4221 NEUMARKT/M.",
datum = "12.13.04.2026",
turnierAnzahl = 2,
nennungen = 0,
letzteAktivitaet = "vor 1 Min",
status = VeranstaltungStatus.VORBEREITUNG,
turniere = listOf(
TurnierUiModel(id = 26129, nummer = 26129, name = "CDN-C-NEU CDNP-C-NEU", bewerbAnzahl = 16),
TurnierUiModel(id = 26128, nummer = 26128, name = "CSN-C-NEU CSNP-C-NEU", bewerbAnzahl = 18),
)
)
)
val veranstaltungen = remember { mutableStateListOf<VeranstaltungUiModel>().also { it.addAll(sample) } }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// KPI-Kacheln // KPI-Kacheln
@ -75,10 +93,6 @@ fun AdminUebersichtScreen(
singleLine = true, singleLine = true,
) )
OutlinedButton(onClick = onPingService) {
Text("🔧 Ping")
}
// Status-Filter Chips // Status-Filter Chips
StatusFilterChip("Alle", selected = true) StatusFilterChip("Alle", selected = true)
StatusFilterChip("Vorbereitung", selected = false) StatusFilterChip("Vorbereitung", selected = false)
@ -123,11 +137,11 @@ fun AdminUebersichtScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
items(veranstaltungen) { veranstaltung -> items(items = veranstaltungen, key = { it.id }) { veranstaltung ->
VeranstaltungCard( VeranstaltungCard(
veranstaltung = veranstaltung, veranstaltung = veranstaltung,
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) }, onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
onLoeschen = { /* TODO */ }, onLoeschen = { veranstaltungen.removeAll { it.id == veranstaltung.id } },
) )
} }
} }
@ -184,21 +198,21 @@ private fun KpiKachel(
) { ) {
Card( Card(
modifier = modifier, modifier = modifier,
border = BorderStroke(2.dp, akzentFarbe), border = BorderStroke(1.dp, akzentFarbe.copy(alpha = 0.4f)),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Text( Text(
text = label, text = label,
fontSize = 10.sp, fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.9f),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
Text( Text(
text = wert, text = wert,
fontSize = 28.sp, fontSize = 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
color = akzentFarbe, color = akzentFarbe.copy(alpha = 0.9f),
) )
} }
} }
@ -274,7 +288,7 @@ private fun VeranstaltungCard(
Text("${turnier.name} (${turnier.bewerbAnzahl} Bewerbe)", fontSize = 12.sp) Text("${turnier.name} (${turnier.bewerbAnzahl} Bewerbe)", fontSize = 12.sp)
} }
OutlinedButton(onClick = onOeffnen, modifier = Modifier.height(28.dp)) { OutlinedButton(onClick = onOeffnen, modifier = Modifier.height(28.dp)) {
Text("Öffnen", fontSize = 11.sp) Text("Zum Turnier", fontSize = 11.sp)
} }
} }
} }
@ -297,7 +311,7 @@ private fun VeranstaltungCard(
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
modifier = Modifier.height(32.dp), modifier = Modifier.height(32.dp),
) { ) {
Text("Öffnen", fontSize = 12.sp) Text("Zur Veranstaltung", fontSize = 12.sp)
} }
IconButton(onClick = onLoeschen, modifier = Modifier.size(32.dp)) { IconButton(onClick = onLoeschen, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626))

View File

@ -36,10 +36,11 @@ fun DesktopApp() {
val authState by authTokenManager.authState.collectAsState() val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login // Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login) { if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Onboarding) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen)) // Wenn noch keine Authentifizierung vorhanden ist, zuerst Onboarding anzeigen
nav.navigateToScreen(AppScreen.Onboarding)
} }
} }

View File

@ -51,7 +51,7 @@ fun main() = application {
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "Meldestelle", title = "Meldestelle",
state = WindowState(width = 1400.dp, height = 900.dp), state = WindowState(width = 1600.dp, height = 900.dp),
) { ) {
DesktopApp() DesktopApp()
} }

View File

@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow
* Hält den aktuellen Screen als StateFlow, den DesktopApp beobachtet. * Hält den aktuellen Screen als StateFlow, den DesktopApp beobachtet.
*/ */
class DesktopNavigationPort : NavigationPort { class DesktopNavigationPort : NavigationPort {
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Login()) private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Onboarding)
override val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow() override val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
override fun navigateTo(route: String) { override fun navigateTo(route: String) {

View File

@ -3,6 +3,8 @@ package at.mocode.desktop.screens.layout
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.Logout
@ -24,6 +26,8 @@ import at.mocode.turnier.feature.presentation.TurnierNeuScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore
import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
@ -264,6 +268,19 @@ private fun BreadcrumbSeparator() {
) )
} }
@Composable
private fun InvalidContextNotice(message: String, onBack: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message, color = Color(0xFFB91C1C), fontSize = 14.sp)
Spacer(Modifier.height(12.dp))
Button(onClick = onBack) { Text("Zur Auswahl") }
}
}
/** /**
* Content-Bereich: rendert den passenden Screen je nach aktuellem AppScreen. * Content-Bereich: rendert den passenden Screen je nach aktuellem AppScreen.
*/ */
@ -273,44 +290,92 @@ private fun DesktopContentArea(
onNavigate: (AppScreen) -> Unit, onNavigate: (AppScreen) -> Unit,
) { ) {
when (currentScreen) { when (currentScreen) {
// Root-Screen: Admin-Übersicht // Onboarding ohne Login
is AppScreen.Veranstaltungen -> AdminUebersichtScreen( is AppScreen.Onboarding -> {
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) }, val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) }, // V2 Onboarding (Vision_03)
onPingService = { onNavigate(AppScreen.Ping) }, at.mocode.desktop.v2.OnboardingScreenV2 { _, _ ->
authTokenManager.setToken("dummy.jwt.token")
onNavigate(AppScreen.VeranstalterAuswahl)
}
}
// Root-Screen: Leitet in V2-Fluss
is AppScreen.Veranstaltungen -> {
// Direkt zur Veranstalter-Auswahl V2
at.mocode.desktop.v2.VeranstalterAuswahlV2(
onBack = { /* bleibt root */ },
onWeiter = { vId -> onNavigate(AppScreen.VeranstalterDetail(vId)) },
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
) )
}
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht // Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlScreen( is AppScreen.VeranstalterAuswahl -> at.mocode.desktop.v2.VeranstalterAuswahlV2(
onZurueck = { onNavigate(AppScreen.Veranstaltungen) }, onBack = { onNavigate(AppScreen.Veranstaltungen) },
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) }, onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) }, onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
) )
is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen( is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen(
onAbbrechen = { onNavigate(AppScreen.VeranstalterAuswahl) }, onAbbrechen = { onNavigate(AppScreen.VeranstalterAuswahl) },
onSpeichern = { _, _, _ -> onNavigate(AppScreen.VeranstalterAuswahl) }, onSpeichern = { _, _, _ -> onNavigate(AppScreen.VeranstalterAuswahl) },
) )
is AppScreen.VeranstalterDetail -> VeranstalterDetailScreen( is AppScreen.VeranstalterDetail -> {
veranstalterId = currentScreen.veranstalterId, val vId = currentScreen.veranstalterId
onZurueck = { onNavigate(AppScreen.VeranstalterAuswahl) }, if (!FakeVeranstalterStore.exists(vId)) {
onVeranstaltungOeffnen = { vId -> InvalidContextNotice(
onNavigate(AppScreen.VeranstaltungUebersicht(currentScreen.veranstalterId, vId)) message = "Veranstalter (ID=$vId) nicht gefunden.",
}, onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) },
) )
is AppScreen.VeranstaltungUebersicht -> VeranstaltungUebersichtScreen( } else {
veranstalterId = currentScreen.veranstalterId, at.mocode.desktop.v2.VeranstalterDetailV2(
veranstaltungId = currentScreen.veranstaltungId, veranstalterId = vId,
onZurueck = { onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) }, onBack = { onNavigate(AppScreen.VeranstalterAuswahl) },
onTurnierOeffnen = { tId -> onZurVeranstaltung = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) },
onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, tId)) onNeuVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig(vId)) },
},
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.veranstaltungId)) },
onZnsImport = { /* TODO: ZNS-Import Dialog für Turnier */ },
onDbImport = { /* TODO: DB-Import Dialog */ },
onDbExport = { /* TODO: DB-Export Dialog */ },
) )
}
}
is AppScreen.VeranstaltungKonfig -> {
val vId = currentScreen.veranstalterId
// V2: Validierung über StoreV2
if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) {
InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
)
} else {
at.mocode.desktop.v2.VeranstaltungKonfigV2(
veranstalterId = vId,
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
onSaved = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) }
)
}
}
is AppScreen.VeranstaltungUebersicht -> {
val vId = currentScreen.veranstalterId
val evtId = currentScreen.veranstaltungId
if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) {
InvalidContextNotice(
message = "Veranstalter (ID=$vId) nicht gefunden.",
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
)
} else if (at.mocode.desktop.v2.StoreV2.eventsFor(vId).none { it.id == evtId }) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) gehört nicht zu Veranstalter #$vId.",
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) }
)
} else {
at.mocode.desktop.v2.VeranstaltungUebersichtV2(
veranstalterId = vId,
veranstaltungId = evtId,
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(evtId)) },
onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) },
)
}
}
// Veranstaltungs-Screens // Veranstaltungs-Screens
is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen( is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen(
@ -325,16 +390,41 @@ private fun DesktopContentArea(
) )
// Turnier-Screens // Turnier-Screens
is AppScreen.TurnierDetail -> TurnierDetailScreen( is AppScreen.TurnierDetail -> {
veranstaltungId = currentScreen.veranstaltungId, val evtId = currentScreen.veranstaltungId
if (!FakeVeranstaltungStore.exists(evtId)) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = { onNavigate(AppScreen.Veranstaltungen) }
)
} else {
TurnierDetailScreen(
veranstaltungId = evtId,
turnierId = currentScreen.turnierId, turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) }, onBack = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) },
) )
is AppScreen.TurnierNeu -> TurnierNeuScreen( }
veranstaltungId = currentScreen.veranstaltungId, }
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) }, is AppScreen.TurnierNeu -> {
onSave = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) }, val evtId = currentScreen.veranstaltungId
// V2: wir erlauben Turnier-Nr nur, wenn die Veranstaltung im V2-Store existiert
val parent = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { v ->
at.mocode.desktop.v2.StoreV2.eventsFor(v.id).any { it.id == evtId }
}
if (parent == null) {
InvalidContextNotice(
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
onBack = { onNavigate(AppScreen.Veranstaltungen) }
) )
} else {
at.mocode.desktop.v2.TurnierWizardV2(
veranstalterId = parent.id,
veranstaltungId = evtId,
onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
onSaved = { _ -> onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
)
}
}
// Ping-Screen // Ping-Screen
is AppScreen.Ping -> { is AppScreen.Ping -> {

View File

@ -0,0 +1,92 @@
package at.mocode.desktop.screens.onboarding
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
enum class ZnsStatus { NONE, LOCAL, SYNCED }
@Composable
fun OnboardingScreen(
initialName: String = "",
initialKey: String = "",
initialZns: ZnsStatus = ZnsStatus.NONE,
onZnsSync: () -> Unit = {},
onZnsUsb: () -> Unit = {},
onContinue: (geraetName: String, sharedKey: String, znsStatus: ZnsStatus) -> Unit,
) {
var geraetName by remember { mutableStateOf(initialName) }
var sharedKey by remember { mutableStateOf(initialKey) }
var znsStatus by remember { mutableStateOf(initialZns) }
var showPassword by remember { mutableStateOf(false) }
val nameValid = geraetName.trim().length >= 3
val keyValid = sharedKey.trim().length >= 8
val canContinue = nameValid && keyValid
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Onboarding", style = MaterialTheme.typography.headlineSmall)
Card {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gerätename (Pflicht)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = geraetName,
onValueChange = { geraetName = it },
placeholder = { Text("z. B. Meldestelle") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !nameValid && geraetName.isNotBlank()
)
Text("Sicherheitsschlüssel (Pflicht)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = sharedKey,
onValueChange = { sharedKey = it },
placeholder = { Text("z. B. Neumarkt2026") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !keyValid && sharedKey.isNotBlank(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val label = if (showPassword) "Verbergen" else "Anzeigen"
TextButton(onClick = { showPassword = !showPassword }) {
Text(label)
}
}
)
Text("ZNS-Daten (optional)", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = {
znsStatus = ZnsStatus.SYNCED
onZnsSync()
}, label = { Text("Aktualisieren") })
AssistChip(onClick = {
znsStatus = ZnsStatus.LOCAL
onZnsUsb()
}, label = { Text("USB-Import") })
Spacer(Modifier.width(8.dp))
Text("Status: $znsStatus")
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { onContinue(geraetName.trim(), sharedKey.trim(), znsStatus) }, enabled = canContinue) {
Text("Weiter zu den Veranstaltungen")
}
if (!canContinue) {
Text("Bitte Gerätename (min. 3) und Schlüssel (min. 8) angeben.", color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@ -0,0 +1,159 @@
package at.mocode.desktop.v2
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.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun OnboardingScreenV2(onContinue: (String, String) -> Unit) {
DesktopThemeV2 {
Surface(color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text("Onboarding", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
var geraetName by remember { mutableStateOf("") }
var key by remember { mutableStateOf("") }
var showPw by remember { mutableStateOf(false) }
OutlinedTextField(
value = geraetName,
onValueChange = { geraetName = it },
label = { Text("Gerätename (Pflicht)") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = key,
onValueChange = { key = it },
label = { Text("Sicherheitsschlüssel (Pflicht)") },
trailingIcon = {
IconButton(onClick = { showPw = !showPw }) {
Icon(if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = null)
}
},
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
val enabled = geraetName.trim().length >= 3 && key.trim().length >= 8
Button(onClick = { onContinue(geraetName, key) }, enabled = enabled) {
Text("Weiter zum VeranstalterFlow")
}
if (!enabled) Text("Mind. 3 Zeichen für Namen und 8 Zeichen für Schlüssel", color = Color(0xFFB00020))
}
}
}
}
@Composable
fun VeranstalterAuswahlV2(
onBack: () -> Unit,
onWeiter: (Long) -> Unit,
onNeu: () -> Unit,
) {
DesktopThemeV2 {
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() })
Text("Veranstalter auswählen", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = onNeu) { Text("+ Neuer Veranstalter") }
}
var selectedId by remember { mutableStateOf<Long?>(null) }
LazyColumn(Modifier.fillMaxSize()) {
items(StoreV2.vereine) { v ->
val sel = selectedId == v.id
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.clickable {
selectedId = v.id
// Direktnavigation beim Klick auf eine Karte
onWeiter(v.id)
},
border = if (sel) ButtonDefaults.outlinedButtonBorder else null,
) {
Column(Modifier.padding(12.dp)) {
Text(v.name, fontWeight = FontWeight.SemiBold)
Text("OEPS: ${v.oepsNummer} · ${v.ort}", color = Color(0xFF6B7280))
}
}
}
}
Button(onClick = { selectedId?.let(onWeiter) }, enabled = selectedId != null) { Text("Weiter zum Veranstalter") }
}
}
}
@Composable
fun VeranstalterDetailV2(
veranstalterId: Long,
onBack: () -> Unit,
onZurVeranstaltung: (Long) -> Unit,
onNeuVeranstaltung: () -> Unit,
) {
DesktopThemeV2 {
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() })
val verein = StoreV2.vereine.firstOrNull { it.id == veranstalterId }
Text(verein?.name ?: "Veranstalter", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.weight(1f))
Button(onClick = onNeuVeranstaltung) { Text("+ Neue Veranstaltung") }
}
val events = StoreV2.eventsFor(veranstalterId)
if (events.isEmpty()) Text("Noch keine Veranstaltungen angelegt.", color = Color(0xFF6B7280))
LazyColumn(Modifier.fillMaxSize()) {
items(events) { evt ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(evt.titel, fontWeight = FontWeight.SemiBold)
Text("${evt.datumVon}${evt.datumBis?.let { " $it" } ?: ""}", color = Color(0xFF6B7280))
AssistChip(onClick = {}, label = { Text(evt.status) })
}
Button(onClick = { onZurVeranstaltung(evt.id) }) { Text("Zur Veranstaltung") }
Spacer(Modifier.width(8.dp))
var confirm by remember { mutableStateOf(false) }
if (confirm) {
AlertDialog(
onDismissRequest = { confirm = false },
confirmButton = {
TextButton(onClick = {
StoreV2.removeEvent(veranstalterId, evt.id)
confirm = false
}) { Text("Löschen") }
},
dismissButton = { TextButton(onClick = { confirm = false }) { Text("Abbrechen") } },
title = { Text("Veranstaltung löschen?") },
text = { Text("Diese Aktion entfernt die Veranstaltung und alle zugehörigen Turniere im Prototypen.") }
)
}
IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) }
}
}
}
}
}
}
}

View File

@ -0,0 +1,42 @@
package at.mocode.desktop.v2
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
data class Verein(
val id: Long,
val name: String,
val oepsNummer: String,
val ort: String,
)
data class VeranstaltungV2(
val id: Long,
val veranstalterId: Long,
var titel: String,
var datumVon: String,
var datumBis: String?,
var status: String = "In Vorbereitung",
)
object StoreV2 {
val vereine: SnapshotStateList<Verein> = mutableStateListOf(
Verein(1, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
Verein(2, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
)
private val veranstaltungen: MutableMap<Long, SnapshotStateList<VeranstaltungV2>> = mutableMapOf()
fun eventsFor(vereinId: Long): SnapshotStateList<VeranstaltungV2> =
veranstaltungen.getOrPut(vereinId) { mutableStateListOf() }
fun addEventFirst(vereinId: Long, v: VeranstaltungV2) {
eventsFor(vereinId).add(0, v)
}
fun removeEvent(vereinId: Long, veranstaltungId: Long) {
val list = eventsFor(vereinId)
val idx = list.indexOfFirst { it.id == veranstaltungId }
if (idx >= 0) list.removeAt(idx)
}
}

View File

@ -0,0 +1,35 @@
package at.mocode.desktop.v2
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// Vision_03 Farbschema (vereinfacht)
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
private val Surface = Color(0xFFFFFFFF)
private val Background = Color(0xFFF7F8FA)
private val LightColors: ColorScheme = lightColorScheme(
primary = PrimaryBlue,
secondary = AccentBlue,
background = Background,
surface = Surface,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF0F172A),
onSurface = Color(0xFF111827),
)
@Composable
fun DesktopThemeV2(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = LightColors,
typography = Typography(),
content = content,
)
}

View File

@ -0,0 +1,198 @@
package at.mocode.desktop.v2
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.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun VeranstaltungKonfigV2(
veranstalterId: Long,
onBack: () -> Unit,
onSaved: (Long) -> Unit,
) {
DesktopThemeV2 {
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() })
Text("Neue Veranstaltung", style = MaterialTheme.typography.titleLarge)
}
var titel by remember { mutableStateOf("") }
var von by remember { mutableStateOf("") }
var bis by remember { mutableStateOf("") }
OutlinedTextField(value = titel, onValueChange = { titel = it }, label = { Text("Titel (Pflicht)") }, modifier = Modifier.fillMaxWidth())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = von, onValueChange = { von = it }, label = { Text("von (YYYY-MM-DD)") }, modifier = Modifier.weight(1f))
OutlinedTextField(value = bis, onValueChange = { bis = it }, label = { Text("bis (YYYY-MM-DD)") }, modifier = Modifier.weight(1f))
}
val validDates = von.isNotBlank() && (bis.isBlank() || bis >= von)
if (!validDates) Text("bis-Datum darf nicht vor von-Datum liegen", color = Color(0xFFB00020))
val enabled = titel.trim().isNotEmpty() && validDates
Button(onClick = {
val id = System.currentTimeMillis()
StoreV2.addEventFirst(veranstalterId, VeranstaltungV2(id, veranstalterId, titel.trim(), von.trim(), bis.trim().ifBlank { null }))
onSaved(id)
}, enabled = enabled) { Text("Speichern") }
}
}
}
data class TurnierV2(
val id: Long,
val veranstaltungId: Long,
val turnierNr: Int,
var kategorie: String,
var datumVon: String,
var datumBis: String?,
)
object TurnierStoreV2 {
private val map = mutableMapOf<Long, MutableList<TurnierV2>>()
fun list(veranstaltungId: Long): MutableList<TurnierV2> = map.getOrPut(veranstaltungId) { mutableListOf() }
fun add(veranstaltungId: Long, t: TurnierV2) { list(veranstaltungId).add(0, t) }
fun remove(veranstaltungId: Long, tId: Long) { list(veranstaltungId).removeAll { it.id == tId } }
}
@Composable
fun VeranstaltungUebersichtV2(
veranstalterId: Long,
veranstaltungId: Long,
onBack: () -> Unit,
onTurnierNeu: () -> Unit,
onTurnierOpen: (Long) -> Unit,
) {
DesktopThemeV2 {
val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() })
Text(veranstaltung?.titel ?: "Veranstaltung", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.weight(1f))
Button(onClick = onTurnierNeu) { Text("+ Neues Turnier") }
}
if (veranstaltung != null) {
Text("Zeitraum: ${veranstaltung.datumVon}${veranstaltung.datumBis?.let { " $it" } ?: ""}")
Text("Status: ${veranstaltung.status}")
}
val list = remember(veranstaltungId) { TurnierStoreV2.list(veranstaltungId) }
if (list.isEmpty()) Text("Noch keine Turniere angelegt.", color = Color(0xFF6B7280))
LazyColumn(Modifier.fillMaxSize()) {
items(list) { t ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Turnier ${t.turnierNr}", fontWeight = FontWeight.SemiBold)
Text(t.kategorie, color = Color(0xFF6B7280))
Text("${t.datumVon}${t.datumBis?.let { " $it" } ?: ""}", color = Color(0xFF6B7280))
}
Button(onClick = { onTurnierOpen(t.id) }) { Text("Zum Turnier") }
Spacer(Modifier.width(8.dp))
var confirm by remember { mutableStateOf(false) }
if (confirm) {
AlertDialog(
onDismissRequest = { confirm = false },
confirmButton = { TextButton(onClick = { TurnierStoreV2.remove(veranstaltungId, t.id); confirm = false }) { Text("Löschen") } },
dismissButton = { TextButton(onClick = { confirm = false }) { Text("Abbrechen") } },
title = { Text("Turnier löschen?") },
text = { Text("Dieses Turnier wird aus der Veranstaltung entfernt (Prototyp).") }
)
}
IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) }
}
}
}
}
}
}
}
@Composable
fun TurnierWizardV2(
veranstalterId: Long,
veranstaltungId: Long,
onBack: () -> Unit,
onSaved: (Long) -> Unit,
) {
DesktopThemeV2 {
val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() })
Text("Neues Turnier", style = MaterialTheme.typography.titleLarge)
}
var nr by remember { mutableStateOf("") }
var locked by remember { mutableStateOf(false) }
// Kategorie wird gemäß Neumarkt-Logik automatisch vorbelegt aus der Turnier-Nr.
var kat by remember { mutableStateOf("") }
var von by remember { mutableStateOf(veranstaltung?.datumVon ?: "") }
var bis by remember { mutableStateOf(veranstaltung?.datumBis ?: "") }
OutlinedTextField(value = nr, onValueChange = {
if (!locked) nr = it.filter { ch -> ch.isDigit() }.take(5)
}, label = { Text("TurnierNr. (5stellig)") }, enabled = !locked, modifier = Modifier.fillMaxWidth())
val nrValid = nr.length == 5
Button(onClick = {
// Auto-Mapping gemäß vorhandener Neumarkt-Dokumentation
kat = when (nr) {
"26128" -> "CSN-C-NEU CSNP-C-NEU"
"26129" -> "CDN-C-NEU CDNP-C-NEU"
else -> ""
}
locked = true
}, enabled = nrValid && !locked) { Text("Nummer bestätigen & initialisieren") }
if (!nrValid) Text("Genau 5 Ziffern erforderlich", color = Color(0xFFB00020))
val freigeschaltet = locked
// Kategorie-Auswahl gemäß Vorlage: Dropdown mit sinnvollen Optionen und Auto-Vorbelegung
val kategorien = remember { listOf("CDN-C-NEU CDNP-C-NEU", "CSN-C-NEU CSNP-C-NEU") }
var katMenu by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Kategorie:")
Box {
OutlinedButton(onClick = { if (freigeschaltet) katMenu = true }, enabled = freigeschaltet) {
Text(if (kat.isBlank()) "Kategorie wählen" else kat)
}
DropdownMenu(expanded = katMenu, onDismissRequest = { katMenu = false }) {
kategorien.forEach { k ->
DropdownMenuItem(onClick = { kat = k; katMenu = false }, text = { Text(k) })
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = von, onValueChange = { von = it }, label = { Text("von (YYYY-MM-DD)") }, enabled = freigeschaltet, modifier = Modifier.weight(1f))
OutlinedTextField(value = bis, onValueChange = { bis = it }, label = { Text("bis (YYYY-MM-DD)") }, enabled = freigeschaltet, modifier = Modifier.weight(1f))
}
val parentVon = veranstaltung?.datumVon
val parentBis = veranstaltung?.datumBis
val dateOk = freigeschaltet && von.isNotBlank() && (bis.isBlank() || bis >= von) &&
(parentVon == null || (von >= parentVon && (parentBis == null || (bis.isBlank() || bis <= parentBis))))
if (freigeschaltet && !dateOk) Text("Turnier-Datum muss im Veranstaltungszeitraum liegen", color = Color(0xFFB00020))
Button(onClick = {
val id = System.currentTimeMillis()
TurnierStoreV2.add(veranstaltungId, TurnierV2(id, veranstaltungId, nr.toInt(), kat, von, bis.ifBlank { null }))
onSaved(id)
}, enabled = freigeschaltet && nrValid && kat.isNotBlank() && dateOk) { Text("Speichern") }
}
}
}