Compare commits

...

2 Commits

Author SHA1 Message Date
a72953cea7 Audit and enhance playbook documentation: fix path inconsistencies, add missing "Abschluss" sections, standardize "Curator" frontmatter, and update Agent roles in README
Some checks failed
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 8m24s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 7m17s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Failing after 2m49s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 1m57s
2026-03-25 23:58:39 +01:00
49e97915e8 Upgrade dependencies and refactor: Update Gradle to 9.4.0, adjust TopBar and TurnierDetailScreen UI, and add ZNS import feature to Docker build context 2026-03-25 23:47:33 +01:00
54 changed files with 1674 additions and 2676 deletions

View File

@ -65,6 +65,7 @@ RUN mkdir -p \
frontend/shared \
frontend/shells/meldestelle-portal \
frontend/shells/meldestelle-desktop \
frontend/features/zns-import-feature \
docs
# Copy root build configuration

View File

@ -63,6 +63,7 @@ RUN mkdir -p \
frontend/shared \
frontend/shells/meldestelle-portal \
frontend/shells/meldestelle-desktop \
frontend/features/zns-import-feature \
docs
# Copy root build configuration

View File

@ -1,15 +1,15 @@
package at.mocode.ping.api
interface PingApi {
suspend fun simplePing(): PingResponse
suspend fun enhancedPing(simulate: Boolean = false): EnhancedPingResponse
suspend fun healthCheck(): HealthResponse
suspend fun simplePing(): PingResponse
suspend fun enhancedPing(simulate: Boolean = false): EnhancedPingResponse
suspend fun healthCheck(): HealthResponse
// Neue Endpunkte für Security Hardening
suspend fun publicPing(): PingResponse
suspend fun securePing(): PingResponse
// Neue Endpunkte für Security Hardening
suspend fun publicPing(): PingResponse
suspend fun securePing(): PingResponse
// Phase 3: Delta-Sync
// Changed parameter name to 'since' to match SyncManager convention and backend controller
suspend fun syncPings(since: Long): List<PingEvent>
// Phase 3: Delta-Sync
// Changed parameter name to 'since' to match SyncManager convention and backend controller
suspend fun syncPings(since: Long): List<PingEvent>
}

View File

@ -8,19 +8,19 @@ data class PingResponse(val status: String, val timestamp: String, val service:
@Serializable
data class EnhancedPingResponse(
val status: String,
val timestamp: String,
val service: String,
val circuitBreakerState: String,
val responseTime: Long
val status: String,
val timestamp: String,
val service: String,
val circuitBreakerState: String,
val responseTime: Long
)
@Serializable
data class HealthResponse(
val status: String,
val timestamp: String,
val service: String,
val healthy: Boolean
val status: String,
val timestamp: String,
val service: String,
val healthy: Boolean
)
/**
@ -31,6 +31,6 @@ data class PingEvent(
// Using a String for the ID to be compatible with UUIDs from the backend.
override val id: String,
val message: String,
// Using a Long for the timestamp, which can be derived from a UUIDv7.
// Using along with the timestamp, which can be derived from a UUIDv7.
override val lastModified: Long
) : Syncable

View File

@ -146,7 +146,7 @@ und über definierte Schnittstellen kommunizieren.
→ Detaillierte Planung: `docs/01_Architecture/Roadmap_ZNS_Importer.md`
* [x] Backend-Infrastruktur & CP850 Parser (Phase 1 Parser/Modul)
* [x] Domain-Mapping & Upsert in DB (Phase 2)
* [ ] REST-API & Job-Management (Phase 1 Controller/Job-Registry)
* [x] REST-API & Job-Management (Phase 1 Controller/Job-Registry)
* [ ] Frontend-Integration mit File-Picker & Status-Polling (Phase 3)
---

View File

@ -31,4 +31,13 @@ Deine Aufgaben:
5. Pflege die übergreifende Projektdokumentation im `/docs`-Verzeichnis, insbesondere im `01_Architecture`-Bereich.
6. **Handover:** Stelle Architekturentscheidungen nicht nur als Text, sondern auch als Diagramm (Mermaid/PlantUML) bereit.
7. Erstelle und pflege die MASTER ROADMAP. Du bist der "Hüter des Plans". Du delegierst Aufgaben an die spezialisierten Agenten (Backend, Frontend, DevOps, QA), führst sie aber nicht selbst aus, es sei denn, es betrifft direkt die Architektur oder das Build-System.
Don't:
- Implementiere keine Business-Logik in Backend-Services (→ Backend Developer).
- Schreibe keine UI-Komponenten oder Compose-Code (→ Frontend Expert).
- Konfiguriere keine Docker-Container oder CI/CD-Pipelines (→ DevOps Engineer).
- Erstelle keine Testfälle oder Teststrategien (→ QA Specialist).
```
## Abschluss (Pflicht)
Am Ende der Session genau **ein** Artefakt gemäß `docs/04_Agents/README.md` erzeugen oder aktualisieren (ADR / Reference / How-to / Journal Entry).

View File

@ -34,3 +34,6 @@ Regeln:
6. **Pre-Flight Check:** Prüfe vor Abschluss, ob API-Änderungen (insb. Sync) mit den Anforderungen des Frontend-Experts kompatibel sind.
7. **Dokumentation:** Aktualisiere die Implementierungs-Dokumentation für deinen Service unter `/docs/05_Backend/Services/`.
```
## Abschluss (Pflicht)
Am Ende der Session genau **ein** Artefakt gemäß `docs/04_Agents/README.md` erzeugen oder aktualisieren (ADR / Reference / How-to / Journal Entry).

View File

@ -1,3 +1,9 @@
---
type: Reference
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-25
---
# Playbook: Documentation & Knowledge Curator (Pflichtrolle)
## Beschreibung

View File

@ -40,3 +40,6 @@ Arbeitsweise:
- **Smoke Tests:** Verlasse dich nicht auf "sollte gehen". Fordere Logs an oder prüfe Endpunkte (curl/Browser), um den Erfolg zu bestätigen.
- **Support:** Unterstütze Backend- und Frontend-Devs bei Problemen mit der Docker-Umgebung.
```
## Abschluss (Pflicht)
Am Ende der Session genau **ein** Artefakt gemäß `docs/04_Agents/README.md` erzeugen oder aktualisieren (ADR / Reference / How-to / Journal Entry).

View File

@ -32,3 +32,6 @@ Output:
- **Handover-Format:** Nutze **Gherkin (Given/When/Then)** für Akzeptanzkriterien, damit QA und Devs diese direkt verarbeiten können.
- Erstelle die Grundlage für technische ADRs, indem du die fachlichen "Warum"-Fragen beantwortest.
```
## Abschluss (Pflicht)
Am Ende der Session genau **ein** Artefakt gemäß `docs/04_Agents/README.md` erzeugen oder aktualisieren (ADR / Reference / How-to / Journal Entry).

View File

@ -33,3 +33,6 @@ Regeln:
5. **Pre-Flight Check:** Stimme dich bei API-Anforderungen (insb. Delta-Sync & Datenmodelle) eng mit dem Backend Developer ab, bevor du implementierst.
6. **Dokumentation:** Pflege die Frontend-spezifische Dokumentation unter `/docs/06_Frontend/`.
```
## Abschluss (Pflicht)
Am Ende der Session genau **ein** Artefakt gemäß `docs/04_Agents/README.md` erzeugen oder aktualisieren (ADR / Reference / How-to / Journal Entry).

View File

@ -10,8 +10,8 @@ Gemini wird genutzt für **Konzeptarbeit**: Varianten vergleichen, Argumente/Tra
## Startpunkt
1. `docs/README.md`
2. `docs/03_Agents/README.md` (Artefakt-Vertrag)
3. Je nach Thema: Architektur (`docs/01_Architecture/`), Backend (`docs/04_Backend/`), Frontend (`docs/05_Frontend/`), Infrastruktur (`docs/06_Infrastructure/`)
2. `docs/04_Agents/README.md` (Artefakt-Vertrag)
3. Je nach Thema: Architektur (`docs/01_Architecture/`), Backend (`docs/05_Backend/`), Frontend (`docs/06_Frontend/`), Infrastruktur (`docs/07_Infrastructure/`)
## Do
* Immer 24 Optionen mit Vor-/Nachteilen liefern.

View File

@ -10,8 +10,8 @@ Junie wird genutzt für **Repo-nahe Arbeit**: Code lesen, reale Pfade/Module fin
## Startpunkt
1. `docs/README.md`
2. Relevanter Bereich (z.B. `docs/01_Architecture/`, `docs/04_Backend/`, `docs/05_Frontend/`)
3. Bei Rollen/Prozessfragen: `docs/03_Agents/README.md`
2. Relevanter Bereich (z.B. `docs/01_Architecture/`, `docs/05_Backend/`, `docs/06_Frontend/`)
3. Bei Rollen/Prozessfragen: `docs/04_Agents/README.md`
## Do
* Immer mit **konkreten Dateipfaden** arbeiten.

View File

@ -29,3 +29,6 @@ Regeln:
4. Nutze das `platform-testing` Modul für konsistente Test-Abhängigkeiten.
5. **Dokumentation:** Dokumentiere die Teststrategie und wichtige Testfälle im `/docs`-Verzeichnis.
```
## Abschluss (Pflicht)
Am Ende der Session genau **ein** Artefakt gemäß `docs/04_Agents/README.md` erzeugen oder aktualisieren (ADR / Reference / How-to / Journal Entry).

View File

@ -32,3 +32,6 @@ Regeln für deine Antworten:
4. **Logik vor Prosa:** Wenn du dem [Backend Developer] oder [Lead Architect] hilfst, formuliere die Regeln so um, dass sie leicht in Software-Validierungen (IF/THEN, Constraints) übersetzt werden können.
5. **Sparringspartner:** Hinterfrage Annahmen kritisch. Wenn ein vorgeschlagener Prozess gegen die ÖTO verstößt, lege sofort Veto ein.
```
## Abschluss (Pflicht)
Am Ende der Session genau **ein** Artefakt gemäß `docs/04_Agents/README.md` erzeugen oder aktualisieren (ADR / Reference / How-to / Journal Entry).

View File

@ -1,7 +1,7 @@
---
type: Playbook
status: ACTIVE
owner: Curator
owner: Lead Architect
role: UI/UX Designer
last_update: 2026-01-23
---
@ -105,3 +105,8 @@ Column(Modifier.fillMaxSize()) {
* **Mit Domain Expert:** Kläre, welche Daten *wirklich* wichtig sind (Prio 1) und welche ausgeblendet werden können (Details).
* **Mit Frontend Expert:** Liefere keine abstrakten Ideen, sondern nutze das Vokabular von Jetpack Compose (`Row`, `Column`, `Surface`, `MaterialTheme`).
---
## Abschluss (Pflicht)
Am Ende der Session genau **ein** Artefakt gemäß `docs/04_Agents/README.md` erzeugen oder aktualisieren (ADR / Reference / How-to / Journal Entry).

View File

@ -34,6 +34,16 @@ Jede KI-Session endet mit **genau einem** Artefakt in `docs/`:
## Playbooks
* `Playbooks/Junie.md`
* `Playbooks/Gemini.md`
* `Playbooks/Curator.md`
| Playbook | Rolle | Typ |
|----------|-------|-----|
| `Playbooks/Architect.md` | 🏗️ Lead Architect | Strategie, Planung, Build-System |
| `Playbooks/BackendDeveloper.md` | 👷 Backend Developer | Spring Boot, Kotlin, DDD |
| `Playbooks/Curator.md` | 🧹 Curator | Dokumentation, Wissensmanagement |
| `Playbooks/DevOpsEngineer.md` | 🐧 DevOps Engineer | Docker, CI/CD, Infrastruktur |
| `Playbooks/DomainExpert.md` | 📋 Domain Expert | Fachlichkeit, Regelwerke, Gherkin |
| `Playbooks/FrontendExpert.md` | 🎨 Frontend Expert | KMP, Compose, Offline-First |
| `Playbooks/Gemini.md` | 🤖 Gemini (extern) | Konzeptarbeit, Optionen, ADR-Formulierung |
| `Playbooks/Junie.md` | 🤖 Junie (IDE) | Repo-nahe Arbeit, Code, Implementierung |
| `Playbooks/QASpecialist.md` | 🧐 QA Specialist | Teststrategie, Edge-Cases |
| `Playbooks/RulebookExpert.md` | 📜 ÖTO/FEI Rulebook Expert | Regelwerks-Compliance, Validierung |
| `Playbooks/UIUXDesigner.md` | 🖌️ UI/UX Designer | High-Density Design, Wireframes |

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@ -0,0 +1,42 @@
---
type: Journal
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-25
---
# Journal: Playbook Audit & Fixes
## Session-Ziel
Vollständige Analyse aller Agent-Playbooks auf Konsistenz, Korrektheit und Vollständigkeit.
## Befunde
### 🔴 Kritisch (behoben)
- **`Gemini.md` & `Junie.md`:** Falsche Pfade in der Startpunkt-Sektion.
- `docs/03_Agents/` → korrigiert zu `docs/04_Agents/`
- `docs/04_Backend/` → korrigiert zu `docs/05_Backend/`
- `docs/05_Frontend/` → korrigiert zu `docs/06_Frontend/`
- `docs/06_Infrastructure/` → korrigiert zu `docs/07_Infrastructure/`
### ⚠️ Mittel (behoben)
- **`Curator.md`:** Fehlender Frontmatter-Header — ergänzt (`type`, `status`, `owner`, `last_update`).
- **`04_Agents/README.md`:** Nur 3 von 11 Playbooks aufgelistet — vollständige Tabelle ergänzt.
### Nachträglich behoben (Session 2)
- **Alle Playbooks:** `## Abschluss (Pflicht)`-Abschnitt mit Curator-Hinweis ergänzt.
- **`UIUXDesigner.md`:** `owner: Curator``owner: Lead Architect` korrigiert.
- **`Architect.md`:** Expliziter `Don't`-Block zur Rollenabgrenzung ergänzt.
## Geänderte Dateien
- `docs/04_Agents/Playbooks/Gemini.md`
- `docs/04_Agents/Playbooks/Junie.md`
- `docs/04_Agents/Playbooks/Curator.md`
- `docs/04_Agents/README.md`
- `docs/04_Agents/Playbooks/Architect.md`
- `docs/04_Agents/Playbooks/BackendDeveloper.md`
- `docs/04_Agents/Playbooks/DevOpsEngineer.md`
- `docs/04_Agents/Playbooks/DomainExpert.md`
- `docs/04_Agents/Playbooks/FrontendExpert.md`
- `docs/04_Agents/Playbooks/QASpecialist.md`
- `docs/04_Agents/Playbooks/RulebookExpert.md`
- `docs/04_Agents/Playbooks/UIUXDesigner.md`

View File

@ -17,6 +17,13 @@ sealed class AppScreen(val route: String) {
// --- Desktop-Navigation (Vision_03) ---
data object Veranstaltungen : AppScreen("/veranstaltungen")
// Neuer Flow: + Neue Veranstaltung → Veranstalter auswählen → Veranstalter-Detail → Veranstaltung-Übersicht
data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl")
data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId")
data class VeranstaltungUebersicht(val veranstalterId: Long, val veranstaltungId: Long) :
AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId")
data class VeranstaltungDetail(val id: Long) : AppScreen("/veranstaltung/$id")
data object VeranstaltungNeu : AppScreen("/veranstaltung/neu")
data class TurnierDetail(val veranstaltungId: Long, val turnierId: Long) :
@ -34,6 +41,8 @@ sealed class AppScreen(val route: String) {
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 VERANSTALTER_DETAIL = Regex("/veranstalter/(\\d+)$")
private val VERANSTALTUNG_UEBERSICHT = Regex("/veranstalter/(\\d+)/veranstaltung/(\\d+)$")
fun fromRoute(route: String): AppScreen {
return when (route) {
@ -48,6 +57,7 @@ sealed class AppScreen(val route: String) {
"/auth/callback" -> AuthCallback
"/nennung" -> Nennung
"/veranstaltungen" -> Veranstaltungen
"/veranstalter/auswahl" -> VeranstalterAuswahl
"/veranstaltung/neu" -> VeranstaltungNeu
"/reiter" -> Reiter
"/pferde" -> Pferde
@ -65,6 +75,12 @@ sealed class AppScreen(val route: String) {
VERANSTALTUNG_DETAIL.matchEntire(route)?.destructured?.let { (id) ->
return VeranstaltungDetail(id.toLong())
}
VERANSTALTER_DETAIL.matchEntire(route)?.destructured?.let { (vId) ->
return VeranstalterDetail(vId.toLong())
}
VERANSTALTUNG_UEBERSICHT.matchEntire(route)?.destructured?.let { (verId, vId) ->
return VeranstaltungUebersicht(verId.toLong(), vId.toLong())
}
Landing // Default fallback
}
}

View File

@ -2,15 +2,21 @@ package at.mocode.frontend.core.navigation
import at.mocode.frontend.core.domain.models.AppRoles
import at.mocode.frontend.core.domain.models.User
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
private class FakeNav : NavigationPort {
var last: String? = null
override val currentScreen: StateFlow<AppScreen> = MutableStateFlow(AppScreen.Landing)
override fun navigateTo(route: String) {
last = route
}
override fun navigateToScreen(screen: AppScreen) {
last = screen.route
}
}
private class FakeUserProvider(private val user: User?) : CurrentUserProvider {

View File

@ -0,0 +1,348 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.BorderStroke
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.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
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
// Status-Farben gemäß Vision_03
private val StatusVorbereitung = Color(0xFFEA580C) // Orange
private val StatusLive = Color(0xFF16A34A) // Grün
private val StatusAbgeschlossen = Color(0xFF6B7280) // Grau
/**
* Root-Screen der Desktop-App gemäß Vision_03 (Screenshot 23/24).
*
* Layout:
* - KPI-Kacheln oben (Live/Aktiv, In Vorbereitung, Gesamt, Archiv)
* - Toolbar: "+ Neue Veranstaltung" + Suche + Status-Filter
* - Veranstaltungs-Cards (expandiert mit Turnier-Liste)
*
* TODO: Echte Daten aus event-management-context laden (Phase 4/5).
*/
@Composable
fun AdminUebersichtScreen(
onVeranstalterAuswahl: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
) {
// Placeholder-Daten für die UI-Struktur
val veranstaltungen = listOf<VeranstaltungUiModel>() // leer bis Backend angebunden
Column(modifier = Modifier.fillMaxSize()) {
// KPI-Kacheln
KpiKachelRow(
liveAktiv = 0,
inVorbereitung = 0,
gesamt = 0,
archiv = 0,
)
// Toolbar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onVeranstalterAuswahl,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neue Veranstaltung")
}
OutlinedTextField(
value = "",
onValueChange = {},
placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 13.sp) },
modifier = Modifier.weight(1f).height(48.dp),
singleLine = true,
)
// Status-Filter Chips
StatusFilterChip("Alle", selected = true)
StatusFilterChip("Vorbereitung", selected = false)
StatusFilterChip("Live", selected = false)
StatusFilterChip("Abgeschlossen", selected = false)
}
HorizontalDivider()
// Veranstaltungs-Liste
if (veranstaltungen.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Noch keine Veranstaltungen",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Lege eine neue Veranstaltung an, um zu beginnen.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Button(
onClick = onVeranstalterAuswahl,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neue Veranstaltung anlegen")
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
) {
items(veranstaltungen) { veranstaltung ->
VeranstaltungCard(
veranstaltung = veranstaltung,
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
onLoeschen = { /* TODO */ },
)
}
}
}
}
}
@Composable
private fun KpiKachelRow(
liveAktiv: Int,
inVorbereitung: Int,
gesamt: Int,
archiv: Int,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
KpiKachel(
label = "LIVE / AKTIV",
wert = liveAktiv.toString(),
akzentFarbe = StatusLive,
modifier = Modifier.weight(1f),
)
KpiKachel(
label = "IN VORBEREITUNG",
wert = inVorbereitung.toString(),
akzentFarbe = Color(0xFF3B82F6),
modifier = Modifier.weight(1f),
)
KpiKachel(
label = "GESAMT",
wert = gesamt.toString(),
akzentFarbe = Color(0xFF6B7280),
modifier = Modifier.weight(1f),
)
KpiKachel(
label = "ARCHIV",
wert = archiv.toString(),
akzentFarbe = Color(0xFF9CA3AF),
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun KpiKachel(
label: String,
wert: String,
akzentFarbe: Color,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
border = BorderStroke(2.dp, akzentFarbe),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = label,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
Text(
text = wert,
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = akzentFarbe,
)
}
}
}
@Composable
private fun StatusFilterChip(label: String, selected: Boolean) {
FilterChip(
selected = selected,
onClick = {},
label = { Text(label, fontSize = 12.sp) },
)
}
@Composable
private fun VeranstaltungCard(
veranstaltung: VeranstaltungUiModel,
onOeffnen: () -> Unit,
onLoeschen: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
border = if (veranstaltung.status == VeranstaltungStatus.VORBEREITUNG)
BorderStroke(1.dp, Color(0xFF3B82F6)) else null,
) {
Column(modifier = Modifier.padding(16.dp)) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = veranstaltung.name,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(top = 2.dp),
) {
Text("📍 ${veranstaltung.ort}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📅 ${veranstaltung.datum}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
StatusBadge(veranstaltung.status)
}
// Turnier-Liste
if (veranstaltung.turniere.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Turniere (${veranstaltung.turniere.size}):", fontSize = 12.sp, fontWeight = FontWeight.Medium)
veranstaltung.turniere.forEach { turnier ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Surface(
shape = MaterialTheme.shapes.small,
color = Color(0xFF1E3A8A),
) {
Text(
text = turnier.nummer.toString(),
color = Color.White,
fontSize = 11.sp,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
Text("${turnier.name} (${turnier.bewerbAnzahl} Bewerbe)", fontSize = 12.sp)
}
OutlinedButton(onClick = onOeffnen, modifier = Modifier.height(28.dp)) {
Text("Öffnen", fontSize = 11.sp)
}
}
}
}
// Footer
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Nennungen: ${veranstaltung.nennungen}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("Letzte Aktivität: ${veranstaltung.letzteAktivitaet}", fontSize = 12.sp, color = Color(0xFF6B7280))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onOeffnen,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
modifier = Modifier.height(32.dp),
) {
Text("Öffnen", fontSize = 12.sp)
}
IconButton(onClick = onLoeschen, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626))
}
}
}
}
}
}
@Composable
private fun StatusBadge(status: VeranstaltungStatus) {
val (text, color) = when (status) {
VeranstaltungStatus.VORBEREITUNG -> "Vorbereitung" to StatusVorbereitung
VeranstaltungStatus.LIVE -> "Live" to StatusLive
VeranstaltungStatus.ABGESCHLOSSEN -> "Abgeschlossen" to StatusAbgeschlossen
}
Surface(
shape = MaterialTheme.shapes.small,
color = color.copy(alpha = 0.15f),
border = BorderStroke(1.dp, color),
) {
Text(
text = text,
color = color,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
// --- UI-Modelle (Placeholder bis echte Domain-Modelle angebunden sind) ---
data class VeranstaltungUiModel(
val id: Long,
val name: String,
val ort: String,
val datum: String,
val turnierAnzahl: Int,
val nennungen: Int,
val letzteAktivitaet: String,
val status: VeranstaltungStatus,
val turniere: List<TurnierUiModel> = emptyList(),
)
data class TurnierUiModel(
val id: Long,
val nummer: Long,
val name: String,
val bewerbAnzahl: Int,
)
enum class VeranstaltungStatus { VORBEREITUNG, LIVE, ABGESCHLOSSEN }

View File

@ -3,27 +3,32 @@ package at.mocode.desktop.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.nennung.feature.presentation.NennungViewModel
import org.koin.compose.viewmodel.koinViewModel
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
private val TopBarColor = Color(0xFF1E3A8A)
private val TopBarTextColor = Color.White
/**
* Haupt-Layout der Desktop-App gemäß Vision_03.
* Sidebar (links) + Content-Bereich (rechts).
*
* Struktur:
* - TopBar (dunkelblau): App-Titel + Breadcrumb + Logout
* - Content: kontextabhängiger Screen
*
* Kein Nav-Rail, keine Sidebar Navigation erfolgt über
* Breadcrumb-Klicks und horizontale Tabs innerhalb der Screens.
*/
@Composable
fun DesktopMainLayout(
@ -31,16 +36,12 @@ fun DesktopMainLayout(
onNavigate: (AppScreen) -> Unit,
onLogout: () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
DesktopSidebar(
Column(modifier = Modifier.fillMaxSize()) {
DesktopTopBar(
currentScreen = currentScreen,
onNavigate = onNavigate,
onLogout = onLogout,
)
HorizontalDivider(
modifier = Modifier.fillMaxHeight().width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
Box(modifier = Modifier.fillMaxSize()) {
DesktopContentArea(
currentScreen = currentScreen,
@ -50,182 +51,246 @@ fun DesktopMainLayout(
}
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
private data class NavItem(
val label: String,
val icon: ImageVector,
val screen: AppScreen,
)
private val navItems = listOf(
NavItem("Veranstaltungen", Icons.Default.Event, AppScreen.Veranstaltungen),
NavItem("Reiter", Icons.Default.Person, AppScreen.Reiter),
NavItem("Pferde", Icons.Default.Star, AppScreen.Pferde),
NavItem("Funktionäre", Icons.Default.Badge, AppScreen.Funktionaere),
NavItem("Meisterschaften", Icons.Default.EmojiEvents, AppScreen.Meisterschaften),
NavItem("Cups", Icons.Default.WorkspacePremium, AppScreen.Cups),
NavItem("Stammdaten-Import", Icons.Default.CloudUpload, AppScreen.StammdatenImport),
)
/**
* TopBar: dunkelblauer Balken mit Breadcrumb-Navigation und Logout-Button.
*
* Breadcrumb-Logik:
* - Root: "🏠 Admin - Verwaltung"
* - Veranstaltung: "🏠 Admin - Verwaltung / Veranstaltung #<id>"
* - Turnier: "🏠 Admin - Verwaltung / Veranstaltung #<id> / Turnier <tid>"
*/
@Composable
private fun DesktopSidebar(
private fun DesktopTopBar(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onLogout: () -> Unit,
) {
Column(
Row(
modifier = Modifier
.width(220.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(vertical = 16.dp),
.fillMaxWidth()
.height(48.dp)
.background(TopBarColor)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// App-Titel
Text(
text = "Meldestelle",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.primary,
)
Row(verticalAlignment = Alignment.CenterVertically) {
// Zurück-Pfeil (nur wenn nicht Root)
if (currentScreen !is AppScreen.Veranstaltungen) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück",
tint = TopBarTextColor,
modifier = Modifier
.size(20.dp)
.clickable { onNavigate(AppScreen.Veranstaltungen) },
)
Spacer(Modifier.width(8.dp))
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(modifier = Modifier.height(8.dp))
// Navigations-Einträge
navItems.forEach { item ->
val isSelected = currentScreen::class == item.screen::class
SidebarNavItem(
item = item,
isSelected = isSelected,
onClick = { onNavigate(item.screen) },
// Root-Link
Text(
text = "🏠 Admin - Verwaltung",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.clickable { onNavigate(AppScreen.Veranstaltungen) },
)
// Breadcrumb-Segmente je nach Screen
when (currentScreen) {
is AppScreen.VeranstalterAuswahl -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
is AppScreen.VeranstalterDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterAuswahl) },
)
BreadcrumbSeparator()
Text(
text = "Veranstalter #${currentScreen.veranstalterId}",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.VeranstaltungUebersicht -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterAuswahl) },
)
BreadcrumbSeparator()
Text(
text = "Veranstalter #${currentScreen.veranstalterId}",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId))
},
)
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.VeranstaltungDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.id}",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
is AppScreen.VeranstaltungNeu -> {
BreadcrumbSeparator()
Text(
text = "Neue Veranstaltung",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
is AppScreen.TurnierDetail -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Turnier ${currentScreen.turnierId}",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.TurnierNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstaltung #${currentScreen.veranstaltungId}",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable {
onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId))
},
)
BreadcrumbSeparator()
Text(
text = "Neues Turnier",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
else -> {}
}
}
Spacer(modifier = Modifier.weight(1f))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(modifier = Modifier.height(8.dp))
// Logout
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onLogout() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Logout rechts
IconButton(onClick = onLogout) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Logout",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Logout",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
contentDescription = "Abmelden",
tint = TopBarTextColor,
)
}
}
}
@Composable
private fun SidebarNavItem(
item: NavItem,
isSelected: Boolean,
onClick: () -> Unit,
) {
val bgColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
val contentColor = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.background(bgColor, RoundedCornerShape(8.dp))
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = item.icon,
contentDescription = item.label,
tint = contentColor,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = item.label,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
),
color = contentColor,
)
}
private fun BreadcrumbSeparator() {
Text(
text = " / ",
color = TopBarTextColor.copy(alpha = 0.6f),
fontSize = 14.sp,
)
}
// ---------------------------------------------------------------------------
// Content-Bereich: Screen-Routing
// ---------------------------------------------------------------------------
/**
* Content-Bereich: rendert den passenden Screen je nach aktuellem AppScreen.
*/
@Composable
private fun DesktopContentArea(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
) {
val nennungViewModel: NennungViewModel = koinViewModel()
when (currentScreen) {
is AppScreen.Veranstaltungen -> VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) },
// Root-Screen: Admin-Übersicht
is AppScreen.Veranstaltungen -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) },
)
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlScreen(
onZurueck = { onNavigate(AppScreen.Veranstaltungen) },
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
)
is AppScreen.VeranstalterDetail -> VeranstalterDetailScreen(
veranstalterId = currentScreen.veranstalterId,
onZurueck = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { vId ->
onNavigate(AppScreen.VeranstaltungUebersicht(currentScreen.veranstalterId, vId))
},
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) },
)
is AppScreen.VeranstaltungUebersicht -> VeranstaltungUebersichtScreen(
veranstalterId = currentScreen.veranstalterId,
veranstaltungId = currentScreen.veranstaltungId,
onZurueck = { onNavigate(AppScreen.VeranstalterDetail(currentScreen.veranstalterId)) },
onTurnierOeffnen = { tId ->
onNavigate(AppScreen.TurnierDetail(currentScreen.veranstaltungId, tId))
},
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.veranstaltungId)) },
onZnsImport = { /* TODO: ZNS-Import Dialog für Turnier */ },
onDbImport = { /* TODO: DB-Import Dialog */ },
onDbExport = { /* TODO: DB-Export Dialog */ },
)
// Veranstaltungs-Screens
is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id,
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) },
onTurnierOeffnen = { tid -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tid)) },
)
is AppScreen.VeranstaltungNeu -> VeranstaltungNeuScreen(
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onSave = { onNavigate(AppScreen.Veranstaltungen) },
)
is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id,
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) },
onTurnierOeffnen = { turnierId -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, turnierId)) },
// Turnier-Screens
is AppScreen.TurnierDetail -> TurnierDetailScreen(
veranstaltungId = currentScreen.veranstaltungId,
turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
)
is AppScreen.TurnierNeu -> TurnierNeuScreen(
veranstaltungId = currentScreen.veranstaltungId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
onSave = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
)
is AppScreen.TurnierDetail -> TurnierDetailScreen(
veranstaltungId = currentScreen.veranstaltungId,
turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
nennungViewModel = nennungViewModel,
)
is AppScreen.Reiter -> ReiterScreen()
is AppScreen.Pferde -> PferdeScreen()
is AppScreen.Funktionaere -> FunktionaereScreen()
is AppScreen.Meisterschaften -> MeisterschaftenScreen()
is AppScreen.Cups -> CupsScreen()
is AppScreen.StammdatenImport -> StammdatenImportScreen()
// Fallback für alle anderen Screens (Dashboard, Ping etc.)
else -> VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) },
// Fallback → Root
else -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) },
)
}

View File

@ -1,84 +1,265 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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 at.mocode.nennung.feature.presentation.NennungViewModel
import at.mocode.nennung.feature.presentation.NennungsMaske
import androidx.compose.ui.unit.sp
/**
* Detailansicht eines bestehenden Turniers (Vision_03: /veranstaltung/{id}/turnier/{tid}).
* Tabs: Übersicht | Stammdaten (A-Satz) | Organisation | Bewerbe | Preisliste
* Der Bewerbe-Tab integriert die NennungsMaske aus dem nennung-feature.
* Detailansicht eines Turniers gemäß Vision_03.
*
* Layout: Horizontale Tab-Bar mit 8 Tabs (kein eigener Toolbar-Zurück-Button
* Navigation erfolgt über den Breadcrumb in der TopBar).
*
* Tabs:
* 1. STAMMDATEN Turnier-Konfiguration, ZNS-Import, Sparten, Datum
* 2. ORGANISATION Funktionäre, Richterkollegium, Austragungsplätze
* 3. BEWERBE 3-spaltiges Layout (Aktionen | Tabelle | Detail-Panel)
* 4. ARTIKEL Gebühren, Stallungen & Boxen, Zusatzgebühren
* 5. ABRECHNUNG Buchungen, Offene Posten, Rechnung
* 6. NENNUNGEN Pferd+Reiter-Suche, Verkauf/Buchungen, Bewerbsübersicht
* 7. STARTLISTEN Bewerbs-Tabs, Sortierung, Zeit/Dauer
* 8. ERGEBNISLISTEN Bewerbs-Tabs, Platzierung & Geldpreise
*
* TODO: Echte Inhalte pro Tab implementieren (Phase 4/5).
*/
@Composable
fun TurnierDetailScreen(
veranstaltungId: Long,
turnierId: Long,
onBack: () -> Unit,
nennungViewModel: NennungViewModel,
) {
var selectedTab by remember { mutableIntStateOf(3) } // Bewerbe ist Standard-Tab (⭐)
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Bewerbe ⭐", "Preisliste")
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf(
"STAMMDATEN",
"ORGANISATION",
"BEWERBE",
"ARTIKEL",
"ABRECHNUNG",
"NENNUNGEN",
"STARTLISTEN",
"ERGEBNISLISTEN",
)
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
// Horizontale Tab-Bar (direkt unter der TopBar)
ScrollableTabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
edgePadding = 0.dp,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Turnier #$turnierId",
style = MaterialTheme.typography.headlineSmall,
)
}
PrimaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
text = {
Text(
text = title,
fontSize = 13.sp,
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
HorizontalDivider()
// Tab-Inhalte
Box(modifier = Modifier.fillMaxSize()) {
when (selectedTab) {
0 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Übersicht", "Turnier-Stammdaten und Status.")
}
1 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Stammdaten (A-Satz)", "OEPS-Turniernummer, Kategorie, Sparte …")
}
2 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Organisation", "Richter, Parcourschef, Tierarzt …")
}
3 -> {
// Nennungs-Workflow: NennungsMaske aus nennung-feature
NennungsMaske(
viewModel = nennungViewModel,
onStartlisteOeffnen = { /* TODO: Navigation zu Startliste */ },
onErgebnisseOeffnen = { /* TODO: Navigation zu Ergebnisse */ },
onAbrechnungOeffnen = { /* TODO: Navigation zu Abrechnung */ },
)
}
4 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
}
0 -> StammdatenTabContent(turnierId = turnierId)
1 -> OrganisationTabContent()
2 -> BewerbeTabContent()
3 -> ArtikelTabContent()
4 -> AbrechnungTabContent()
5 -> NennungenTabContent()
6 -> StartlistenTabContent()
7 -> ErgebnislistenTabContent()
}
}
}
}
// ---------------------------------------------------------------------------
// Tab-Inhalte (Placeholder werden in späteren Phasen befüllt)
// ---------------------------------------------------------------------------
@Composable
private fun StammdatenTabContent(turnierId: Long) {
PlaceholderContent(
title = "Stammdaten Turnier $turnierId",
subtitle = "Turnier-Konfiguration, ZNS-Import, Sparten, Klassen, Datum …",
)
}
@Composable
private fun OrganisationTabContent() {
PlaceholderContent(
title = "Organisation",
subtitle = "Funktionäre & Offizielle (C-Satz), Richterkollegium, Austragungsplätze …",
)
}
@Composable
private fun BewerbeTabContent() {
// Typ C: 3-spaltiges Layout (Aktionen | Tabelle | Detail-Panel)
Row(modifier = Modifier.fillMaxSize()) {
// Linke Aktions-Spalte
BewerbeAktionsSpalte(modifier = Modifier.width(140.dp).fillMaxHeight())
VerticalDivider()
// Mittlere Tabelle
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
PlaceholderContent(
title = "Bewerbe",
subtitle = "Liste aller Bewerbe dieses Turniers …",
)
}
VerticalDivider()
// Rechtes Detail-Panel
BewerbeDetailPanel(modifier = Modifier.width(320.dp).fillMaxHeight())
}
}
@Composable
private fun BewerbeAktionsSpalte(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
AktionsButton("Änderungen\nSpeichern")
AktionsButton("Änderungen\nRückgängig")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsButton("Bewerb\nEinfügen")
AktionsButton("Bewerb\nLöschen")
AktionsButton("Bewerb Teilen")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsButton("Bewerb nach\noben verschieben")
AktionsButton("Bewerb nach\nunten verschieben")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsButton("Startliste\nBearbeiten")
AktionsButton("Startliste\nDrucken")
AktionsButton("Ergebnisliste\nBearbeiten")
AktionsButton("Ergebnisliste\nDrucken")
}
}
@Composable
private fun AktionsButton(label: String) {
OutlinedButton(
onClick = {},
modifier = Modifier.fillMaxWidth().height(48.dp),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp),
) {
Text(label, fontSize = 11.sp, lineHeight = 13.sp)
}
}
@Composable
private fun BewerbeDetailPanel(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(12.dp)) {
// Sub-Tabs: Bewerb | Bewertung | Geldpreise | Ort/Zeit
var subTab by remember { mutableIntStateOf(0) }
val subTabs = listOf("Bewerb", "Bewertung", "Geldpreise", "Ort/Zeit")
TabRow(
selectedTabIndex = subTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
) {
subTabs.forEachIndexed { i, title ->
Tab(
selected = subTab == i,
onClick = { subTab = i },
text = { Text(title, fontSize = 12.sp) },
)
}
}
Spacer(Modifier.height(8.dp))
PlaceholderContent(
title = subTabs[subTab],
subtitle = "Bewerb-Details …",
)
}
}
@Composable
private fun ArtikelTabContent() {
PlaceholderContent(
title = "Artikel Nennungen & Gebühren",
subtitle = "Nenngebühr, Startgebühr, Sporteuro, Stallungen & Boxen, Zusatzgebühren …",
)
}
@Composable
private fun AbrechnungTabContent() {
PlaceholderContent(
title = "Abrechnung",
subtitle = "Buchungen, Offene Posten, Rechnung …",
)
}
@Composable
private fun NennungenTabContent() {
// Typ B: 2-spaltig (Pferd+Reiter-Suche | Verkauf/Buchungen)
Row(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
PlaceholderContent(
title = "Nennungen",
subtitle = "Pferd- und Reiter-Suche, Nennungs-Tabelle …",
)
}
VerticalDivider()
Box(modifier = Modifier.width(340.dp).fillMaxHeight()) {
PlaceholderContent(
title = "Verkauf / Buchungen",
subtitle = "Artikel-Buchungen, Bewerbsübersicht …",
)
}
}
}
@Composable
private fun StartlistenTabContent() {
// Typ B: Tabelle + rechtes Sortier/Zeit-Panel
Row(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
PlaceholderContent(
title = "Startlisten",
subtitle = "Bewerbs-Tabs, Starter-Liste …",
)
}
VerticalDivider()
Box(modifier = Modifier.width(280.dp).fillMaxHeight()) {
PlaceholderContent(
title = "Sortierung & Zeit",
subtitle = "Aufsteigend/Absteigend, Auslosung, Beginnzeit, Reitdauer …",
)
}
}
}
@Composable
private fun ErgebnislistenTabContent() {
// Typ B: Tabelle + rechtes Platzierungs-Panel
Row(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
PlaceholderContent(
title = "Ergebnislisten",
subtitle = "Bewerbs-Tabs, Ergebnis-Eingabe (Fehler, Zeit) …",
)
}
VerticalDivider()
Box(modifier = Modifier.width(280.dp).fillMaxHeight()) {
PlaceholderContent(
title = "Platzierung & Geldpreis",
subtitle = "Anzahl Platzierte, Geldpreis, Import/Export/Drucken …",
)
}
}
}

View File

@ -0,0 +1,172 @@
package at.mocode.desktop.screens
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.Search
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
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
/**
* Screen: "Admin - Verwaltung / Veranstalter auswählen"
*
* Gemäß Figma Vision_03 (figma-entwurf_22 / figma-entwurf_20):
* - Tabelle aller registrierten Veranstalter/Kunden
* - Klick auf Zeile Veranstalter markiert (selektiert)
* - "Weiter zum Veranstalter"-Button wird aktiv sobald ein Veranstalter ausgewählt ist
*
* TODO: Echte Daten aus customer-context laden (Phase 4/5).
*/
@Composable
fun VeranstalterAuswahlScreen(
onZurueck: () -> Unit,
onWeiter: (Long) -> Unit,
) {
var selectedId by remember { mutableStateOf<Long?>(null) }
var suchtext by remember { mutableStateOf("") }
// Placeholder-Daten
val veranstalter = remember {
listOf(
VeranstalterUiModel(1L, "Reit- und Fahrverein Wels", "Wels", "", 12),
VeranstalterUiModel(2L, "Pferdesportverein Linz", "Linz", "", 8),
VeranstalterUiModel(3L, "Reiterverein Salzburg", "Salzburg", "S", 5),
VeranstalterUiModel(4L, "Reitclub Wien Nord", "Wien", "W", 3),
VeranstalterUiModel(5L, "Fahrverein Graz", "Graz", "ST", 7),
)
}
val gefiltert = veranstalter.filter {
suchtext.isBlank() || it.name.contains(suchtext, ignoreCase = true) ||
it.ort.contains(suchtext, ignoreCase = true)
}
Column(modifier = Modifier.fillMaxSize()) {
// Seiten-Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = "Veranstalter auswählen",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "Wähle einen registrierten Veranstalter aus, um eine neue Veranstaltung anzulegen.",
fontSize = 13.sp,
color = Color(0xFF6B7280),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onZurueck) {
Text("Abbrechen")
}
Button(
onClick = { selectedId?.let { onWeiter(it) } },
enabled = selectedId != null,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Text("Weiter zum Veranstalter")
}
}
}
HorizontalDivider()
// Suchfeld
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
placeholder = { Text("Suche nach Name oder Ort...", fontSize = 13.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(48.dp),
singleLine = true,
)
// Tabellen-Header
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF3F4F6))
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Text("Name", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(3f))
Text("Ort", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f))
Text("Bundesland", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text("Veranstaltungen", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1f))
}
HorizontalDivider()
// Tabellen-Inhalt
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(gefiltert) { v ->
val isSelected = v.id == selectedId
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (isSelected) AccentBlue.copy(alpha = 0.1f)
else Color.Transparent
)
.clickable { selectedId = v.id }
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Auswahl-Indikator
RadioButton(
selected = isSelected,
onClick = { selectedId = v.id },
colors = RadioButtonDefaults.colors(selectedColor = AccentBlue),
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = v.name,
fontSize = 13.sp,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(3f),
)
Text(v.ort, fontSize = 13.sp, modifier = Modifier.weight(1.5f))
Text(v.bundesland, fontSize = 13.sp, modifier = Modifier.weight(1f))
Text(
text = "${v.veranstaltungsAnzahl}",
fontSize = 13.sp,
modifier = Modifier.weight(1f),
)
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
}
}
// --- UI-Modell ---
data class VeranstalterUiModel(
val id: Long,
val name: String,
val ort: String,
val bundesland: String,
val veranstaltungsAnzahl: Int,
)

View File

@ -0,0 +1,213 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.BorderStroke
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.Add
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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
private val PrimaryBlue = Color(0xFF1E3A8A)
private val StatusVorbereitungColor = Color(0xFFEA580C)
private val StatusLiveColor = Color(0xFF16A34A)
private val StatusAbgeschlossenColor = Color(0xFF6B7280)
/**
* Screen: "Admin - Verwaltung / Veranstalter auswählen / <Vereinsname>"
*
* Gemäß Figma Vision_03 (figma-entwurf_19):
* - Veranstalter-Profil (Name, Ort, Kontakt)
* - Liste aller Veranstaltungen dieses Veranstalters
* - Klick auf Veranstaltung VeranstaltungUebersicht
*
* TODO: Echte Daten aus customer-context / event-management-context laden (Phase 4/5).
*/
@Composable
fun VeranstalterDetailScreen(
veranstalterId: Long,
onZurueck: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
onVeranstaltungNeu: () -> Unit,
) {
// Placeholder-Daten
val veranstalter = remember(veranstalterId) {
VeranstalterUiModel(
id = veranstalterId,
name = "Reit- und Fahrverein Wels",
ort = "Wels",
bundesland = "",
veranstaltungsAnzahl = 12,
)
}
val veranstaltungen = remember(veranstalterId) {
listOf(
VeranstaltungUiModel(
id = 1L, name = "Frühjahrsturnier Wels 2026", ort = "Wels", datum = "15.04.2026",
turnierAnzahl = 3, nennungen = 47, letzteAktivitaet = "heute",
status = VeranstaltungStatus.VORBEREITUNG,
),
VeranstaltungUiModel(
id = 2L, name = "Sommerturnier Wels 2025", ort = "Wels", datum = "20.07.2025",
turnierAnzahl = 5, nennungen = 112, letzteAktivitaet = "vor 8 Monaten",
status = VeranstaltungStatus.ABGESCHLOSSEN,
),
VeranstaltungUiModel(
id = 3L, name = "Herbstturnier Wels 2025", ort = "Wels", datum = "12.10.2025",
turnierAnzahl = 4, nennungen = 89, letzteAktivitaet = "vor 5 Monaten",
status = VeranstaltungStatus.ABGESCHLOSSEN,
),
)
}
Column(modifier = Modifier.fillMaxSize()) {
// Veranstalter-Profil-Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color(0xFFF8FAFC),
border = BorderStroke(1.dp, Color(0xFFE2E8F0)),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = veranstalter.name,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("📍 ${veranstalter.ort}, ${veranstalter.bundesland}", fontSize = 13.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstalter.veranstaltungsAnzahl} Veranstaltungen gesamt", fontSize = 13.sp, color = Color(0xFF6B7280))
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onZurueck) {
Text("← Zurück zur Auswahl")
}
Button(
onClick = onVeranstaltungNeu,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neue Veranstaltung")
}
}
}
}
HorizontalDivider()
// Veranstaltungs-Liste
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Veranstaltungen (${veranstaltungen.size})",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(veranstaltungen) { veranstaltung ->
VeranstaltungListCard(
veranstaltung = veranstaltung,
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
)
}
}
}
}
@Composable
private fun VeranstaltungListCard(
veranstaltung: VeranstaltungUiModel,
onOeffnen: () -> Unit,
) {
val statusColor = when (veranstaltung.status) {
VeranstaltungStatus.VORBEREITUNG -> StatusVorbereitungColor
VeranstaltungStatus.LIVE -> StatusLiveColor
VeranstaltungStatus.ABGESCHLOSSEN -> StatusAbgeschlossenColor
}
val statusText = when (veranstaltung.status) {
VeranstaltungStatus.VORBEREITUNG -> "Vorbereitung"
VeranstaltungStatus.LIVE -> "Live"
VeranstaltungStatus.ABGESCHLOSSEN -> "Abgeschlossen"
}
Card(
modifier = Modifier.fillMaxWidth(),
border = if (veranstaltung.status == VeranstaltungStatus.VORBEREITUNG)
BorderStroke(1.dp, Color(0xFF3B82F6)) else null,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = veranstaltung.name,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
)
Surface(
shape = MaterialTheme.shapes.small,
color = statusColor.copy(alpha = 0.15f),
border = BorderStroke(1.dp, statusColor),
) {
Text(
text = statusText,
color = statusColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("📍 ${veranstaltung.ort}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📅 ${veranstaltung.datum}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📋 ${veranstaltung.nennungen} Nennungen", fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
Button(
onClick = onOeffnen,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Text("Veranstaltung öffnen →")
}
}
}
}

View File

@ -0,0 +1,349 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.BorderStroke
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.Add
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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
private val PrimaryBlue = Color(0xFF1E3A8A)
private val ZnsGreen = Color(0xFF16A34A)
private val ImportOrange = Color(0xFFEA580C)
private val ExportBlue = Color(0xFF2563EB)
/**
* Screen: "Veranstaltung - Übersicht"
*
* Gemäß Figma Vision_03 (figma-entwurf_17):
* - Veranstaltungs-Header (Name, Datum, Ort, Status)
* - Liste aller Turniere dieser Veranstaltung als Cards
* - Jede Turnier-Card hat Buttons:
* - "Öffnen" Meldestelle des Turniers öffnen (TurnierDetail)
* - "Import" Datenbank-Sicherung importieren
* - "Export" Datenbank-Sicherung exportieren
* - "ZNS" ZNS-Import für dieses Turnier starten
*
* Warum ZNS hier? Jedes Turnier hat seine eigene Datenbank/Kassa.
* Der ZNS-Import muss daher turnierspezifisch sein.
*
* TODO: Echte Daten aus event-management-context laden (Phase 4/5).
*/
@Composable
fun VeranstaltungUebersichtScreen(
veranstalterId: Long,
veranstaltungId: Long,
onZurueck: () -> Unit,
onTurnierOeffnen: (turnierId: Long) -> Unit,
onTurnierNeu: () -> Unit,
onZnsImport: (turnierId: Long) -> Unit,
onDbImport: (turnierId: Long) -> Unit,
onDbExport: (turnierId: Long) -> Unit,
) {
// Placeholder-Daten
val veranstaltung = remember(veranstaltungId) {
VeranstaltungUiModel(
id = veranstaltungId,
name = "Frühjahrsturnier Wels 2026",
ort = "Wels",
datum = "15.04.2026",
turnierAnzahl = 3,
nennungen = 47,
letzteAktivitaet = "heute",
status = VeranstaltungStatus.VORBEREITUNG,
)
}
val turniere = remember(veranstaltungId) {
listOf(
TurnierKarteUiModel(
id = 1L,
nummer = 1L,
name = "Dressurturnier",
sparte = "Dressur",
bewerbAnzahl = 8,
nennungen = 24,
status = TurnierKarteStatus.VORBEREITUNG,
datum = "15.04.2026",
),
TurnierKarteUiModel(
id = 2L,
nummer = 2L,
name = "Springturnier",
sparte = "Springen",
bewerbAnzahl = 6,
nennungen = 18,
status = TurnierKarteStatus.VORBEREITUNG,
datum = "15.04.2026",
),
TurnierKarteUiModel(
id = 3L,
nummer = 3L,
name = "Vielseitigkeitsturnier",
sparte = "Vielseitigkeit",
bewerbAnzahl = 4,
nennungen = 5,
status = TurnierKarteStatus.VORBEREITUNG,
datum = "16.04.2026",
),
)
}
Column(modifier = Modifier.fillMaxSize()) {
// Veranstaltungs-Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color(0xFFF8FAFC),
border = BorderStroke(1.dp, Color(0xFFE2E8F0)),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
text = veranstaltung.name,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("📍 ${veranstaltung.ort}", fontSize = 13.sp, color = Color(0xFF6B7280))
Text("📅 ${veranstaltung.datum}", fontSize = 13.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 13.sp, color = Color(0xFF6B7280))
Text("📋 ${veranstaltung.nennungen} Nennungen gesamt", fontSize = 13.sp, color = Color(0xFF6B7280))
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onZurueck) {
Text("← Zurück")
}
Button(
onClick = onTurnierNeu,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neues Turnier")
}
}
}
}
HorizontalDivider()
// Turnier-Liste
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Turniere (${turniere.size})",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.width(8.dp))
Text(
text = "Jedes Turnier hat eine eigene Datenbank und Kassa.",
fontSize = 12.sp,
color = Color(0xFF6B7280),
)
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(turniere) { turnier ->
TurnierKarte(
turnier = turnier,
onOeffnen = { onTurnierOeffnen(turnier.id) },
onZns = { onZnsImport(turnier.id) },
onImport = { onDbImport(turnier.id) },
onExport = { onDbExport(turnier.id) },
)
}
}
}
}
@Composable
private fun TurnierKarte(
turnier: TurnierKarteUiModel,
onOeffnen: () -> Unit,
onZns: () -> Unit,
onImport: () -> Unit,
onExport: () -> Unit,
) {
val statusColor = when (turnier.status) {
TurnierKarteStatus.VORBEREITUNG -> Color(0xFFEA580C)
TurnierKarteStatus.LIVE -> Color(0xFF16A34A)
TurnierKarteStatus.ABGESCHLOSSEN -> Color(0xFF6B7280)
}
val statusText = when (turnier.status) {
TurnierKarteStatus.VORBEREITUNG -> "Vorbereitung"
TurnierKarteStatus.LIVE -> "Live"
TurnierKarteStatus.ABGESCHLOSSEN -> "Abgeschlossen"
}
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
// Turnier-Header
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
// Turnier-Nummer Badge
Surface(
shape = MaterialTheme.shapes.small,
color = PrimaryBlue,
) {
Text(
text = "T${turnier.nummer}",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
Column {
Text(
text = turnier.name,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("🏇 ${turnier.sparte}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📅 ${turnier.datum}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("${turnier.bewerbAnzahl} Bewerbe", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("${turnier.nennungen} Nennungen", fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
}
// Status-Badge
Surface(
shape = MaterialTheme.shapes.small,
color = statusColor.copy(alpha = 0.15f),
border = BorderStroke(1.dp, statusColor),
) {
Text(
text = statusText,
color = statusColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
Spacer(Modifier.height(12.dp))
HorizontalDivider(color = Color(0xFFE5E7EB))
Spacer(Modifier.height(12.dp))
// Aktions-Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Öffnen Hauptaktion
Button(
onClick = onOeffnen,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
modifier = Modifier.height(36.dp),
) {
Icon(
Icons.Default.FolderOpen,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Öffnen", fontSize = 13.sp)
}
// ZNS-Import turnierspezifisch (eigene DB!)
OutlinedButton(
onClick = onZns,
border = BorderStroke(1.dp, ZnsGreen),
modifier = Modifier.height(36.dp),
) {
Text("ZNS", fontSize = 13.sp, color = ZnsGreen, fontWeight = FontWeight.SemiBold)
}
Spacer(Modifier.weight(1f))
// Import / Export DB-Sicherung
OutlinedButton(
onClick = onImport,
border = BorderStroke(1.dp, ImportOrange),
modifier = Modifier.height(36.dp),
) {
Icon(
Icons.Default.FileUpload,
contentDescription = null,
tint = ImportOrange,
modifier = Modifier.size(15.dp),
)
Spacer(Modifier.width(4.dp))
Text("Import", fontSize = 13.sp, color = ImportOrange)
}
OutlinedButton(
onClick = onExport,
border = BorderStroke(1.dp, ExportBlue),
modifier = Modifier.height(36.dp),
) {
Icon(
Icons.Default.FileDownload,
contentDescription = null,
tint = ExportBlue,
modifier = Modifier.size(15.dp),
)
Spacer(Modifier.width(4.dp))
Text("Export", fontSize = 13.sp, color = ExportBlue)
}
}
}
}
}
// --- UI-Modelle ---
data class TurnierKarteUiModel(
val id: Long,
val nummer: Long,
val name: String,
val sparte: String,
val bewerbAnzahl: Int,
val nennungen: Int,
val status: TurnierKarteStatus,
val datum: String,
)
enum class TurnierKarteStatus { VORBEREITUNG, LIVE, ABGESCHLOSSEN }

Binary file not shown.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

2
gradlew vendored
View File

@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.

File diff suppressed because it is too large Load Diff