Compare commits

...

2 Commits

Author SHA1 Message Date
c2b3b5889f chore: remove obsolete screens from meldestelle-desktop module
Some checks failed
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Failing after 2m56s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Failing after 3m3s
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 2m13s
- Deleted unused screens including `AdminUebersichtScreen`, `AktorScreens`, `StammdatenImportScreen`, `TurnierDetailScreen`, and supporting components such as `PlaceholderContent`.
- Cleaned up references and placeholders to streamline module structure.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-26 15:09:44 +01:00
1d393fdefe chore: fix typo in exposed library version comment
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-26 10:35:30 +01:00
50 changed files with 5068 additions and 1017 deletions

View File

@ -18,7 +18,7 @@ env:
REGISTRY_INTERNAL: 10.0.0.22:3000
IMAGE_PREFIX: mocode-software/meldestelle
JAVA_VERSION: "25"
GRADLE_VERSION: "9.3.1"
GRADLE_VERSION: "9.4.0"
KEYCLOAK_IMAGE_TAG: "26.5.5"
# Workers auf 4 limitiert: verhindert OOM auf dem 16GB Runner (VM 102)
GRADLE_OPTS: "-Dorg.gradle.parallel=true -Dorg.gradle.workers.max=4"

View File

@ -0,0 +1,163 @@
# Frontend-Architektur-Richtlinien
> **Status:** Verbindlich ab 26.03.2026
> **Zuständig:** 🏗️ Lead Architect
> **Zweck:** Verhindert Architektur-Drift und inkonsistente Schichtentrennung.
---
## Die 3 Schichten
```
frontend/
├── core/ ← Infrastruktur (plattformübergreifend, kein Business-Code)
├── features/ ← Fachliche Bausteine (je ein Bounded Context)
└── shells/ ← Ausführbare Apps (nur Verdrahtung, kein Fach-UI)
```
---
## Schicht 1: `core/`
### Aufgabe
Gemeinsame Infrastruktur, die von **allen** Features und Shells genutzt wird.
### Module
| Modul | Inhalt |
|-----------------|-----------------------------------------------------------------|
| `auth` | Login, Token-Management, OIDC/PKCE, `LoginScreen` |
| `design-system` | Farben, Typografie, gemeinsame UI-Komponenten, `SharedUiModels` |
| `domain` | Gemeinsame Domain-Modelle (plattformübergreifend) |
| `navigation` | `AppScreen`-Sealed-Class (einzige Wahrheit über alle Routen) |
| `network` | Ktor-Client, `NetworkConfig` |
| `local-db` | SQLDelight/Room-Setup, `DatabaseProvider` |
| `sync` | Offline-Sync-Infrastruktur |
### Regeln
- ✅ Darf importieren: externe Libraries, andere `core`-Module (keine Zyklen)
- ❌ Darf NICHT importieren: `features/*`, `shells/*`
- ❌ Kein Business-Code, keine fachlichen Screens
---
## Schicht 2: `features/`
### Aufgabe
Jedes Feature kapselt **einen Bounded Context** vollständig: Daten, Logik und UI.
### Pflicht-Struktur eines Feature-Moduls
```
features/<name>-feature/
└── src/
└── jvmMain/kotlin/at/mocode/<name>/feature/
├── data/ ← Repository, API-Client
├── domain/ ← Modelle, Use Cases
├── presentation/ ← ViewModel + Screen-Composables ← PFLICHT
└── di/ ← Koin-Module
```
### Vorhandene Features
| Feature | Bounded Context |
|-------------------------|-----------------------------------------------|
| `ping-feature` | Verbindungstest / Sync-Status |
| `nennung-feature` | Nennungs-Erfassung am Turnier |
| `zns-import-feature` | ZNS-Stammdaten-Import |
| `veranstalter-feature` | Veranstalter-Auswahl, -Detail, -Neuanlage |
| `veranstaltung-feature` | Veranstaltungs-Übersicht, -Detail, -Neuanlage |
| `turnier-feature` | Turnier-Detail, alle Tabs, Akteure |
### Regeln
- ✅ Darf importieren: `core/*`
- ❌ Darf NICHT importieren: andere `features/*`, `shells/*`
- ✅ **Jedes Feature MUSS seinen eigenen Screen in `presentation/` haben**
- ❌ Screen-Composables gehören NICHT in den Shell
---
## Schicht 3: `shells/`
### Aufgabe
Einstiegspunkt einer konkreten App. Verdrahtet Features und Core zu einer lauffähigen Anwendung.
### Erlaubter Inhalt im Shell
```
shells/<name>/
└── src/jvmMain/kotlin/at/mocode/desktop/
├── main.kt ← App-Einstiegspunkt, Koin-Init
├── DesktopApp.kt ← Root-Composable, Login-Gate
├── di/DesktopModule.kt ← Shell-spezifische DI
├── navigation/ ← Navigation-Port (optional)
└── screens/
├── layout/DesktopMainLayout.kt ← Navigation + Layout-Gerüst
└── preview/ScreenPreviews.kt ← @Preview-Funktionen (IDE-only)
```
### Regeln
- ✅ Darf importieren: `core/*`, `features/*`
- ✅ Darf enthalten: `main.kt`, `DesktopApp.kt`, DI-Verdrahtung, Layout, Previews
- ❌ Darf NICHT enthalten: fachliche Screen-Composables (gehören in Features)
- ❌ Darf NICHT enthalten: ViewModels, Repositories, Business-Logik
---
## Abhängigkeits-Diagramm
```
shells/meldestelle-desktop
├── core/auth
├── core/design-system
├── core/domain
├── core/navigation
├── core/network
├── core/local-db
├── core/sync
├── features/ping-feature
├── features/nennung-feature
├── features/zns-import-feature
├── features/veranstalter-feature
├── features/veranstaltung-feature
└── features/turnier-feature
features/* → core/* (nur)
core/* → (keine internen Abhängigkeiten außer erlaubte core-zu-core)
```
---
## Checkliste: Neues Feature anlegen
1. `frontend/features/<name>-feature/` Verzeichnis anlegen
2. `build.gradle.kts` nach Vorlage `nennung-feature` erstellen
3. Eintrag in `settings.gradle.kts` unter `// --- FEATURES ---` hinzufügen
4. Eintrag in `shells/meldestelle-desktop/build.gradle.kts` unter `// Feature-Module` hinzufügen
5. Screen in `presentation/` implementieren
6. DI-Modul in `di/` implementieren
7. DI-Modul in `shells/.../main.kt` registrieren
8. Route in `core/navigation/AppScreen.kt` eintragen
9. Navigation-Case in `shells/.../screens/layout/DesktopMainLayout.kt` eintragen
---
## Anti-Patterns (verboten)
| Anti-Pattern | Warum verboten |
|------------------------------------|------------------------------------------------------|
| Screen-Composable direkt im Shell | Verletzt Schichttrennung, nicht wiederverwendbar |
| Feature importiert anderes Feature | Erzeugt Kopplung, verhindert unabhängige Entwicklung |
| `core` importiert `features` | Zirkuläre Abhängigkeit |
| Shared-Modelle im Shell definieren | Gehören in `core/design-system` oder `core/domain` |
| ViewModel im Shell | Gehört ins Feature |
---
*Letzte Aktualisierung: 26.03.2026 — nach Architektur-Refactor (Screens aus Shell in Features verschoben)*

View File

@ -0,0 +1,200 @@
---
type: Reference
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-26
---
# Navigation & Routing Diagramm — Meldestelle Desktop
🏗️ **[Lead Architect]** | 26. März 2026
Dieses Dokument visualisiert alle Screens und Navigationsübergänge der Compose Desktop App.
Generiert aus: `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/`
---
## 1. Übersicht: NavRail-Einstiegspunkte
Die linke Navigationsleiste (NavRail) bietet folgende Direkteinstiege:
| Icon | Label | Ziel-Screen | Status |
|------|-------------------|--------------------|-----------------------------|
| 📅 | Veranstaltungen | `Veranstaltungen` | ✅ Implementiert |
| 🏇 | Reiter | `Reiter` | ⬜ Placeholder |
| 🐴 | Pferde | `Pferde` | ⬜ Placeholder |
| 👤 | Funktionäre | `Funktionaere` | ⬜ Placeholder |
| 🏆 | Meisterschaften | `Meisterschaften` | ⬜ Placeholder |
| 🥇 | Cups | `Cups` | ⬜ Placeholder |
| 📥 | Stammdaten-Import | `StammdatenImport` | 🟡 UI fertig, Polling offen |
---
## 2. Vollständiges Navigationsfluss-Diagramm
```mermaid
flowchart TD
%% ─── App-Start & Auth ───────────────────────────────────────────
START([App Start]) --> LOGIN
LOGIN["🔐 Login\n/auth/login"]
LOGIN -->|"onSuccess (returnTo)"| VERANSTALTUNGEN
AUTH_GUARD{{"🛡️ Auth Guard\n(nicht eingeloggt?)"}}
AUTH_GUARD -->|"nicht authentifiziert"| LOGIN
%% ─── NavRail Top-Level ──────────────────────────────────────────
NAVRAIL(["🗂️ NavRail"])
NAVRAIL --> VERANSTALTUNGEN
NAVRAIL --> REITER
NAVRAIL --> PFERDE
NAVRAIL --> FUNKTIONAERE
NAVRAIL --> MEISTERSCHAFTEN
NAVRAIL --> CUPS
NAVRAIL --> STAMMDATEN_IMPORT
%% ─── Veranstaltungen-Flow ───────────────────────────────────────
VERANSTALTUNGEN["📅 Veranstaltungen\n(AdminUebersichtScreen)\n/veranstaltungen"]
VERANSTALTUNGEN -->|"+ Neue Veranstaltung"| VERANSTALTER_AUSWAHL
VERANSTALTUNGEN -->|"Veranstaltung öffnen (id)"| VERANSTALTUNG_DETAIL
VERANSTALTER_AUSWAHL["🏢 Veranstalter auswählen\n/veranstalter/auswahl"]
VERANSTALTER_AUSWAHL -->|"Zurück"| VERANSTALTUNGEN
VERANSTALTER_AUSWAHL -->|"Weiter (veranstalterId)"| VERANSTALTER_DETAIL
VERANSTALTER_DETAIL["🏢 Veranstalter Detail\n/veranstalter/{id}"]
VERANSTALTER_DETAIL -->|"Zurück"| VERANSTALTER_AUSWAHL
VERANSTALTER_DETAIL -->|"Veranstaltung öffnen (vId)"| VERANSTALTUNG_UEBERSICHT
VERANSTALTER_DETAIL -->|"Neue Veranstaltung gespeichert"| VERANSTALTER_DETAIL
VERANSTALTUNG_UEBERSICHT["📋 Veranstaltung Übersicht\n/veranstalter/{verId}/veranstaltung/{vId}"]
VERANSTALTUNG_UEBERSICHT -->|"Zurück"| VERANSTALTER_DETAIL
VERANSTALTUNG_UEBERSICHT -->|"Turnier öffnen (tId)"| TURNIER_DETAIL
VERANSTALTUNG_UEBERSICHT -->|"+ Neues Turnier"| TURNIER_NEU
VERANSTALTUNG_DETAIL["📄 Veranstaltung Detail\n/veranstaltung/{id}"]
VERANSTALTUNG_DETAIL -->|"Zurück"| VERANSTALTUNGEN
VERANSTALTUNG_DETAIL -->|"+ Neues Turnier"| TURNIER_NEU
VERANSTALTUNG_DETAIL -->|"Turnier öffnen (tId)"| TURNIER_DETAIL
VERANSTALTUNG_NEU[" Neue Veranstaltung\n/veranstaltung/neu"]
VERANSTALTUNG_NEU -->|"Zurück"| VERANSTALTUNGEN
VERANSTALTUNG_NEU -->|"Speichern"| VERANSTALTUNGEN
TURNIER_DETAIL["🏟️ Turnier Detail\n/veranstaltung/{vId}/turnier/{tId}\n(inkl. Nennungs-Tab ⭐)"]
TURNIER_DETAIL -->|"Zurück"| VERANSTALTUNG_DETAIL
TURNIER_NEU[" Neues Turnier\n/veranstaltung/{vId}/turnier/neu"]
TURNIER_NEU -->|"Zurück"| VERANSTALTUNG_DETAIL
TURNIER_NEU -->|"Speichern"| VERANSTALTUNG_DETAIL
%% ─── Stammdaten-Import ──────────────────────────────────────────
STAMMDATEN_IMPORT["📥 Stammdaten Import\n/stammdaten/import\n(ZNS ZIP-Import)"]
%% ─── Placeholder Screens ────────────────────────────────────────
REITER["🏇 Reiter\n/reiter\n⬜ Placeholder"]
PFERDE["🐴 Pferde\n/pferde\n⬜ Placeholder"]
FUNKTIONAERE["👤 Funktionäre\n/funktionaere\n⬜ Placeholder"]
MEISTERSCHAFTEN["🏆 Meisterschaften\n/meisterschaften\n⬜ Placeholder"]
CUPS["🥇 Cups\n/cups\n⬜ Placeholder"]
%% ─── Logout ─────────────────────────────────────────────────────
LOGOUT(["🚪 Logout"])
LOGOUT -->|"Token löschen"| LOGIN
%% ─── Styling ────────────────────────────────────────────────────
style LOGIN fill:#f0a500,color:#000
style AUTH_GUARD fill:#e74c3c,color:#fff
style VERANSTALTUNGEN fill:#2ecc71,color:#000
style TURNIER_DETAIL fill:#3498db,color:#fff
style STAMMDATEN_IMPORT fill:#9b59b6,color:#fff
style REITER fill:#bdc3c7,color:#555
style PFERDE fill:#bdc3c7,color:#555
style FUNKTIONAERE fill:#bdc3c7,color:#555
style MEISTERSCHAFTEN fill:#bdc3c7,color:#555
style CUPS fill:#bdc3c7,color:#555
```
---
## 3. Screens nach Status
### ✅ Vollständig implementiert
| Screen | Route | Komponente |
|-----------------------------|---------------------------------------------|-----------------------------------------|
| Login | `/auth/login` | `LoginScreen` |
| Veranstaltungen (Übersicht) | `/veranstaltungen` | `AdminUebersichtScreen` |
| Veranstalter Auswahl | `/veranstalter/auswahl` | `VeranstalterAuswahlScreen` |
| Veranstalter Detail | `/veranstalter/{id}` | `VeranstalterDetailScreen` |
| Veranstaltung Übersicht | `/veranstalter/{verId}/veranstaltung/{vId}` | `VeranstaltungUebersichtScreen` |
| Veranstaltung Detail | `/veranstaltung/{id}` | `VeranstaltungDetailScreen` |
| Veranstaltung Neu | `/veranstaltung/neu` | `VeranstaltungNeuScreen` |
| Turnier Detail | `/veranstaltung/{vId}/turnier/{tId}` | `TurnierDetailScreen` + `NennungsMaske` |
| Turnier Neu | `/veranstaltung/{vId}/turnier/neu` | `TurnierNeuScreen` |
### 🟡 Teilweise implementiert
| Screen | Route | Offen |
|-------------------|----------------------|------------------------------------------------|
| Stammdaten Import | `/stammdaten/import` | Status-Polling zum Backend fehlt (ZNS Phase 3) |
### ⬜ Placeholder (NavRail sichtbar, Screen leer)
| Screen | Route |
|-----------------|--------------------|
| Reiter | `/reiter` |
| Pferde | `/pferde` |
| Funktionäre | `/funktionaere` |
| Meisterschaften | `/meisterschaften` |
| Cups | `/cups` |
### 🗑️ Definiert aber nicht in Desktop-Navigation eingebunden
| Screen | Route | Hinweis |
|------------------|----------------------|---------------------------------|
| Landing | `/` | Web-App Relikt |
| Home | `/home` | Web-App Relikt |
| Dashboard | `/dashboard` | Web-App Relikt |
| Ping | `/ping` | Dev/Health-Check |
| Profile | `/profile` | Web-App Relikt |
| OrganizerProfile | `/organizer/profile` | Web-App Relikt |
| AuthCallback | `/auth/callback` | Web-App Relikt (OAuth Redirect) |
| Nennung | `/nennung` | Web-App Relikt |
| CreateTournament | `/tournament/create` | Web-App Relikt |
---
## 4. Wichtige Hinweise
### Auth Guard
Jeder Screen (außer `Login`) ist durch den Auth Guard geschützt:
```
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login) {
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen))
}
```
### Web-App Relikte im AppScreen
Es existieren 9 Screens (`Landing`, `Home`, `Dashboard`, `Ping`, `Profile`, `OrganizerProfile`,
`AuthCallback`, `Nennung`, `CreateTournament`), die aus der alten Web-App stammen und in der
Desktop-App nicht gerendert werden. → **Offene Entscheidung:** Bereinigen oder für zukünftige
Web-App-Phase behalten? Siehe ADR-Bedarf.
### Nennungs-Tab ⭐
Der `TurnierDetailScreen` enthält den wichtigsten fachlichen Screen: die `NennungsMaske`
(Bewerbe-Tab). Dies ist das Herzstück des `registration-context`.
---
## 5. Referenzen
- Quellcode: `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt`
- Quellcode: `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/DesktopMainLayout.kt`
- Quellcode: `frontend/core/src/commonMain/kotlin/at/mocode/frontend/core/navigation/AppScreen.kt`
- ZNS-Importer Roadmap: `docs/01_Architecture/Roadmap_ZNS_Importer.md`
- Figma Design-Baseline: Vision_03 (ADR Session Log 2026-03-24)

View File

@ -0,0 +1,77 @@
# Session Log: Frontend-Architektur-Refactor
**Datum:** 26.03.2026
**Agent:** 🏗️ Lead Architect
**Dauer:** ~1 Session
**Trigger:** Inkonsistente Schichttrennung — fachliche Screens lagen im Shell statt in Features
---
## Problem
Die Desktop-App hatte alle Screen-Composables direkt im Shell-Modul (`meldestelle-desktop/screens/`).
Das verletzt das Shell-Feature-Core-Pattern:
- Shell enthielt Fachlogik (Veranstalter, Veranstaltung, Turnier, ZNS)
- `zns-import-feature` war gespalten: ViewModel im Feature, Screen im Shell
- `SharedUiModels` (Enums, Badges) lagen im Shell statt in `core/design-system`
---
## Durchgeführte Änderungen
### Neue Feature-Module
| Modul | Inhalt |
|-------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------|
| `frontend/features/veranstalter-feature` | VeranstalterAuswahlScreen, VeranstalterDetailScreen, VeranstalterNeuScreen |
| `frontend/features/veranstaltung-feature` | AdminUebersichtScreen, VeranstaltungenScreen, VeranstaltungDetailScreen, VeranstaltungNeuScreen, VeranstaltungUebersichtScreen |
| `frontend/features/turnier-feature` | TurnierDetailScreen, TurnierNeuScreen, alle 8 Turnier-Tabs, AktorScreens |
### Verschobene Dateien
| Von | Nach |
|----------------------------------------------------|----------------------------------------------------|
| `shells/.../screens/zns/StammdatenImportScreen.kt` | `features/zns-import-feature/.../presentation/` |
| `shells/.../screens/shared/SharedUiModels.kt` | `core/design-system/.../models/` |
| `shells/.../screens/shared/PlaceholderContent.kt` | `core/design-system/.../models/` |
| `shells/.../screens/veranstalter/*.kt` | `features/veranstalter-feature/.../presentation/` |
| `shells/.../screens/veranstaltung/*.kt` | `features/veranstaltung-feature/.../presentation/` |
| `shells/.../screens/turnier/*.kt` | `features/turnier-feature/.../presentation/` |
| `shells/.../screens/aktor/AktorScreens.kt` | `features/turnier-feature/.../presentation/` |
### Konfiguration
- `settings.gradle.kts`: 3 neue Feature-Module eingetragen
- `shells/meldestelle-desktop/build.gradle.kts`: 3 neue Feature-Abhängigkeiten
- `shells/.../DesktopMainLayout.kt`: Imports auf neue Feature-Packages umgestellt
- `shells/.../ScreenPreviews.kt`: Imports auf neue Feature-Packages umgestellt
### Shell nach Refactor
Der Shell enthält jetzt nur noch:
- `main.kt` — Koin-Init, Window
- `DesktopApp.kt` — Root-Composable, Login-Gate
- `di/DesktopModule.kt` — Shell-DI
- `navigation/DesktopNavigationPort.kt` — Navigation-Port
- `screens/layout/DesktopMainLayout.kt` — Layout + Navigation
- `screens/preview/ScreenPreviews.kt` — IDE-Previews
### Neue Dokumentation
- `docs/06_Frontend/ARCHITECTURE_RULES.md` — verbindliche Architektur-Richtlinien mit Checkliste und Anti-Patterns
---
## Offene Punkte
- Gradle-Sync erforderlich, damit Typesafe-Accessors für neue Module generiert werden
- `ArchitectureTest` (falls vorhanden) sollte die neuen Schichtgrenzen prüfen
---
## Lessons Learned
Architektur-Regeln müssen **schriftlich und verbindlich** festgehalten werden, bevor Code geschrieben wird.
Die `ARCHITECTURE_RULES.md` ist ab sofort Pflichtlektüre für jeden Agenten vor dem ersten Commit.

View File

@ -0,0 +1,114 @@
---
type: Journal
status: ACTIVE
owner: Frontend Expert + UI/UX Designer
last_update: 2026-03-26
---
# Session Log: Desktop-App Figma-Konformität (Vision_03)
🎨 **[Frontend Expert]** / 🖌️ **[UI/UX Designer]** / 🧹 **[Curator]** | 26. März 2026
## Kontext
Ziel: Desktop-App an Figma Vision_03 (22 Screenshots) angleichen.
Styling hat keine Priorität — Struktur, Layout und Inhalte stehen im Vordergrund.
---
## Analyse: Figma Vision_03 (22 Screenshots)
| Screenshot | Screen / Tab | Status |
|----------------|--------------------------------|---------------------|
| 01, 04, 05 | TurnierDetail > NENNUNGEN | Struktur vorhanden |
| 02 | TurnierDetail > ERGEBNISLISTEN | Struktur vorhanden |
| 03 | TurnierDetail > STARTLISTEN | Struktur vorhanden |
| 06 | TurnierDetail > ABRECHNUNG | ✅ Neu implementiert |
| 07, 08 | TurnierDetail > ARTIKEL | ✅ Neu implementiert |
| 09, 10, 11, 12 | TurnierDetail > BEWERBE | Struktur vorhanden |
| 13, 14 | TurnierDetail > ORGANISATION | ✅ Neu implementiert |
| 15, 16 | TurnierDetail > STAMMDATEN | ✅ Neu implementiert |
| 17 | VeranstaltungUebersichtScreen | ✅ Überarbeitet |
| 18, 19 | VeranstalterDetailScreen | ✅ Neu implementiert |
| 20, 22 | VeranstalterAuswahlScreen | ✅ Neu implementiert |
| 21 | Neuer Veranstalter (Formular) | ⬜ TODO |
---
## Erledigte Änderungen
### 1. ✅ SharedUiModels.kt (NEU)
- Gemeinsame Enums: `LoginStatus`, `VeranstaltungStatus`
- Gemeinsame Composable: `LoginStatusBadge`
- Eliminiert Duplikate aus 3 Dateien
### 2. ✅ VeranstalterAuswahlScreen.kt (ÜBERARBEITET)
- OEPS-Nummer, Ansprechpartner, E-Mail, Login-Status-Badge
- "+ Neuer Veranstalter"-Button
- Hinweis-Box (blau)
- Abbrechen / "Weiter zum Veranstalter"-Buttons unten
### 3. ✅ VeranstalterDetailScreen.kt (ÜBERARBEITET)
- Avatar-Circle (Initialen)
- OEPS-Nummer, Kontaktdetails-Grid (Ansprechpartner, E-Mail, Telefon, Adresse, Login-Status, Mitglied-seit)
- "Profil bearbeiten"-Button
- Suchfeld + Status-Filter-Chips (Alle/Vorbereitung/Live/Abgeschlossen)
- Veranstaltungs-Liste mit Statistiken (Nennungen, Bewerbe, Letzte Aktivität)
### 4. ✅ VeranstaltungUebersichtScreen.kt (ÜBERARBEITET)
- "VERANSTALTUNG - ÜBERSICHT"-Tab-Header
- Turnier-Nummer als echte ZNS-Nummer (26128, 26129, ...)
- Buttons: Öffnen / Import / Export / USB
### 5. ✅ TurnierStammdatenTab.kt (NEU)
- Turnier-Konfiguration: Nr., Typ (OTO/FEI), ZNS-Import via Internet/USB, Sprache
- Sparten-Checkboxen (Dressur, Springen), Klassen (C/B/A), Kategorien, Datum
- Turnier-Beschreibung: Titel, Sub-Titel
- Sponsoren-Sektion
### 6. ✅ TurnierOrganisationTab.kt (NEU)
- Funktionäre & Offizielle: Turnierleiter, Turnierbeauftragter, Technischer Delegierter, Parcourschef
- Support-Team: Tierarzt, Schmied, Steward
- Richterkollegium: dynamische Liste (Name, Qualifikation-Dropdown, Funktion-Dropdown, Löschen)
- Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen)
### 7. ✅ TurnierArtikelTab.kt (NEU)
- Nennungs- und Startgebühren: Nenngebühr, Startgebühr, Sporteuro, Nachnennungsgebühr, Nennungstausch
- Stallungen & Boxen: Box/Tag, Einstreu, Paddock
- Zusatzgebühren: dynamische Liste (Bezeichnung, Betrag, Pflicht-Checkbox)
- Hinweis-Box zur Preisliste
### 8. ✅ TurnierAbrechnungTab.kt (NEU)
- Sub-Tabs: BUCHUNGEN | OFFENE POSTEN | RECHNUNG
- Rechte Sidebar: AUSWAHL | VERKAUF | BUCHUNGEN | ADRESSEN
- Buchungstabelle: Buchungstext, Soll, Haben, Saldo (rot bei offen), Buchen/Rechnung-Checkboxen
- Sidebar: Reiter/Pferd-Suche, Buchen-Betrag, Direkt-Drucken, Zahlungsart (BAR/Scheck/Bankomat/Kreditkarte)
---
## Offene Punkte (TODO)
| Prio | Thema | Aufwand |
|-------|------------------------------------------------------|---------|
| 🟡 P1 | "Neuer Veranstalter"-Formular (Screenshot 21) | Klein |
| 🟡 P1 | BEWERBE-Tab: echte Datentabelle + Bewerb-Formular | Mittel |
| 🟡 P1 | NENNUNGEN-Tab: Pferd+Reiter-Suche + Bewerbsübersicht | Mittel |
| 🟡 P2 | STARTLISTEN-Tab: Bewerbs-Tabs + Sortierung/Zeit | Mittel |
| 🟡 P2 | ERGEBNISLISTEN-Tab: Bewerbs-Tabs + Platzierung | Mittel |
| 🟢 P3 | Styling: Farben, Fonts, Abstände gemäß Figma | Klein |
---
## Referenzen
- Figma Screenshots: `docs/06_Frontend/FIGMA/Vision_03/Screenshots/`
- Quellcode: `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/`
- Routing-Diagramm: `docs/06_Frontend/Navigation_Routing_Diagramm.md`

View File

@ -0,0 +1,54 @@
# Session Log Figma-Konformität Teil 2
**Datum:** 26.03.2026
**Agent:** 🎨 Frontend Expert + 🏗️ Lead Architect (Junie)
**Dauer:** ~1h
---
## Ziel
Offene Punkte aus Session "Figma-Konformität Teil 1" abarbeiten:
- "Neuer Veranstalter"-Formular (Screenshot 21)
- BEWERBE-Tab: echte Datentabelle + alle 4 Detail-Panel Sub-Tabs
- NENNUNGEN-Tab: Pferd+Reiter-Suche + Nennungs-Tabelle + Verkauf/Buchungen
- STARTLISTEN-Tab: Bewerbs-Tabs + Sortierung & Zeit-Panel
- ERGEBNISLISTEN-Tab: Bewerbs-Tabs + Platzierung & Geldpreis-Panel
---
## Umgesetzte Änderungen
### Neue Dateien
| Datei | Inhalt |
|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|
| `VeranstalterNeuScreen.kt` | Formular gemäß Screenshot 21: Info-Banner, Vereinsdaten, Kontaktdaten, Adresse, Footer-Buttons mit Validierung |
| `TurnierBewerbeTab.kt` | Vollständige BEWERBE-Implementierung: Datentabelle (12 Spalten), 4 Sub-Tabs (Bewerb/Bewertung/Geldpreise/Ort/Zeit) mit echten Feldern |
| `TurnierNennungenTab.kt` | NENNUNGEN: Pferd+Reiter-Suche, Nennungs-Tabelle mit Status-Badges, Verkauf/Buchungen, Bewerbsübersicht |
| `TurnierStartlistenTab.kt` | STARTLISTEN: Bewerbs-Tabs, Starter-Tabelle, Sortierungs-Optionen, Zeiten-Panel |
| `TurnierErgebnislistenTab.kt` | ERGEBNISLISTEN: Bewerbs-Tabs, Ergebnis-Tabelle, Platzierung & Geldpreis-Panel |
### Geänderte Dateien
| Datei | Änderung |
|--------------------------------|----------------------------------------------------------------------------|
| `AppScreen.kt` | `VeranstalterNeu` als neuer Screen hinzugefügt |
| `VeranstalterAuswahlScreen.kt` | `onNeuerVeranstalter`-Parameter + Button verdrahtet |
| `DesktopMainLayout.kt` | VeranstalterNeu-Screen registriert + Breadcrumb-Eintrag |
| `TurnierDetailScreen.kt` | Placeholder-Implementierungen entfernt, Verweis auf dedizierte Tab-Dateien |
---
## Qualitätssicherung
- Lint-Check auf alle 9 geänderten/neuen Dateien: **keine Fehler**
---
## Offene Punkte (Phase 4/5)
- Echte Daten aus Backend laden (alle Tabs zeigen noch Placeholder-Daten)
- ZNS-Frontend-Integration (Status-Polling) noch offen
- Web-App-Strategie (ADR-0017) noch nicht entschieden

View File

@ -0,0 +1,49 @@
# Session Log: Screens-Reorganisation
**Datum:** 2026-03-26
**Agent:** 🎨 Frontend Expert (Junie)
**Scope:** `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/`
---
## Ziel
Ordnung in das `screens/`-Paket bringen: 24 Flat-Dateien in logische Unterordner aufteilen für bessere Übersicht und
Wartbarkeit.
## Neue Paketstruktur
```
screens/
├── shared/ → SharedUiModels.kt, PlaceholderContent.kt
├── layout/ → DesktopMainLayout.kt
├── veranstalter/ → VeranstalterAuswahlScreen.kt, VeranstalterDetailScreen.kt, VeranstalterNeuScreen.kt
├── veranstaltung/ → AdminUebersichtScreen.kt, VeranstaltungenScreen.kt, VeranstaltungDetailScreen.kt,
│ VeranstaltungNeuScreen.kt, VeranstaltungUebersichtScreen.kt
├── turnier/ → TurnierDetailScreen.kt, TurnierNeuScreen.kt,
│ TurnierStammdatenTab.kt, TurnierOrganisationTab.kt, TurnierBewerbeTab.kt,
│ TurnierArtikelTab.kt, TurnierAbrechnungTab.kt, TurnierNennungenTab.kt,
│ TurnierStartlistenTab.kt, TurnierErgebnislistenTab.kt
├── zns/ → StammdatenImportScreen.kt
├── aktor/ → AktorScreens.kt
└── preview/ → ScreenPreviews.kt
```
## Durchgeführte Änderungen
| Datei | Änderung |
|--------------------------------------------------|--------------------------------------------------------------------------------------|
| Alle 24 Dateien | Package-Deklaration auf neues Sub-Package angepasst |
| `layout/DesktopMainLayout.kt` | Imports für alle Screen-Packages ergänzt |
| `preview/ScreenPreviews.kt` | Imports für alle Screen- und Tab-Composables ergänzt |
| `veranstalter/VeranstalterAuswahlScreen.kt` | Import `shared.LoginStatus`, `shared.LoginStatusBadge` |
| `veranstalter/VeranstalterDetailScreen.kt` | Import `shared.LoginStatus`, `shared.LoginStatusBadge`, `shared.VeranstaltungStatus` |
| `veranstaltung/AdminUebersichtScreen.kt` | Import `shared.VeranstaltungStatus` |
| `veranstaltung/VeranstaltungUebersichtScreen.kt` | Import `shared.VeranstaltungStatus` |
| `DesktopApp.kt` | Import von `screens.DesktopMainLayout``screens.layout.DesktopMainLayout` |
## Verifikation
- Lint-Check auf alle kritischen Dateien: ✅ keine Fehler
- Alte Flat-Dateien gelöscht
- `screens/`-Root enthält nur noch die 8 Unterordner

View File

@ -0,0 +1,78 @@
---
type: Journal
status: ACTIVE
owner: Lead Architect
last_update: 2026-03-26
---
# Session Log: Struktur-Sprint & Orientierung
🏗️ **[Lead Architect]** / 🧹 **[Curator]** | 26. März 2026
## Kontext
Nach intensiver Phase-4-Arbeit fehlte der Überblick. Ziel dieser Session: Orientierung
wiederherstellen, offene Baustellen priorisieren, zwei konkrete Aufgaben abarbeiten.
---
## Erledigte Aufgaben
### 1. ✅ Routing-Diagramm erstellt
- **Artefakt:** `docs/06_Frontend/Navigation_Routing_Diagramm.md`
- Vollständiges Mermaid-Flowchart aller Screens und Navigationsübergänge
- Screen-Status-Tabellen (✅ implementiert / 🟡 teilweise / ⬜ Placeholder / 🗑️ Relikt)
- **Fund:** 9 Web-App-Relikte im `AppScreen` (siehe offene Entscheidung unten)
### 2. ✅ CI/CD Gradle-Version synchronisiert
- **Datei:** `.gitea/workflows/docker-publish.yaml`
- `GRADLE_VERSION` von `9.3.1` auf `9.4.0` korrigiert (synchron mit `gradle-wrapper.properties`)
- `paths:`-Whitelist war bereits korrekt — Doku-Änderungen triggern die Pipeline nicht
---
## Priorisierte Backlog-Übersicht (Stand 26.03.2026)
| Prio | Thema | Agent | Status |
|-------|--------------------------------------------|--------------------------------|----------------------|
| 🔴 P1 | Desktop-App: Figma-Konformität (Vision_03) | 🎨 Frontend Expert + 🖌️ UI/UX | ⬜ Offen |
| 🔴 P1 | ZNS-Importer Phase 3: Status-Polling | 🎨 Frontend Expert | ⬜ Offen |
| 🟡 P2 | Web-App-Strategie: ADR erforderlich | 🏗️ Lead Architect | ⬜ Entscheidung offen |
| 🟡 P2 | Docker & Datenbanken aufräumen | 🐧 DevOps Engineer | ⬜ Offen |
| 🟢 P3 | CI/CD weiter optimieren | 🐧 DevOps Engineer | ✅ Teilweise erledigt |
---
## Offene Entscheidung: Web-App-Strategie
Im `AppScreen` existieren 9 Screens, die aus der alten Web-App stammen und in der
Desktop-App nicht gerendert werden:
`Landing`, `Home`, `Dashboard`, `Ping`, `Profile`, `OrganizerProfile`,
`AuthCallback`, `Nennung`, `CreateTournament`
**Optionen:**
- **A) Bereinigen:** Relikte aus `AppScreen` entfernen → sauberer Code, weniger Verwirrung
- **B) Behalten:** Für zukünftige Web-App-Phase (Phase 7) aufheben → kein Aufwand jetzt
- **C) Web-App wieder aufbauen:** `meldestelle-portal` reaktivieren → ADR + Planung nötig
**ADR-0017 erforderlich**, sobald Entscheidung getroffen.
---
## Nächste empfohlene Schritte
1. **Web-App-Entscheidung** treffen (Option A/B/C) → ADR-0017
2. **Desktop-App Figma-Delta** aufnehmen: Figma Vision_03 vs. aktueller Compose-Code
3. **ZNS Phase 3** abschließen: Status-Polling im `StammdatenImportScreen`
4. **Docker/DB** Ist-Zustand prüfen
---
## Referenzen
- Routing-Diagramm: `docs/06_Frontend/Navigation_Routing_Diagramm.md`
- CI/CD Pipeline: `.gitea/workflows/docker-publish.yaml`
- MASTER_ROADMAP: `docs/01_Architecture/MASTER_ROADMAP.md`

View File

@ -21,6 +21,7 @@ kotlin {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.materialIconsExtended)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(libs.bundles.kmp.common)

View File

@ -1,4 +1,4 @@
package at.mocode.desktop.screens
package at.mocode.frontend.core.designsystem.models
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons

View File

@ -0,0 +1,38 @@
package at.mocode.frontend.core.designsystem.models
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
// ── Gemeinsame Enums ─────────────────────────────────────────────────────────
enum class LoginStatus { AKTIV, AUSSTEHEND }
enum class VeranstaltungStatus { VORBEREITUNG, LIVE, ABGESCHLOSSEN }
// ── Gemeinsame Composables ───────────────────────────────────────────────────
@Composable
fun LoginStatusBadge(status: LoginStatus, modifier: Modifier = Modifier) {
val (text, color) = when (status) {
LoginStatus.AKTIV -> "Aktiv" to Color(0xFF16A34A)
LoginStatus.AUSSTEHEND -> "Ausstehend" to Color(0xFFEA580C)
}
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = color.copy(alpha = 0.15f),
modifier = modifier,
) {
Text(
text = text,
color = color,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}

View File

@ -20,6 +20,7 @@ sealed class AppScreen(val route: String) {
// Neuer Flow: + Neue Veranstaltung → Veranstalter auswählen → Veranstalter-Detail → Veranstaltung-Übersicht
data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl")
data object VeranstalterNeu : AppScreen("/veranstalter/neu")
data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId")
data class VeranstaltungUebersicht(val veranstalterId: Long, val veranstaltungId: Long) :
AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId")

View File

@ -61,6 +61,7 @@ kotlin {
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
implementation(compose.uiTooling)
}
jsMain.dependencies {

View File

@ -33,13 +33,13 @@ data class PingUiState(
val logs: List<LogEntry> = emptyList()
)
class PingViewModel(
open class PingViewModel(
private val apiClient: PingApi,
private val syncService: PingSyncService
) : ViewModel() {
var uiState by mutableStateOf(PingUiState())
private set
internal set
private fun addLog(source: String, message: String, isError: Boolean = false) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())

View File

@ -0,0 +1,109 @@
package at.mocode.ping.feature.presentation
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingEvent
import at.mocode.ping.api.PingResponse
import at.mocode.ping.feature.domain.PingSyncService
// ─────────────────────────────────────────────────────────────────────────────
// Fake-Implementierungen für Preview (kein Koin, kein Netzwerk nötig)
// ─────────────────────────────────────────────────────────────────────────────
private val fakePingResponse = PingResponse(
status = "OK", timestamp = "2026-03-26T12:00:00Z", service = "ping-service"
)
private val fakeEnhancedResponse = EnhancedPingResponse(
status = "OK", timestamp = "2026-03-26T12:00:00Z", service = "ping-service",
circuitBreakerState = "CLOSED", responseTime = 42L
)
private val fakeHealthResponse = HealthResponse(
status = "UP", timestamp = "2026-03-26T12:00:00Z", service = "ping-service", healthy = true
)
private object FakePingApi : PingApi {
override suspend fun simplePing() = fakePingResponse
override suspend fun enhancedPing(simulate: Boolean) = fakeEnhancedResponse
override suspend fun healthCheck() = fakeHealthResponse
override suspend fun publicPing() = fakePingResponse
override suspend fun securePing() = fakePingResponse
override suspend fun syncPings(since: Long): List<PingEvent> = emptyList()
}
private object FakePingSyncService : PingSyncService {
override suspend fun syncPings() { /* no-op */
}
}
// Subclass um uiState für Preview direkt setzen zu können
private class PreviewPingViewModel(state: PingUiState) :
PingViewModel(FakePingApi, FakePingSyncService) {
init {
uiState = state
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Previews
// ─────────────────────────────────────────────────────────────────────────────
@Preview
@Composable
fun PreviewPingScreen_Empty() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(PingUiState()),
onBack = {}
)
}
}
@Preview
@Composable
fun PreviewPingScreen_WithData() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(
PingUiState(
simplePingResponse = fakePingResponse,
healthResponse = fakeHealthResponse,
logs = listOf(
LogEntry("12:00:01", "SimplePing", "Success: OK from ping-service"),
LogEntry("12:00:00", "HealthCheck", "Status: UP, Healthy: true"),
)
)
),
onBack = {}
)
}
}
@Preview
@Composable
fun PreviewPingScreen_Loading() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(PingUiState(isLoading = true, isSyncing = true)),
onBack = {}
)
}
}
@Preview
@Composable
fun PreviewPingScreen_Error() {
MaterialTheme {
PingScreen(
viewModel = PreviewPingViewModel(
PingUiState(errorMessage = "Connection refused: Backend nicht erreichbar")
),
onBack = {}
)
}
}

View File

@ -0,0 +1,32 @@
/**
* Feature-Modul: Turnier-Verwaltung (Desktop-only)
* Kapselt alle Screens und Tabs für Turnier-Detail, -Neuanlage und alle Turnier-Tabs
* (Stammdaten, Organisation, Bewerbe, Artikel, Abrechnung, Nennungen, Startlisten, Ergebnislisten).
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
}

View File

@ -1,10 +1,11 @@
package at.mocode.desktop.screens
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
/**
* Placeholder-Screens für Akteur-Verwaltung (actor-context).

View File

@ -0,0 +1,277 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
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.Print
import androidx.compose.material.icons.filled.Refresh
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
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
private val OffenePostenRot = Color(0xFFDC2626)
/**
* ABRECHNUNG-Tab im TurnierDetailScreen.
* Gemäß Figma Vision_03 (figma-entwurf_06):
* - Sub-Tabs: BUCHUNGEN | OFFENE POSTEN | RECHNUNG
* - Rechte Sidebar: AUSWAHL | VERKAUF | BUCHUNGEN | ADRESSEN
* - Buchungstabelle: Buchungstext, Soll, Haben, Saldo, Buchen-Checkbox, Rechnung-Checkbox
* - Rechte Sidebar: Suche nach Reiter/Pferd, Zahlungsart, Buchen-Button
*/
@Composable
fun AbrechnungTabContent() {
var subTab by remember { mutableIntStateOf(0) }
var sidebarTab by remember { mutableIntStateOf(2) } // BUCHUNGEN default
val subTabs = listOf("BUCHUNGEN", "OFFENE POSTEN", "RECHNUNG")
val sidebarTabs = listOf("AUSWAHL", "VERKAUF", "BUCHUNGEN", "ADRESSEN")
// Placeholder-Buchungen
val buchungen = remember {
listOf(
BuchungspositionUiModel("Startgebühr Bewerb 12 - Dressur Kl. A", 25.00, 0.00),
BuchungspositionUiModel("Startgebühr Bewerb 15 - Springen Kl. B", 30.00, 0.00),
BuchungspositionUiModel("Nenngeld", 15.00, 0.00),
BuchungspositionUiModel("Box 3 Tage", 45.00, 0.00),
)
}
Row(modifier = Modifier.fillMaxSize()) {
// ── Hauptbereich ─────────────────────────────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
// Sub-Tabs
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,
fontWeight = if (subTab == i) FontWeight.Bold else FontWeight.Normal
)
},
)
}
}
when (subTab) {
0 -> BuchungenContent(buchungen)
1 -> OffenePostenContent()
2 -> RechnungContent()
}
}
VerticalDivider()
// ── Rechte Sidebar ───────────────────────────────────────────────────
Column(modifier = Modifier.width(320.dp).fillMaxHeight()) {
TabRow(
selectedTabIndex = sidebarTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
) {
sidebarTabs.forEachIndexed { i, title ->
Tab(
selected = sidebarTab == i,
onClick = { sidebarTab = i },
text = { Text(title, fontSize = 11.sp) },
)
}
}
when (sidebarTab) {
2 -> BuchungenSidebar()
else -> PlaceholderContent(title = sidebarTabs[sidebarTab], subtitle = "")
}
}
}
}
@Composable
private fun BuchungenContent(buchungen: List<BuchungspositionUiModel>) {
val gesamtSoll = buchungen.sumOf { it.soll }
val gesamtHaben = buchungen.sumOf { it.haben }
val gesamtSaldo = gesamtSoll - gesamtHaben
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Aktualisieren", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Übersicht", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Tabelle Leeren", fontSize = 12.sp, color = Color(0xFFEA580C))
}
OutlinedButton(onClick = {}, modifier = Modifier.height(36.dp)) {
Text("Pferd aus Liste entfernen", fontSize = 12.sp)
}
}
// Tabellen-Header
Row(
modifier = Modifier.fillMaxWidth().background(Color(0xFFF3F4F6)).padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Buchungstext", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
Text("Soll", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Haben", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Saldo", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Buchen", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
Text("Rechnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
}
HorizontalDivider()
LazyColumn(modifier = Modifier.weight(1f)) {
items(buchungen) { b ->
val saldo = b.soll - b.haben
var buchen by remember { mutableStateOf(false) }
var rechnung by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(b.buchungstext, fontSize = 13.sp, modifier = Modifier.weight(3f))
Text("%.2f €".format(b.soll), fontSize = 13.sp, modifier = Modifier.weight(1f))
Text("%.2f €".format(b.haben), fontSize = 13.sp, modifier = Modifier.weight(1f))
Text(
"%.2f €".format(saldo),
fontSize = 13.sp,
color = if (saldo > 0) OffenePostenRot else Color.Unspecified,
fontWeight = if (saldo > 0) FontWeight.SemiBold else FontWeight.Normal,
modifier = Modifier.weight(1f),
)
Checkbox(checked = buchen, onCheckedChange = { buchen = it }, modifier = Modifier.width(60.dp))
Checkbox(checked = rechnung, onCheckedChange = { rechnung = it }, modifier = Modifier.width(70.dp))
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
// Gesamt-Zeile
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("GESAMT", fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(3f))
Text("%.2f €".format(gesamtSoll), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text("%.2f €".format(gesamtHaben), fontSize = 13.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text(
"%.2f €".format(gesamtSaldo),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = if (gesamtSaldo > 0) OffenePostenRot else Color.Unspecified,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun BuchungenSidebar() {
var suchtext by remember { mutableStateOf("") }
var zahlungsart by remember { mutableStateOf("BAR") }
Column(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Nach Reiter oder Pferd", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
placeholder = { Text("Bitte auswählen...", fontSize = 12.sp) },
modifier = Modifier.fillMaxWidth().height(44.dp),
singleLine = true,
)
HorizontalDivider()
Text("Buchen:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("0.00 €", fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Button(
onClick = {},
enabled = false,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Buchen") }
}
HorizontalDivider()
Text("Direkt Drucken:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) {
Icon(Icons.Default.Print, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Saldo", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.weight(1f)) {
Text("Rechnung", fontSize = 12.sp)
}
}
HorizontalDivider()
Text("Zahlungsart:", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
listOf("BAR", "Scheck (+30 €)", "Bankomat", "Kreditkarte").forEach { art ->
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = zahlungsart == art, onClick = { zahlungsart = art })
Text(art, fontSize = 13.sp)
}
}
Button(
onClick = {},
enabled = false,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Gebühr buchen") }
// Hinweis
Surface(
color = Color(0xFFEFF6FF),
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)),
) {
Text(
"💡 Hinweis: Bei Barzahlung werden die Buchungen sofort verarbeitet. Scheck-Zahlungen erfordern eine zusätzliche Gebühr von 30 €.",
fontSize = 11.sp,
color = Color(0xFF1E40AF),
modifier = Modifier.padding(8.dp),
)
}
}
}
@Composable
private fun OffenePostenContent() {
PlaceholderContent(title = "Offene Posten", subtitle = "Alle offenen Forderungen …")
}
@Composable
private fun RechnungContent() {
PlaceholderContent(title = "Rechnung", subtitle = "Rechnungserstellung …")
}
// --- UI-Modelle ---
data class BuchungspositionUiModel(val buchungstext: String, val soll: Double, val haben: Double)

View File

@ -0,0 +1,263 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
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
import androidx.compose.ui.unit.sp
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
private val DeleteRed = Color(0xFFDC2626)
/**
* ARTIKEL-Tab im TurnierDetailScreen.
* Gemäß Figma Vision_03 (figma-entwurf_07 / figma-entwurf_08):
* - Nennungen & Gebühren: Nenngebühr, Startgebühr, Sporteuro, Nachnennungsgebühr, Nennungstausch
* - Stallungen & Boxen: Box/Tag, Einstreu, Paddock
* - Zusatzgebühren: dynamische Liste (Bezeichnung, Betrag, Pflicht)
*/
@Composable
fun ArtikelTabContent() {
var nenngebuehr by remember { mutableStateOf("0.00") }
var startgebuehr by remember { mutableStateOf("15.00") }
var sporteuro by remember { mutableStateOf("0.00") }
var nachnennungsgebuehr by remember { mutableStateOf("0.00") }
var nennungstauschGebuehr by remember { mutableStateOf("0.00") }
var boxProTag by remember { mutableStateOf("0.00") }
var einstreuErstEinstreu by remember { mutableStateOf("0.00") }
var einstreuNachlegen by remember { mutableStateOf("0.00") }
var paddockProTag by remember { mutableStateOf("0.00") }
var zusatzgebuehren by remember {
mutableStateOf(
listOf(
ZusatzgebuehrUiModel("Stromanschluss pro Tag", "5.00", false),
ZusatzgebuehrUiModel("Camping pro Nacht", "10.00", false),
),
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ── Nennungen & Gebühren ─────────────────────────────────────────────
ArtikelSectionCard(title = "Nennungen & Gebühren") {
ArtikelSubSection("Nennungs- und Startgebühren") {
ArtikelFormRow("Nenngebühr pro Pferd/Reiter:", "(Grundgebühr unabhängig von Anzahl Bewerben)") {
EuroTextField(nenngebuehr) { nenngebuehr = it }
}
ArtikelFormRow("Startgebühr pro Bewerb:", "(Pro einzelner Prüfung)") {
EuroTextField(startgebuehr) { startgebuehr = it }
}
ArtikelFormRow("Sporteuro (Beitrag OEPS):", null) {
EuroTextField(sporteuro) { sporteuro = it }
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
ArtikelFormRow("Nachnennungsgebühr:", "(Nach Nennschluss)") {
EuroTextField(nachnennungsgebuehr) { nachnennungsgebuehr = it }
}
ArtikelFormRow("Nennungstausch-Gebühr:", "(Pferd- oder Reiter-Wechsel)") {
EuroTextField(nennungstauschGebuehr) { nennungstauschGebuehr = it }
}
}
}
// ── Stallungen & Boxen ───────────────────────────────────────────────
ArtikelSectionCard(title = "Stallungen & Boxen") {
ArtikelFormRow("Box pro Tag:", null) {
EuroTextField(boxProTag) { boxProTag = it }
}
ArtikelFormRow("Einstreu (Erst-Einstreu):", null) {
EuroTextField(einstreuErstEinstreu) { einstreuErstEinstreu = it }
}
ArtikelFormRow("Einstreu (Nachlegen):", null) {
EuroTextField(einstreuNachlegen) { einstreuNachlegen = it }
}
ArtikelFormRow("Paddock pro Tag:", null) {
EuroTextField(paddockProTag) { paddockProTag = it }
}
}
// ── Zusatzgebühren ───────────────────────────────────────────────────
ArtikelSectionCard(
title = "Zusatzgebühren",
action = {
TextButton(onClick = {
zusatzgebuehren = zusatzgebuehren + ZusatzgebuehrUiModel("", "0.00", false)
}) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue)
Spacer(Modifier.width(4.dp))
Text("Hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
},
) {
// Tabellen-Header
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
Text("Bezeichnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
Text("Betrag", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
Text("Pflicht", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Spacer(Modifier.width(44.dp))
}
HorizontalDivider()
zusatzgebuehren.forEachIndexed { idx, z ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = z.bezeichnung,
onValueChange = { v ->
zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(bezeichnung = v) }
},
modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp),
singleLine = true,
)
OutlinedTextField(
value = z.betrag,
onValueChange = { v ->
zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(betrag = v) }
},
suffix = { Text("") },
modifier = Modifier.weight(1.5f).height(44.dp).padding(end = 8.dp),
singleLine = true,
)
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = z.pflicht,
onCheckedChange = { v ->
zusatzgebuehren = zusatzgebuehren.toMutableList().also { it[idx] = z.copy(pflicht = v) }
},
)
Text("Pflicht", fontSize = 12.sp)
}
IconButton(
onClick = { zusatzgebuehren = zusatzgebuehren.toMutableList().also { it.removeAt(idx) } },
modifier = Modifier.size(44.dp),
) {
Icon(
Icons.Default.Delete,
contentDescription = "Löschen",
tint = DeleteRed,
modifier = Modifier.size(18.dp)
)
}
}
}
}
// ── Hinweis ──────────────────────────────────────────────────────────
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color(0xFFFFFBEB),
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFFDE68A)),
) {
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("", fontSize = 14.sp)
Column {
Text("Hinweis zur Preisliste", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
Text(
"Die Gebührenstruktur wird in der offiziellen Ausschreibung veröffentlicht und ist für alle Teilnehmer verbindlich. " +
"Bei nationalen Turnieren der Kategorie C-Neu sind oft reduzierte Gebühren oder Gebührenbefreiungen üblich (z.B. kein Nenngeld, kein Sporteuro).",
fontSize = 12.sp,
color = Color(0xFF92400E),
)
}
}
}
// ── Aktions-Buttons ──────────────────────────────────────────────────
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
OutlinedButton(onClick = {}) { Text("Zurücksetzen") }
Spacer(Modifier.width(8.dp))
Button(onClick = {}, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)) { Text("Speichern") }
}
}
}
@Composable
private fun ArtikelSectionCard(
title: String,
action: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
action?.invoke()
}
content()
}
}
}
@Composable
private fun ArtikelSubSection(title: String, content: @Composable ColumnScope.() -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF9FAFB)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
content()
}
}
}
@Composable
private fun ArtikelFormRow(label: String, hint: String?, content: @Composable RowScope.() -> Unit) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(label, fontSize = 13.sp, modifier = Modifier.width(220.dp), color = Color(0xFF374151))
content()
if (hint != null) {
Spacer(Modifier.width(8.dp))
Text(
hint,
fontSize = 11.sp,
color = Color(0xFF9CA3AF),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
}
}
}
@Composable
private fun EuroTextField(value: String, onValueChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
suffix = { Text("") },
modifier = Modifier.width(120.dp).height(44.dp),
singleLine = true,
)
}
// --- UI-Modelle ---
data class ZusatzgebuehrUiModel(val bezeichnung: String, val betrag: String, val pflicht: Boolean)

View File

@ -0,0 +1,644 @@
package at.mocode.turnier.feature.presentation
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.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
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 HeaderBg = Color(0xFFF1F5F9)
private val SelectedRowBg = Color(0xFFEFF6FF)
/**
* BEWERBE-Tab gemäß Vision_03 (Screenshots 0912).
*
* Layout: 3-spaltig
* - Links (140dp): Aktions-Buttons (Speichern, Rückgängig, Einfügen, Löschen, Teilen, Verschieben, Startliste, Ergebnisliste)
* - Mitte (flex): Datentabelle (Tag | Platz | Bewerb | Beginn | Ende | Bewerbname | ZNS | Nennungen)
* - Rechts (340dp): Detail-Panel mit Sub-Tabs (Bewerb | Bewertung | Geldpreise | Ort/Zeit)
*/
@Composable
fun BewerbeTabContent() {
var selectedIndex by remember { mutableIntStateOf(0) }
val bewerbe = remember { sampleBewerbe() }
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Aktions-Spalte ──────────────────────────────────────────────
BewerbeAktionsSpalte(modifier = Modifier.width(140.dp).fillMaxHeight())
VerticalDivider()
// ── Mittlere Tabelle ──────────────────────────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
// Toolbar über der Tabelle
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
) {
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Aktualisieren", fontSize = 12.sp)
}
Surface(
shape = MaterialTheme.shapes.small,
color = PrimaryBlue,
) {
Text(
text = "${bewerbe.size} Bewerbe",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
) {
Text("Filtern", fontSize = 12.sp)
}
}
// Tabellen-Header
BewerbeTableHeader()
HorizontalDivider()
// Tabellen-Zeilen
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(bewerbe) { index, bewerb ->
BewerbeTableRow(
bewerb = bewerb,
isSelected = index == selectedIndex,
onClick = { selectedIndex = index },
)
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
}
VerticalDivider()
// ── Rechtes Detail-Panel ──────────────────────────────────────────────
BewerbeDetailPanel(
bewerb = bewerbe.getOrNull(selectedIndex),
modifier = Modifier.width(340.dp).fillMaxHeight(),
)
}
}
@Composable
private fun BewerbeTableHeader() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(HeaderBg)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TableHeaderCell("Tag", 90.dp)
TableHeaderCell("Platz", 50.dp)
TableHeaderCell("Bewerb", 55.dp)
TableHeaderCell("Beginn", 55.dp)
TableHeaderCell("Ende", 55.dp)
TableHeaderCell("Bewerbname", weight = 1f)
TableHeaderCell("ZNS", 45.dp)
TableHeaderCell("Nennungen", 75.dp)
}
}
@Composable
private fun RowScope.TableHeaderCell(text: String, width: androidx.compose.ui.unit.Dp? = null, weight: Float? = null) {
val mod = when {
weight != null -> Modifier.weight(weight)
width != null -> Modifier.width(width)
else -> Modifier
}
Text(
text = text,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF374151),
modifier = mod,
)
}
@Composable
private fun BewerbeTableRow(bewerb: BewerbUiModel, isSelected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (isSelected) SelectedRowBg else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(bewerb.tag, fontSize = 12.sp, modifier = Modifier.width(90.dp))
Text("${bewerb.platz}", fontSize = 12.sp, modifier = Modifier.width(50.dp))
Text(
"${bewerb.nummer}",
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.width(55.dp),
color = if (isSelected) PrimaryBlue else Color.Unspecified
)
Text(bewerb.beginn, fontSize = 12.sp, modifier = Modifier.width(55.dp))
Text(bewerb.ende, fontSize = 12.sp, modifier = Modifier.width(55.dp))
Text(bewerb.name, fontSize = 12.sp, modifier = Modifier.weight(1f), maxLines = 2)
Text("${bewerb.zns}", fontSize = 12.sp, modifier = Modifier.width(45.dp))
Text("${bewerb.nennungen}", fontSize = 12.sp, modifier = Modifier.width(75.dp))
}
}
@Composable
private fun BewerbeAktionsSpalte(modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
AktionsBtn("Änderungen\nSpeichern")
AktionsBtn("Änderungen\nRückgängig")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Bewerb\nEinfügen")
AktionsBtn("Bewerb\nLöschen")
AktionsBtn("Bewerb Teilen")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Bewerb nach\noben verschieben")
AktionsBtn("Bewerb nach\nunten verschieben")
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
AktionsBtn("Startliste\nBearbeiten")
AktionsBtn("Startliste\nDrucken")
AktionsBtn("Ergebnisliste\nBearbeiten")
AktionsBtn("Ergebnisliste\nDrucken")
}
}
@Composable
private fun AktionsBtn(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(bewerb: BewerbUiModel?, modifier: Modifier = Modifier) {
var subTab by remember { mutableIntStateOf(0) }
val subTabs = listOf("Bewerb", "Bewertung", "Geldpreise", "Ort/Zeit")
Column(modifier = modifier) {
PrimaryTabRow(
selectedTabIndex = subTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = PrimaryBlue,
) {
subTabs.forEachIndexed { i, title ->
Tab(
selected = subTab == i,
onClick = { subTab = i },
text = { Text(title, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
when (subTab) {
0 -> BewerbSubTab(bewerb)
1 -> BewertungSubTab(bewerb)
2 -> GeldpreiseSubTab()
3 -> OrtZeitSubTab(bewerb)
}
}
}
}
// ── Sub-Tab: Bewerb ───────────────────────────────────────────────────────────
@Composable
private fun BewerbSubTab(bewerb: BewerbUiModel?) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
DetailField("Nummer:", bewerb?.nummer?.toString() ?: "")
DetailField("Abteilung:", "")
DetailField("Typ:", bewerb?.typ ?: "")
DetailField("Name:", bewerb?.name ?: "")
DetailField("Bezeichnung:", bewerb?.bezeichnung ?: "")
DetailDropdown("Kategorie:")
DetailDropdown("Klasse:")
DetailDropdown("Lizenz:")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Maximal:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = "3",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
Spacer(Modifier.width(8.dp))
Text("Pferde je Reiter", fontSize = 12.sp, color = Color(0xFF6B7280))
}
DetailDropdown("Pferdealter:")
DetailField("Zeile 1:", bewerb?.zeile1 ?: "")
DetailField("Zeile 2:", "")
DetailField("Zeile 3:", "")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Logo Bewerb:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = "",
onValueChange = {},
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
Spacer(Modifier.width(4.dp))
OutlinedButton(
onClick = {},
modifier = Modifier.height(40.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Text("", fontSize = 13.sp)
}
}
}
}
// ── Sub-Tab: Bewertung ────────────────────────────────────────────────────────
@Composable
private fun BewertungSubTab(bewerb: BewerbUiModel?) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Bewertungs-Konfiguration", fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = Color(0xFF374151))
DetailField("Prüfung:", "Dressurreiterprüfung")
DetailField("Richtverfahren:", "A")
DetailField("Para-Grade:", "")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Richteranzahl:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = "2",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
}
DetailField("Aufgabe:", "Aufgabe R")
DetailField("Aufgabennummer:", "")
DetailField("Maximalpunkte:", "")
HorizontalDivider()
Text("Richter", fontSize = 12.sp, color = Color(0xFF6B7280))
RichterRow("C:", "Schuster Alexandra")
RichterRow("C:", "Vankova Kamila (CZ)")
}
}
@Composable
private fun RichterRow(position: String, name: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(position, fontSize = 13.sp, modifier = Modifier.width(30.dp))
OutlinedTextField(
value = name,
onValueChange = {},
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
Checkbox(checked = true, onCheckedChange = {})
}
}
// ── Sub-Tab: Geldpreise ───────────────────────────────────────────────────────
@Composable
private fun GeldpreiseSubTab() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Geldpreis-Sektion
Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = false, onCheckedChange = {})
Text("Geldpreis", fontSize = 13.sp)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Startgeld:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = "15,00",
onValueChange = {},
modifier = Modifier.width(100.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Auszahlung:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
DetailDropdown("fortführend", modifier = Modifier.weight(1f))
}
}
}
// Geldpreis für Kadererreiter
Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Geldpreis für Kadererreiter", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = false, onCheckedChange = {})
Text("Geldpreis für Kadererreiter", fontSize = 13.sp)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Startgeld für Kadererreiter:", fontSize = 13.sp, modifier = Modifier.width(180.dp))
OutlinedTextField(
value = "15,00",
onValueChange = {},
modifier = Modifier.width(100.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
}
}
}
// Geldpreisvorlage
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Geldpreisvorlage wählen:", fontSize = 13.sp, modifier = Modifier.width(180.dp))
DetailDropdown("", modifier = Modifier.weight(1f))
}
// Tabelle
Text("0 Geldpreise", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(
modifier = Modifier.fillMaxWidth().background(HeaderBg).padding(horizontal = 8.dp, vertical = 6.dp),
) {
Text("Nummer", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Geldpreis", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
}
HorizontalDivider()
Text("", fontSize = 12.sp, color = Color(0xFF9CA3AF), modifier = Modifier.padding(8.dp))
}
}
// ── Sub-Tab: Ort/Zeit ─────────────────────────────────────────────────────────
@Composable
private fun OrtZeitSubTab(bewerb: BewerbUiModel?) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Tag:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
DetailDropdown(bewerb?.tag ?: "28.05.2023", modifier = Modifier.weight(1f))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Beginnzeit:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
DetailDropdown("fix um", modifier = Modifier.width(100.dp))
}
LabeledTimeField("Beginnzeit:", bewerb?.beginn ?: "08:00", "(hh:mm)")
LabeledTimeField("Reitdauer:", "02:00", "(mm:ss)")
LabeledTimeField("Umbau:", "10", "(mm)")
LabeledTimeField("Besichtigung:", "10", "(mm)")
LabeledTimeField("Stechen:", "", "(mm)")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Platz:", fontSize = 13.sp, modifier = Modifier.width(130.dp))
DetailDropdown("Vorderer Turnierplatz", modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun LabeledTimeField(label: String, value: String, unit: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = value,
onValueChange = {},
modifier = Modifier.width(80.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
Spacer(Modifier.width(8.dp))
Text(unit, fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
// ── Hilfs-Composables ─────────────────────────────────────────────────────────
@Composable
private fun DetailField(label: String, value: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, fontSize = 13.sp, modifier = Modifier.width(130.dp))
OutlinedTextField(
value = value,
onValueChange = {},
modifier = Modifier.weight(1f),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
)
}
}
@Composable
private fun DetailDropdown(placeholder: String, modifier: Modifier = Modifier) {
OutlinedTextField(
value = placeholder,
onValueChange = {},
modifier = modifier,
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 13.sp),
trailingIcon = {
Text("", fontSize = 10.sp, color = Color(0xFF6B7280))
},
)
}
// ── UI-Modell ─────────────────────────────────────────────────────────────────
data class BewerbUiModel(
val tag: String,
val platz: Int,
val nummer: Int,
val beginn: String,
val ende: String,
val name: String,
val bezeichnung: String,
val typ: String,
val zeile1: String,
val zns: Int,
val nennungen: Int,
)
private fun sampleBewerbe() = listOf(
BewerbUiModel(
"28.05.2023",
1,
1,
"08:00",
"08:00",
"Dressurreiterprüfung Reiterpass\n(Aufgabe R 1)\nPony Einsteiger Cup OO",
"Dressurreiterprüfung Reiterpass",
"Dressur",
"Pony Einsteiger Cup OO",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
2,
"08:20",
"08:20",
"Dressurreiterprüfung Reitenadel\n(Aufgabe R 4)\nPony Einsteiger Cup OO",
"Dressurreiterprüfung Reitenadel",
"Dressur",
"Pony Einsteiger Cup OO",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
3,
"08:40",
"08:40",
"Dressurreiterprüfung lsf. (Istzfrei)\n(Aufgabe LF 1)",
"Dressurreiterprüfung lsf.",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
4,
"09:00",
"09:00",
"Dressurreiterprüfung lsf. (Lizenzfrei)\n(Aufgabe LF 3)",
"Dressurreiterprüfung lsf.",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
5,
"09:20",
"09:20",
"Führzügelklasse\nOO Kids Cup",
"Führzügelklasse",
"Dressur",
"OO Kids Cup",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
6,
"09:40",
"09:40",
"First Ridden\nOO Kids Cup",
"First Ridden",
"Dressur",
"OO Kids Cup",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
7,
"10:00",
"10:00",
"Pony Dressurprüfung Kl. A (Aufgabe P 1)",
"Pony Dressurprüfung Kl. A",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
8,
"10:20",
"10:20",
"Dressurreiterprüfung Kl. A (Aufgabe DRA 1)",
"Dressurreiterprüfung Kl. A",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
9,
"10:40",
"10:40",
"Dressurreiterprüfung Kl. A (Aufgabe A 5)",
"Dressurreiterprüfung Kl. A",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
10,
"11:00",
"11:00",
"Pony Dressurprüfung Kl. A (Aufgabe P 9)",
"Pony Dressurprüfung Kl. A",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
11,
"11:20",
"11:20",
"Dressurreiterprüfung Kl. L (Aufgabe DRL 1)",
"Dressurreiterprüfung Kl. L",
"Dressur",
"",
0,
0
),
BewerbUiModel(
"28.05.2023",
1,
12,
"11:40",
"11:40",
"Dressurprüfung Kl. L (Aufgabe L 3)",
"Dressurprüfung Kl. L",
"Dressur",
"",
0,
0
),
)

View File

@ -0,0 +1,93 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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
/**
* 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
*
*/
@Composable
fun TurnierDetailScreen(
veranstaltungId: Long,
turnierId: Long,
onBack: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf(
"STAMMDATEN",
"ORGANISATION",
"BEWERBE",
"ARTIKEL",
"ABRECHNUNG",
"NENNUNGEN",
"STARTLISTEN",
"ERGEBNISLISTEN",
)
Column(modifier = Modifier.fillMaxSize()) {
// Horizontale Tab-Bar (direkt unter der TopBar)
PrimaryScrollableTabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
edgePadding = 0.dp,
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
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 -> StammdatenTabContent(turnierId = turnierId)
1 -> OrganisationTabContent()
2 -> BewerbeTabContent()
3 -> ArtikelTabContent()
4 -> AbrechnungTabContent()
5 -> NennungenTabContent()
6 -> StartlistenTabContent()
7 -> ErgebnislistenTabContent()
}
}
}
}
// Tab-Inhalte werden in dedizierten Dateien implementiert:
// TurnierBewerbeTab.kt → BewerbeTabContent()
// TurnierNennungenTab.kt → NennungenTabContent()
// TurnierStartlistenTab.kt → StartlistenTabContent()
// TurnierErgebnislistenTab.kt → ErgebnislistenTabContent()

View File

@ -0,0 +1,215 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val ElBlue = Color(0xFF1E3A8A)
private val ElHeaderBg = Color(0xFFF1F5F9)
/**
* ERGEBNISLISTEN-Tab gemäß Vision_03.
*
* Layout: 2-spaltig
* - Links (flex): Bewerbs-Tabs + Ergebnis-Tabelle (Platz | Startnr | Pferd | Reiter | Fehler | Zeit | Punkte)
* - Rechts (280dp): Platzierung & Geldpreis-Panel
*/
@Composable
fun ErgebnislistenTabContent() {
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
ErgebnislistenBewerbsTabs()
}
VerticalDivider()
// ── Rechte Spalte: Platzierung & Geldpreis ───────────────────────────
PlatzierungGeldpreisPanel(modifier = Modifier.width(280.dp).fillMaxHeight())
}
}
@Composable
private fun ErgebnislistenBewerbsTabs() {
val bewerbe = remember {
listOf("Bewerb 1", "Bewerb 2", "Bewerb 3", "Bewerb 4", "Bewerb 5")
}
var selectedBewerb by remember { mutableIntStateOf(0) }
PrimaryScrollableTabRow(
selectedTabIndex = selectedBewerb,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = ElBlue,
edgePadding = 0.dp,
) {
bewerbe.forEachIndexed { index, title ->
Tab(
selected = selectedBewerb == index,
onClick = { selectedBewerb = index },
text = { Text(title, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Bewerb ${selectedBewerb + 1} Ergebnisliste",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
)
Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Importieren", fontSize = 12.sp)
}
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Exportieren", fontSize = 12.sp)
}
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Drucken", fontSize = 12.sp)
}
}
// Tabellen-Header
Row(
modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(65.dp))
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Fehler", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
Text("Zeit", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
Text("Punkte", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
}
HorizontalDivider()
// Leere Liste
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Ergebnisse vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(8.dp))
Text("Ergebnisse werden nach dem Turnier eingetragen.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {}) {
Text("Ergebnisse importieren", fontSize = 13.sp)
}
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
) {
Text("Ergebnisse eingeben", fontSize = 13.sp)
}
}
}
}
}
@Composable
private fun PlatzierungGeldpreisPanel(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Platzierung & Geldpreis", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
HorizontalDivider()
// Anzahl Platzierte
Text("Platzierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Anzahl Platzierte:", fontSize = 12.sp, modifier = Modifier.width(140.dp))
OutlinedTextField(
value = "3",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Stechen ab Platz:", fontSize = 12.sp, modifier = Modifier.width(140.dp))
OutlinedTextField(
value = "",
onValueChange = {},
modifier = Modifier.width(60.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
HorizontalDivider()
// Geldpreise
Text("Geldpreise", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
Row(
modifier = Modifier.fillMaxWidth().background(ElHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text("Platz", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
Text("Betrag (€)", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
}
HorizontalDivider()
listOf(1 to "", 2 to "", 3 to "").forEach { (platz, betrag) ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"$platz.",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = ElBlue,
modifier = Modifier.width(50.dp)
)
OutlinedTextField(
value = betrag,
onValueChange = {},
modifier = Modifier.weight(1f).height(36.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
HorizontalDivider()
// Aktions-Buttons
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = ElBlue),
modifier = Modifier.fillMaxWidth(),
) {
Text("Platzierung berechnen", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text("Ergebnisliste drucken", fontSize = 12.sp)
}
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) {
Text("Geldpreise auszahlen", fontSize = 12.sp)
}
}
}
}

View File

@ -0,0 +1,251 @@
package at.mocode.turnier.feature.presentation
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.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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 NennBlue = Color(0xFF1E3A8A)
private val NennHeaderBg = Color(0xFFF1F5F9)
private val NennSelectedBg = Color(0xFFEFF6FF)
/**
* NENNUNGEN-Tab gemäß Vision_03.
*
* Layout: 2-spaltig
* - Links (flex): Pferd+Reiter-Suche + Nennungs-Tabelle
* - Rechts (360dp): Verkauf/Buchungen + Bewerbsübersicht
*/
@Composable
fun NennungenTabContent() {
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Suche + Tabelle ─────────────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
NennungenSuchePanel()
HorizontalDivider()
NennungenTabelle()
}
VerticalDivider()
// ── Rechte Spalte: Verkauf + Bewerbsübersicht ─────────────────────────
Column(
modifier = Modifier
.width(360.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
) {
VerkaufBuchungenPanel()
HorizontalDivider()
BewerbsuebersichtPanel()
}
}
}
@Composable
private fun NennungenSuchePanel() {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Pferd & Reiter suchen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = "",
onValueChange = {},
placeholder = { Text("Pferd suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) },
modifier = Modifier.weight(1f).height(44.dp),
singleLine = true,
)
OutlinedTextField(
value = "",
onValueChange = {},
placeholder = { Text("Reiter suchen (Name, OEPS-Nr.)…", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(16.dp)) },
modifier = Modifier.weight(1f).height(44.dp),
singleLine = true,
)
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = NennBlue),
modifier = Modifier.height(44.dp),
) {
Text("Suchen", fontSize = 12.sp)
}
}
}
}
@Composable
private fun NennungenTabelle() {
val nennungen = remember { sampleNennungen() }
var selectedIndex by remember { mutableIntStateOf(-1) }
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.background(NennHeaderBg)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Bewerb", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Status", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
}
HorizontalDivider()
if (nennungen.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Nennungen vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(8.dp))
Text(
"Suchen Sie nach Pferd und Reiter, um eine Nennung hinzuzufügen.",
fontSize = 12.sp,
color = Color(0xFF9CA3AF)
)
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
itemsIndexed(nennungen) { index, nennung ->
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (index == selectedIndex) NennSelectedBg else Color.Transparent)
.clickable { selectedIndex = index }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"${nennung.startnr}",
fontSize = 12.sp,
modifier = Modifier.width(60.dp),
color = NennBlue,
fontWeight = FontWeight.Bold
)
Text(nennung.pferd, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.reiter, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text(nennung.bewerb, fontSize = 12.sp, modifier = Modifier.weight(1f))
NennungStatusBadge(nennung.status)
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
}
}
}
@Composable
private fun NennungStatusBadge(status: String) {
val (bg, fg) = when (status) {
"Gemeldet" -> Color(0xFFDCFCE7) to Color(0xFF16A34A)
"Bezahlt" -> Color(0xFFDBEAFE) to NennBlue
"Abgemeldet" -> Color(0xFFFEE2E2) to Color(0xFFDC2626)
else -> Color(0xFFF3F4F6) to Color(0xFF6B7280)
}
Surface(shape = MaterialTheme.shapes.small, color = bg) {
Text(
text = status,
fontSize = 10.sp,
color = fg,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
@Composable
private fun VerkaufBuchungenPanel() {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Verkauf / Buchungen", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
// Artikel-Buchungen
Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Artikel-Buchungen", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = Color(0xFF374151))
Row(
modifier = Modifier.fillMaxWidth().background(NennHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text("Artikel", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Menge", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(50.dp))
Text("Preis", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(60.dp))
}
HorizontalDivider()
Text(
"Keine Buchungen",
fontSize = 12.sp,
color = Color(0xFF9CA3AF),
modifier = Modifier.padding(vertical = 8.dp)
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("+ Artikel buchen", fontSize = 11.sp)
}
}
}
}
}
}
@Composable
private fun BewerbsuebersichtPanel() {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Bewerbsübersicht", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
Card(elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth().background(NennHeaderBg).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Text("Bewerb", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Nennungen", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
}
HorizontalDivider()
listOf(
"Bewerb 1 Dressur Kl. A" to 0,
"Bewerb 2 Dressur Kl. L" to 0,
"Bewerb 3 Springen Kl. A" to 0,
).forEach { (name, count) ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(name, fontSize = 12.sp, modifier = Modifier.weight(1f))
Text("$count", fontSize = 12.sp, modifier = Modifier.width(80.dp), color = Color(0xFF6B7280))
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
}
}
}
// ── UI-Modell ─────────────────────────────────────────────────────────────────
private data class NennungUiModel(
val startnr: Int,
val pferd: String,
val reiter: String,
val bewerb: String,
val status: String,
)
private fun sampleNennungen(): List<NennungUiModel> = emptyList()

View File

@ -1,4 +1,4 @@
package at.mocode.desktop.screens
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
@ -7,6 +7,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
/**
* Formular zum Anlegen eines neuen Turniers (Vision_03: /veranstaltung/{id}/turnier/neu).

View File

@ -0,0 +1,336 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
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
import androidx.compose.ui.unit.sp
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
private val DeleteRed = Color(0xFFDC2626)
/**
* ORGANISATION-Tab im TurnierDetailScreen.
* Gemäß Figma Vision_03 (figma-entwurf_13 / figma-entwurf_14):
* - Funktionäre & Offizielle (C-Satz): Turnierleiter, Turnierbeauftragter, Technischer Delegierter, Parcourschef
* - Support-Team: Tierarzt, Schmied, Steward
* - Richterkollegium: dynamische Liste (Name, Qualifikation, Funktion, Löschen)
* - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen)
*/
@Composable
fun OrganisationTabContent() {
var turnierleiter by remember { mutableStateOf("") }
var turnierbeauftragter by remember { mutableStateOf("") }
var technischerDelegierter by remember { mutableStateOf("") }
var parcourschef by remember { mutableStateOf("") }
var tierarzt by remember { mutableStateOf("") }
var schmied by remember { mutableStateOf("") }
var steward by remember { mutableStateOf("") }
var richter by remember {
mutableStateOf(
listOf(
RichterUiModel("Alexandra Schuster", "D-GP", "Hauptrichter"),
RichterUiModel("Ulrike Knasmüller-Prinz", "D-M", "Beisitzer"),
),
)
}
var plaetze by remember {
mutableStateOf(
listOf(
AustragungsplatzUiModel("Dressur", "20 x 60 m", "Hauptplatz"),
AustragungsplatzUiModel("Dressur", "20 x 40 m", "Abreiteplatz 1"),
),
)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ── Funktionäre & Offizielle ─────────────────────────────────────────
OrgSectionCard(title = "Funktionäre & Offizielle (C-Satz)") {
OrgSubSection("Turnier-Organisation") {
OrgSearchField("Turnierleiter:", turnierleiter) { turnierleiter = it }
OrgSearchField("Turnierbeauftragter:", turnierbeauftragter) { turnierbeauftragter = it }
OrgSearchField("Technischer Delegierter:", technischerDelegierter) { technischerDelegierter = it }
OrgSearchField("Parcourschef:", parcourschef) { parcourschef = it }
}
OrgSubSection("Support-Team") {
OrgSearchField("Tierarzt:", tierarzt) { tierarzt = it }
OrgSearchField("Schmied:", schmied) { schmied = it }
OrgSearchField("Steward:", steward) { steward = it }
}
}
// ── Richterkollegium ─────────────────────────────────────────────────
OrgSectionCard(
title = "Richterkollegium",
action = {
TextButton(onClick = {
richter = richter + RichterUiModel("", "", "")
}) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue)
Spacer(Modifier.width(4.dp))
Text("Richter hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
},
) {
// Tabellen-Header
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
Text("Name", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
Text("Qualifikation", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
Text("Funktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
Text("Aktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(48.dp))
}
HorizontalDivider()
richter.forEachIndexed { idx, r ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = r.name,
onValueChange = { v -> richter = richter.toMutableList().also { it[idx] = r.copy(name = v) } },
modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp),
singleLine = true,
)
// Qualifikation-Dropdown
var qualExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) {
OutlinedTextField(
value = r.qualifikation,
onValueChange = {},
readOnly = true,
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
modifier = Modifier.fillMaxWidth().height(44.dp),
singleLine = true,
)
DropdownMenu(expanded = qualExpanded, onDismissRequest = { qualExpanded = false }) {
listOf("D-GP", "D-M", "D-L", "S-GP", "S-M").forEach { q ->
DropdownMenuItem(text = { Text(q) }, onClick = {
richter = richter.toMutableList().also { it[idx] = r.copy(qualifikation = q) }
qualExpanded = false
})
}
}
}
// Funktion-Dropdown
var funExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) {
OutlinedTextField(
value = r.funktion,
onValueChange = {},
readOnly = true,
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
modifier = Modifier.fillMaxWidth().height(44.dp),
singleLine = true,
)
DropdownMenu(expanded = funExpanded, onDismissRequest = { funExpanded = false }) {
listOf("Hauptrichter", "Beisitzer", "Schreiber").forEach { f ->
DropdownMenuItem(text = { Text(f) }, onClick = {
richter = richter.toMutableList().also { it[idx] = r.copy(funktion = f) }
funExpanded = false
})
}
}
}
IconButton(
onClick = { richter = richter.toMutableList().also { it.removeAt(idx) } },
modifier = Modifier.size(44.dp),
) {
Icon(
Icons.Default.Delete,
contentDescription = "Löschen",
tint = DeleteRed,
modifier = Modifier.size(18.dp)
)
}
}
}
}
// ── Austragungsplätze ────────────────────────────────────────────────
OrgSectionCard(
title = "Austragungsplätze",
) {
OrgSubSection(
title = "Plätze & Anlagen",
action = {
TextButton(onClick = {
plaetze = plaetze + AustragungsplatzUiModel("", "", "")
}) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp), tint = AccentBlue)
Spacer(Modifier.width(4.dp))
Text("Platz hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
},
) {
Row(modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp)) {
Text("Sparte", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
Text("Größe", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.5f))
Text("Bezeichnung", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(3f))
Text("Aktion", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(48.dp))
}
HorizontalDivider()
plaetze.forEachIndexed { idx, p ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
var sparteExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) {
OutlinedTextField(
value = p.sparte,
onValueChange = {},
readOnly = true,
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
modifier = Modifier.fillMaxWidth().height(44.dp),
singleLine = true,
)
DropdownMenu(expanded = sparteExpanded, onDismissRequest = { sparteExpanded = false }) {
listOf("Dressur", "Springen", "Vielseitigkeit").forEach { s ->
DropdownMenuItem(text = { Text(s) }, onClick = {
plaetze = plaetze.toMutableList().also { it[idx] = p.copy(sparte = s) }
sparteExpanded = false
})
}
}
}
var groesseExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.weight(1.5f).padding(end = 8.dp)) {
OutlinedTextField(
value = p.groesse,
onValueChange = {},
readOnly = true,
trailingIcon = { Icon(Icons.Default.ArrowDropDown, contentDescription = null) },
modifier = Modifier.fillMaxWidth().height(44.dp),
singleLine = true,
)
DropdownMenu(expanded = groesseExpanded, onDismissRequest = { groesseExpanded = false }) {
listOf("20 x 60 m", "20 x 40 m", "60 x 80 m").forEach { g ->
DropdownMenuItem(text = { Text(g) }, onClick = {
plaetze = plaetze.toMutableList().also { it[idx] = p.copy(groesse = g) }
groesseExpanded = false
})
}
}
}
OutlinedTextField(
value = p.bezeichnung,
onValueChange = { v -> plaetze = plaetze.toMutableList().also { it[idx] = p.copy(bezeichnung = v) } },
modifier = Modifier.weight(3f).height(44.dp).padding(end = 8.dp),
singleLine = true,
)
IconButton(
onClick = { plaetze = plaetze.toMutableList().also { it.removeAt(idx) } },
modifier = Modifier.size(44.dp),
) {
Icon(
Icons.Default.Delete,
contentDescription = "Löschen",
tint = DeleteRed,
modifier = Modifier.size(18.dp)
)
}
}
}
}
}
// ── Speichern ────────────────────────────────────────────────────────
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Speichern") }
}
}
}
@Composable
private fun OrgSectionCard(
title: String,
action: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
action?.invoke()
}
content()
}
}
}
@Composable
private fun OrgSubSection(
title: String,
action: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color(0xFFF9FAFB)),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
action?.invoke()
}
content()
}
}
}
@Composable
private fun OrgSearchField(label: String, value: String, onValueChange: (String) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, fontSize = 13.sp, modifier = Modifier.width(200.dp), color = Color(0xFF374151))
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = { Text("Name suchen...", fontSize = 12.sp) },
modifier = Modifier.weight(1f).height(44.dp),
singleLine = true,
)
}
}
// --- UI-Modelle ---
data class RichterUiModel(val name: String, val qualifikation: String, val funktion: String)
data class AustragungsplatzUiModel(val sparte: String, val groesse: String, val bezeichnung: String)

View File

@ -0,0 +1,261 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Usb
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)
/**
* STAMMDATEN-Tab im TurnierDetailScreen.
* Gemäß Figma Vision_03 (figma-entwurf_16 / figma-entwurf_15):
* - Turnier-Konfiguration: Nr., Typ (OTO/FEI), ZNS-Import, Sprache
* - Sparten-Checkboxen, Klassen, Kategorien, Datum
* - Turnier-Beschreibung: Titel, Sub-Titel
* - Sponsoren
*/
@Composable
fun StammdatenTabContent(turnierId: Long) {
var turnierNr by remember { mutableStateOf("") }
var typOto by remember { mutableStateOf(true) }
var spracheDe by remember { mutableStateOf(true) }
var sparteDressur by remember { mutableStateOf(false) }
var sparteSpringen by remember { mutableStateOf(false) }
var klasseC by remember { mutableStateOf(false) }
var klasseB by remember { mutableStateOf(false) }
var klasseA by remember { mutableStateOf(false) }
var datumVon by remember { mutableStateOf("") }
var datumBis by remember { mutableStateOf("") }
var titel by remember { mutableStateOf("") }
var subTitel by remember { mutableStateOf("") }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// ── Turnier-Konfiguration ────────────────────────────────────────────
SectionCard(title = "Turnier-Konfiguration") {
FormRow("Turnier-Nr.:") {
OutlinedTextField(
value = turnierNr,
onValueChange = { turnierNr = it },
placeholder = { Text("z.B. 26128", fontSize = 13.sp) },
modifier = Modifier.width(200.dp).height(48.dp),
singleLine = true,
)
}
FormRow("Typ:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = typOto, onClick = { typOto = true })
Text("OTO (National)", fontSize = 13.sp)
RadioButton(selected = !typOto, onClick = { typOto = false })
Text("FEI (International)", fontSize = 13.sp)
}
}
FormRow("ZNS-Daten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue),
) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Import via Internet", fontSize = 13.sp)
}
OutlinedButton(onClick = {}) {
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Import via USB", fontSize = 13.sp)
}
}
Text(
"Reiter-, Pferde-, Funktionärs- und Vereinsdaten vom OEPS Backend",
fontSize = 11.sp,
color = Color(0xFF6B7280),
)
}
FormRow("Sprache:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = spracheDe, onClick = { spracheDe = true })
Text("Deutsch", fontSize = 13.sp)
RadioButton(selected = !spracheDe, onClick = { spracheDe = false })
Text("English", fontSize = 13.sp)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
FormRow("Sparten:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = sparteDressur, onCheckedChange = { sparteDressur = it })
Text("Dressur", fontSize = 13.sp)
Checkbox(checked = sparteSpringen, onCheckedChange = { sparteSpringen = it })
Text("Springen", fontSize = 13.sp)
}
}
FormRow("Klassen:") {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = klasseC, onCheckedChange = { klasseC = it })
Text("C", fontSize = 13.sp)
Checkbox(checked = klasseB, onCheckedChange = { klasseB = it })
Text("B", fontSize = 13.sp)
Checkbox(checked = klasseA, onCheckedChange = { klasseA = it })
Text("A", fontSize = 13.sp)
}
}
FormRow("Kategorien:") {
Surface(
modifier = Modifier.fillMaxWidth().height(60.dp),
color = Color(0xFFF3F4F6),
shape = MaterialTheme.shapes.small,
) {
Box(contentAlignment = Alignment.Center) {
Text(
"Bitte Sparte(n) auswählen",
fontSize = 13.sp,
color = Color(0xFF9CA3AF),
)
}
}
}
FormRow("Datum:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = datumVon,
onValueChange = { datumVon = it },
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
modifier = Modifier.width(160.dp).height(48.dp),
singleLine = true,
)
Text("bis", fontSize = 13.sp)
OutlinedTextField(
value = datumBis,
onValueChange = { datumBis = it },
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
modifier = Modifier.width(160.dp).height(48.dp),
singleLine = true,
)
}
}
}
// ── Turnier-Beschreibung ─────────────────────────────────────────────
SectionCard(title = "Turnier-Beschreibung") {
OutlinedTextField(
value = titel,
onValueChange = { titel = it },
placeholder = { Text("z.B. Frühjahrs-Turnier 2026", fontSize = 13.sp) },
label = { Text("Titel") },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
)
OutlinedTextField(
value = subTitel,
onValueChange = { subTitel = it },
placeholder = { Text("z.B. KIDS CUP • PONY EINSTEIGER CUP OÖ", fontSize = 13.sp) },
label = { Text("Sub-Titel") },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
)
}
// ── Sponsoren ────────────────────────────────────────────────────────
SectionCard(
title = "Sponsoren",
action = {
TextButton(onClick = {}) {
Text("+ Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
},
) {
Surface(
modifier = Modifier.fillMaxWidth().height(80.dp),
color = Color(0xFFF9FAFB),
shape = MaterialTheme.shapes.small,
) {
Box(contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Noch keine Sponsoren hinzugefügt", fontSize = 13.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(4.dp))
TextButton(onClick = {}) {
Text("+ Ersten Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
}
}
}
}
}
// ── Aktions-Buttons ──────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {}) { Text("Zurücksetzen") }
Spacer(Modifier.width(8.dp))
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Speichern") }
}
}
}
@Composable
private fun SectionCard(
title: String,
action: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
action?.invoke()
}
content()
}
}
}
@Composable
private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
Text(
text = label,
fontSize = 13.sp,
modifier = Modifier.width(140.dp).padding(top = 12.dp),
color = Color(0xFF374151),
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
content()
}
}
}

View File

@ -0,0 +1,182 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.background
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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val SlBlue = Color(0xFF1E3A8A)
private val SlHeaderBg = Color(0xFFF1F5F9)
/**
* STARTLISTEN-Tab gemäß Vision_03.
*
* Layout: 2-spaltig
* - Links (flex): Bewerbs-Tabs + Starter-Tabelle (Startnr | Pferd | Reiter | Abteilung | Beginn)
* - Rechts (280dp): Sortierung & Zeit-Panel
*/
@Composable
fun StartlistenTabContent() {
Row(modifier = Modifier.fillMaxSize()) {
// ── Linke Spalte: Bewerbs-Tabs + Tabelle ─────────────────────────────
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
StartlistenBewerbsTabs()
}
VerticalDivider()
// ── Rechte Spalte: Sortierung & Zeit ─────────────────────────────────
StartlistenSortierPanel(modifier = Modifier.width(280.dp).fillMaxHeight())
}
}
@Composable
private fun StartlistenBewerbsTabs() {
val bewerbe = remember {
listOf("Bewerb 1", "Bewerb 2", "Bewerb 3", "Bewerb 4", "Bewerb 5")
}
var selectedBewerb by remember { mutableIntStateOf(0) }
PrimaryScrollableTabRow(
selectedTabIndex = selectedBewerb,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = SlBlue,
edgePadding = 0.dp,
) {
bewerbe.forEachIndexed { index, title ->
Tab(
selected = selectedBewerb == index,
onClick = { selectedBewerb = index },
text = { Text(title, fontSize = 12.sp) },
)
}
}
HorizontalDivider()
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Bewerb ${selectedBewerb + 1} Startliste",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
)
Spacer(Modifier.weight(1f))
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Drucken", fontSize = 12.sp)
}
OutlinedButton(
onClick = {},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 10.dp)
) {
Text("Exportieren", fontSize = 12.sp)
}
}
// Tabellen-Header
Row(
modifier = Modifier.fillMaxWidth().background(SlHeaderBg).padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text("Startnr.", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
Text("Pferd", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Reiter", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
Text("Abteilung", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(80.dp))
Text("Beginn", fontSize = 11.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(70.dp))
}
HorizontalDivider()
// Leere Liste
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine Starter vorhanden", fontSize = 14.sp, color = Color(0xFF6B7280))
Spacer(Modifier.height(8.dp))
Text("Startliste wird nach Nennungsschluss generiert.", fontSize = 12.sp, color = Color(0xFF9CA3AF))
Spacer(Modifier.height(16.dp))
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
) {
Text("Startliste generieren", fontSize = 13.sp)
}
}
}
}
@Composable
private fun StartlistenSortierPanel(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Sortierung & Zeit", fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
HorizontalDivider()
// Sortierung
Text("Sortierung", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
SortierOption("Aufsteigend (Startnummer)")
SortierOption("Absteigend (Startnummer)")
SortierOption("Auslosung (zufällig)")
SortierOption("Alphabetisch (Pferd)")
SortierOption("Alphabetisch (Reiter)")
}
HorizontalDivider()
// Zeiten
Text("Zeiten", fontSize = 12.sp, color = Color(0xFF374151), fontWeight = FontWeight.Medium)
LabeledInput("Beginnzeit:", "08:00", "(hh:mm)")
LabeledInput("Reitdauer:", "02:00", "(mm:ss)")
LabeledInput("Umbau:", "10", "(mm)")
LabeledInput("Besichtigung:", "10", "(mm)")
HorizontalDivider()
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = SlBlue),
modifier = Modifier.fillMaxWidth(),
) {
Text("Zeiten neu berechnen", fontSize = 12.sp)
}
}
}
@Composable
private fun SortierOption(label: String) {
var selected by remember { mutableStateOf(false) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
RadioButton(selected = selected, onClick = { selected = !selected })
Text(label, fontSize = 12.sp)
}
}
@Composable
private fun LabeledInput(label: String, value: String, unit: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, fontSize = 12.sp, modifier = Modifier.width(100.dp))
OutlinedTextField(
value = value,
onValueChange = {},
modifier = Modifier.width(70.dp),
singleLine = true,
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
)
Spacer(Modifier.width(6.dp))
Text(unit, fontSize = 11.sp, color = Color(0xFF6B7280))
}
}

View File

@ -0,0 +1,31 @@
/**
* Feature-Modul: Veranstalter-Verwaltung (Desktop-only)
* Kapselt alle Screens und Logik für Veranstalter-Auswahl, -Detail und -Neuanlage.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
}

View File

@ -0,0 +1,259 @@
package at.mocode.veranstalter.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Info
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
import at.mocode.frontend.core.designsystem.models.LoginStatus
import at.mocode.frontend.core.designsystem.models.LoginStatusBadge
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
/**
* Screen: "Admin - Verwaltung / Veranstalter auswählen"
*
* Gemäß Figma Vision_03 (figma-entwurf_20 / figma-entwurf_22):
* - Titel + Untertitel
* - Suchfeld + "+ Neuer Veranstalter"-Button
* - Tabelle: Vereinsname, OEPS-Nummer, Ort, Ansprechpartner, E-Mail, Login-Status
* - Hinweis-Box
* - Abbrechen / "Weiter zum Veranstalter"-Buttons (unten)
*
* TODO: Echte Daten aus customer-context laden (Phase 4/5).
*/
@Composable
fun VeranstalterAuswahlScreen(
onZurueck: () -> Unit,
onWeiter: (Long) -> Unit,
onNeuerVeranstalter: () -> Unit = {},
) {
var selectedId by remember { mutableStateOf<Long?>(null) }
var suchtext by remember { mutableStateOf("") }
// Placeholder-Daten gemäß Figma
val veranstalter = remember {
listOf(
VeranstalterUiModel(
id = 1L,
name = "Reit- und Fahrverein Wels",
oepsNummer = "V-OOE-1234",
ort = "4600 Wels",
ansprechpartner = "Maria Huber",
email = "office@rfv-wels.at",
loginStatus = LoginStatus.AKTIV,
),
VeranstalterUiModel(
id = 2L,
name = "Pferdesportverein Linz",
oepsNummer = "V-OOE-5678",
ort = "4020 Linz",
ansprechpartner = "Thomas Maier",
email = "kontakt@psv-linz.at",
loginStatus = LoginStatus.AKTIV,
),
VeranstalterUiModel(
id = 3L,
name = "Reitclub Eferding",
oepsNummer = "V-OOE-9012",
ort = "4070 Eferding",
ansprechpartner = "Anna Schmid",
email = "info@rc-eferding.at",
loginStatus = LoginStatus.AUSSTEHEND,
),
)
}
val gefiltert = veranstalter.filter {
suchtext.isBlank() ||
it.name.contains(suchtext, ignoreCase = true) ||
it.oepsNummer.contains(suchtext, ignoreCase = true) ||
it.ort.contains(suchtext, ignoreCase = true)
}
Column(modifier = Modifier.fillMaxSize()) {
// ── Titel ────────────────────────────────────────────────────────────
Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) {
Text(
text = "Veranstalter für neue Veranstaltung auswählen",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "Wählen Sie einen bestehenden Veranstalter aus oder legen Sie einen neuen Veranstalter an.",
fontSize = 13.sp,
color = Color(0xFF6B7280),
)
}
// ── Suchfeld + Neuer Veranstalter ────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
placeholder = { Text("Veranstalter suchen (Name, OEPS-Nummer, Ort)...", fontSize = 13.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
modifier = Modifier.weight(1f).height(48.dp),
singleLine = true,
)
Button(
onClick = onNeuerVeranstalter,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neuer Veranstalter")
}
}
// ── Tabellen-Header ──────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF3F4F6))
.padding(horizontal = 24.dp, vertical = 8.dp),
) {
Spacer(Modifier.width(28.dp)) // Checkmark-Spalte
Text("Vereinsname", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(2.5f))
Text("OEPS-Nummer", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f))
Text("Ort", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f))
Text("Ansprechpartner", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1.5f))
Text("E-Mail", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(2f))
Text("Login", fontWeight = FontWeight.SemiBold, fontSize = 12.sp, modifier = Modifier.weight(1f))
}
HorizontalDivider()
// ── Tabellen-Inhalt ──────────────────────────────────────────────────
LazyColumn(modifier = Modifier.weight(1f)) {
items(gefiltert) { v ->
val isSelected = v.id == selectedId
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (isSelected) AccentBlue.copy(alpha = 0.08f)
else Color.Transparent,
)
.clickable { selectedId = v.id }
.padding(horizontal = 24.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Auswahl-Checkmark
Box(modifier = Modifier.width(28.dp)) {
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = AccentBlue,
modifier = Modifier.size(18.dp),
)
}
}
Text(
text = v.name,
fontSize = 13.sp,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
color = AccentBlue,
modifier = Modifier.weight(2.5f),
)
Text(v.oepsNummer, fontSize = 13.sp, modifier = Modifier.weight(1.5f))
Text(v.ort, fontSize = 13.sp, modifier = Modifier.weight(1.5f))
Text(v.ansprechpartner, fontSize = 13.sp, modifier = Modifier.weight(1.5f))
Text(v.email, fontSize = 13.sp, modifier = Modifier.weight(2f))
// Login-Status-Badge
Box(modifier = Modifier.weight(1f)) {
LoginStatusBadge(v.loginStatus)
}
}
HorizontalDivider(color = Color(0xFFE5E7EB))
}
}
// ── Hinweis-Box ──────────────────────────────────────────────────────
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
color = Color(0xFFEFF6FF),
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFBFDBFE)),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = AccentBlue,
modifier = Modifier.size(16.dp).padding(top = 2.dp),
)
Text(
text = "Veranstalter sind Vereine, die beim österreichischen Pferdesportverband (OEPS) registriert sind. " +
"Beim Anlegen eines neuen Veranstalters werden automatisch Login-Daten generiert und per E-Mail verschickt. " +
"Der Veranstalter kann dann sein Profil (Logo, Kontaktdaten, etc.) selbst verwalten.",
fontSize = 12.sp,
color = Color(0xFF1E40AF),
)
}
}
HorizontalDivider()
// ── Aktions-Buttons ──────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onZurueck) {
Text("Abbrechen")
}
Spacer(Modifier.width(12.dp))
Button(
onClick = { selectedId?.let { onWeiter(it) } },
enabled = selectedId != null,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) {
Text("Weiter zum Veranstalter")
}
}
}
}
// --- UI-Modelle ---
data class VeranstalterUiModel(
val id: Long,
val name: String,
val oepsNummer: String,
val ort: String,
val ansprechpartner: String,
val email: String,
val loginStatus: LoginStatus,
)

View File

@ -0,0 +1,379 @@
package at.mocode.veranstalter.feature.presentation
import androidx.compose.foundation.BorderStroke
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.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.models.LoginStatus
import at.mocode.frontend.core.designsystem.models.LoginStatusBadge
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
private val PrimaryBlue = Color(0xFF1E3A8A)
private val AccentBlue = Color(0xFF3B82F6)
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_18 / figma-entwurf_19):
* - Veranstalter-Header: Avatar-Circle, Name, OEPS-Nr., Kontaktdetails-Grid, Login-Status, Mitglied-seit
* - Aktionsleiste: "+ Neue Veranstaltung", Suchfeld, Status-Filter-Chips
* - Veranstaltungs-Liste: Status-Badge, Datum, Ort, Turniere, Nennungen, Bewerbe, Letzte Aktivität, Bearbeiten-Icon
*
* 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,
) {
var suchtext by remember { mutableStateOf("") }
var statusFilter by remember { mutableStateOf(VeranstaltungStatusFilter.ALLE) }
// Placeholder-Daten gemäß Figma
val veranstalter = remember(veranstalterId) {
VeranstalterDetailUiModel(
id = veranstalterId,
name = "Reit- und Fahrverein Wels",
oepsNummer = "V-OOE-1234",
ansprechpartner = "Maria Huber",
email = "office@rfv-wels.at",
telefon = "+43 7242 12345",
adresse = "Reitweg 15\n4600 Wels",
loginStatus = LoginStatus.AKTIV,
mitgliedSeit = "15.1.2023",
)
}
val veranstaltungen = remember(veranstalterId) {
listOf(
VeranstaltungListUiModel(
id = 1L,
name = "Union Reit- und Fahrverein Neumarkt Frühjahrsturnier 2026",
datum = "25.-26. April 2026",
ort = "Reitanlage Stroblmair, Neumarkt/M., OO",
turnierAnzahl = 2,
nennungen = 87,
bewerbe = 26,
letzteAktivitaet = "22.03.2026 14:30",
status = VeranstaltungStatus.VORBEREITUNG,
),
VeranstaltungListUiModel(
id = 2L,
name = "AWÖ-Cup Stadl-Paura 2025",
datum = "15.-17. Mai 2025",
ort = "Bundesgestüt Piber, Stadl-Paura",
turnierAnzahl = 2,
nennungen = 142,
bewerbe = 33,
letzteAktivitaet = "17.05.2025 18:45",
status = VeranstaltungStatus.ABGESCHLOSSEN,
),
VeranstaltungListUiModel(
id = 3L,
name = "Linzer Pferdetage 2026",
datum = "12.-14. Juni 2026",
ort = "Reitsportzentrum Linz-Ebelsberg",
turnierAnzahl = 2,
nennungen = 23,
bewerbe = 30,
letzteAktivitaet = "20.03.2026 09:15",
status = VeranstaltungStatus.VORBEREITUNG,
),
)
}
val gefiltert = veranstaltungen.filter { v ->
val matchesStatus = when (statusFilter) {
VeranstaltungStatusFilter.ALLE -> true
VeranstaltungStatusFilter.VORBEREITUNG -> v.status == VeranstaltungStatus.VORBEREITUNG
VeranstaltungStatusFilter.LIVE -> v.status == VeranstaltungStatus.LIVE
VeranstaltungStatusFilter.ABGESCHLOSSEN -> v.status == VeranstaltungStatus.ABGESCHLOSSEN
}
val matchesSuche = suchtext.isBlank() ||
v.name.contains(suchtext, ignoreCase = true) ||
v.ort.contains(suchtext, ignoreCase = true)
matchesStatus && matchesSuche
}
Column(modifier = Modifier.fillMaxSize()) {
// ── Veranstalter-Header-Card ─────────────────────────────────────────
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White,
border = BorderStroke(1.dp, Color(0xFFE2E8F0)),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top,
) {
// Avatar-Circle
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(PrimaryBlue),
contentAlignment = Alignment.Center,
) {
Text(
text = veranstalter.name.first().uppercase(),
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
}
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = veranstalter.name,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "OEPS-Nummer: ${veranstalter.oepsNummer}",
fontSize = 12.sp,
color = Color(0xFF6B7280),
)
Spacer(Modifier.height(6.dp))
// Kontaktdetails-Grid
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
KontaktSpalte("Ansprechpartner", veranstalter.ansprechpartner)
KontaktSpalte("E-Mail", veranstalter.email)
KontaktSpalte("Telefon", veranstalter.telefon)
KontaktSpalte("Adresse", veranstalter.adresse)
Column {
Text("Login-Status", fontSize = 11.sp, color = Color(0xFF9CA3AF))
Spacer(Modifier.height(2.dp))
LoginStatusBadge(veranstalter.loginStatus)
}
KontaktSpalte("Mitglied seit", veranstalter.mitgliedSeit)
}
}
}
// Profil bearbeiten
OutlinedButton(
onClick = { /* TODO */ },
border = BorderStroke(1.dp, Color(0xFFD1D5DB)),
) {
Icon(Icons.Default.Settings, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(4.dp))
Text("Profil bearbeiten", fontSize = 13.sp)
}
}
}
// ── Aktionsleiste ────────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
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")
}
OutlinedTextField(
value = suchtext,
onValueChange = { suchtext = it },
placeholder = { Text("Suche nach Name, Ort oder Turnier-Nr.", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
modifier = Modifier.weight(1f).height(44.dp),
singleLine = true,
)
// Status-Filter-Chips
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
VeranstaltungStatusFilter.entries.forEach { filter ->
val isActive = statusFilter == filter
FilterChip(
selected = isActive,
onClick = { statusFilter = filter },
label = {
Text(
text = when (filter) {
VeranstaltungStatusFilter.ALLE -> "Alle"
VeranstaltungStatusFilter.VORBEREITUNG -> "Vorbereitung"
VeranstaltungStatusFilter.LIVE -> "Live"
VeranstaltungStatusFilter.ABGESCHLOSSEN -> "Abgeschlossen"
},
fontSize = 12.sp,
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = PrimaryBlue,
selectedLabelColor = Color.White,
),
)
}
}
}
// ── Veranstaltungs-Liste ─────────────────────────────────────────────
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(gefiltert) { veranstaltung ->
VeranstaltungListRow(
veranstaltung = veranstaltung,
onOeffnen = { onVeranstaltungOeffnen(veranstaltung.id) },
)
}
}
}
}
@Composable
private fun KontaktSpalte(label: String, wert: String) {
Column {
Text(label, fontSize = 11.sp, color = Color(0xFF9CA3AF))
Text(wert, fontSize = 12.sp, color = Color(0xFF374151))
}
}
@Composable
private fun VeranstaltungListRow(
veranstaltung: VeranstaltungListUiModel,
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"
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White,
border = BorderStroke(1.dp, Color(0xFFE5E7EB)),
shape = MaterialTheme.shapes.small,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Status-Badge
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = statusColor.copy(alpha = 0.15f),
modifier = Modifier.width(100.dp),
) {
Text(
text = statusText,
color = statusColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
Spacer(Modifier.width(12.dp))
// Name + Meta
Column(modifier = Modifier.weight(1f)) {
Text(
text = veranstaltung.name,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
Spacer(Modifier.height(3.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("📅 ${veranstaltung.datum}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("📍 ${veranstaltung.ort}", fontSize = 12.sp, color = Color(0xFF6B7280))
Text("🏆 ${veranstaltung.turnierAnzahl} Turniere", fontSize = 12.sp, color = Color(0xFF6B7280))
}
}
// Statistiken
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
StatSpalte("Nennungen", "${veranstaltung.nennungen}")
StatSpalte("Bewerbe", "${veranstaltung.bewerbe}")
StatSpalte("Letzte Aktivität", veranstaltung.letzteAktivitaet)
}
Spacer(Modifier.width(12.dp))
// Bearbeiten-Icon
IconButton(onClick = onOeffnen) {
Icon(
Icons.Default.Edit,
contentDescription = "Öffnen",
tint = Color(0xFF9CA3AF),
modifier = Modifier.size(18.dp),
)
}
}
}
}
@Composable
private fun StatSpalte(label: String, wert: String) {
Column(horizontalAlignment = Alignment.End) {
Text(label, fontSize = 10.sp, color = Color(0xFF9CA3AF))
Text(wert, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
// --- UI-Modelle ---
enum class VeranstaltungStatusFilter { ALLE, VORBEREITUNG, LIVE, ABGESCHLOSSEN }
data class VeranstalterDetailUiModel(
val id: Long,
val name: String,
val oepsNummer: String,
val ansprechpartner: String,
val email: String,
val telefon: String,
val adresse: String,
val loginStatus: LoginStatus,
val mitgliedSeit: String,
)
data class VeranstaltungListUiModel(
val id: Long,
val name: String,
val datum: String,
val ort: String,
val turnierAnzahl: Int,
val nennungen: Int,
val bewerbe: Int,
val letzteAktivitaet: String,
val status: VeranstaltungStatus,
)

View File

@ -0,0 +1,231 @@
package at.mocode.veranstalter.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
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 eines neuen Veranstalters (Vision_03: Screenshot 21).
*
* Layout:
* - Info-Banner: "Login-Daten werden automatisch verschickt"
* - Abschnitt "Vereinsdaten": Vereinsname*, OEPS-Nummer*
* - Abschnitt "Kontaktdaten": Ansprechpartner*, E-Mail*, Telefon
* - Abschnitt "Adresse": Straße & Hausnummer, PLZ + Ort
* - Footer-Buttons: Abbrechen | Veranstalter anlegen & Login-Daten senden
*/
@Composable
fun VeranstalterNeuScreen(
onAbbrechen: () -> Unit,
onSpeichern: (vereinsname: String, oepsNummer: String, email: String) -> Unit,
) {
var vereinsname by remember { mutableStateOf("") }
var oepsNummer by remember { mutableStateOf("") }
var ansprechpartner by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var telefon by remember { mutableStateOf("") }
var strasse by remember { mutableStateOf("") }
var plz by remember { mutableStateOf("") }
var ort by remember { mutableStateOf("") }
val isValid = vereinsname.isNotBlank() && oepsNummer.isNotBlank() &&
ansprechpartner.isNotBlank() && email.isNotBlank()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
// Header
Column(modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp)) {
Text(
text = "Neuen Veranstalter anlegen",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Legen Sie einen neuen Veranstalter (Verein) mit OEPS-Daten an. Nach dem Speichern werden automatisch Login-Daten generiert.",
fontSize = 13.sp,
color = Color(0xFF6B7280),
)
}
// Info-Banner
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
color = Color(0xFFEFF6FF),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = Color(0xFF2563EB),
modifier = Modifier.size(20.dp),
)
Column {
Text(
text = "Login-Daten werden automatisch verschickt",
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
color = Color(0xFF1E40AF),
)
Spacer(Modifier.height(2.dp))
Text(
text = "Nach dem Anlegen werden Login-Daten generiert und an die angegebene E-Mail-Adresse verschickt. Der Veranstalter kann dann sein Profil selbst vervollständigen.",
fontSize = 12.sp,
color = Color(0xFF1E40AF),
)
}
}
}
Spacer(Modifier.height(24.dp))
// 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)) {
// --- Vereinsdaten ---
Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField(
value = vereinsname,
onValueChange = { vereinsname = it },
label = { Text("Vereinsname *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Column {
OutlinedTextField(
value = oepsNummer,
onValueChange = { oepsNummer = it },
label = { Text("OEPS-Nummer *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
text = "Offizielle Vereinsnummer des OEPS",
fontSize = 11.sp,
color = Color(0xFF2563EB),
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
)
}
HorizontalDivider()
// --- Kontaktdaten ---
Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField(
value = ansprechpartner,
onValueChange = { ansprechpartner = it },
label = { Text("Ansprechpartner *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Column {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
text = "Login-Daten werden an diese Adresse verschickt",
fontSize = 11.sp,
color = Color(0xFF6B7280),
modifier = Modifier.padding(start = 4.dp, top = 2.dp),
)
}
OutlinedTextField(
value = telefon,
onValueChange = { telefon = it },
label = { Text("Telefon") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
HorizontalDivider()
// --- Adresse ---
Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
OutlinedTextField(
value = strasse,
onValueChange = { strasse = it },
label = { Text("Straße & Hausnummer") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = plz,
onValueChange = { plz = it },
label = { Text("PLZ") },
modifier = Modifier.width(120.dp),
singleLine = true,
)
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text("Ort") },
modifier = Modifier.weight(1f),
singleLine = true,
)
}
}
}
Spacer(Modifier.height(24.dp))
// Footer-Buttons
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = onAbbrechen) {
Text("Abbrechen")
}
Spacer(Modifier.width(12.dp))
Button(
onClick = { onSpeichern(vereinsname, oepsNummer, email) },
enabled = isValid,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
) {
Text("Veranstalter anlegen & Login-Daten senden")
}
}
Spacer(Modifier.height(24.dp))
}
}

View File

@ -0,0 +1,31 @@
/**
* Feature-Modul: Veranstaltungs-Verwaltung (Desktop-only)
* Kapselt alle Screens und Logik für Veranstaltungs-Übersicht, -Detail und -Neuanlage.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
}
}
}

View File

@ -1,4 +1,4 @@
package at.mocode.desktop.screens
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
@ -15,6 +15,7 @@ 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.designsystem.models.VeranstaltungStatus
// Status-Farben gemäß Vision_03
private val StatusVorbereitung = Color(0xFFEA580C) // Orange
@ -35,6 +36,7 @@ private val StatusAbgeschlossen = Color(0xFF6B7280) // Grau
fun AdminUebersichtScreen(
onVeranstalterAuswahl: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
onPingService: () -> Unit = {},
) {
// Placeholder-Daten für die UI-Struktur
val veranstaltungen = listOf<VeranstaltungUiModel>() // leer bis Backend angebunden
@ -73,6 +75,10 @@ fun AdminUebersichtScreen(
singleLine = true,
)
OutlinedButton(onClick = onPingService) {
Text("🔧 Ping")
}
// Status-Filter Chips
StatusFilterChip("Alle", selected = true)
StatusFilterChip("Vorbereitung", selected = false)
@ -345,4 +351,3 @@ data class TurnierUiModel(
val bewerbAnzahl: Int,
)
enum class VeranstaltungStatus { VORBEREITUNG, LIVE, ABGESCHLOSSEN }

View File

@ -1,4 +1,4 @@
package at.mocode.desktop.screens
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
/**
* Detailansicht einer bestehenden Veranstaltung (Vision_03: /veranstaltung/{id}).

View File

@ -1,4 +1,4 @@
package at.mocode.desktop.screens
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
@ -7,6 +7,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
/**
* Formular zum Anlegen einer neuen Veranstaltung (Vision_03: /veranstaltung/neu).

View File

@ -0,0 +1,370 @@
package at.mocode.veranstaltung.feature.presentation
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.material.icons.filled.Usb
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
import at.mocode.frontend.core.designsystem.models.VeranstaltungStatus
private val PrimaryBlue = Color(0xFF1E3A8A)
private val ImportOrange = Color(0xFFEA580C)
private val ExportBlue = Color(0xFF2563EB)
private val UsbColor = Color(0xFF7C3AED)
/**
* 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 = 26128L,
name = "Dressurturnier",
sparte = "Dressur",
bewerbAnzahl = 8,
nennungen = 24,
status = VeranstaltungStatus.VORBEREITUNG,
datum = "15.04.2026",
),
TurnierKarteUiModel(
id = 2L,
nummer = 26129L,
name = "Springturnier",
sparte = "Springen",
bewerbAnzahl = 6,
nennungen = 18,
status = VeranstaltungStatus.VORBEREITUNG,
datum = "15.04.2026",
),
TurnierKarteUiModel(
id = 3L,
nummer = 26130L,
name = "Vielseitigkeitsturnier",
sparte = "Vielseitigkeit",
bewerbAnzahl = 4,
nennungen = 5,
status = VeranstaltungStatus.VORBEREITUNG,
datum = "16.04.2026",
),
)
}
Column(modifier = Modifier.fillMaxSize()) {
// Tab-Header gemäß Figma
TabRow(
selectedTabIndex = 0,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
modifier = Modifier.fillMaxWidth(),
) {
Tab(
selected = true,
onClick = {},
text = { Text("VERANSTALTUNG - ÜBERSICHT", fontSize = 13.sp, fontWeight = FontWeight.Bold) },
)
}
HorizontalDivider()
// 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) {
VeranstaltungStatus.VORBEREITUNG -> Color(0xFFEA580C)
VeranstaltungStatus.LIVE -> Color(0xFF16A34A)
VeranstaltungStatus.ABGESCHLOSSEN -> Color(0xFF6B7280)
}
val statusText = when (turnier.status) {
VeranstaltungStatus.VORBEREITUNG -> "Vorbereitung"
VeranstaltungStatus.LIVE -> "Live"
VeranstaltungStatus.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 = "${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)
}
// USB-Import
OutlinedButton(
onClick = onZns,
modifier = Modifier.height(36.dp),
) {
Icon(
Icons.Default.Usb,
contentDescription = null,
modifier = Modifier.size(15.dp),
)
Spacer(Modifier.width(4.dp))
Text("USB", fontSize = 13.sp)
}
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: VeranstaltungStatus,
val datum: String,
)

View File

@ -1,4 +1,4 @@
package at.mocode.desktop.screens
package at.mocode.veranstaltung.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
/**
* Veranstaltungs-Übersicht (Drawer-Einstieg gemäß Vision_03).

View File

@ -1,9 +1,10 @@
/**
* Feature-Modul: ZNS-Stammdaten-Import (Desktop-only)
* Kapselt ViewModel, State und API-Kommunikation für den ZNS-Import.
* Kapselt ViewModel, State, API-Kommunikation und UI-Screen für den ZNS-Import.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
group = "at.mocode.clients"
@ -13,8 +14,17 @@ kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.auth)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.bundles.kmp.common)
implementation(libs.koin.core)
implementation(libs.ktor.client.core)

View File

@ -1,4 +1,4 @@
package at.mocode.desktop.screens
package at.mocode.zns.feature.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*

View File

@ -10,6 +10,7 @@ plugins {
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.kotlinSerialization)
id("org.jetbrains.compose.hot-reload")
}
kotlin {
@ -30,6 +31,9 @@ kotlin {
implementation(projects.frontend.features.nennungFeature)
implementation(projects.frontend.features.pingFeature)
implementation(projects.frontend.features.znsImportFeature)
implementation(projects.frontend.features.veranstalterFeature)
implementation(projects.frontend.features.veranstaltungFeature)
implementation(projects.frontend.features.turnierFeature)
// Compose Desktop
implementation(compose.desktop.currentOs)
@ -39,6 +43,7 @@ kotlin {
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(compose.uiTooling)
implementation(libs.composeHotReloadApi)
// DI (Koin)
implementation(libs.koin.core)
@ -59,6 +64,7 @@ kotlin {
}
}
compose.desktop {
application {
mainClass = "at.mocode.desktop.MainKt"

View File

@ -9,7 +9,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import at.mocode.desktop.navigation.DesktopNavigationPort
import at.mocode.desktop.screens.DesktopMainLayout
import at.mocode.desktop.screens.layout.DesktopMainLayout
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel

View File

@ -0,0 +1,50 @@
package at.mocode.desktop
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.window.singleWindowApplication
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
/**
* Hot-Reload Preview Entry Point
*
* Starten mit:
* ./gradlew :frontend:shells:meldestelle-desktop:hotRunJvm
*
* Einfach den gewünschten Screen in `PreviewContent()` eintragen,
* Gradle-Task starten Änderungen werden live ohne Neustart übernommen.
* Bei singleWindowApplication ist kein DevelopmentEntryPoint-Wrapper nötig.
*/
fun main() = singleWindowApplication(title = "🔥 Hot-Reload Preview") {
PreviewContent()
}
@Preview
@Composable
private fun PreviewContent() {
MaterialTheme {
Surface {
// ── Hier den gewünschten Screen eintragen ──────────────────────
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
// VeranstalterNeuScreen(onBack = {}, onSave = {})
// VeranstalterDetailScreen(veranstalterId = 1L, onBack = {}, onVeranstaltungSelected = {})
// VeranstaltungUebersichtScreen(veranstalterId = 1L, onBack = {}, onTurnierSelected = {})
// TurnierDetailScreen(turnierId = 1L, onBack = {})
// ──────────────────────────────────────────────────────────────
// Standard: AdminUebersichtScreen (Startseite nach Login)
AdminUebersichtScreen(
onVeranstalterAuswahl = {},
onVeranstaltungOeffnen = {},
onPingService = {}
)
}
}
}

View File

@ -1,265 +0,0 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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
/**
* 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,
) {
var selectedTab by remember { mutableIntStateOf(0) }
val tabs = listOf(
"STAMMDATEN",
"ORGANISATION",
"BEWERBE",
"ARTIKEL",
"ABRECHNUNG",
"NENNUNGEN",
"STARTLISTEN",
"ERGEBNISLISTEN",
)
Column(modifier = Modifier.fillMaxSize()) {
// Horizontale Tab-Bar (direkt unter der TopBar)
ScrollableTabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = Color(0xFF1E3A8A),
edgePadding = 0.dp,
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
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 -> 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

@ -1,172 +0,0 @@
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

@ -1,213 +0,0 @@
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

@ -1,349 +0,0 @@
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 }

View File

@ -1,4 +1,4 @@
package at.mocode.desktop.screens
package at.mocode.desktop.screens.layout
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -15,6 +15,19 @@ 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.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.turnier.feature.presentation.TurnierNeuScreen
import at.mocode.zns.feature.presentation.StammdatenImportScreen
import at.mocode.ping.feature.presentation.PingScreen
import org.koin.compose.koinInject
import at.mocode.ping.feature.presentation.PingViewModel
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
private val TopBarColor = Color(0xFF1E3A8A)
@ -55,9 +68,9 @@ fun DesktopMainLayout(
* TopBar: dunkelblauer Balken mit Breadcrumb-Navigation und Logout-Button.
*
* Breadcrumb-Logik:
* - Root: "🏠 Admin - Verwaltung"
* - Root: "🏠 Admin - Verwaltung"
* - Veranstaltung: "🏠 Admin - Verwaltung / Veranstaltung #<id>"
* - Turnier: "🏠 Admin - Verwaltung / Veranstaltung #<id> / Turnier <tid>"
* - Turnier: "🏠 Admin - Verwaltung / Veranstaltung #<id> / Turnier <tid>"
*/
@Composable
private fun DesktopTopBar(
@ -107,6 +120,23 @@ private fun DesktopTopBar(
fontSize = 14.sp,
)
}
is AppScreen.VeranstalterNeu -> {
BreadcrumbSeparator()
Text(
text = "Veranstalter auswählen",
color = TopBarTextColor.copy(alpha = 0.75f),
fontSize = 14.sp,
modifier = Modifier.clickable { onNavigate(AppScreen.VeranstalterAuswahl) },
)
BreadcrumbSeparator()
Text(
text = "Neuer Veranstalter",
color = TopBarTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
)
}
is AppScreen.VeranstalterDetail -> {
BreadcrumbSeparator()
Text(
@ -199,6 +229,15 @@ private fun DesktopTopBar(
fontSize = 14.sp,
)
}
is AppScreen.Ping -> {
BreadcrumbSeparator()
Text(
text = "Ping Service",
color = TopBarTextColor,
fontSize = 14.sp,
)
}
else -> {}
}
}
@ -236,12 +275,19 @@ private fun DesktopContentArea(
is AppScreen.Veranstaltungen -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) },
onPingService = { onNavigate(AppScreen.Ping) },
)
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlScreen(
onZurueck = { onNavigate(AppScreen.Veranstaltungen) },
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) },
)
is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen(
onAbbrechen = { onNavigate(AppScreen.VeranstalterAuswahl) },
onSpeichern = { _, _, _ -> onNavigate(AppScreen.VeranstalterAuswahl) },
)
is AppScreen.VeranstalterDetail -> VeranstalterDetailScreen(
veranstalterId = currentScreen.veranstalterId,
@ -288,6 +334,15 @@ private fun DesktopContentArea(
onSave = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
)
// Ping-Screen
is AppScreen.Ping -> {
val pingViewModel: PingViewModel = koinInject()
PingScreen(
viewModel = pingViewModel,
onBack = { onNavigate(AppScreen.Veranstaltungen) },
)
}
// Fallback → Root
else -> AdminUebersichtScreen(
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },

View File

@ -0,0 +1,175 @@
package at.mocode.desktop.screens.preview
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.turnier.feature.presentation.StammdatenTabContent
import at.mocode.turnier.feature.presentation.OrganisationTabContent
import at.mocode.turnier.feature.presentation.BewerbeTabContent
import at.mocode.turnier.feature.presentation.ArtikelTabContent
import at.mocode.turnier.feature.presentation.AbrechnungTabContent
import at.mocode.turnier.feature.presentation.NennungenTabContent
import at.mocode.turnier.feature.presentation.StartlistenTabContent
import at.mocode.turnier.feature.presentation.ErgebnislistenTabContent
// ─────────────────────────────────────────────────────────────────────────────
// Compose Desktop Previews alle wichtigen Screens auf einen Blick
//
// Verwendung: In IntelliJ IDEA / Android Studio die @Preview-Funktion öffnen
// und auf das Preview-Icon in der Gutter klicken.
// ─────────────────────────────────────────────────────────────────────────────
// ── Veranstalter-Auswahl ─────────────────────────────────────────────────────
@Preview
@Composable
fun PreviewVeranstalterAuswahlScreen() {
MaterialTheme {
VeranstalterAuswahlScreen(
onZurueck = {},
onWeiter = {},
onNeuerVeranstalter = {},
)
}
}
// ── Neuer Veranstalter ───────────────────────────────────────────────────────
@Preview
@Composable
fun PreviewVeranstalterNeuScreen() {
MaterialTheme {
VeranstalterNeuScreen(
onAbbrechen = {},
onSpeichern = { _, _, _ -> },
)
}
}
// ── Veranstalter-Detail ──────────────────────────────────────────────────────
@Preview
@Composable
fun PreviewVeranstalterDetailScreen() {
MaterialTheme {
VeranstalterDetailScreen(
veranstalterId = 1L,
onZurueck = {},
onVeranstaltungOeffnen = {},
onVeranstaltungNeu = {},
)
}
}
// ── Veranstaltung-Übersicht ──────────────────────────────────────────────────
@Preview
@Composable
fun PreviewVeranstaltungUebersichtScreen() {
MaterialTheme {
VeranstaltungUebersichtScreen(
veranstalterId = 1L,
veranstaltungId = 1L,
onZurueck = {},
onTurnierOeffnen = {},
onTurnierNeu = {},
onZnsImport = {},
onDbImport = {},
onDbExport = {},
)
}
}
// ── Turnier-Detail (alle Tabs) ───────────────────────────────────────────────
@Preview
@Composable
fun PreviewTurnierDetailScreen() {
MaterialTheme {
TurnierDetailScreen(
veranstaltungId = 1L,
turnierId = 1L,
onBack = {},
)
}
}
// ── Turnier-Tabs einzeln ─────────────────────────────────────────────────────
@Preview
@Composable
fun PreviewTurnierStammdatenTab() {
MaterialTheme {
StammdatenTabContent(turnierId = 1L)
}
}
@Preview
@Composable
fun PreviewTurnierOrganisationTab() {
MaterialTheme {
OrganisationTabContent()
}
}
@Preview
@Composable
fun PreviewTurnierBewerbeTab() {
MaterialTheme {
BewerbeTabContent()
}
}
@Preview
@Composable
fun PreviewTurnierArtikelTab() {
MaterialTheme {
ArtikelTabContent()
}
}
@Preview
@Composable
fun PreviewTurnierAbrechnungTab() {
MaterialTheme {
AbrechnungTabContent()
}
}
@Preview
@Composable
fun PreviewTurnierNennungenTab() {
MaterialTheme {
NennungenTabContent()
}
}
@Preview
@Composable
fun PreviewTurnierStartlistenTab() {
MaterialTheme {
StartlistenTabContent()
}
}
@Preview
@Composable
fun PreviewTurnierErgebnislistenTab() {
MaterialTheme {
ErgebnislistenTabContent()
}
}
// ── Stammdaten-Import ────────────────────────────────────────────────────────
// StammdatenImportScreen benötigt einen ZnsImportViewModel (Koin) Preview nur als Hinweis.
// @Preview
// @Composable
// fun PreviewStammdatenImportScreen() {
// MaterialTheme { StammdatenImportScreen() }
// }

View File

@ -50,7 +50,7 @@ springDependencyManagement = "1.1.7"
springdoc = "3.0.0"
# Server Persistence
# Final release 1.0.0 (UUID API refinements vs rc-4)
# Final release 1.0.0 (UUID API refinements vs. rc-4)
exposed = "1.0.0"
postgresql = "42.7.8"
hikari = "7.0.2"
@ -126,6 +126,7 @@ kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
ui-desktop = { module = "androidx.compose.ui:ui-desktop", version.ref = "uiDesktop" }
composeHotReloadApi = { module = "org.jetbrains.compose.hot-reload:hot-reload-runtime-api", version.ref = "composeHotReload" }
# ==============================================================================
# === FRONTEND: NETWORK (KTOR CLIENT) ===

View File

@ -135,6 +135,9 @@ include(":frontend:core:sync")
include(":frontend:features:ping-feature")
include(":frontend:features:nennung-feature")
include(":frontend:features:zns-import-feature")
include(":frontend:features:veranstalter-feature")
include(":frontend:features:veranstaltung-feature")
include(":frontend:features:turnier-feature")
// --- SHELLS ---
include(":frontend:shells:meldestelle-desktop")