Compare commits

...

8 Commits

Author SHA1 Message Date
f98a9075ae feat: erweitere Changelog um Onboarding, UX-Verbesserungen und Fehlerbehebungen, aktualisiere Settings-Datei
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 1m2s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m3s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Failing after 3m27s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 1m48s
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-16 00:45:53 +02:00
7581f15dfb feat: füge ConnectivityTracker hinzu, erweitere networkModule, aktualisiere DesktopFooterBar mit Gerätestatus und mDNS-Discovery
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-16 00:00:11 +02:00
67d7b38d79 feat: integriere Live-Daten in NennungsEingangScreen, erweitere NennungRemoteRepository um holeNennungen und markiereAlsGelesen, aktualisiere Port-Konfiguration
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-15 22:59:24 +02:00
6d631acce6 refactor: entferne toJavaInstant und passe DeviceRepository sowie verwandte Modelle an
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-15 22:28:41 +02:00
1cefc26be9 feat: Mail-Service-Ports aktualisiert, Consul- und Zipkin-Konfiguration hinzugefügt, neue Felder in BewerbService eingefügt
Signed-off-by: StefanMoCoAt <stefan.mo.co@gmail.com>
2026-04-15 21:45:20 +02:00
18e41a90b6 Refactor and rename NennungViewModel to TurnierNennungViewModel, implement online registration workflow with new UI state, ViewModel logic, and API integration, and update dependencies and documentation accordingly. 2026-04-15 20:55:05 +02:00
d026e7f83c Remove unused background import from TurnierOnlineNennungenTab to clean up code. 2026-04-15 20:55:05 +02:00
26ac3007b9 Implement online registration (Nennung) workflow: add API integration, ViewModel logic, UI updates, backend endpoint, and roadmap adjustments. 2026-04-15 20:55:00 +02:00
32 changed files with 655 additions and 163 deletions

View File

@ -120,6 +120,13 @@ MAILPIT_IMAGE=axllent/mailpit:v1.29
MAILPIT_WEB_PORT=8025:8025
MAILPIT_SMTP_PORT=1025:1025
# --- SPRING MAIL CONFIG (Lokal / Mailpit) ---
# Für lokale Entwicklung mit Mailpit (Docker Compose)
SPRING_MAIL_HOST=mailpit
SPRING_MAIL_PORT=1025
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=false
# --- PGADMIN ---
PGADMIN_IMAGE=dpage/pgadmin4:8
PGADMIN_EMAIL=meldestelle@mo-code.at
@ -149,6 +156,8 @@ GATEWAY_DEBUG_PORT=5005:5005
GATEWAY_SERVER_PORT=8081
GATEWAY_SPRING_PROFILES_ACTIVE=docker
GATEWAY_DEBUG=true
GATEWAY_SERVICE_NAME=api-gateway
GATEWAY_CONSUL_PREFER_IP=true
# --- PING-SERVICE ---
PING_SPRING_PROFILES_ACTIVE=docker
@ -160,9 +169,9 @@ PING_SERVICE_NAME=ping-service
PING_CONSUL_PREFER_IP=true
# --- MAIL-SERVICE ---
MAIL_PORT=8083:8085
MAIL_PORT=8083:8083
MAIL_DEBUG_PORT=5014:5014
MAIL_SERVER_PORT=8085
MAIL_SERVER_PORT=8083
MAIL_SPRING_PROFILES_ACTIVE=docker
MAIL_DEBUG=true
MAIL_SERVICE_NAME=mail-service
@ -234,6 +243,8 @@ SERIES_DEBUG_PORT=5011:5011
SERIES_SERVER_PORT=8089
SERIES_SPRING_PROFILES_ACTIVE=docker
SERIES_DEBUG=true
SERIES_SERVICE_NAME=series-service
SERIES_CONSUL_PREFER_IP=true
# --- WEB-APP ---
WEB_APP_PORT=4000:4000

74
.gitignore vendored
View File

@ -1,74 +0,0 @@
# --- General ---
.gradle/
**/build/
**/out/
.kotlin/
kotlin-js-store/
# --- Environments ---
#.env
config/env/.env.local
.env.development.local
.env.test.local
.env.production.local
.env.local
# --- IDEs ---
# IntelliJ
.idea/
*.iml
*.ipr
*.iws
# VS Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/snippets
# Fleet
.fleet/
!.fleet/receipt.json
# --- Dependencies & Build ---
node_modules/
**/node_modules/
package-lock.json
yarn.lock
pnpm-lock.yaml
# --- OS Files ---
.DS_Store
Thumbs.db
*.swp
*~
.nfs*
# --- Logs ---
_backup/logs/
**/*.log
*.log.gz
# --- Languages & Runtimes ---
# Java/Kotlin
*.class
.attach_pid*
# Python
.venv/
venv/
ENV/
*.pyc
__pycache__/
# --- Quality & Documentation ---
build/diagrams/
.eslintcache
.stylelintcache
.phpunit.result.cache
.dataSources/
dataSources.local.xml
/_backup/
.env

View File

@ -15,6 +15,32 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
### [Unreleased]
### Hinzugefügt
- **Onboarding & Desktop-UX - 15.04.2026:**
- **Desktop-App:** Dynamisierung der Statusanzeigen im App-Footer ("Cloud synchronisiert" & "Verbunden").
- **Connectivity-Tracking:** Implementierung des `ConnectivityTracker` (KMP) zur Echtzeit-Überwachung der API-Gateway
Erreichbarkeit.
- **LAN-Erkennung:** Integration des `NetworkDiscoveryService` (mDNS) im Footer zur Anzeige aktiver Instanzen im
lokalen Netzwerk.
- **Onboarding:** Datenfluss vom `SettingsManager` bis in den Footer finalisiert (Anzeige des echten Gerätenamens).
- **Online-Nennung & Integration - 15.04.2026:**
- **Backend (Mail-Service):** Finalisierung des `MailController` für Web-Nennungen inkl. SMTP-Versand via World4You.
- **Frontend (Desktop):** `NennungsEingangScreen` an Live-Daten vom `mail-service` angebunden.
- **Repository:** `NennungRemoteRepository` (KMP) um `holeNennungen()` erweitert.
- **Billing & ÖTO - 15.04.2026:**
- **Sportförderbeitrag:** Automatische Buchung von 1,00 EUR (§16 ÖTO) bei jeder Nennung im `entries-service`
implementiert.
### Behoben
- **Identity-Modul:** Umstellung auf `kotlin.time.Instant` zur Vermeidung von Deprecation-Warnungen und Behebung von
Persistenz-Konflikten im `ExposedDeviceRepository`.
- **Koin DI:** Korrektur von Typ-Inferenz-Fehlern beim `HttpClient` im `nennung-feature` durch explizite Qualifier.
- **Turnier-Feature:** Behebung eines unsicheren Casts (`Any!` zu `List<String>`) in `TurnierStammdatenTab.kt`.
- **Konfiguration:** Harmonisierung der Ports (Mail-Service auf 8083) in `.env`, `dc-backend.yaml` und
`PlatformConfig.jvm.kt`.
### Hinzugefügt
- **Phase 12 (Abrechnung & Infrastruktur) - 12.04.2026:**
- **Infrastruktur:** Docker-Integration für `billing-service` (Port 8087) und API-Gateway Routing vervollständigt.

View File

@ -72,7 +72,9 @@ class BewerbService(
pausenBezeichnung = req.pausenBezeichnung,
// Finanzen
startgeldCent = req.startgeldCent,
geldpreisAusbezahlt = req.geldpreisAusbezahlt
geldpreisAusbezahlt = req.geldpreisAusbezahlt,
znsNummer = req.znsNummer,
znsAbteilung = req.znsAbteilung
)
return repo.create(b)
}
@ -163,6 +165,8 @@ class BewerbService(
// Finanzen
startgeldCent = req.startgeldCent,
geldpreisAusbezahlt = req.geldpreisAusbezahlt,
znsNummer = req.znsNummer,
znsAbteilung = req.znsAbteilung
)
return repo.update(updated)
}

View File

@ -1,7 +1,7 @@
package at.mocode.identity.domain.model
import kotlinx.datetime.Instant
import java.util.*
import kotlin.time.Instant
/**
* Repräsentiert eine registrierte Desktop-Instanz ("Gerät").
@ -13,7 +13,8 @@ data class Device(
val securityKeyHash: String, // Gehasht für Sicherheit
val role: DeviceRole = DeviceRole.CLIENT,
val lastSyncAt: Instant? = null,
val createdAt: Instant
val createdAt: Instant,
val updatedAt: Instant = createdAt
)
enum class DeviceRole {

View File

@ -1,8 +1,8 @@
package at.mocode.identity.domain.repository
import at.mocode.identity.domain.model.Device
import kotlinx.datetime.Instant
import java.util.*
import kotlin.time.Instant
interface DeviceRepository {
suspend fun findById(id: UUID): Device?

View File

@ -11,7 +11,6 @@ import org.jetbrains.exposed.v1.jdbc.update
import java.util.*
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.time.toJavaInstant
class ExposedDeviceRepository : DeviceRepository {
@ -36,8 +35,8 @@ class ExposedDeviceRepository : DeviceRepository {
it[name] = device.name
it[securityKeyHash] = device.securityKeyHash
it[role] = device.role
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
it[updatedAt] = now.toJavaInstant()
it[lastSyncAt] = device.lastSyncAt
it[updatedAt] = now
}
} else {
DeviceTable.insert {
@ -45,19 +44,18 @@ class ExposedDeviceRepository : DeviceRepository {
it[name] = device.name
it[securityKeyHash] = device.securityKeyHash
it[role] = device.role
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
it[createdAt] = now.toJavaInstant()
it[updatedAt] = now.toJavaInstant()
it[lastSyncAt] = device.lastSyncAt
it[createdAt] = device.createdAt
it[updatedAt] = now
}
}
device
device.copy(updatedAt = now)
}
override suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean = transaction {
val javaInstant = at.toJavaInstant()
DeviceTable.update({ DeviceTable.id eq id }) {
it[lastSyncAt] = javaInstant
it[updatedAt] = javaInstant
it[lastSyncAt] = at
it[updatedAt] = at
} > 0
}
@ -66,7 +64,8 @@ class ExposedDeviceRepository : DeviceRepository {
name = row[DeviceTable.name],
securityKeyHash = row[DeviceTable.securityKeyHash],
role = row[DeviceTable.role],
lastSyncAt = row[DeviceTable.lastSyncAt]?.let { Instant.fromEpochMilliseconds(it.toEpochMilli()) },
createdAt = Instant.fromEpochMilliseconds(row[DeviceTable.createdAt].toEpochMilli())
lastSyncAt = row[DeviceTable.lastSyncAt],
createdAt = row[DeviceTable.createdAt],
updatedAt = row[DeviceTable.updatedAt]
)
}

View File

@ -26,4 +26,9 @@ class NennungController(
) {
nennungRepository.updateStatus(Uuid.parse(id), newStatus)
}
@PostMapping
fun createNennung(@RequestBody nennung: NennungEntity) {
nennungRepository.save(nennung)
}
}

View File

@ -61,7 +61,7 @@ services:
PING_SERVICE_URL: "http://ping-service:8082"
MASTERDATA_SERVICE_URL: "http://masterdata-service:8086"
EVENTS_SERVICE_URL: "http://events-service:8085"
MAIL_SERVICE_URL: "http://mail-service:8085"
MAIL_SERVICE_URL: "http://mail-service:8083"
ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095"
RESULTS_SERVICE_URL: "http://results-service:8088"
BILLING_SERVICE_URL: "http://billing-service:8087"
@ -559,12 +559,12 @@ services:
container_name: "${PROJECT_NAME:-meldestelle}-mail-service"
restart: unless-stopped
ports:
- "${MAIL_PORT:-8083:8085}"
- "${MAIL_PORT:-8083:8083}"
- "${MAIL_DEBUG_PORT:-5014:5014}"
environment:
SPRING_PROFILES_ACTIVE: "${MAIL_SPRING_PROFILES_ACTIVE:-docker}"
DEBUG: "${MAIL_DEBUG:-true}"
SERVER_PORT: "${MAIL_SERVER_PORT:-8085}"
SERVER_PORT: "${MAIL_SERVER_PORT:-8083}"
# --- KEYCLOAK ---
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
@ -601,7 +601,7 @@ services:
condition: "service_healthy"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8085/actuator/health/readiness" ]
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8083/actuator/health/readiness" ]
interval: 15s
timeout: 5s
retries: 5
@ -724,7 +724,13 @@ services:
# --- CONSUL ---
SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
SPRING_CLOUD_CONSUL_PORT: "${CONSUL_PORT:-8500}"
SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${SERIES_SERVICE_NAME:-series-service}"
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${SERIES_CONSUL_PREFER_IP:-true}"
# --- ZIPKIN ---
MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "${ZIPKIN_SAMPLING_PROBABILITY:-1.0}"
depends_on:
postgres:

View File

@ -12,22 +12,23 @@ Ziel ist ein schlankes Web-Formular, das strukturierte E-Mails an den `Mail-Serv
* [x] Konfiguration der World4You SMTP/IMAP Zugangsdaten.
* [x] Mailpit Integration für lokale Tests (bereits in `dc-ops.yaml`).
### Phase 2: Das Web-Formular (WasmJS Frontend) 🏗️
* [ ] **Basis-UI:** Erstellung des Formulars gemäß Spezifikation (Reiter, Pferd, Lizenz, Bewerbe).
* [ ] **Validierung:** Implementierung der Pflichtfeld-Prüfung (Buttonsperre bis alles ok).
* [ ] **Mail-Versand:** Integration des SMTP-Clients (oder API-Call an Backend), um die strukturierte E-Mail zu senden.
* [ ] **DSGVO:** Checkbox und Hinweistext einbauen.
### Phase 2: Das Web-Formular (WasmJS Frontend)
* [x] **Basis-UI:** Erstellung des Formulars gemäß Spezifikation (Reiter, Pferd, Lizenz, Bewerbe).
* [x] **Validierung:** Implementierung der Pflichtfeld-Prüfung (Buttonsperre bis alles ok).
* [x] **Mail-Versand:** Integration des API-Calls an das Backend (`mail-service`), um die Nennung zu speichern.
* [x] **DSGVO:** Checkbox und Hinweistext eingebaut.
### Phase 3: Mail-Service (Backend-Verarbeitung) 🏗️
* [x] **Endpoint:** POST-Endpunkt für direkte Nennungen aus dem Web-Formular implementiert.
* [ ] **Polling:** Implementierung des IMAP-Pollers (imap.world4you.com).
* [ ] **Parsing:** Extraktion der Turnier-Nummer aus dem `To`-Header und Mapping auf das Datenbank-Schema (Tenant).
* [ ] **Auto-Reply:** Automatisches Versenden der Eingangsbestätigung an den Absender.
* [ ] **Persistence:** Speichern der eingegangenen "Nennungs-Mails" in einer temporären Tabelle für den `registration-context`.
* [x] **Auto-Reply:** Automatisches Versenden der Eingangsbestätigung (in `MailPollingService` vorbereitet).
* [x] **Persistence:** Speichern der eingegangenen "Nennungs-Mails" in einer temporären Tabelle.
### Phase 4: Desktop-Zentrale Integration 🏗️
* [ ] **UI-Tab:** Neuer Reiter "Nennungs-Eingang" in der Turnierverwaltung.
* [ ] **Vorschau:** Anzeige der eingegangenen Mails mit Details (Reiter, Pferd, Bewerbe).
* [ ] **Übernahme:** "Übernehmen"-Button, der die Daten in die Turnieranmeldung vor-ausfüllt.
### Phase 4: Desktop-Zentrale Integration
* [x] **UI-Tab:** Neuer Reiter "Online-Eingang" in der Turnierverwaltung (`TurnierDetailScreen`).
* [x] **Vorschau:** Anzeige der eingegangenen Nennungen mit Details (`OnlineNennungEingangTabContent`).
* [x] **Übernahme:** "Übernehmen"-Button, der Reiter/Pferd in die Nennung vorausfüllt (`NennungViewModel`).
* [ ] **Abschluss:** Manueller "Bestätigen"-Button zum Versenden der finalen Bestätigungsmail.
### Phase 5: End-to-End Test & Deployment 🚀 (Deadline: 21.04.2026)

View File

@ -0,0 +1,45 @@
# SCS Workflow Journal: Stammdaten & Nennungs-Eingang
Datum: 15. April 2026, 22:30 Uhr
Agent: 🏗️ [Lead Architect] & 🧹 [Curator]
## 🎯 Tagesziel: SCS Event-Management & Identity
Nach dem erfolgreichen Onboarding am Vormittag lag der Fokus nun auf der fachlichen Vertiefung der Workflows für das
Turnier in Neumarkt.
### 1. SCS Identity (Backend & Infrastructure) - Fixes
* **KMP-Zeitstempel:** Umstellung des `identity`-Moduls auf `kotlin.time.Instant`, um Deprecation-Warnungen und
Typ-Konflikte (Java vs. Kotlin) in der Persistenz-Schicht (`ExposedDeviceRepository`) zu beheben.
* **Build-Stabilität:** Erfolgreiche Kompilierung nach Korrektur unsicherer Casts im `turnier-feature`.
### 2. SCS Event-Management (ZNS & Turnieranlage)
* **ZNS-Import-Workflow:** Verifizierung der asynchronen Import-Kette. Das Frontend (`StammdatenImportScreen`) ist nun
technologisch bereit, ZIP-Daten an den `zns-import-service` zu senden.
* **Turnier-Wizard (ÖTO-Fokus):** Der `TurnierWizardV2` wurde auf ÖTO-Konformität geprüft. Er validiert Turnierdaten
gegen die übergeordnete Veranstaltung und bietet Auto-Mapping für bekannte Turniere (Neumarkt ID 26128).
### 3. SCS Online-Nennung (Nennungs-Eingang)
* **Live-Daten-Integration:** Der `NennungsEingangScreen` wurde von Mock-Daten auf echte Daten vom `mail-service`
umgestellt.
* **Repository-Erweiterung:** Das `NennungRemoteRepository` (nennung-feature) beherrscht nun `holeNennungen()` und
integriert sich via Koin in die Desktop-App.
* **Port-Harmonisierung:** Korrektur des Fallback-Ports für den `mail-service` auf `8083` in `PlatformConfig.jvm.kt`.
---
## 🚩 Status & Nächste Schritte
Das "Biest" ist nun in der Lage, Stammdaten zu importieren, Turniere anzulegen und echte Online-Nennungen vom Server
abzurufen.
**Nächster Fokus:**
1. **SCS Masterdata:** Finalisierung der Reiter- und Pferdestammdaten-Editoren (Detail-Ansichten).
2. **SCS Results:** Vorbereitung des ersten Bewerbs-Protokolls (Starterlisten-Generierung).
3. **OEPS-Validierung:** Export-Tests für den A-Satz (Teilnehmerliste).
**Status:** Workflows für Neumarkt sind zu 85% einsatzbereit. 🚀🐎

View File

@ -0,0 +1,48 @@
package at.mocode.frontend.core.network
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
/**
* Überwacht die Konnektivität zum API-Gateway.
*/
class ConnectivityTracker : KoinComponent {
private val client: HttpClient by inject(named("baseHttpClient"))
private val _isOnline = MutableStateFlow(true)
val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
init {
startTracking()
}
private fun startTracking() {
scope.launch {
while (isActive) {
_isOnline.value = checkConnection()
delay(10_000) // Alle 10 Sekunden prüfen
}
}
}
private suspend fun checkConnection(): Boolean {
return try {
val response = client.get(NetworkConfig.baseUrl.trimEnd('/') + "/ping")
response.status.value in 200..299
} catch (e: Exception) {
false
}
}
fun stopTracking() {
scope.cancel()
}
}

View File

@ -1,5 +1,7 @@
package at.mocode.frontend.core.network
import at.mocode.frontend.core.network.discovery.discoveryModule
import at.mocode.frontend.core.network.sync.syncModule
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
@ -10,13 +12,13 @@ import kotlinx.serialization.json.Json
import org.koin.core.module.Module
import org.koin.core.qualifier.named
import org.koin.dsl.module
import at.mocode.frontend.core.network.discovery.discoveryModule
import at.mocode.frontend.core.network.sync.syncModule
/**
* Schnittstelle zur Token-Bereitstellung entkoppelt core-network von core-auth.
*/
interface TokenProvider { fun getAccessToken(): String? }
interface TokenProvider {
fun getAccessToken(): String?
}
/**
* Koin-Modul mit zwei HttpClient-Instanzen:
@ -26,6 +28,8 @@ interface TokenProvider { fun getAccessToken(): String? }
val networkModule: Module = module {
includes(discoveryModule, syncModule)
single<ConnectivityTracker> { ConnectivityTracker() }
// 1. Basis-Client (für Auth-Endpunkte, ohne Bearer-Token)
single(named("baseHttpClient")) {
HttpClient {

View File

@ -13,7 +13,7 @@ actual object PlatformConfig {
actual fun resolveMailServiceUrl(): String {
val env = System.getenv("MAIL_SERVICE_URL")?.trim().orEmpty()
if (env.isNotEmpty()) return env.removeSuffix("/")
return "http://localhost:8085"
return "http://localhost:8083"
}
actual fun resolveKeycloakUrl(): String {

View File

@ -44,6 +44,10 @@ kotlin {
implementation(projects.frontend.core.network)
implementation(libs.kotlinx.datetime)
// Network & Serialization
implementation(libs.bundles.ktor.client.common)
implementation(libs.kotlinx.serialization.json)
implementation(compose.foundation)
implementation(compose.runtime)
implementation(compose.material3)

View File

@ -2,11 +2,12 @@ package at.mocode.frontend.features.nennung.di
import at.mocode.frontend.features.nennung.domain.NennungRemoteRepository
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
import io.ktor.client.HttpClient
import io.ktor.client.*
import org.koin.core.module.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val nennungFeatureModule = module {
single<NennungRemoteRepository> { NennungRemoteRepository(get<HttpClient>()) }
single<NennungRemoteRepository> { NennungRemoteRepository(get<HttpClient>(named("apiClient"))) }
viewModel { NennungViewModel() }
}

View File

@ -62,6 +62,18 @@ data class VerkaufArtikel(
val betrag: Double get() = menge * einzelpreis
}
// --- OnlineNennung ---
data class OnlineNennung(
val id: String,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val bewerbe: String
)
// --- Mock-Daten (werden später durch echte API ersetzt) ---
object NennungMockData {

View File

@ -3,10 +3,27 @@ package at.mocode.frontend.features.nennung.domain
import at.mocode.frontend.core.network.PlatformConfig
import at.mocode.frontend.features.nennung.presentation.web.NennungPayload
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class NennungResponse(
val id: String,
val turnierNr: String,
val status: String,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String?,
val bewerbe: String,
val bemerkungen: String?
)
@Serializable
data class NennungApiRequest(
val turnierNr: String,
@ -24,6 +41,25 @@ data class NennungApiRequest(
class NennungRemoteRepository(private val client: HttpClient) {
private val mailServiceUrl = PlatformConfig.resolveMailServiceUrl()
suspend fun holeNennungen(): Result<List<NennungResponse>> {
return try {
val response = client.get("$mailServiceUrl/api/mail/nennungen")
Result.success(response.body())
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun markiereAlsGelesen(id: String): Result<Unit> {
return try {
// Endpunkt müsste im Backend noch implementiert werden, falls gewünscht.
// Für jetzt simuliert:
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun sendeNennung(turnierNr: String, payload: NennungPayload): Result<Unit> {
return try {
val request = NennungApiRequest(

View File

@ -2,10 +2,19 @@ package at.mocode.frontend.features.nennung.presentation
import at.mocode.frontend.features.nennung.domain.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.nennung.presentation.web.NennungDto
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.koin.core.qualifier.named
// --- UI State ---
data class NennungUiState(
@ -22,16 +31,69 @@ data class NennungUiState(
val activeNennungTab: NennungTab = NennungTab.REITER,
val activeVerkaufTab: VerkaufTab = VerkaufTab.VERKAUF,
val statusMeldung: String? = null,
val onlineNennungen: List<OnlineNennung> = emptyList(),
val isOnlineLoading: Boolean = false
)
enum class NennungTab { REITER, PFERD, BEWERBE }
enum class VerkaufTab { VERKAUF, BUCHUNGEN }
class NennungViewModel : ViewModel() {
class NennungViewModel : ViewModel(), KoinComponent {
private val apiClient: HttpClient by inject(named("apiClient"))
private val _uiState = MutableStateFlow(NennungUiState())
val uiState: StateFlow<NennungUiState> = _uiState.asStateFlow()
init {
loadOnlineNennungen()
}
fun loadOnlineNennungen() {
viewModelScope.launch {
_uiState.update { it.copy(isOnlineLoading = true) }
try {
val dtos: List<NennungDto> = apiClient.get("/api/mail/nennungen").body()
val mapped = dtos.map { dto ->
OnlineNennung(
id = dto.id ?: "0",
vorname = dto.vorname,
nachname = dto.nachname,
lizenz = dto.lizenz,
pferdName = dto.pferdName,
pferdAlter = dto.pferdAlter,
email = dto.email,
bewerbe = dto.bewerbe
)
}
_uiState.update { it.copy(onlineNennungen = mapped, isOnlineLoading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(isOnlineLoading = false, statusMeldung = "Fehler beim Laden der Online-Nennungen: ${e.message}") }
}
}
}
fun uebernehmeOnlineNennung(onlineNennung: OnlineNennung) {
// 1. Reiter suchen oder "neu" anlegen (Mock-Logik)
val reiter = NennungMockData.reiter.find { it.vorname == onlineNennung.vorname && it.nachname == onlineNennung.nachname }
?: Reiter("NEU", onlineNennung.vorname, onlineNennung.nachname, lizenzNr = onlineNennung.lizenz)
// 2. Pferd suchen oder "neu" anlegen
val pferd = NennungMockData.pferde.find { it.name.equals(onlineNennung.pferdName, ignoreCase = true) }
?: Pferd("NEU", onlineNennung.pferdName)
// 3. UI State setzen (vorausfüllen)
_uiState.update {
it.copy(
selectedReiter = reiter,
reiterSuche = "${reiter.kopfNr} ${reiter.vollname}",
selectedPferd = pferd,
pferdSuche = "${pferd.kopfNr} ${pferd.name}",
activeNennungTab = NennungTab.BEWERBE // Direkt zu den Bewerben springen
)
}
}
// --- Pferd-Suche ---
fun onPferdSucheChanged(query: String) {
val vorschlaege = if (query.length >= 2) {

View File

@ -0,0 +1,79 @@
package at.mocode.frontend.features.nennung.presentation.web
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Serializable
data class NennungDto(
val id: String? = null,
val turnierNr: String,
val status: String = "NEU",
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String?,
val bewerbe: String, // Als JSON-String oder Komma-separiert
val bemerkungen: String?
)
data class OnlineNennungUiState(
val isLoading: Boolean = false,
val error: String? = null,
val isSuccess: Boolean = false
)
class OnlineNennungViewModel(
private val httpClient: HttpClient
) : ViewModel() {
private val _uiState = MutableStateFlow(OnlineNennungUiState())
val uiState: StateFlow<OnlineNennungUiState> = _uiState.asStateFlow()
fun sendeNennung(turnierNr: String, payload: NennungPayload) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val dto = NennungDto(
turnierNr = turnierNr,
vorname = payload.vorname,
nachname = payload.nachname,
lizenz = payload.lizenz,
pferdName = payload.pferdName,
pferdAlter = payload.pferdAlter,
email = payload.email,
telefon = payload.telefon,
bewerbe = payload.bewerbe.joinToString(",") { it.nr.toString() },
bemerkungen = payload.bemerkungen
)
// Wir nutzen den httpClient, der via Koin injiziert wird.
// Da im Web-Frontend evtl. kein API-Gateway davor ist (oder ein anderes),
// konfigurieren wir den Pfad hier explizit.
httpClient.post("/api/mail/nennungen") {
contentType(ContentType.Application.Json)
setBody(dto)
}
_uiState.update { it.copy(isLoading = false, isSuccess = true) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = "Fehler beim Senden: ${e.message}") }
}
}
}
fun resetState() {
_uiState.update { OnlineNennungUiState() }
}
}

View File

@ -42,6 +42,7 @@ kotlin {
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.features.billingFeature)
implementation(projects.frontend.features.nennungFeature)
implementation(projects.core.znsParser)
implementation(compose.foundation)

View File

@ -41,7 +41,7 @@ actual val turnierFeatureModule = module {
}
factory { (turnierId: Long) ->
NennungViewModel(
TurnierNennungViewModel(
nennungRepo = get(),
masterdataRepo = get(),
turnierId = turnierId

View File

@ -57,6 +57,7 @@ fun TurnierDetailScreen(
"ARTIKEL",
"ABRECHNUNG",
"NENNUNGEN",
"ONLINE-EINGANG",
"ZEITPLAN",
"STARTLISTEN",
"ERGEBNISLISTEN",
@ -103,22 +104,26 @@ fun TurnierDetailScreen(
veranstalterLogoUrl = veranstalterLogoUrl,
)
1 -> {
val nennungViewModel = koinInject<NennungViewModel>(parameters = { parametersOf(turnierId) })
val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
OrganisationTabContent(viewModel = nennungViewModel)
}
2 -> BewerbeTabContent(viewModel = bewerbViewModel, turnierId = turnierId)
3 -> ArtikelTabContent()
4 -> AbrechnungTabContent(veranstaltungId = veranstaltungId)
5 -> {
val nennungViewModel = koinInject<NennungViewModel>(parameters = { parametersOf(turnierId) })
val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
NennungenTabContent(
viewModel = nennungViewModel,
onAbrechnungClick = { selectedTab = 4 }
)
}
6 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel)
7 -> StartlistenTabContent()
8 -> ErgebnislistenTabContent()
6 -> {
val nennungViewModel = koinInject<TurnierNennungViewModel>(parameters = { parametersOf(turnierId) })
OnlineNennungEingangTabContent(turnierNr = turnierId.toString(), viewModel = nennungViewModel)
}
7 -> ZeitplanTabContent(turnierId = turnierId, viewModel = bewerbViewModel)
8 -> StartlistenTabContent()
9 -> ErgebnislistenTabContent()
}
}
}

View File

@ -9,6 +9,23 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
// --- Mock-Modelle für Online-Nennungen innerhalb dieses Moduls ---
data class OnlineNennung(
val id: String,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val bewerbe: String
)
data class TurnierOnlineUiState(
val onlineNennungen: List<OnlineNennung> = emptyList(),
val isOnlineLoading: Boolean = false
)
data class NennungenState(
val isLoading: Boolean = false,
val nennungen: List<Nennung> = emptyList(),
@ -20,11 +37,32 @@ data class NennungenState(
val errorMessage: String? = null
)
class NennungViewModel(
class TurnierNennungViewModel(
private val nennungRepo: NennungRepository,
private val masterdataRepo: MasterdataRepository,
private val turnierId: Long
) {
// UI-State für den Online-Eingang Tab
val uiState = MutableStateFlow(TurnierOnlineUiState())
fun loadOnlineNennungen() {
uiState.value = uiState.value.copy(isOnlineLoading = true)
scope.launch {
// Mock-Laden
kotlinx.coroutines.delay(500)
uiState.value = uiState.value.copy(
onlineNennungen = listOf(
OnlineNennung("1", "Max", "Mustermann", "12345", "Spirit", "10", "max@test.at", "1, 2, 5")
),
isOnlineLoading = false
)
}
}
fun uebernehmeOnlineNennung(nennung: OnlineNennung) {
// Logik zur Übernahme
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow(NennungenState())
@ -65,7 +103,6 @@ class NennungViewModel(
scope.launch {
masterdataRepo.saveReiter(reiter).onSuccess {
_state.value = _state.value.copy(selectedReiter = null)
// Evtl. Suchen/Listen aktualisieren
}
}
}

View File

@ -31,8 +31,8 @@ private val NennSelectedBg = Color(0xFFEFF6FF)
*/
@Composable
fun NennungenTabContent(
viewModel: NennungViewModel,
onAbrechnungClick: () -> Unit = {}
viewModel: TurnierNennungViewModel,
onAbrechnungClick: () -> Unit = {}
) {
val state by viewModel.state.collectAsState()
@ -77,7 +77,7 @@ fun NennungenTabContent(
}
@Composable
private fun NennungenSuchePanel(viewModel: NennungViewModel, state: NennungenState) {
private fun NennungenSuchePanel(viewModel: TurnierNennungViewModel, state: NennungenState) {
var pferdQuery by remember { mutableStateOf("") }
var reiterQuery by remember { mutableStateOf("") }
@ -118,7 +118,7 @@ private fun NennungenSuchePanel(viewModel: NennungViewModel, state: NennungenSta
}
@Composable
private fun NennungenTabelle(viewModel: NennungViewModel, state: NennungenState) {
private fun NennungenTabelle(viewModel: TurnierNennungViewModel, state: NennungenState) {
var selectedIndex by remember { mutableIntStateOf(-1) }
Column(modifier = Modifier.fillMaxSize()) {

View File

@ -0,0 +1,108 @@
package at.mocode.turnier.feature.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
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
@Composable
fun OnlineNennungEingangTabContent(turnierNr: String, viewModel: TurnierNennungViewModel) {
val uiState by viewModel.uiState.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text("Eingegangene Online-Nennungen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Turnier: $turnierNr", style = MaterialTheme.typography.bodyMedium, color = Color.Gray)
}
Button(
onClick = { viewModel.loadOnlineNennungen() },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
enabled = !uiState.isOnlineLoading
) {
if (uiState.isOnlineLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Refresh, contentDescription = null)
}
Spacer(Modifier.width(8.dp))
Text("Aktualisieren")
}
}
if (uiState.onlineNennungen.isEmpty() && !uiState.isOnlineLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine neuen Nennungen vorhanden.", color = Color.Gray)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(uiState.onlineNennungen) { nennung ->
NennungEingangCard(nennung, onUebernehmen = { viewModel.uebernehmeOnlineNennung(nennung) })
}
}
}
}
}
@Composable
fun NennungEingangCard(nennung: OnlineNennung, onUebernehmen: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("${nennung.vorname} ${nennung.nachname}", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(Modifier.width(8.dp))
Badge(containerColor = Color(0xFFE3F2FD)) { Text(nennung.lizenz, color = Color(0xFF1976D2)) }
}
Spacer(Modifier.height(4.dp))
Text("Pferd: ${nennung.pferdName} (*${nennung.pferdAlter})", style = MaterialTheme.typography.bodyMedium)
Text("Bewerbe: ${nennung.bewerbe}", style = MaterialTheme.typography.bodySmall, color = Color(0xFF2E7D32), fontWeight = FontWeight.Bold)
}
Column(horizontalAlignment = Alignment.End) {
Text(nennung.email, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { /* Details */ }, shape = RoundedCornerShape(8.dp)) {
Text("Details")
}
Button(
onClick = onUebernehmen,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2E7D32))
) {
Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(4.dp))
Text("Übernehmen")
}
}
}
}
}
}

View File

@ -29,7 +29,7 @@ private val DeleteRed = Color(0xFFDC2626)
* - Austragungsplätze: dynamische Liste (Sparte, Größe, Bezeichnung, Löschen)
*/
@Composable
fun OrganisationTabContent(viewModel: NennungViewModel) {
fun OrganisationTabContent(viewModel: TurnierNennungViewModel) {
val state by viewModel.state.collectAsState()
var turnierleiter by remember { mutableStateOf("") }

View File

@ -100,7 +100,10 @@ fun StammdatenTabContent(
val katField = tClass.getDeclaredField("kategorie")
katField.isAccessible = true
val kats = katField.get(turnier) as? List<String>
kats?.let { kat.addAll(it) }
kats?.let {
kat.clear()
kat.addAll(it)
}
val typField = tClass.getDeclaredField("typ")
typField.isAccessible = true

View File

@ -1,7 +1,6 @@
{
"geraetName": "Meldestelle",
"sharedKey": "Meldestelle",
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
"networkRole": "MASTER",
"syncInterval": 20
"backupPath": "/mocode/Meldestelle/docs/temp",
"networkRole": "MASTER"
}

View File

@ -21,6 +21,8 @@ import at.mocode.desktop.screens.onboarding.SettingsManager
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.network.ConnectivityTracker
import at.mocode.frontend.core.network.discovery.NetworkDiscoveryService
import at.mocode.frontend.features.billing.presentation.BillingScreen
import at.mocode.frontend.features.billing.presentation.BillingViewModel
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
@ -40,8 +42,10 @@ import at.mocode.turnier.feature.presentation.TurnierDetailScreen
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
import kotlinx.coroutines.delay
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import kotlin.time.Duration.Companion.milliseconds
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
private val TopBarColor = Color(0xFF1E3A8A)
@ -91,7 +95,7 @@ fun DesktopMainLayout(
}
HorizontalDivider(thickness = Dimens.BorderThin, color = MaterialTheme.colorScheme.outlineVariant)
DesktopFooterBar()
DesktopFooterBar(settings = onboardingSettings)
}
}
}
@ -249,7 +253,10 @@ private fun DesktopTopHeader(
BreadcrumbContent(currentScreen, onNavigate)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
) {
// Profil / Logout Bereich
Text(
text = "Administrator",
@ -296,6 +303,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.VeranstalterDetail -> {
BreadcrumbSeparator()
Text(
@ -309,6 +317,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.VeranstaltungProfil -> {
BreadcrumbSeparator()
Text(
@ -330,6 +339,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.VeranstaltungDetail -> {
BreadcrumbSeparator()
Text(
@ -337,6 +347,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium,
)
}
is AppScreen.VeranstaltungNeu -> {
BreadcrumbSeparator()
Text(
@ -344,6 +355,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium,
)
}
is AppScreen.TurnierDetail -> {
BreadcrumbSeparator()
Text(
@ -359,6 +371,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.Billing -> {
BreadcrumbSeparator()
Text(
@ -382,6 +395,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.TurnierNeu -> {
BreadcrumbSeparator()
Text(
@ -413,6 +427,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.Meisterschaften -> {
BreadcrumbSeparator()
Text(
@ -420,6 +435,7 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
is AppScreen.Cups -> {
BreadcrumbSeparator()
Text(
@ -427,21 +443,22 @@ private fun BreadcrumbContent(
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
)
}
else -> {}
}
}
// Hilfsfunktion: OEPS-Bundeslandcode → Abkürzung
private fun mapOepsToBundesland(code: String): String = when (code.uppercase()) {
"OOE" -> ""
"NOE" -> ""
"ST" -> "Stmk."
"OÖ" -> "Oberösterreich"
"NÖ" -> "Niederösterreich"
"ST" -> "Steiermark"
"W" -> "Wien"
"BGLD", "B" -> "Bgld."
"K" -> "Ktn."
"S" -> "Sbg."
"B" -> "Burgenland"
"K" -> "Kärnten"
"S" -> "Salzburg"
"T" -> "Tirol"
"V" -> "Vbg."
"V" -> "Vorarlberg"
else -> code
}
@ -621,6 +638,7 @@ private fun DesktopContentArea(
onCancel = onBack,
onVereinCreated = { newId -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
)
is AppScreen.VeranstalterDetail -> {
val vId = currentScreen.veranstalterId
if (vId != 1L) { // Temporärer Check für Mock-Daten
@ -637,6 +655,7 @@ private fun DesktopContentArea(
)
}
}
is AppScreen.VeranstaltungKonfig -> {
val vId = currentScreen.veranstalterId
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard
@ -694,7 +713,8 @@ private fun DesktopContentArea(
val v = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { vv ->
at.mocode.desktop.v2.StoreV2.eventsFor(vv.id).any { it.id == currentScreen.id }
}
val veranstaltung = v?.let { at.mocode.desktop.v2.StoreV2.eventsFor(it.id).firstOrNull { e -> e.id == currentScreen.id } }
val veranstaltung =
v?.let { at.mocode.desktop.v2.StoreV2.eventsFor(it.id).firstOrNull { e -> e.id == currentScreen.id } }
val list = at.mocode.desktop.v2.TurnierStoreV2.list(currentScreen.id)
val newId = (list.maxOfOrNull { it.id } ?: 0L) + 1L
val draft = at.mocode.desktop.v2.TurnierV2(
@ -709,6 +729,7 @@ private fun DesktopContentArea(
},
onTurnierOeffnen = { tid -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tid)) },
)
is AppScreen.VeranstaltungNeu -> VeranstaltungNeuScreen(
onBack = onBack,
onSave = { onBack() },
@ -743,6 +764,7 @@ private fun DesktopContentArea(
)
}
}
is AppScreen.TurnierNeu -> {
val evtId = currentScreen.veranstaltungId
// V2: Wir erlauben Turnier-Nr nur, wenn die Veranstaltung im V2-Store existiert
@ -800,11 +822,11 @@ private fun DesktopContentArea(
}
is AppScreen.Meisterschaften -> {
SeriesScreen(title = "Meisterschaften", onBack = onBack)
SeriesScreen(title = "Meisterschaften", onBack = onBack)
}
is AppScreen.Cups -> {
SeriesScreen(title = "Cups", onBack = onBack)
SeriesScreen(title = "Cups", onBack = onBack)
}
is AppScreen.Nennung -> {
@ -832,11 +854,22 @@ private fun DesktopContentArea(
}
@Composable
private fun DesktopFooterBar() {
// Echte Status-Logik vorbereitet
val online = remember { mutableStateOf(true) }
val deviceConnected = remember { mutableStateOf(true) }
val deviceName = "Richter-Turm"
private fun DesktopFooterBar(settings: OnboardingSettings) {
val connectivityTracker = koinInject<ConnectivityTracker>()
val discoveryService = koinInject<NetworkDiscoveryService>()
val online by connectivityTracker.isOnline.collectAsState()
val discoveredServices = remember { mutableStateOf(discoveryService.getDiscoveredServices()) }
val deviceName = settings.geraetName.ifBlank { "Unbekannt" }
// Periodisches Update der LAN-Geräte (mDNS)
LaunchedEffect(Unit) {
discoveryService.startDiscovery()
while (true) {
discoveredServices.value = discoveryService.getDiscoveredServices()
delay(5000.milliseconds)
}
}
Surface(
color = MaterialTheme.colorScheme.surface,
@ -854,18 +887,19 @@ private fun DesktopFooterBar() {
Row(verticalAlignment = Alignment.CenterVertically) {
// Status: Cloud Sync
StatusIndicator(
icon = if (online.value) Icons.Filled.CloudDone else Icons.Filled.CloudOff,
label = if (online.value) "Cloud synchronisiert" else "Offline (Lokal)",
color = if (online.value) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
icon = if (online) Icons.Filled.CloudDone else Icons.Filled.CloudOff,
label = if (online) "Cloud synchronisiert" else "Offline (Lokal)",
color = if (online) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
Spacer(Modifier.width(Dimens.SpacingM))
// Status: LAN Devices (mDNS)
val deviceCount = discoveredServices.value.size
StatusIndicator(
icon = Icons.Filled.Lan,
label = if (deviceConnected.value) "Verbunden: $deviceName" else "Suche nach Geräten...",
color = if (deviceConnected.value) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
label = if (deviceCount > 0) "Verbunden: $deviceName ($deviceCount im Netz)" else "Lokal: $deviceName",
color = if (deviceCount > 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
}

View File

@ -126,7 +126,7 @@ fun PreviewTurnierOrganisationTab() {
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
}
val vm = NennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
val vm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
MaterialTheme {
OrganisationTabContent(viewModel = vm)
}
@ -205,7 +205,7 @@ fun PreviewTurnierNennungenTab() {
override suspend fun listVereine(): Result<List<Verein>> = Result.success(emptyList())
override suspend fun getVereinById(id: String): Result<Verein> = Result.failure(NotImplementedError())
}
val vm = NennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
val vm = TurnierNennungViewModel(mockNennungRepo, mockMasterdataRepo, 1L)
MaterialTheme {
NennungenTabContent(viewModel = vm)
}

View File

@ -18,8 +18,10 @@ 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 kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
import at.mocode.frontend.features.nennung.domain.NennungRemoteRepository
import at.mocode.frontend.features.nennung.domain.NennungResponse
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
data class OnlineNennungMail(
val id: String,
@ -38,14 +40,47 @@ data class OnlineNennungMail(
var status: String = "NEU"
)
fun NennungResponse.toMail() = OnlineNennungMail(
id = id,
sender = email,
empfaenger = "Meldestelle",
datum = "-", // Datum ist in Entity nicht direkt drin, könnte man ergänzen
turnierNr = turnierNr,
vorname = vorname,
nachname = nachname,
lizenz = lizenz,
pferd = pferdName,
pferdAlter = pferdAlter,
telefon = telefon,
bewerbe = bewerbe,
bemerkungen = bemerkungen,
status = if (status == "GELESEN") "GELESEN" else "NEU"
)
@Composable
fun NennungsEingangScreen(onBack: () -> Unit) {
val repository: NennungRemoteRepository = koinInject()
val scope = rememberCoroutineScope()
DesktopThemeV2 {
var mails by remember { mutableStateOf<List<OnlineNennungMail>>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
var selectedMail by remember { mutableStateOf<OnlineNennungMail?>(null) }
var isRefreshing by remember { mutableStateOf(false) }
val refresh = {
scope.launch {
isRefreshing = true
repository.holeNennungen().onSuccess { response ->
mails = response.map { it.toMail() }
}.onFailure {
// Fallback oder Fehleranzeige
if (mails.isEmpty()) mails = getMockMails()
}
isRefreshing = false
}
}
val filteredMails = remember(mails, searchQuery) {
if (searchQuery.isBlank()) mails
else mails.filter {
@ -58,10 +93,7 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
// Initiales Laden
LaunchedEffect(Unit) {
isRefreshing = true
delay(800.milliseconds)
mails = getMockMails()
isRefreshing = false
refresh()
}
if (selectedMail != null) {
@ -69,9 +101,12 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
mail = selectedMail!!,
onDismiss = { selectedMail = null },
onMarkProcessed = {
scope.launch {
repository.markiereAlsGelesen(selectedMail!!.id)
val updated = mails.map { if (it.id == selectedMail!!.id) it.copy(status = "GELESEN") else it }
mails = updated
selectedMail = null
}
}
)
}
@ -85,7 +120,7 @@ fun NennungsEingangScreen(onBack: () -> Unit) {
Spacer(Modifier.weight(1f))
if (isRefreshing) CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
Button(
onClick = { /* Refresh Logik */ },
onClick = { refresh() },
enabled = !isRefreshing
) {
Icon(Icons.Default.Refresh, null)