Compare commits
6 Commits
03f0c3a90b
...
a6fcb81594
| Author | SHA1 | Date | |
|---|---|---|---|
| a6fcb81594 | |||
| a5f5e7a24b | |||
| d0b756694b | |||
| 8c804832d8 | |||
| c542094196 | |||
| b4c400efea |
76
.env.example
76
.env.example
|
|
@ -159,6 +159,82 @@ PING_DEBUG=true
|
||||||
PING_SERVICE_NAME=ping-service
|
PING_SERVICE_NAME=ping-service
|
||||||
PING_CONSUL_PREFER_IP=true
|
PING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- MAIL-SERVICE ---
|
||||||
|
MAIL_PORT=8083:8085
|
||||||
|
MAIL_DEBUG_PORT=5014:5014
|
||||||
|
MAIL_SERVER_PORT=8085
|
||||||
|
MAIL_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
MAIL_DEBUG=true
|
||||||
|
MAIL_SERVICE_NAME=mail-service
|
||||||
|
MAIL_CONSUL_PREFER_IP=true
|
||||||
|
MAIL_SMTP_HOST=smtp.world4you.com
|
||||||
|
MAIL_SMTP_PORT=587
|
||||||
|
MAIL_SMTP_USER=online-nennen@mo-code.at
|
||||||
|
MAIL_SMTP_PASSWORD=<DEIN_WORLD4YOU_PASSWORT>
|
||||||
|
MAIL_SMTP_AUTH=true
|
||||||
|
MAIL_SMTP_STARTTLS=true
|
||||||
|
|
||||||
|
# --- MASTERDATA-SERVICE ---
|
||||||
|
MASTERDATA_PORT=8086:8086
|
||||||
|
MASTERDATA_DEBUG_PORT=5007:5007
|
||||||
|
MASTERDATA_SERVER_PORT=8086
|
||||||
|
MASTERDATA_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
MASTERDATA_DEBUG=true
|
||||||
|
MASTERDATA_SERVICE_NAME=masterdata-service
|
||||||
|
MASTERDATA_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- EVENTS-SERVICE ---
|
||||||
|
EVENTS_PORT=8085:8085
|
||||||
|
EVENTS_DEBUG_PORT=5008:5008
|
||||||
|
EVENTS_SERVER_PORT=8085
|
||||||
|
EVENTS_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
EVENTS_DEBUG=true
|
||||||
|
EVENTS_SERVICE_NAME=events-service
|
||||||
|
EVENTS_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- ZNS-IMPORT-SERVICE ---
|
||||||
|
ZNS_IMPORT_PORT=8095:8095
|
||||||
|
ZNS_IMPORT_DEBUG_PORT=5009:5009
|
||||||
|
ZNS_IMPORT_SERVER_PORT=8095
|
||||||
|
ZNS_IMPORT_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
ZNS_IMPORT_DEBUG=true
|
||||||
|
ZNS_IMPORT_SERVICE_NAME=zns-import-service
|
||||||
|
ZNS_IMPORT_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- RESULTS-SERVICE ---
|
||||||
|
RESULTS_PORT=8088:8088
|
||||||
|
RESULTS_DEBUG_PORT=5010:5010
|
||||||
|
RESULTS_SERVER_PORT=8088
|
||||||
|
RESULTS_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
RESULTS_DEBUG=true
|
||||||
|
RESULTS_SERVICE_NAME=results-service
|
||||||
|
RESULTS_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- BILLING-SERVICE ---
|
||||||
|
BILLING_PORT=8087:8087
|
||||||
|
BILLING_DEBUG_PORT=5012:5012
|
||||||
|
BILLING_SERVER_PORT=8087
|
||||||
|
BILLING_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
BILLING_DEBUG=true
|
||||||
|
BILLING_SERVICE_NAME=billing-service
|
||||||
|
BILLING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- SCHEDULING-SERVICE ---
|
||||||
|
SCHEDULING_PORT=8084:8084
|
||||||
|
SCHEDULING_DEBUG_PORT=5013:5013
|
||||||
|
SCHEDULING_SERVER_PORT=8084
|
||||||
|
SCHEDULING_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
SCHEDULING_DEBUG=true
|
||||||
|
SCHEDULING_SERVICE_NAME=scheduling-service
|
||||||
|
SCHEDULING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- SERIES-SERVICE ---
|
||||||
|
SERIES_PORT=8089:8089
|
||||||
|
SERIES_DEBUG_PORT=5011:5011
|
||||||
|
SERIES_SERVER_PORT=8089
|
||||||
|
SERIES_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
SERIES_DEBUG=true
|
||||||
|
|
||||||
# --- WEB-APP ---
|
# --- WEB-APP ---
|
||||||
WEB_APP_PORT=4000:4000
|
WEB_APP_PORT=4000:4000
|
||||||
# URL für API-Zugriffe vom Browser (Public URL via Pangolin)
|
# URL für API-Zugriffe vom Browser (Public URL via Pangolin)
|
||||||
|
|
|
||||||
53
AGENTS.md
53
AGENTS.md
|
|
@ -1,35 +1,42 @@
|
||||||
# 🤖 Project Agents & Protocol
|
# 🤖 Projekt Agenten & Protokoll (Meldestelle-Biest)
|
||||||
|
|
||||||
Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den KI-Agenten.
|
Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den spezialisierten KI-Agenten.
|
||||||
Es dient als "System Prompt" für neue Chat-Sessions.
|
Es dient als zentraler **System-Prompt-Erweiterung** für neue Chat-Sessions.
|
||||||
|
|
||||||
## 1. Protokoll & Badges
|
## 🚀 Strategische Ausrichtung
|
||||||
Jeder Agent muss seine Antwort mit einem Badge beginnen, um den Kontext zu setzen. Detaillierte Anweisungen finden sich in den jeweiligen Playbooks.
|
Das Projekt **"Meldestelle-Biest"** entwickelt eine ÖTO/FEI-konforme, offline-fähige Turnier-Software.
|
||||||
|
1. **Desktop-First:** Primäres Ziel ist die Compose Desktop App (KMP). UX & Performance sind auf Profis optimiert.
|
||||||
|
2. **Offline-First:** Das System muss autark (ohne Internet) funktionieren. Sync-Logik ist Kernbestandteil.
|
||||||
|
3. **Domain-Driven:** 6 Bounded Contexts (SCS) bilden den fachlichen Rahmen.
|
||||||
|
|
||||||
* **🏗️ [Lead Architect]**: Strategie, Planung, Entscheidungen, Master Roadmap.
|
## 1. Protokoll & Rollen-Badges
|
||||||
|
Jede Agenten-Antwort **muss** mit dem entsprechenden Badge beginnen, um den Kontext und die Verantwortlichkeit zu klären.
|
||||||
|
|
||||||
|
* **🏗️ [Lead Architect]**: Hüter der **MASTER_ROADMAP**. Verantwortlich für System-Design, Build-Logik (Gradle), Modulstruktur und ADRs.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/Architect.md)
|
* [Playbook](docs/04_Agents/Playbooks/Architect.md)
|
||||||
* **🧹 [Curator]**: Dokumentation, Logs, Reports, Aufräumen.
|
* **📜 [Rulebook Expert]**: Wächter über **ÖTO & FEI**. Validiert Business-Rules gegen das offizielle Pferdesport-Regelwerk.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/Curator.md)
|
* [Playbook](docs/04_Agents/Playbooks/RulebookExpert.md)
|
||||||
* **👷 [Backend Developer]**: Spring Boot, Kotlin, SQL, API-Design.
|
* **👷 [Backend Developer]**: Kotlin & Spring Boot Experte. Fokus auf DDD, Persistenz (Postgres) und **Delta-Sync APIs**.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/BackendDeveloper.md)
|
* [Playbook](docs/04_Agents/Playbooks/BackendDeveloper.md)
|
||||||
* **🎨 [Frontend Expert]**: KMP, Compose, State-Management, Auth.
|
* **🎨 [Frontend Expert]**: KMP & Compose Desktop Spezialist. Implementiert State-Management und High-Performance UI.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/FrontendExpert.md)
|
* [Playbook](docs/04_Agents/Playbooks/FrontendExpert.md)
|
||||||
* **🖌️ [UI/UX Designer]**: High-Density Design, Wireframes, Usability.
|
* **🖌️ [UI/UX Designer]**: "Toolsmith" für High-Density Enterprise-UIs. Fokus auf Tastatur-Bedienbarkeit und Effizienz.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/UIUXDesigner.md)
|
* [Playbook](docs/04_Agents/Playbooks/UIUXDesigner.md)
|
||||||
* **🐧 [DevOps Engineer]**: Docker, CI/CD, Gradle, Security.
|
* **🐧 [DevOps Engineer]**: Infrastruktur-Automatisierung (Docker, Gitea-Actions). Fokus auf Stabilität und lokale Dev-Umgebung.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/DevOpsEngineer.md)
|
* [Playbook](docs/04_Agents/Playbooks/DevOpsEngineer.md)
|
||||||
* **🧐 [QA Specialist]**: Test-Strategie, Edge-Cases.
|
* **🧐 [QA Specialist]**: Test-Stratege (Shift-Left). Fokus auf Unit-, Integration- und Edge-Case-Tests (Testing Pyramid).
|
||||||
* [Playbook](docs/04_Agents/Playbooks/QASpecialist.md)
|
* [Playbook](docs/04_Agents/Playbooks/QASpecialist.md)
|
||||||
* **📜 [ÖTO/FEI Rulebook Expert]**: Regelwerks-Wächter, Validierungs-Spezialist, Compliance.
|
* **🧹 [Curator]**: Wissens-Management & Dokumentations-Check (ADR, Reference, Journal). Beendet jede Session.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/RulebookExpert.md)
|
* [Playbook](docs/04_Agents/Playbooks/Curator.md)
|
||||||
|
|
||||||
## 2. Workflow
|
## 2. Der "Biest"-Workflow
|
||||||
1. **Kontext:** Lies immer zuerst die `MASTER_ROADMAP` in `docs/01_Architecture/`.
|
1. **Kontext-Check:** Lies immer zuerst die `MASTER_ROADMAP` in `docs/01_Architecture/`.
|
||||||
2. **Fokus:** Bearbeite immer nur EINE Aufgabe zur Zeit.
|
2. **SCS-Rahmen:** Identifiziere, in welchem der 6 Bounded Contexts du arbeitest.
|
||||||
3. **Doku:** Jede Session endet mit einem Eintrag durch den **Curator**.
|
3. **Fokus:** Bearbeite immer nur EINE fachliche Aufgabe pro Session.
|
||||||
4. **Code:** Änderungen am Code werden sofort via Tool ausgeführt, nicht nur vorgeschlagen.
|
4. **Doku-as-Code:** Änderungen an Code/Architektur müssen sofort in `docs/` (ADR/Reference) reflektiert werden.
|
||||||
|
5. **Session-Abschluss:** Jede Session endet mit einem Eintrag durch den **Curator** (Journal oder Artefakt).
|
||||||
|
|
||||||
## 3. Projekt-Philosophie
|
## 3. Projekt-Philosophie
|
||||||
* **Startup-Mode:** Wir bauen ein echtes Produkt. Code-Qualität und Geschwindigkeit sind gleich wichtig.
|
* **Information Density over White Space:** Wir bauen ein Profi-Werkzeug, kein Spielzeug.
|
||||||
* **Docs-as-Code:** Die Dokumentation ist die Single Source of Truth.
|
* **Speed over Animation:** Reaktionsgeschwindigkeit der UI hat höchste Priorität.
|
||||||
* **Offline-First:** Das System muss ohne Internet funktionieren (Sync).
|
* **Offline-Authentizität:** Lokale Daten sind die "Source of Truth" für den User; der Server ist das Backup/Sync-Target.
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ dependencies {
|
||||||
|
|
||||||
// Web (for CORS config)
|
// Web (for CORS config)
|
||||||
implementation(libs.spring.web)
|
implementation(libs.spring.web)
|
||||||
|
implementation(libs.spring.boot.starter.web)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(projects.platform.platformTesting)
|
testImplementation(projects.platform.platformTesting)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package at.mocode.infrastructure.security
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter zur Authentifizierung von Desktop-Clients via Security Key.
|
||||||
|
* Dieser Filter ist für die Offline-First-Synchronisation gedacht.
|
||||||
|
*
|
||||||
|
* Header:
|
||||||
|
* - X-Device-Name: Name der Desktop-Instanz
|
||||||
|
* - X-Security-Key: Der konfigurierte Sicherheitsschlüssel
|
||||||
|
*
|
||||||
|
* HINWEIS: In einer echten Produktionsumgebung sollte der Key gehasht sein
|
||||||
|
* oder eine Signatur-Prüfung erfolgen.
|
||||||
|
*/
|
||||||
|
class DeviceSecurityFilter : OncePerRequestFilter() {
|
||||||
|
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain
|
||||||
|
) {
|
||||||
|
val deviceName = request.getHeader("X-Device-Name")
|
||||||
|
val securityKey = request.getHeader("X-Security-Key")
|
||||||
|
|
||||||
|
// Falls Header vorhanden sind, versuchen wir die Authentifizierung
|
||||||
|
if (!deviceName.isNullOrBlank() && !securityKey.isNullOrBlank()) {
|
||||||
|
// WICHTIG: Die eigentliche Validierung gegen die DB (DeviceTable)
|
||||||
|
// müsste hier über einen Service erfolgen.
|
||||||
|
// Für den Prototyp setzen wir einen Authentifizierungs-Kontext,
|
||||||
|
// wenn die Header vorhanden sind.
|
||||||
|
|
||||||
|
val auth = UsernamePasswordAuthenticationToken(
|
||||||
|
deviceName,
|
||||||
|
null,
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_DEVICE"))
|
||||||
|
)
|
||||||
|
SecurityContextHolder.getContext().authentication = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
|
|
@ -23,9 +24,11 @@ class GlobalSecurityConfig {
|
||||||
// Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client.
|
// Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client.
|
||||||
.cors { it.disable() }
|
.cors { it.disable() }
|
||||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||||
|
.addFilterBefore(DeviceSecurityFilter(), UsernamePasswordAuthenticationFilter::class.java)
|
||||||
.authorizeHttpRequests { auth ->
|
.authorizeHttpRequests { auth ->
|
||||||
// Explizite Freigaben (Health, Info, Public Endpoints)
|
// Explizite Freigaben (Health, Info, Public Endpoints)
|
||||||
auth.requestMatchers("/actuator/**").permitAll()
|
auth.requestMatchers("/actuator/**").permitAll()
|
||||||
|
auth.requestMatchers("/api/v1/devices/register").permitAll() // Onboarding erlauben
|
||||||
auth.requestMatchers("/ping/public").permitAll()
|
auth.requestMatchers("/ping/public").permitAll()
|
||||||
auth.requestMatchers("/ping/simple").permitAll()
|
auth.requestMatchers("/ping/simple").permitAll()
|
||||||
auth.requestMatchers("/ping/enhanced").permitAll()
|
auth.requestMatchers("/ping/enhanced").permitAll()
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ enum class BuchungsTyp {
|
||||||
NACHNENNGEBUEHR,
|
NACHNENNGEBUEHR,
|
||||||
STARTGEBUEHR,
|
STARTGEBUEHR,
|
||||||
BOXENGEBUEHR,
|
BOXENGEBUEHR,
|
||||||
|
SPORTFOERDERBEITRAG,
|
||||||
ZAHLUNG_BAR,
|
ZAHLUNG_BAR,
|
||||||
ZAHLUNG_KARTE,
|
ZAHLUNG_KARTE,
|
||||||
GUTSCHRIFT,
|
GUTSCHRIFT,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ class TeilnehmerKontoService(
|
||||||
BuchungsTyp.NENNGEBUEHR,
|
BuchungsTyp.NENNGEBUEHR,
|
||||||
BuchungsTyp.NACHNENNGEBUEHR,
|
BuchungsTyp.NACHNENNGEBUEHR,
|
||||||
BuchungsTyp.STARTGEBUEHR,
|
BuchungsTyp.STARTGEBUEHR,
|
||||||
|
BuchungsTyp.SPORTFOERDERBEITRAG,
|
||||||
BuchungsTyp.BOXENGEBUEHR -> if (betragCent > 0) -betragCent else betragCent
|
BuchungsTyp.BOXENGEBUEHR -> if (betragCent > 0) -betragCent else betragCent
|
||||||
|
|
||||||
BuchungsTyp.ZAHLUNG_BAR,
|
BuchungsTyp.ZAHLUNG_BAR,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ package at.mocode.entries.service.usecase
|
||||||
|
|
||||||
import at.mocode.billing.domain.model.BuchungsTyp
|
import at.mocode.billing.domain.model.BuchungsTyp
|
||||||
import at.mocode.billing.service.TeilnehmerKontoService
|
import at.mocode.billing.service.TeilnehmerKontoService
|
||||||
import at.mocode.entries.service.notification.MailService
|
|
||||||
import at.mocode.core.domain.model.NennStatusE
|
import at.mocode.core.domain.model.NennStatusE
|
||||||
import at.mocode.entries.api.*
|
import at.mocode.entries.api.*
|
||||||
import at.mocode.entries.domain.model.Nennung
|
import at.mocode.entries.domain.model.Nennung
|
||||||
|
|
@ -12,6 +11,7 @@ import at.mocode.entries.domain.model.NennungsTransfer
|
||||||
import at.mocode.entries.domain.repository.NennungRepository
|
import at.mocode.entries.domain.repository.NennungRepository
|
||||||
import at.mocode.entries.domain.repository.NennungsTransferRepository
|
import at.mocode.entries.domain.repository.NennungsTransferRepository
|
||||||
import at.mocode.entries.service.bewerbe.BewerbRepository
|
import at.mocode.entries.service.bewerbe.BewerbRepository
|
||||||
|
import at.mocode.entries.service.notification.MailService
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|
@ -109,6 +109,14 @@ class NennungUseCases(
|
||||||
zweck = "Nachnenngebühr Bewerb ${bewerb.bezeichnung}"
|
zweck = "Nachnenngebühr Bewerb ${bewerb.bezeichnung}"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sportförderbeitrag buchen (1€ gemäß § 16 ÖTO)
|
||||||
|
kontoService.buche(
|
||||||
|
kontoId = konto.kontoId,
|
||||||
|
betragCent = -100, // 1,00 EUR
|
||||||
|
typ = BuchungsTyp.SPORTFOERDERBEITRAG,
|
||||||
|
zweck = "Sportförderbeitrag ÖTO (§ 16)"
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.error("Fehler bei der automatischen Buchung für Nennung {}: {}", saved.nennungId, e.message, e)
|
log.error("Fehler bei der automatischen Buchung für Nennung {}: {}", saved.nennungId, e.message, e)
|
||||||
// Wir lassen die Nennung bestehen, loggen aber den Fehler.
|
// Wir lassen die Nennung bestehen, loggen aber den Fehler.
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,15 @@ class NennungBillingIntegrationTest {
|
||||||
// WHEN: Nennung einreichen
|
// WHEN: Nennung einreichen
|
||||||
nennungUseCases.nennungEinreichen(request)
|
nennungUseCases.nennungEinreichen(request)
|
||||||
|
|
||||||
// THEN: Konto muss existieren und Saldo muss -25,00 EUR sein (Gebühr)
|
// THEN: Konto muss existieren und Saldo muss -26,00 EUR sein (25,00 Gebühr + 1,00 Sportförderbeitrag)
|
||||||
val konto = kontoService.getKonto(turnierId, reiterId)
|
val konto = kontoService.getKonto(turnierId, reiterId)
|
||||||
assertNotNull(konto, "Konto sollte automatisch erstellt worden sein")
|
assertNotNull(konto, "Konto sollte automatisch erstellt worden sein")
|
||||||
assertEquals(-2500L, konto?.saldoCent)
|
assertEquals(-2600L, konto?.saldoCent)
|
||||||
|
|
||||||
val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId)
|
val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId)
|
||||||
assertEquals(1, buchungen.size)
|
assertEquals(2, buchungen.size)
|
||||||
assertEquals(BuchungsTyp.NENNGELD, buchungen[0].typ)
|
assertNotNull(buchungen.find { it.typ == BuchungsTyp.NENNGELD })
|
||||||
assertEquals(-2500L, buchungen[0].betragCent)
|
assertNotNull(buchungen.find { it.typ == BuchungsTyp.SPORTFOERDERBEITRAG })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -165,13 +165,14 @@ class NennungBillingIntegrationTest {
|
||||||
// WHEN: Nennung einreichen
|
// WHEN: Nennung einreichen
|
||||||
nennungUseCases.nennungEinreichen(request)
|
nennungUseCases.nennungEinreichen(request)
|
||||||
|
|
||||||
// THEN: Saldo muss -45,00 EUR sein (-30 - 15)
|
// THEN: Saldo muss -46,00 EUR sein (-30 - 15 - 1 Sportförderbeitrag)
|
||||||
val konto = kontoService.getKonto(turnierId, reiterId)
|
val konto = kontoService.getKonto(turnierId, reiterId)
|
||||||
assertEquals(-4500L, konto?.saldoCent)
|
assertEquals(-4600L, konto?.saldoCent)
|
||||||
|
|
||||||
val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId)
|
val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId)
|
||||||
assertEquals(2, buchungen.size)
|
assertEquals(3, buchungen.size)
|
||||||
// Einer muss NACHNENNGEBUEHR sein
|
// Einer muss NACHNENNGEBUEHR sein
|
||||||
assertNotNull(buchungen.find { it.typ == BuchungsTyp.NACHNENNGEBUEHR })
|
assertNotNull(buchungen.find { it.typ == BuchungsTyp.NACHNENNGEBUEHR })
|
||||||
|
assertNotNull(buchungen.find { it.typ == BuchungsTyp.SPORTFOERDERBEITRAG })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package at.mocode.identity.domain.model
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert eine registrierte Desktop-Instanz ("Gerät").
|
||||||
|
* Die Identität wird während des Onboarding-Prozesses festgelegt.
|
||||||
|
*/
|
||||||
|
data class Device(
|
||||||
|
val id: UUID = UUID.randomUUID(),
|
||||||
|
val name: String,
|
||||||
|
val securityKeyHash: String, // Gehasht für Sicherheit
|
||||||
|
val role: DeviceRole = DeviceRole.CLIENT,
|
||||||
|
val lastSyncAt: Instant? = null,
|
||||||
|
val createdAt: Instant
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class DeviceRole {
|
||||||
|
MASTER, CLIENT
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package at.mocode.identity.domain.repository
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.Device
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
interface DeviceRepository {
|
||||||
|
suspend fun findById(id: UUID): Device?
|
||||||
|
suspend fun findByName(name: String): Device?
|
||||||
|
suspend fun save(device: Device): Device
|
||||||
|
suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package at.mocode.identity.domain.service
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.Device
|
||||||
|
import at.mocode.identity.domain.model.DeviceRole
|
||||||
|
import at.mocode.identity.domain.repository.DeviceRepository
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
class DeviceService(
|
||||||
|
private val deviceRepository: DeviceRepository
|
||||||
|
) {
|
||||||
|
suspend fun registerDevice(name: String, securityKeyHash: String, role: DeviceRole): Device {
|
||||||
|
val existing = deviceRepository.findByName(name)
|
||||||
|
if (existing != null) {
|
||||||
|
throw IllegalArgumentException("Gerät mit dem Namen $name existiert bereits.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val device = Device(
|
||||||
|
name = name,
|
||||||
|
securityKeyHash = securityKeyHash,
|
||||||
|
role = role,
|
||||||
|
createdAt = Clock.System.now()
|
||||||
|
)
|
||||||
|
return deviceRepository.save(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun validateDeviceKey(name: String, securityKeyHash: String): Boolean {
|
||||||
|
val device = deviceRepository.findByName(name) ?: return false
|
||||||
|
return device.securityKeyHash == securityKeyHash
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getDeviceByName(name: String): Device? {
|
||||||
|
return deviceRepository.findByName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateSyncTime(deviceId: UUID): Boolean {
|
||||||
|
return deviceRepository.updateLastSyncAt(deviceId, Clock.System.now())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package at.mocode.identity.infrastructure.persistence
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.DeviceRole
|
||||||
|
import org.jetbrains.exposed.v1.core.Table
|
||||||
|
import org.jetbrains.exposed.v1.core.java.javaUUID
|
||||||
|
import org.jetbrains.exposed.v1.datetime.timestamp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed Table definition für registrierte Desktop-Geräte.
|
||||||
|
*/
|
||||||
|
object DeviceTable : Table("identity_devices") {
|
||||||
|
val id = javaUUID("id").autoGenerate()
|
||||||
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
|
val name = varchar("name", 100).uniqueIndex()
|
||||||
|
val securityKeyHash = varchar("security_key_hash", 255)
|
||||||
|
val role = enumerationByName("role", 20, DeviceRole::class)
|
||||||
|
|
||||||
|
val lastSyncAt = timestamp("last_sync_at").nullable()
|
||||||
|
val createdAt = timestamp("created_at")
|
||||||
|
val updatedAt = timestamp("updated_at")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package at.mocode.identity.infrastructure.persistence
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.Device
|
||||||
|
import at.mocode.identity.domain.repository.DeviceRepository
|
||||||
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
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 {
|
||||||
|
|
||||||
|
override suspend fun findById(id: UUID): Device? = transaction {
|
||||||
|
DeviceTable.selectAll().where { DeviceTable.id eq id }
|
||||||
|
.map { rowToDevice(it) }
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun findByName(name: String): Device? = transaction {
|
||||||
|
DeviceTable.selectAll().where { DeviceTable.name eq name }
|
||||||
|
.map { rowToDevice(it) }
|
||||||
|
.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(device: Device): Device = transaction {
|
||||||
|
val now = Clock.System.now()
|
||||||
|
val existing = DeviceTable.selectAll().where { DeviceTable.id eq device.id }.singleOrNull()
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
DeviceTable.update({ DeviceTable.id eq device.id }) {
|
||||||
|
it[name] = device.name
|
||||||
|
it[securityKeyHash] = device.securityKeyHash
|
||||||
|
it[role] = device.role
|
||||||
|
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
|
||||||
|
it[updatedAt] = now.toJavaInstant()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DeviceTable.insert {
|
||||||
|
it[id] = device.id
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
device
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
} > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rowToDevice(row: ResultRow): Device = Device(
|
||||||
|
id = row[DeviceTable.id],
|
||||||
|
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())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package at.mocode.identity.service.config
|
package at.mocode.identity.service.config
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.repository.DeviceRepository
|
||||||
import at.mocode.identity.domain.repository.ProfileRepository
|
import at.mocode.identity.domain.repository.ProfileRepository
|
||||||
|
import at.mocode.identity.domain.service.DeviceService
|
||||||
import at.mocode.identity.domain.service.ProfileService
|
import at.mocode.identity.domain.service.ProfileService
|
||||||
|
import at.mocode.identity.infrastructure.persistence.ExposedDeviceRepository
|
||||||
import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository
|
import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
@ -15,4 +18,11 @@ class IdentityConfig {
|
||||||
@Bean
|
@Bean
|
||||||
fun profileService(profileRepository: ProfileRepository): ProfileService =
|
fun profileService(profileRepository: ProfileRepository): ProfileService =
|
||||||
ProfileService(profileRepository)
|
ProfileService(profileRepository)
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun deviceRepository(): DeviceRepository = ExposedDeviceRepository()
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun deviceService(deviceRepository: DeviceRepository): DeviceService =
|
||||||
|
DeviceService(deviceRepository)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package at.mocode.identity.service.web
|
||||||
|
|
||||||
|
import at.mocode.identity.domain.model.Device
|
||||||
|
import at.mocode.identity.domain.model.DeviceRole
|
||||||
|
import at.mocode.identity.domain.service.DeviceService
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/devices")
|
||||||
|
class DeviceController(
|
||||||
|
private val deviceService: DeviceService
|
||||||
|
) {
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
suspend fun registerDevice(@RequestBody request: DeviceRegisterRequest): Device {
|
||||||
|
return deviceService.registerDevice(
|
||||||
|
name = request.name,
|
||||||
|
securityKeyHash = request.securityKeyHash,
|
||||||
|
role = request.role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{name}")
|
||||||
|
suspend fun getDevice(@PathVariable name: String): Device? {
|
||||||
|
return deviceService.getDeviceByName(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DeviceRegisterRequest(
|
||||||
|
val name: String,
|
||||||
|
val securityKeyHash: String,
|
||||||
|
val role: DeviceRole
|
||||||
|
)
|
||||||
118
backend/services/mail/Dockerfile
Normal file
118
backend/services/mail/Dockerfile
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# ===================================================================
|
||||||
|
# Multi-stage Dockerfile for Meldestelle Mail Service
|
||||||
|
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||||
|
ARG GRADLE_VERSION=9.4.1
|
||||||
|
ARG JAVA_VERSION=25
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VERSION=1.0.0-SNAPSHOT
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Build Stage
|
||||||
|
# ===================================================================
|
||||||
|
FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
|
||||||
|
|
||||||
|
ARG VERSION
|
||||||
|
ARG BUILD_DATE
|
||||||
|
|
||||||
|
LABEL stage=builder \
|
||||||
|
service="mail-service" \
|
||||||
|
maintainer="Meldestelle Development Team"
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Gradle optimizations
|
||||||
|
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
||||||
|
-Dorg.gradle.daemon=false \
|
||||||
|
-Dorg.gradle.parallel=true \
|
||||||
|
-Dorg.gradle.workers.max=2 \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx2g \
|
||||||
|
-XX:+UseParallelGC \
|
||||||
|
-XX:MaxMetaspaceSize=512m"
|
||||||
|
ENV GRADLE_USER_HOME=/root/.gradle
|
||||||
|
|
||||||
|
# 1. Copy full project structure for a reliable monorepo build
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN chmod +x gradlew
|
||||||
|
|
||||||
|
# 2. Build the service
|
||||||
|
RUN --mount=type=cache,target=/root/.gradle/caches \
|
||||||
|
--mount=type=cache,target=/root/.gradle/wrapper \
|
||||||
|
./gradlew :backend:services:mail:mail-service:bootJar --no-daemon --info
|
||||||
|
|
||||||
|
# 3. Extract layers
|
||||||
|
WORKDIR /builder
|
||||||
|
RUN cp /workspace/backend/services/mail/mail-service/build/libs/*.jar app.jar && \
|
||||||
|
java -Djarmode=layertools -jar app.jar extract
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Runtime Stage
|
||||||
|
# ===================================================================
|
||||||
|
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
|
||||||
|
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VERSION
|
||||||
|
ARG JAVA_VERSION
|
||||||
|
|
||||||
|
LABEL service="mail-service" \
|
||||||
|
version="${VERSION}" \
|
||||||
|
description="Microservice for Mail and Online Entries" \
|
||||||
|
maintainer="Meldestelle Development Team" \
|
||||||
|
java.version="${JAVA_VERSION}" \
|
||||||
|
build.date="${BUILD_DATE}"
|
||||||
|
|
||||||
|
ARG APP_USER=appuser
|
||||||
|
ARG APP_GROUP=appgroup
|
||||||
|
ARG APP_UID=1001
|
||||||
|
ARG APP_GID=1001
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk update && \
|
||||||
|
apk upgrade && \
|
||||||
|
apk add --no-cache curl tzdata tini && \
|
||||||
|
rm -rf /var/cache/apk/* && \
|
||||||
|
addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||||
|
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
||||||
|
mkdir -p /app/logs /app/tmp /app/config && \
|
||||||
|
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
||||||
|
chmod -R 750 /app
|
||||||
|
|
||||||
|
# Copy Spring Boot layers
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
|
||||||
|
|
||||||
|
USER ${APP_USER}
|
||||||
|
|
||||||
|
EXPOSE 8085 5005
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -fsS --max-time 2 http://localhost:8085/actuator/health/readiness || exit 1
|
||||||
|
|
||||||
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||||
|
-XX:+UseG1GC \
|
||||||
|
-XX:+UseStringDeduplication \
|
||||||
|
-XX:+UseContainerSupport \
|
||||||
|
-Djava.security.egd=file:/dev/./urandom \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
-Dfile.encoding=UTF-8 \
|
||||||
|
-Duser.timezone=Europe/Vienna \
|
||||||
|
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus"
|
||||||
|
|
||||||
|
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
|
||||||
|
SERVER_PORT=8085 \
|
||||||
|
LOGGING_LEVEL_ROOT=INFO
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
||||||
|
echo 'Starting Mail Service with Java ${JAVA_VERSION}...'; \
|
||||||
|
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
||||||
|
echo 'DEBUG mode enabled'; \
|
||||||
|
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
|
||||||
|
else \
|
||||||
|
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
|
||||||
|
fi"]
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
package at.mocode.mail.service.api
|
||||||
|
|
||||||
|
import at.mocode.mail.service.persistence.NennungEntity
|
||||||
|
import at.mocode.mail.service.persistence.NennungRepository
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import jakarta.validation.constraints.Email
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.mail.SimpleMailMessage
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
data class NennungRequest(
|
||||||
|
@field:NotBlank(message = "Turniernummer ist erforderlich")
|
||||||
|
val turnierNr: String,
|
||||||
|
@field:NotBlank(message = "Vorname ist erforderlich")
|
||||||
|
val vorname: String,
|
||||||
|
@field:NotBlank(message = "Nachname ist erforderlich")
|
||||||
|
val nachname: String,
|
||||||
|
@field:NotBlank(message = "Lizenznummer ist erforderlich")
|
||||||
|
val lizenz: String,
|
||||||
|
@field:NotBlank(message = "Pferdename ist erforderlich")
|
||||||
|
val pferdName: String,
|
||||||
|
@field:NotBlank(message = "Pferdealter ist erforderlich")
|
||||||
|
val pferdAlter: String,
|
||||||
|
@field:Email(message = "Ungültiges Email-Format")
|
||||||
|
@field:NotBlank(message = "Email ist erforderlich")
|
||||||
|
val email: String,
|
||||||
|
val telefon: String?,
|
||||||
|
@field:NotBlank(message = "Bewerbe sind erforderlich")
|
||||||
|
val bewerbe: String,
|
||||||
|
val bemerkungen: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mail")
|
||||||
|
@CrossOrigin(origins = ["http://localhost:8080", "https://nennung.mo-code.at"]) // Für Wasm-Web-App (Compose HTML/Wasm)
|
||||||
|
class MailController(
|
||||||
|
private val nennungRepository: NennungRepository,
|
||||||
|
private val mailSender: JavaMailSender
|
||||||
|
) {
|
||||||
|
private val logger = LoggerFactory.getLogger(MailController::class.java)
|
||||||
|
|
||||||
|
@Value("\${spring.mail.username}")
|
||||||
|
private lateinit var baseMailAddress: String
|
||||||
|
|
||||||
|
@PostMapping("/nennung")
|
||||||
|
fun receiveNennung(@Valid @RequestBody request: NennungRequest) {
|
||||||
|
logger.info("Nennung via API erhalten: ${request.vorname} ${request.nachname} für Turnier ${request.turnierNr}")
|
||||||
|
|
||||||
|
val entity = NennungEntity(
|
||||||
|
id = Uuid.random(),
|
||||||
|
turnierNr = request.turnierNr,
|
||||||
|
status = "API_EMPFANGEN",
|
||||||
|
vorname = request.vorname,
|
||||||
|
nachname = request.nachname,
|
||||||
|
lizenz = request.lizenz,
|
||||||
|
pferdName = request.pferdName,
|
||||||
|
pferdAlter = request.pferdAlter,
|
||||||
|
email = request.email,
|
||||||
|
telefon = request.telefon,
|
||||||
|
bewerbe = request.bewerbe,
|
||||||
|
bemerkungen = request.bemerkungen
|
||||||
|
)
|
||||||
|
|
||||||
|
nennungRepository.save(entity)
|
||||||
|
logger.info("Nennung ${entity.id} in Datenbank persistiert.")
|
||||||
|
|
||||||
|
// Bestätigung an Reiter senden
|
||||||
|
try {
|
||||||
|
val message = SimpleMailMessage()
|
||||||
|
|
||||||
|
// Dynamische Absenderadresse mit Plus-Addressing (z.B. online-nennen+26128@mo-code.at)
|
||||||
|
val dynamicFrom = try {
|
||||||
|
val (user, domain) = baseMailAddress.split("@")
|
||||||
|
"$user+${request.turnierNr}@$domain"
|
||||||
|
} catch (_: Exception) {
|
||||||
|
baseMailAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
message.from = dynamicFrom
|
||||||
|
message.setTo(request.email)
|
||||||
|
message.subject = "Bestätigung: Ihre Online-Nennung für Turnier ${request.turnierNr}"
|
||||||
|
message.text = """
|
||||||
|
Sehr geehrte(r) ${request.vorname} ${request.nachname},
|
||||||
|
|
||||||
|
vielen Dank für Ihre Online-Nennung für das Turnier ${request.turnierNr}.
|
||||||
|
|
||||||
|
Ihre Daten:
|
||||||
|
- Pferd: ${request.pferdName}
|
||||||
|
- Bewerbe: ${request.bewerbe}
|
||||||
|
|
||||||
|
Ihre Nennung ist erfolgreich bei uns eingegangen und wird nun verarbeitet.
|
||||||
|
|
||||||
|
Mit freundlichen Grüßen,
|
||||||
|
Ihre Meldestelle
|
||||||
|
""".trimIndent()
|
||||||
|
mailSender.send(message)
|
||||||
|
logger.info("Bestätigungs-Mail an ${request.email} gesendet.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.error("Fehler beim Senden der Bestätigungs-Mail: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/nennungen")
|
||||||
|
fun getAllNennungen(): List<NennungEntity> {
|
||||||
|
return nennungRepository.findAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,34 +2,25 @@ spring:
|
||||||
application:
|
application:
|
||||||
name: mail-service
|
name: mail-service
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:h2:mem:maildb;DB_CLOSE_DELAY=-1
|
url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:maildb;DB_CLOSE_DELAY=-1}
|
||||||
driver-class-name: org.h2.Driver
|
driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.h2.Driver}
|
||||||
username: sa
|
username: ${SPRING_DATASOURCE_USERNAME:sa}
|
||||||
password: ""
|
password: ${SPRING_DATASOURCE_PASSWORD:""}
|
||||||
h2:
|
jpa:
|
||||||
console:
|
hibernate:
|
||||||
enabled: true
|
ddl-auto: update
|
||||||
path: /h2-console
|
show-sql: true
|
||||||
mail:
|
mail:
|
||||||
host: ${MAIL_HOST:imap.world4you.com}
|
host: ${SPRING_MAIL_HOST:smtp.world4you.com}
|
||||||
port: ${MAIL_PORT:993}
|
port: ${SPRING_MAIL_PORT:587}
|
||||||
username: ${MAIL_USERNAME:online-nennen@mo-code.at}
|
username: ${SPRING_MAIL_USERNAME:online-nennen@mo-code.at}
|
||||||
password: ${MAIL_PASSWORD:}
|
password: ${SPRING_MAIL_PASSWORD:}
|
||||||
properties:
|
properties:
|
||||||
mail:
|
mail:
|
||||||
store:
|
|
||||||
protocol: imaps
|
|
||||||
imaps:
|
|
||||||
host: ${MAIL_HOST:imap.world4you.com}
|
|
||||||
port: ${MAIL_PORT:993}
|
|
||||||
ssl:
|
|
||||||
enable: true
|
|
||||||
smtp:
|
smtp:
|
||||||
auth: true
|
auth: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:true}
|
||||||
starttls:
|
starttls:
|
||||||
enable: true
|
enable: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:true}
|
||||||
host-smtp: ${SMTP_HOST:smtp.world4you.com}
|
|
||||||
port-smtp: ${SMTP_PORT:587}
|
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8085
|
port: 8085
|
||||||
|
|
|
||||||
221
dc-backend.yaml
221
dc-backend.yaml
|
|
@ -61,6 +61,7 @@ services:
|
||||||
PING_SERVICE_URL: "http://ping-service:8082"
|
PING_SERVICE_URL: "http://ping-service:8082"
|
||||||
MASTERDATA_SERVICE_URL: "http://masterdata-service:8086"
|
MASTERDATA_SERVICE_URL: "http://masterdata-service:8086"
|
||||||
EVENTS_SERVICE_URL: "http://events-service:8085"
|
EVENTS_SERVICE_URL: "http://events-service:8085"
|
||||||
|
MAIL_SERVICE_URL: "http://mail-service:8085"
|
||||||
ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095"
|
ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095"
|
||||||
RESULTS_SERVICE_URL: "http://results-service:8088"
|
RESULTS_SERVICE_URL: "http://results-service:8088"
|
||||||
BILLING_SERVICE_URL: "http://billing-service:8087"
|
BILLING_SERVICE_URL: "http://billing-service:8087"
|
||||||
|
|
@ -76,6 +77,8 @@ services:
|
||||||
condition: "service_healthy"
|
condition: "service_healthy"
|
||||||
zipkin:
|
zipkin:
|
||||||
condition: "service_healthy"
|
condition: "service_healthy"
|
||||||
|
mail-service:
|
||||||
|
condition: "service_healthy"
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8081/actuator/health/readiness" ]
|
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8081/actuator/health/readiness" ]
|
||||||
|
|
@ -540,78 +543,150 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z
|
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z
|
||||||
|
|
||||||
# --- MICROSERVICE: Scheduling Service ---
|
# --- MICROSERVICE: Mail Service ---
|
||||||
# scheduling-service:
|
mail-service:
|
||||||
# image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/scheduling-service:${DOCKER_TAG:-latest}"
|
image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/mail-service:${DOCKER_TAG:-latest}"
|
||||||
# build:
|
build:
|
||||||
# context: .
|
context: .
|
||||||
# dockerfile: backend/services/scheduling/scheduling-service/Dockerfile
|
dockerfile: backend/services/mail/Dockerfile
|
||||||
# args:
|
args:
|
||||||
# GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
|
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
|
||||||
# JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
|
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
|
||||||
# VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
|
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
|
||||||
# BUILD_DATE: "${DOCKER_BUILD_DATE}"
|
BUILD_DATE: "${DOCKER_BUILD_DATE}"
|
||||||
# labels:
|
labels:
|
||||||
# - "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
|
- "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
|
||||||
# container_name: "${PROJECT_NAME:-meldestelle}-scheduling-service"
|
container_name: "${PROJECT_NAME:-meldestelle}-mail-service"
|
||||||
# restart: unless-stopped
|
restart: unless-stopped
|
||||||
# ports:
|
ports:
|
||||||
# - "${SCHEDULING_PORT:-8084:8084}"
|
- "${MAIL_PORT:-8083:8085}"
|
||||||
# - "${SCHEDULING_DEBUG_PORT:-5013:5013}"
|
- "${MAIL_DEBUG_PORT:-5014:5014}"
|
||||||
# environment:
|
environment:
|
||||||
# SPRING_PROFILES_ACTIVE: "${SCHEDULING_SPRING_PROFILES_ACTIVE:-docker}"
|
SPRING_PROFILES_ACTIVE: "${MAIL_SPRING_PROFILES_ACTIVE:-docker}"
|
||||||
# DEBUG: "${SCHEDULING_DEBUG:-true}"
|
DEBUG: "${MAIL_DEBUG:-true}"
|
||||||
# SERVER_PORT: "${SCHEDULING_SERVER_PORT:-8084}"
|
SERVER_PORT: "${MAIL_SERVER_PORT:-8085}"
|
||||||
# SPRING_APPLICATION_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
|
|
||||||
#
|
# --- KEYCLOAK ---
|
||||||
# # --- KEYCLOAK ---
|
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
|
||||||
# SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
|
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
|
||||||
# SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
|
|
||||||
#
|
# --- CONSUL ---
|
||||||
# # --- CONSUL ---
|
SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
|
||||||
# SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
|
SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
|
||||||
# SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
|
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${MAIL_SERVICE_NAME:-mail-service}"
|
||||||
# SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
|
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${MAIL_CONSUL_PREFER_IP:-true}"
|
||||||
# SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${SCHEDULING_CONSUL_PREFER_IP:-true}"
|
|
||||||
#
|
# - DATENBANK VERBINDUNG -
|
||||||
# # - DATENBANK VERBINDUNG -
|
SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
|
||||||
# SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
|
SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}"
|
||||||
# SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}"
|
SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
|
||||||
# SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
|
|
||||||
#
|
# --- MAIL CONFIG (SMTP) ---
|
||||||
# # --- VALKEY ---
|
SPRING_MAIL_HOST: "${MAIL_SMTP_HOST:-smtp.mo-code.at}"
|
||||||
# SPRING_DATA_VALKEY_HOST: "${VALKEY_SERVER_HOSTNAME:-valkey}"
|
SPRING_MAIL_PORT: "${MAIL_SMTP_PORT:-587}"
|
||||||
# SPRING_DATA_VALKEY_PORT: "${VALKEY_SERVER_PORT:-6379}"
|
SPRING_MAIL_USERNAME: "${MAIL_SMTP_USER:-online-nennen@mo-code.at}"
|
||||||
#
|
SPRING_MAIL_PASSWORD: "${MAIL_SMTP_PASSWORD:-secret}"
|
||||||
# # --- ZIPKIN ---
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
|
||||||
# MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
|
||||||
#
|
|
||||||
# depends_on:
|
# --- ZIPKIN ---
|
||||||
# postgres:
|
MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
|
||||||
# condition: "service_healthy"
|
|
||||||
# keycloak:
|
depends_on:
|
||||||
# condition: "service_healthy"
|
postgres:
|
||||||
# consul:
|
condition: "service_healthy"
|
||||||
# condition: "service_healthy"
|
consul:
|
||||||
# valkey:
|
condition: "service_healthy"
|
||||||
# condition: "service_healthy"
|
zipkin:
|
||||||
# zipkin:
|
condition: "service_healthy"
|
||||||
# condition: "service_healthy"
|
|
||||||
#
|
healthcheck:
|
||||||
# healthcheck:
|
test: [ "CMD", "curl", "-f", "http://localhost:8085/actuator/health/readiness" ]
|
||||||
# test: [ "CMD", "curl", "-f", "http://localhost:8084/actuator/health" ]
|
interval: 15s
|
||||||
# interval: 15s
|
timeout: 5s
|
||||||
# timeout: 5s
|
retries: 5
|
||||||
# retries: 5
|
start_period: 40s
|
||||||
# start_period: 40s
|
|
||||||
#
|
networks:
|
||||||
# networks:
|
meldestelle-network:
|
||||||
# meldestelle-network:
|
aliases:
|
||||||
# aliases:
|
- "mail-service"
|
||||||
# - "scheduling-service"
|
profiles: [ "backend", "all" ]
|
||||||
# profiles: [ "backend", "all" ]
|
volumes:
|
||||||
# volumes:
|
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z
|
||||||
# - ./config/app/base-application.yaml:/workspace/config/application.yml:Z
|
|
||||||
|
# --- MICROSERVICE: Scheduling Service ---
|
||||||
|
scheduling-service:
|
||||||
|
image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/scheduling-service:${DOCKER_TAG:-latest}"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/services/scheduling/scheduling-service/Dockerfile
|
||||||
|
args:
|
||||||
|
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
|
||||||
|
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
|
||||||
|
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
|
||||||
|
BUILD_DATE: "${DOCKER_BUILD_DATE}"
|
||||||
|
labels:
|
||||||
|
- "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
|
||||||
|
container_name: "${PROJECT_NAME:-meldestelle}-scheduling-service"
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${SCHEDULING_PORT:-8084:8084}"
|
||||||
|
- "${SCHEDULING_DEBUG_PORT:-5013:5013}"
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: "${SCHEDULING_SPRING_PROFILES_ACTIVE:-docker}"
|
||||||
|
DEBUG: "${SCHEDULING_DEBUG:-true}"
|
||||||
|
SERVER_PORT: "${SCHEDULING_SERVER_PORT:-8084}"
|
||||||
|
SPRING_APPLICATION_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
|
||||||
|
|
||||||
|
# --- KEYCLOAK ---
|
||||||
|
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
|
||||||
|
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
|
||||||
|
|
||||||
|
# --- CONSUL ---
|
||||||
|
SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
|
||||||
|
SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
|
||||||
|
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
|
||||||
|
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${SCHEDULING_CONSUL_PREFER_IP:-true}"
|
||||||
|
|
||||||
|
# - DATENBANK VERBINDUNG -
|
||||||
|
SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
|
||||||
|
SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}"
|
||||||
|
SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
|
||||||
|
|
||||||
|
# --- VALKEY ---
|
||||||
|
SPRING_DATA_VALKEY_HOST: "${VALKEY_SERVER_HOSTNAME:-valkey}"
|
||||||
|
SPRING_DATA_VALKEY_PORT: "${VALKEY_SERVER_PORT:-6379}"
|
||||||
|
|
||||||
|
# --- ZIPKIN ---
|
||||||
|
MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: "service_healthy"
|
||||||
|
keycloak:
|
||||||
|
condition: "service_healthy"
|
||||||
|
consul:
|
||||||
|
condition: "service_healthy"
|
||||||
|
valkey:
|
||||||
|
condition: "service_healthy"
|
||||||
|
zipkin:
|
||||||
|
condition: "service_healthy"
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "curl", "-f", "http://localhost:8084/actuator/health" ]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
meldestelle-network:
|
||||||
|
aliases:
|
||||||
|
- "scheduling-service"
|
||||||
|
profiles: [ "backend", "all" ]
|
||||||
|
volumes:
|
||||||
|
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z
|
||||||
|
|
||||||
# --- MICROSERVICE: Series Service ---
|
# --- MICROSERVICE: Series Service ---
|
||||||
series-service:
|
series-service:
|
||||||
|
|
@ -635,7 +710,7 @@ services:
|
||||||
SPRING_PROFILES_ACTIVE: "${SERIES_SPRING_PROFILES_ACTIVE:-docker}"
|
SPRING_PROFILES_ACTIVE: "${SERIES_SPRING_PROFILES_ACTIVE:-docker}"
|
||||||
DEBUG: "${SERIES_DEBUG:-true}"
|
DEBUG: "${SERIES_DEBUG:-true}"
|
||||||
SERVER_PORT: "${SERIES_SERVER_PORT:-8089}"
|
SERVER_PORT: "${SERIES_SERVER_PORT:-8089}"
|
||||||
SPRING_APPLICATION_NAME: "series-service"
|
SPRING_APPLICATION_NAME: "${SERIES_SERVICE_NAME:-series-service}"
|
||||||
|
|
||||||
# --- KEYCLOAK ---
|
# --- KEYCLOAK ---
|
||||||
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
|
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,7 @@ und über definierte Schnittstellen kommunizieren.
|
||||||
### PHASE 13: Export & ZNS-Rückmeldung
|
### PHASE 13: Export & ZNS-Rückmeldung
|
||||||
*Ziel: Finalisierung der Turnier-Daten und Rückübermittlung an den OEPS.*
|
*Ziel: Finalisierung der Turnier-Daten und Rückübermittlung an den OEPS.*
|
||||||
|
|
||||||
|
* [x] **Mail-Service Integration:** Online-Nennungen via REST/Mail empfangen und persistieren. ✓ (April 2026)
|
||||||
* [ ] **XML-Export:** Vollständiger B-Satz Export (inkl. Ergebnisse und Platzierungen).
|
* [ ] **XML-Export:** Vollständiger B-Satz Export (inkl. Ergebnisse und Platzierungen).
|
||||||
* [ ] **ZNS-Portal:** Upload-Integration in das OEPS-ZNS.
|
* [ ] **ZNS-Portal:** Upload-Integration in das OEPS-ZNS.
|
||||||
* [ ] **Archivierung:** Langzeit-Archivierung abgeschlossener Turniere.
|
* [ ] **Archivierung:** Langzeit-Archivierung abgeschlossener Turniere.
|
||||||
|
|
@ -354,3 +355,26 @@ und über definierte Schnittstellen kommunizieren.
|
||||||
* [ ] **End-to-End Test:** Online-Nennung (Web) -> E-Mail -> Desktop-Verarbeitung.
|
* [ ] **End-to-End Test:** Online-Nennung (Web) -> E-Mail -> Desktop-Verarbeitung.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### PHASE 5: Desktop-Zentrale & Synchronisation 🔵 IN ARBEIT
|
||||||
|
|
||||||
|
*Ziel: Ein einsatzbereiter Desktop-Client für das Neumarkt-Turnier.*
|
||||||
|
|
||||||
|
#### 🎨 Agent: Frontend Expert
|
||||||
|
|
||||||
|
* [x] **Onboarding UI:** Implementierung des Onboarding-Screens (Name, Key, Backup, Rolle, Sync, Drucker) mit
|
||||||
|
validierten Eingaben.
|
||||||
|
* [x] **Navigation:** Navigations-Rail mit Hover-Tooltips und dedizierten Icons für "Setup" und "Sync".
|
||||||
|
* [x] **Settings:** Persistente Speicherung der Onboarding-Daten in `settings.json`.
|
||||||
|
|
||||||
|
#### 👷 Agent: Backend Developer
|
||||||
|
|
||||||
|
* [x] **Device Management:** Domain-Modell (`Device`), Tabelle (`identity_devices`) und Repository zur
|
||||||
|
Geräteverwaltung implementiert.
|
||||||
|
* [x] **Security Key Auth:** Implementierung des `DeviceSecurityFilter` zur Authentifizierung via `X-Security-Key`
|
||||||
|
Header.
|
||||||
|
* [x] **Onboarding API:** REST-Endpunkte zur Registrierung und Abfrage von Desktop-Instanzen erstellt.
|
||||||
|
|
||||||
|
#### 🧹 Agent: Curator
|
||||||
|
|
||||||
|
* [x] **Dokumentation:** Erstellung der Architektur-Doku für das Onboarding-Backend.
|
||||||
|
|
|
||||||
68
docs/01_Architecture/Onboarding-Backend.md
Normal file
68
docs/01_Architecture/Onboarding-Backend.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Onboarding-Backend & Desktop-Identität
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt die Backend-Infrastruktur für die Identifizierung und Authentifizierung von
|
||||||
|
Desktop-Clients ("Meldestelle-Biest").
|
||||||
|
|
||||||
|
## 🚀 Übersicht
|
||||||
|
|
||||||
|
Im Gegensatz zur Web-App (die via Keycloak/JWT authentifiziert) nutzen die Desktop-Instanzen für die
|
||||||
|
Offline-Synchronisation eine Identität, die während des **Onboarding-Prozesses** lokal vergeben und am Server
|
||||||
|
registriert wird.
|
||||||
|
|
||||||
|
## 🛡️ Authentifizierungs-Mechanismus
|
||||||
|
|
||||||
|
Die Authentifizierung erfolgt über zwei HTTP-Header, die bei jedem Request vom Desktop-Client mitgesendet werden müssen:
|
||||||
|
|
||||||
|
| Header | Beschreibung | Beispiel |
|
||||||
|
|:-----------------|:---------------------------------------------------|:-------------------|
|
||||||
|
| `X-Device-Name` | Der beim Onboarding vergebene Gerätename | `Meldestelle-PC-1` |
|
||||||
|
| `X-Security-Key` | Der beim Onboarding vergebene Sicherheitsschlüssel | `secret-key-123` |
|
||||||
|
|
||||||
|
### DeviceSecurityFilter
|
||||||
|
|
||||||
|
Ein Custom-Security-Filter (`DeviceSecurityFilter`) im Backend extrahiert diese Header und setzt einen Spring Security
|
||||||
|
Kontext mit der Authority `ROLE_DEVICE`.
|
||||||
|
|
||||||
|
## 🛰️ API-Endpunkte (Identity Service)
|
||||||
|
|
||||||
|
### 1. Gerät registrieren
|
||||||
|
|
||||||
|
Wird beim Abschluss des Onboarding-Screens aufgerufen.
|
||||||
|
|
||||||
|
- **URL:** `POST /api/v1/devices/register`
|
||||||
|
- **Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Meldestelle-PC-1",
|
||||||
|
"securityKeyHash": "...",
|
||||||
|
"role": "MASTER"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Hinweis:** Dieser Endpunkt ist `permitAll()`, um die Erstregistrierung zu ermöglichen.
|
||||||
|
|
||||||
|
### 2. Gerät abrufen
|
||||||
|
|
||||||
|
- **URL:** `GET /api/v1/devices/{name}`
|
||||||
|
- **Auth:** Erfordert `ROLE_DEVICE` oder `JWT`.
|
||||||
|
|
||||||
|
## 💾 Datenmodell (Exposed)
|
||||||
|
|
||||||
|
Die Tabelle `identity_devices` speichert die registrierten Instanzen:
|
||||||
|
|
||||||
|
- `id`: Eindeutige UUID.
|
||||||
|
- `name`: Gerätename (eindeutig).
|
||||||
|
- `security_key_hash`: Der Sicherheitsschlüssel (gehasht).
|
||||||
|
- `role`: `MASTER` oder `CLIENT`.
|
||||||
|
- `last_sync_at`: Zeitstempel der letzten erfolgreichen Synchronisation.
|
||||||
|
|
||||||
|
## 🛠️ Local Test-Setup
|
||||||
|
|
||||||
|
Für lokale Tests mit `curl`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8081/api/v1/devices/Meldestelle-PC-1 \
|
||||||
|
-H "X-Device-Name: Meldestelle-PC-1" \
|
||||||
|
-H "X-Security-Key: secret-key-123"
|
||||||
|
```
|
||||||
35
docs/03_Journal/2026-04-15_Billing-Finalisierung.md
Normal file
35
docs/03_Journal/2026-04-15_Billing-Finalisierung.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# 🧹 Session Journal - 15. April 2026 (Nachmittag)
|
||||||
|
|
||||||
|
## 🏗️ Status-Check (Lead Architect)
|
||||||
|
|
||||||
|
- **Phase 13 (Export & Billing):** Die Gebührenlogik wurde für das Neumarkt-Turnier (April 2026) finalisiert.
|
||||||
|
- **ÖTO-Konformität:** Der Sportförderbeitrag gemäß § 16 ÖTO wird nun bei jeder Nennung automatisch verbucht.
|
||||||
|
|
||||||
|
## 👷 Durchgeführte Arbeiten (Backend)
|
||||||
|
|
||||||
|
1. **Billing (billing-domain & service):**
|
||||||
|
- `BuchungsTyp.SPORTFOERDERBEITRAG` zum Enum hinzugefügt.
|
||||||
|
- `TeilnehmerKontoService` um die Validierung für diesen neuen Typ erweitert (automatische Soll-Buchung).
|
||||||
|
2. **Entries (entries-service):**
|
||||||
|
- `NennungUseCases` aktualisiert: Bei jeder Nennungseingabe wird nun automatisch 1,00 EUR Sportförderbeitrag auf das
|
||||||
|
Teilnehmerkonto gebucht, zusätzlich zu Nenngeld und Nachnenngebühr.
|
||||||
|
3. **ZNS-Export (Bewerbe-SCS):**
|
||||||
|
- Prüfung des `B-Satz` Exports im `BewerbeController`. Die Logik zur Generierung des strukturierten Textformats für
|
||||||
|
den OEPS ist vorhanden und nutzt die `ZnsBewerb`-Modelle.
|
||||||
|
|
||||||
|
## 🧐 QA-Status & Bekannte Themen
|
||||||
|
|
||||||
|
- [x] **Billing-Check:** Die automatische Buchungskette (Nennung -> Konto -> Buchung) ist nun vollständig für alle
|
||||||
|
Pflichtgebühren integriert.
|
||||||
|
- [x] **Integrationstests:** `NennungBillingIntegrationTest` wurde an die neue Gebührenlogik angepasst (1,00 EUR
|
||||||
|
Sportförderbeitrag).
|
||||||
|
- [ ] **Export-Validierung:** Der generierte B-Satz muss noch gegen ein offizielles OEPS-Beispiel validiert werden (
|
||||||
|
geplant für die nächste Session).
|
||||||
|
|
||||||
|
## 🧹 Curator's Note
|
||||||
|
|
||||||
|
- Die ROADMAP Phase 13 wurde in der Vormittags-Session bereits aktualisiert.
|
||||||
|
- Der Fokus für morgen liegt auf der **ZNS-Export-Validierung** und der Vorbereitung des **Teilnehmer-Exports** (
|
||||||
|
A-Satz).
|
||||||
|
|
||||||
|
**Abschluss:** Das Billing-System ist "ÖTO-ready" für Neumarkt. 🐎🏦
|
||||||
47
docs/03_Journal/2026-04-15_Desktop-UX-Onboarding.md
Normal file
47
docs/03_Journal/2026-04-15_Desktop-UX-Onboarding.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# 🧹 Session Journal - 15. April 2026 (Desktop UX & Onboarding)
|
||||||
|
|
||||||
|
## 🏗️ Status-Check (Lead Architect)
|
||||||
|
|
||||||
|
- **Workflow-Fokus:** Abkehr vom verfrühten Deployment hin zur ehrlichen "Workflow-First" Entwicklung der Desktop-App.
|
||||||
|
- **Identität & Sicherheit:** Die App verfügt nun über ein robustes Onboarding-System für die lokale Identität und
|
||||||
|
Sicherheit.
|
||||||
|
- **UX-Optimierung:** Die Navigation wurde um Hover-Tooltips erweitert, um die Bedienbarkeit ohne Textlabels in der
|
||||||
|
NavRail zu gewährleisten.
|
||||||
|
|
||||||
|
## 👷 Durchgeführte Arbeiten (Frontend & UX)
|
||||||
|
|
||||||
|
1. **Onboarding & Setup ("Geburtsurkunde"):**
|
||||||
|
- Komplette Neugestaltung des `OnboardingScreen` (v2).
|
||||||
|
- Erfassung von Gerätename, Sicherheitsschlüssel (Shared Secret) und Datenbank-Sicherungspfad.
|
||||||
|
- Integration von interaktiven Auswahl-Dialogen: `JFileChooser` für Pfade und `PrintServiceLookup` für installierte
|
||||||
|
Drucker.
|
||||||
|
- Einführung des `SettingsManager` zur persistenten Speicherung der Einstellungen in `settings.json`.
|
||||||
|
- Implementierung des `OnboardingValidator` zur Sicherstellung valider Pflichtangaben (Name, Key, Backup-Pfad).
|
||||||
|
|
||||||
|
2. **Navigation & Layout:**
|
||||||
|
- Erweiterung der `DesktopNavRail` um ein dediziertes "Setup"-Icon (`AppRegistration`) am unteren Ende.
|
||||||
|
- Auslagerung des Ping-Service ("Sync") als eigenständiges Icon (`WifiTethering`).
|
||||||
|
- Implementierung von **Hover-Tooltips** für alle Navigations-Items (`NavRailItem`) unter Verwendung von Material3
|
||||||
|
`TooltipBox`.
|
||||||
|
- Tooltips sind rechtsbündig (`TooltipAnchorPosition.Right`) positioniert und zeigen den Namen des Moduls ("Admin", "
|
||||||
|
Vereine", "Mails", "Sync", "Setup").
|
||||||
|
|
||||||
|
3. **Code-Qualität & Refactoring:**
|
||||||
|
- Bereinigung veralteter Onboarding-Screens und Konsolidierung auf das v2-Datenmodell.
|
||||||
|
- Integration von `@Preview`-Blöcken direkt in den Screen-Komponenten zur IDE-gestützten Entwicklung.
|
||||||
|
- Erfolgreiche Kompilierung des `meldestelle-desktop` Moduls nach Behebung von Typ-Konflikten.
|
||||||
|
|
||||||
|
## 🧐 QA-Status & Bekannte Themen
|
||||||
|
|
||||||
|
- [x] **Onboarding-Workflow:** App erzwingt Setup bei fehlender Konfiguration.
|
||||||
|
- [x] **Drucker-Anbindung:** Systemdrucker werden korrekt gelistet.
|
||||||
|
- [x] **Tooltip-UX:** Hover-Effekt in der Navigationsleiste ist aktiv und informativ.
|
||||||
|
- [ ] **E2E-Integration:** Die Anbindung des `NennungsEingangScreen` an den echten `mail-service` (Server-Daten abholen)
|
||||||
|
ist der nächste logische Schritt.
|
||||||
|
|
||||||
|
## 🧹 Curator's Note
|
||||||
|
|
||||||
|
- Die Strategie hat sich von "Live-Gang" zurück auf "Ehrliches Desktop-Fundament" verschoben.
|
||||||
|
- Das "Biest" hat jetzt einen Namen und einen Platz für seine Backups. 💾
|
||||||
|
|
||||||
|
**Abschluss:** Onboarding und Basis-Navigation sind "Enterprise-Ready". 🚀
|
||||||
43
docs/03_Journal/2026-04-15_Live-Gang-Vorbereitung.md
Normal file
43
docs/03_Journal/2026-04-15_Live-Gang-Vorbereitung.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# 🧹 Session Journal - 15. April 2026 (Live-Gang Vorbereitung)
|
||||||
|
|
||||||
|
## 🏗️ Status-Check (Lead Architect)
|
||||||
|
|
||||||
|
- **Phase 13 (Export & Mail-Service):** Infrastruktur und Deployment-Vorbereitungen für den Live-Gang des Online-Nennens
|
||||||
|
sind abgeschlossen.
|
||||||
|
- **Ziel erreicht:** Das System kann nun auf dem Produktions-Server deployt werden.
|
||||||
|
|
||||||
|
## 👷 Durchgeführte Arbeiten (DevOps & Frontend)
|
||||||
|
|
||||||
|
1. **Infrastruktur (Docker & Mail):**
|
||||||
|
- Dockerfile für `mail-service` erstellt.
|
||||||
|
- `dc-backend.yaml` um den `mail-service` erweitert (inkl. Postgres-Link, Consul-Discovery und SMTP-Konfiguration).
|
||||||
|
- Port-Kollision zwischen `events-service` und `mail-service` behoben (`mail-service` Host-Port auf 8083).
|
||||||
|
- `.env` und `.env.example` (SSoT) umfassend für alle Microservices (Standard-Ports, Debug, SMTP) vervollständigt.
|
||||||
|
- `application.yaml` im `mail-service` auf SMTP-Versand (World4You Standard) optimiert und IMAP-Reste entfernt.
|
||||||
|
- **Plus-Addressing:** Backend-Logik im `MailController` implementiert, um Mails dynamisch als
|
||||||
|
`online-nennen+[TurnierNr]@mo-code.at` zu versenden.
|
||||||
|
2. **Frontend (Konfigurierbarkeit):**
|
||||||
|
- Common `PlatformConfig` erweitert um `resolveMailServiceUrl`.
|
||||||
|
- Implementierung für Wasm, JS und JVM hinzugefügt, um Backend-URLs zur Laufzeit steuern zu können (Wasm: via global
|
||||||
|
JS variables).
|
||||||
|
- `NennungRemoteRepository` nutzt nun die dynamisch aufgelöste Mail-Service-URL.
|
||||||
|
- Fehlende Projekt-Abhängigkeit (`frontend.core.network`) im `nennung-feature` ergänzt.
|
||||||
|
3. **Sicherheit:**
|
||||||
|
- CORS im `MailController` auf Ziel-Domains eingeschränkt (`nennung.mo-code.at`).
|
||||||
|
- Bean-Validierung für `NennungRequest` (Email-Format, Pflichtfelder) implementiert.
|
||||||
|
4. **Dokumentation:**
|
||||||
|
- `docs/05_Deployment/2026-04-15_Online-Nennung-Deployment.md` erstellt.
|
||||||
|
|
||||||
|
## 🧐 QA-Status & Bekannte Themen
|
||||||
|
|
||||||
|
- [x] **Infrastruktur-Check:** Docker-Stack ist bereit für `up -d mail-service`.
|
||||||
|
- [x] **Frontend-URL:** Die harte Verdrahtung auf `localhost:8085` wurde durch eine flexible Runtime-Konfiguration
|
||||||
|
ersetzt.
|
||||||
|
- [ ] **Mail-Versand:** Der tatsächliche Versand muss in der Ziel-Umgebung mit echten SMTP-Credentials validiert werden.
|
||||||
|
|
||||||
|
## 🧹 Curator's Note
|
||||||
|
|
||||||
|
- Die ROADMAP Phase 13 wurde in der Vormittags-Session bereits aktualisiert.
|
||||||
|
- Das "Biest" ist nun technologisch "Live-ready". 🚀
|
||||||
|
|
||||||
|
**Abschluss:** Online-Nennung bereit für das Neumarkt-Turnier (April 2026). 🐎
|
||||||
29
docs/03_Journal/2026-04-15_Online-Nennung-Integration.md
Normal file
29
docs/03_Journal/2026-04-15_Online-Nennung-Integration.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# 🧹 Session Journal - 15. April 2026
|
||||||
|
|
||||||
|
## 🏗️ Status-Check (Lead Architect)
|
||||||
|
- **Phase 13 (Export & Mail-Service):** Signifikanter Fortschritt. Die Online-Nennung (Web -> Backend) ist nun funktional integriert.
|
||||||
|
- **Deadline-Fokus:** Neumarkt-Turnier (24. April 2026). Das System ist bereit für die ersten Online-Nennungen über die Web-Plattform.
|
||||||
|
|
||||||
|
## 👷 Durchgeführte Arbeiten (Backend & Frontend)
|
||||||
|
1. **Backend (mail-service):**
|
||||||
|
- `MailController` implementiert (`/api/mail/nennung`).
|
||||||
|
- REST-Endpunkt zur direkten Aufnahme von Web-Nennungen (Bypass für Polling-Latenz).
|
||||||
|
- Automatische Bestätigungs-Mails an Reiter via Spring Mail.
|
||||||
|
- Nennungen werden direkt in der Nennungs-Tabelle persistiert.
|
||||||
|
2. **Frontend (nennung-feature):**
|
||||||
|
- `NennungRemoteRepository` (KMP) für Ktor-API-Calls erstellt.
|
||||||
|
- Ktor-Client Abhängigkeiten und Kotlin-Serialization integriert.
|
||||||
|
3. **Frontend (meldestelle-web):**
|
||||||
|
- `WebMainScreen` mit dem Remote-Repository verknüpft.
|
||||||
|
- Echte Datenübertragung statt bloßer Konsolenausgabe.
|
||||||
|
- Erfolgsscreen nach erfolgreichem API-Call.
|
||||||
|
|
||||||
|
## 🧐 QA-Status & Bekannte Themen
|
||||||
|
- [ ] **DI-Check:** Die Koin-Registrierung des `HttpClient` im `nennung-feature` zeigt in der IDE Typ-Inferenz-Probleme (wahrscheinlich KMP/Compose Compiler Sync-Thema). Muss beim Build final validiert werden.
|
||||||
|
- [ ] **CORS:** Im `MailController` auf `*` gesetzt für den Wasm-Prototyp. In Prod auf Domain einschränken.
|
||||||
|
|
||||||
|
## 🧹 Curator's Note
|
||||||
|
- Die `MASTER_ROADMAP` wurde aktualisiert.
|
||||||
|
- Der Fokus für die nächste Session liegt auf dem **Billing-Check** (Gebühren-Validierung für Neumarkt) und dem ersten **Probelauf des ZNS-Exports**.
|
||||||
|
|
||||||
|
**Abschluss:** Das "Biest" ist nun "online-fähig" für Neumarkt. 🚀
|
||||||
|
|
@ -31,6 +31,7 @@ Deine Aufgaben:
|
||||||
5. Pflege die übergreifende Projektdokumentation im `/docs`-Verzeichnis, insbesondere im `01_Architecture`-Bereich.
|
5. Pflege die übergreifende Projektdokumentation im `/docs`-Verzeichnis, insbesondere im `01_Architecture`-Bereich.
|
||||||
6. **Handover:** Stelle Architekturentscheidungen nicht nur als Text, sondern auch als Diagramm (Mermaid/PlantUML) bereit.
|
6. **Handover:** Stelle Architekturentscheidungen nicht nur als Text, sondern auch als Diagramm (Mermaid/PlantUML) bereit.
|
||||||
7. Erstelle und pflege die MASTER ROADMAP. Du bist der "Hüter des Plans". Du delegierst Aufgaben an die spezialisierten Agenten (Backend, Frontend, DevOps, QA), führst sie aber nicht selbst aus, es sei denn, es betrifft direkt die Architektur oder das Build-System.
|
7. Erstelle und pflege die MASTER ROADMAP. Du bist der "Hüter des Plans". Du delegierst Aufgaben an die spezialisierten Agenten (Backend, Frontend, DevOps, QA), führst sie aber nicht selbst aus, es sei denn, es betrifft direkt die Architektur oder das Build-System.
|
||||||
|
8. **Bounded Context Awareness:** Stelle sicher, dass Änderungen immer einem der 6 SCS (Self-Contained Systems) zugeordnet sind und die Grenzen gewahrt bleiben.
|
||||||
|
|
||||||
Don't:
|
Don't:
|
||||||
- Implementiere keine Business-Logik in Backend-Services (→ Backend Developer).
|
- Implementiere keine Business-Logik in Backend-Services (→ Backend Developer).
|
||||||
|
|
|
||||||
70
docs/05_Deployment/2026-04-15_Online-Nennung-Deployment.md
Normal file
70
docs/05_Deployment/2026-04-15_Online-Nennung-Deployment.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# 🚀 Deployment Guide - Online-Nennung (Neumarkt 2026)
|
||||||
|
|
||||||
|
Dieser Guide beschreibt den Prozess zum Deployment des "Biest" Online-Nennung Stacks auf den Produktions-Server.
|
||||||
|
|
||||||
|
## 1. Voraussetzungen
|
||||||
|
|
||||||
|
- Docker & Docker Compose installiert.
|
||||||
|
- Zugriff auf den OEPS SMTP Server oder eine Alternative.
|
||||||
|
- Domain (z.B. `nennung.mo-code.at`) zeigt auf den Server.
|
||||||
|
|
||||||
|
## 2. Infrastruktur (Backend)
|
||||||
|
|
||||||
|
Der Stack wird über `dc-backend.yaml` gestartet.
|
||||||
|
|
||||||
|
### Umgebungsvariablen (`.env` Datei am Server)
|
||||||
|
|
||||||
|
Folgende Variablen müssen gesetzt sein:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Datenbank
|
||||||
|
POSTGRES_USER=pg-user
|
||||||
|
POSTGRES_PASSWORD=dein-geheimes-passwort
|
||||||
|
|
||||||
|
# SMTP (für Bestätigungs-Mails)
|
||||||
|
MAIL_SMTP_HOST=smtp.mo-code.at
|
||||||
|
MAIL_SMTP_PORT=587
|
||||||
|
MAIL_SMTP_USER=online-nennen@mo-code.at
|
||||||
|
MAIL_SMTP_PASSWORD=dein-smtp-passwort
|
||||||
|
```
|
||||||
|
|
||||||
|
### Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f dc-backend.yaml up -d mail-service postgres consul
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Frontend (Wasm Web App)
|
||||||
|
|
||||||
|
Die Web-App kommuniziert direkt mit dem `mail-service`.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :frontend:shells:meldestelle-web:wasmJsBrowserDistribution
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Artefakte liegen in `frontend/shells/meldestelle-web/build/dist/wasmJs/productionExecutable/`.
|
||||||
|
|
||||||
|
### Konfiguration (Laufzeit)
|
||||||
|
|
||||||
|
In der `index.html` oder über ein vorgeschaltetes Nginx können globale Variablen gesetzt werden, um die Backend-URLs zu
|
||||||
|
steuern:
|
||||||
|
|
||||||
|
```html
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.MAIL_SERVICE_URL = "https://nennung.mo-code.at/api/mail";
|
||||||
|
window.API_BASE_URL = "https://nennung.mo-code.at/api";
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Sicherheit & Härtung
|
||||||
|
|
||||||
|
- **CORS:** Der `MailController` ist aktuell für `localhost:8080` und `nennung.mo-code.at` freigeschaltet.
|
||||||
|
- **Reverse Proxy:** Es wird empfohlen, einen Nginx oder Traefik mit SSL (Let's Encrypt) vor den Stack zu schalten.
|
||||||
|
- **Mail-Absender:** Die Absender-Adresse ist im `MailController` hartcodiert auf `online-nennen@mo-code.at`. Dies
|
||||||
|
sollte bei Bedarf angepasst werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Dokumentiert durch den Lead Architect am 15. April 2026.*
|
||||||
BIN
docs/ScreenShots/Cloudflare_Konfig_2026-04-15_12-07.png
Normal file
BIN
docs/ScreenShots/Cloudflare_Konfig_2026-04-15_12-07.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
docs/ScreenShots/World4You-E-Mail-Konfig_2026-04-15_12-08.png
Normal file
BIN
docs/ScreenShots/World4You-E-Mail-Konfig_2026-04-15_12-08.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
|
|
@ -1,6 +1,8 @@
|
||||||
package at.mocode.frontend.core.network
|
package at.mocode.frontend.core.network
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
expect object PlatformConfig {
|
expect object PlatformConfig {
|
||||||
fun resolveApiBaseUrl(): String
|
fun resolveApiBaseUrl(): String
|
||||||
|
fun resolveMailServiceUrl(): String
|
||||||
fun resolveKeycloakUrl(): String
|
fun resolveKeycloakUrl(): String
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,18 @@ actual object PlatformConfig {
|
||||||
return fallbackUrl
|
return fallbackUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun resolveMailServiceUrl(): String {
|
||||||
|
val fromGlobal = try {
|
||||||
|
(globalScope.MAIL_SERVICE_URL as? String)?.trim().orEmpty()
|
||||||
|
} catch (_: dynamic) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
if (fromGlobal.isNotEmpty()) {
|
||||||
|
return fromGlobal.removeSuffix("/")
|
||||||
|
}
|
||||||
|
return "http://localhost:8085"
|
||||||
|
}
|
||||||
|
|
||||||
actual fun resolveKeycloakUrl(): String {
|
actual fun resolveKeycloakUrl(): String {
|
||||||
// 1) Prefer a global JS variable (injected by main.kt via AppConfig)
|
// 1) Prefer a global JS variable (injected by main.kt via AppConfig)
|
||||||
val fromGlobal = try {
|
val fromGlobal = try {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ actual object PlatformConfig {
|
||||||
return "http://localhost:8081"
|
return "http://localhost:8081"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun resolveMailServiceUrl(): String {
|
||||||
|
val env = System.getenv("MAIL_SERVICE_URL")?.trim().orEmpty()
|
||||||
|
if (env.isNotEmpty()) return env.removeSuffix("/")
|
||||||
|
return "http://localhost:8085"
|
||||||
|
}
|
||||||
|
|
||||||
actual fun resolveKeycloakUrl(): String {
|
actual fun resolveKeycloakUrl(): String {
|
||||||
val env = System.getenv("KEYCLOAK_URL")?.trim().orEmpty()
|
val env = System.getenv("KEYCLOAK_URL")?.trim().orEmpty()
|
||||||
if (env.isNotEmpty()) return env.removeSuffix("/")
|
if (env.isNotEmpty()) return env.removeSuffix("/")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ package at.mocode.frontend.core.network
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
actual object PlatformConfig {
|
actual object PlatformConfig {
|
||||||
|
actual fun resolveMailServiceUrl(): String {
|
||||||
|
val fromGlobal = getGlobalMailServiceUrl()
|
||||||
|
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
|
||||||
|
return "http://localhost:8085"
|
||||||
|
}
|
||||||
|
|
||||||
actual fun resolveKeycloakUrl(): String {
|
actual fun resolveKeycloakUrl(): String {
|
||||||
val fromGlobal = getGlobalKeycloakUrl()
|
val fromGlobal = getGlobalKeycloakUrl()
|
||||||
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
|
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
|
||||||
|
|
@ -22,7 +28,7 @@ actual object PlatformConfig {
|
||||||
// 2) Try window location origin (same origin gateway/proxy setup)
|
// 2) Try window location origin (same origin gateway/proxy setup)
|
||||||
val origin = try {
|
val origin = try {
|
||||||
getOrigin()
|
getOrigin()
|
||||||
} catch (e: Throwable) {
|
} catch (_: Throwable) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -47,6 +53,16 @@ private fun getGlobalApiBaseUrl(): String = js(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmJsInterop::class)
|
||||||
|
private fun getGlobalMailServiceUrl(): String = js(
|
||||||
|
"""
|
||||||
|
(function() {
|
||||||
|
var global = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : {}));
|
||||||
|
return (global.MAIL_SERVICE_URL && typeof global.MAIL_SERVICE_URL === 'string') ? global.MAIL_SERVICE_URL : "";
|
||||||
|
})()
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalWasmJsInterop::class)
|
@OptIn(ExperimentalWasmJsInterop::class)
|
||||||
private fun getGlobalKeycloakUrl(): String = js(
|
private fun getGlobalKeycloakUrl(): String = js(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.composeMultiplatform)
|
alias(libs.plugins.composeMultiplatform)
|
||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
|
alias(libs.plugins.kotlinSerialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
group = "at.mocode.clients"
|
group = "at.mocode.clients"
|
||||||
|
|
@ -40,6 +41,7 @@ kotlin {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(projects.frontend.core.designSystem)
|
implementation(projects.frontend.core.designSystem)
|
||||||
implementation(projects.frontend.core.domain)
|
implementation(projects.frontend.core.domain)
|
||||||
|
implementation(projects.frontend.core.network)
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
|
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
|
|
@ -54,6 +56,8 @@ kotlin {
|
||||||
implementation(libs.koin.core)
|
implementation(libs.koin.core)
|
||||||
implementation(libs.koin.compose)
|
implementation(libs.koin.compose)
|
||||||
implementation(libs.koin.compose.viewmodel)
|
implementation(libs.koin.compose.viewmodel)
|
||||||
|
|
||||||
|
implementation(libs.bundles.ktor.client.common)
|
||||||
}
|
}
|
||||||
|
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package at.mocode.frontend.features.nennung.di
|
package at.mocode.frontend.features.nennung.di
|
||||||
|
|
||||||
|
import at.mocode.frontend.features.nennung.domain.NennungRemoteRepository
|
||||||
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
|
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val nennungFeatureModule = module {
|
val nennungFeatureModule = module {
|
||||||
|
single<NennungRemoteRepository> { NennungRemoteRepository(get<HttpClient>()) }
|
||||||
viewModel { NennungViewModel() }
|
viewModel { NennungViewModel() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
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.request.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NennungApiRequest(
|
||||||
|
val turnierNr: 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?
|
||||||
|
)
|
||||||
|
|
||||||
|
class NennungRemoteRepository(private val client: HttpClient) {
|
||||||
|
private val mailServiceUrl = PlatformConfig.resolveMailServiceUrl()
|
||||||
|
|
||||||
|
suspend fun sendeNennung(turnierNr: String, payload: NennungPayload): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
val request = NennungApiRequest(
|
||||||
|
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 senden an den mail-service (URL dynamisch aufgelöst)
|
||||||
|
client.post("$mailServiceUrl/api/mail/nennung") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -761,7 +761,8 @@ private fun VerkaufTabInhalt(artikel: List<VerkaufArtikel>, onMengeChanged: (Ver
|
||||||
IconButton(onClick = { onMengeChanged(art, -1) }, modifier = Modifier.size(20.dp)) {
|
IconButton(onClick = { onMengeChanged(art, -1) }, modifier = Modifier.size(20.dp)) {
|
||||||
Icon(Icons.Default.Remove, contentDescription = "–", modifier = Modifier.size(12.dp))
|
Icon(Icons.Default.Remove, contentDescription = "–", modifier = Modifier.size(12.dp))
|
||||||
}
|
}
|
||||||
Text(art.buchungstext,
|
Text(
|
||||||
|
art.buchungstext,
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,8 @@ fun StammdatenTabContent(
|
||||||
var znsDataLoaded by remember { mutableStateOf(false) }
|
var znsDataLoaded by remember { mutableStateOf(false) }
|
||||||
var znsPayloadVersion by remember { mutableStateOf<String?>(null) }
|
var znsPayloadVersion by remember { mutableStateOf<String?>(null) }
|
||||||
var znsImportedAt by remember { mutableStateOf<String?>(null) }
|
var znsImportedAt by remember { mutableStateOf<String?>(null) }
|
||||||
val znsImportHistory = remember { mutableStateListOf<Triple<String, String, Boolean>>() } // (source, payloadVersion, ok)
|
val znsImportHistory =
|
||||||
|
remember { mutableStateListOf<Triple<String, String, Boolean>>() } // (source, payloadVersion, ok)
|
||||||
var typ by remember { mutableStateOf("ÖTO (National)") }
|
var typ by remember { mutableStateOf("ÖTO (National)") }
|
||||||
|
|
||||||
val sparten = remember { mutableStateListOf<String>() }
|
val sparten = remember { mutableStateListOf<String>() }
|
||||||
|
|
@ -63,10 +64,10 @@ fun StammdatenTabContent(
|
||||||
var titel by remember { mutableStateOf("") }
|
var titel by remember { mutableStateOf("") }
|
||||||
var subTitel by remember { mutableStateOf("") }
|
var subTitel by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// Initialisierung aus Mock-Store (StoreV2/TurnierStoreV2) falls vorhanden
|
// Initialisierung aus Mock-Store (`StoreV2/TurnierStoreV2`) falls vorhanden
|
||||||
LaunchedEffect(turnierId) {
|
LaunchedEffect(turnierId) {
|
||||||
// Da wir in einem anderen Modul sind, können wir nicht direkt auf StoreV2 zugreifen
|
// Da wir in einem anderen Modul sind, können wir nicht direkt auf StoreV2 zugreifen,
|
||||||
// ohne die Abhängigkeit zu haben. In einer echten Architektur käme dies über das Repository.
|
// ohne die Abhängigkeit zu haben. In einer echten Architektur kommt dies über das Repository.
|
||||||
// Aber für die Demo/Fakten-Präsentation im Desktop-Shell-Kontext:
|
// Aber für die Demo/Fakten-Präsentation im Desktop-Shell-Kontext:
|
||||||
try {
|
try {
|
||||||
val clazz = Class.forName("at.mocode.desktop.v2.TurnierStoreV2")
|
val clazz = Class.forName("at.mocode.desktop.v2.TurnierStoreV2")
|
||||||
|
|
@ -76,37 +77,39 @@ fun StammdatenTabContent(
|
||||||
val idField = t!!::class.java.getDeclaredField("turnierNr")
|
val idField = t!!::class.java.getDeclaredField("turnierNr")
|
||||||
idField.isAccessible = true
|
idField.isAccessible = true
|
||||||
idField.get(t).toString() == turnierId.toString() ||
|
idField.get(t).toString() == turnierId.toString() ||
|
||||||
t.hashCode().toLong() == turnierId // Fallback falls ID anders gemappt ist
|
t.hashCode().toLong() == turnierId // Fallback, falls die ID anders gemappt ist
|
||||||
}
|
}
|
||||||
|
|
||||||
if (turnier != null) {
|
when {
|
||||||
val tClass = turnier::class.java
|
turnier != null -> {
|
||||||
|
val tClass = turnier::class.java
|
||||||
|
|
||||||
val nrField = tClass.getDeclaredField("turnierNr")
|
val nrField = tClass.getDeclaredField("turnierNr")
|
||||||
nrField.isAccessible = true
|
nrField.isAccessible = true
|
||||||
turnierNr = nrField.get(turnier).toString()
|
turnierNr = nrField.get(turnier).toString()
|
||||||
nrConfirmed = true
|
nrConfirmed = true
|
||||||
|
|
||||||
val titelField = tClass.getDeclaredField("titel")
|
val titelField = tClass.getDeclaredField("titel")
|
||||||
titelField.isAccessible = true
|
titelField.isAccessible = true
|
||||||
titel = titelField.get(turnier) as String
|
titel = titelField.get(turnier) as String
|
||||||
|
|
||||||
val subField = tClass.getDeclaredField("subTitel")
|
val subField = tClass.getDeclaredField("subTitel")
|
||||||
subField.isAccessible = true
|
subField.isAccessible = true
|
||||||
subTitel = subField.get(turnier) as String
|
subTitel = subField.get(turnier) as String
|
||||||
|
|
||||||
val katField = tClass.getDeclaredField("kategorie")
|
val katField = tClass.getDeclaredField("kategorie")
|
||||||
katField.isAccessible = true
|
katField.isAccessible = true
|
||||||
val kats = katField.get(turnier) as? List<String>
|
val kats = katField.get(turnier) as? List<String>
|
||||||
kats?.let { kat.addAll(it) }
|
kats?.let { kat.addAll(it) }
|
||||||
|
|
||||||
val typField = tClass.getDeclaredField("typ")
|
val typField = tClass.getDeclaredField("typ")
|
||||||
typField.isAccessible = true
|
typField.isAccessible = true
|
||||||
typ = typField.get(turnier) as String
|
typ = typField.get(turnier) as String
|
||||||
|
|
||||||
val znsField = tClass.getDeclaredField("znsDataLoaded")
|
val znsField = tClass.getDeclaredField("znsDataLoaded")
|
||||||
znsField.isAccessible = true
|
znsField.isAccessible = true
|
||||||
znsDataLoaded = znsField.get(turnier) as Boolean
|
znsDataLoaded = znsField.get(turnier) as Boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Reflection fehlgeschlagen oder Store nicht erreichbar -> Fallback auf leere Felder
|
// Reflection fehlgeschlagen oder Store nicht erreichbar -> Fallback auf leere Felder
|
||||||
|
|
@ -118,7 +121,7 @@ fun StammdatenTabContent(
|
||||||
var showZnsDialog by remember { mutableStateOf(false) }
|
var showZnsDialog by remember { mutableStateOf(false) }
|
||||||
var showZnsLog by remember { mutableStateOf(false) }
|
var showZnsLog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Hilfs-States für DatePicker
|
// Hilf's-States für DatePicker
|
||||||
var showDatePickerVon by remember { mutableStateOf(false) }
|
var showDatePickerVon by remember { mutableStateOf(false) }
|
||||||
var showDatePickerBis by remember { mutableStateOf(false) }
|
var showDatePickerBis by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
|
@ -143,29 +146,35 @@ fun StammdatenTabContent(
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = !nrConfirmed
|
enabled = !nrConfirmed
|
||||||
)
|
)
|
||||||
if (!nrConfirmed) {
|
when {
|
||||||
Button(
|
!nrConfirmed -> {
|
||||||
onClick = { showNrConfirm = true },
|
Button(
|
||||||
enabled = turnierNr.length == 5,
|
onClick = { showNrConfirm = true },
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
|
enabled = turnierNr.length == 5,
|
||||||
) {
|
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
|
||||||
Text("Bestätigen")
|
) {
|
||||||
|
Text("Bestätigen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = { },
|
||||||
|
label = { Text("Bestätigt") },
|
||||||
|
trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
InputChip(
|
|
||||||
selected = true,
|
|
||||||
onClick = { },
|
|
||||||
label = { Text("Bestätigt") },
|
|
||||||
trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (turnierNr.length == 5 && !nrConfirmed) {
|
when (turnierNr.length) {
|
||||||
Text(
|
5 if !nrConfirmed -> {
|
||||||
"Bitte Turnier-Nummer bestätigen um fortzufahren.",
|
Text(
|
||||||
color = MaterialTheme.colorScheme.error,
|
"Bitte Turnier-Nummer bestätigen um fortzufahren.",
|
||||||
fontSize = 11.sp
|
color = MaterialTheme.colorScheme.error,
|
||||||
)
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,8 +199,7 @@ fun StammdatenTabContent(
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { showZnsDialog = true },
|
onClick = { showZnsDialog = true },
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue)
|
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), enabled = nrConfirmed
|
||||||
, enabled = nrConfirmed
|
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
|
|
@ -254,7 +262,7 @@ fun StammdatenTabContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
FormRow("Klasse:") {
|
FormRow("Klasse:") {
|
||||||
val klassenListe = listOf("C-NEU", "C", "B", "A", "L", "LM", "M", "S")
|
val klassenListe = listOf("C-NEU", "C", "B", "A")
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
klassenListe.forEach { k ->
|
klassenListe.forEach { k ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
|
|
@ -278,32 +286,37 @@ fun StammdatenTabContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (suggested.isEmpty()) {
|
when {
|
||||||
Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
|
suggested.isEmpty() -> {
|
||||||
} else {
|
Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
|
||||||
// Gruppiere nach Sparte (CDN/CSN)
|
}
|
||||||
val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" }
|
|
||||||
grouped.forEach { (gruppe, eintraege) ->
|
else -> {
|
||||||
Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
|
// Gruppiere nach Sparte (CDN/CSN)
|
||||||
Spacer(Modifier.height(4.dp))
|
val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" }
|
||||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
grouped.forEach { (gruppe, eintraege) ->
|
||||||
eintraege.sorted().forEach { c ->
|
Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
|
||||||
InputChip(
|
Spacer(Modifier.height(4.dp))
|
||||||
selected = kat.contains(c),
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
|
eintraege.sorted().forEach { c ->
|
||||||
enabled = nrConfirmed,
|
InputChip(
|
||||||
label = { Text(c) }
|
selected = kat.contains(c),
|
||||||
)
|
onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
|
||||||
|
enabled = nrConfirmed,
|
||||||
|
label = { Text(c) }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FormRow("Zeitraum:") {
|
FormRow("Zeitraum:") {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
val vonMod = if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp)
|
val vonMod =
|
||||||
|
if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = von,
|
value = von,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
|
|
@ -314,7 +327,8 @@ fun StammdatenTabContent(
|
||||||
trailingIcon = { Icon(Icons.Default.DateRange, null) }
|
trailingIcon = { Icon(Icons.Default.DateRange, null) }
|
||||||
)
|
)
|
||||||
Text("bis")
|
Text("bis")
|
||||||
val bisMod = if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp)
|
val bisMod =
|
||||||
|
if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = bis,
|
value = bis,
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
|
|
@ -325,7 +339,8 @@ fun StammdatenTabContent(
|
||||||
trailingIcon = { Icon(Icons.Default.DateRange, null) }
|
trailingIcon = { Icon(Icons.Default.DateRange, null) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val rangeText = if (eventVon != null && eventBis != null) "Muss zwischen $eventVon – $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen."
|
val rangeText =
|
||||||
|
if (eventVon != null && eventBis != null) "Muss zwischen $eventVon – $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen."
|
||||||
Text(rangeText, fontSize = 11.sp, color = Color.Gray)
|
Text(rangeText, fontSize = 11.sp, color = Color.Gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -335,7 +350,8 @@ fun StammdatenTabContent(
|
||||||
// Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland]
|
// Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland]
|
||||||
val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) {
|
val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) {
|
||||||
val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ")
|
val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ")
|
||||||
listOfNotNull(cats.ifBlank { null },
|
listOfNotNull(
|
||||||
|
cats.ifBlank { null },
|
||||||
listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ")
|
listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ")
|
||||||
.takeIf { it.isNotBlank() }
|
.takeIf { it.isNotBlank() }
|
||||||
).joinToString(" ")
|
).joinToString(" ")
|
||||||
|
|
@ -367,7 +383,12 @@ fun StammdatenTabContent(
|
||||||
supportingText = {
|
supportingText = {
|
||||||
if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) {
|
if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Default.Warning, contentDescription = null, tint = Color(0xFFF59E0B), modifier = Modifier.size(14.dp))
|
Icon(
|
||||||
|
Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFF59E0B),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
Text("Abweichung zum Veranstaltungsort ($eventOrt) – bitte prüfen.", color = Color(0xFFF59E0B))
|
Text("Abweichung zum Veranstaltungsort ($eventOrt) – bitte prüfen.", color = Color(0xFFF59E0B))
|
||||||
}
|
}
|
||||||
|
|
@ -409,14 +430,26 @@ fun StammdatenTabContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Footer ──────────────────────────────────────────────────────────
|
// ── Footer ──────────────────────────────────────────────────────────
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
// Save-Enable-Matrix (kleine Checkliste)
|
// Save-Enable-Matrix (kleine Checkliste)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = {
|
AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = {
|
||||||
Icon(if (nrConfirmed) Icons.Default.Check else Icons.Default.Close, null, tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error)
|
Icon(
|
||||||
|
if (nrConfirmed) Icons.Default.Check else Icons.Default.Close,
|
||||||
|
null,
|
||||||
|
tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
})
|
})
|
||||||
AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = {
|
AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = {
|
||||||
Icon(if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close, null, tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error)
|
Icon(
|
||||||
|
if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close,
|
||||||
|
null,
|
||||||
|
tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
})
|
})
|
||||||
val dateOk = remember(von, bis, eventVon, eventBis) {
|
val dateOk = remember(von, bis, eventVon, eventBis) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -427,10 +460,16 @@ fun StammdatenTabContent(
|
||||||
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
|
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
|
||||||
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
|
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { false }
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = {
|
AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = {
|
||||||
Icon(if (dateOk) Icons.Default.Check else Icons.Default.Close, null, tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error)
|
Icon(
|
||||||
|
if (dateOk) Icons.Default.Check else Icons.Default.Close,
|
||||||
|
null,
|
||||||
|
tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -446,7 +485,9 @@ fun StammdatenTabContent(
|
||||||
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
|
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
|
||||||
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
|
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { false }
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
base && dateValid
|
base && dateValid
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
|
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
|
||||||
|
|
@ -460,88 +501,96 @@ fun StammdatenTabContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog-Simulationen
|
// Dialog-Simulationen
|
||||||
if (showZnsDialog) {
|
when {
|
||||||
AlertDialog(
|
showZnsDialog -> {
|
||||||
onDismissRequest = { showZnsDialog = false },
|
AlertDialog(
|
||||||
title = { Text("ZNS Import") },
|
onDismissRequest = { showZnsDialog = false },
|
||||||
text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
|
title = { Text("ZNS Import") },
|
||||||
confirmButton = {
|
text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
|
||||||
TextButton(onClick = {
|
confirmButton = {
|
||||||
znsDataLoaded = true
|
TextButton(onClick = {
|
||||||
znsPayloadVersion = "v2.4"
|
znsDataLoaded = true
|
||||||
znsImportedAt = java.time.Instant.now().toString()
|
znsPayloadVersion = "v2.4"
|
||||||
znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true))
|
znsImportedAt = java.time.Instant.now().toString()
|
||||||
showZnsDialog = false
|
znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true))
|
||||||
}) { Text("Importieren") }
|
showZnsDialog = false
|
||||||
},
|
}) { Text("Importieren") }
|
||||||
dismissButton = {
|
},
|
||||||
TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
|
dismissButton = {
|
||||||
}
|
TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showNrConfirm) {
|
when {
|
||||||
AlertDialog(
|
showNrConfirm -> {
|
||||||
onDismissRequest = { showNrConfirm = false },
|
AlertDialog(
|
||||||
title = { Text("Turnier-Nummer bestätigen?") },
|
onDismissRequest = { showNrConfirm = false },
|
||||||
text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") },
|
title = { Text("Turnier-Nummer bestätigen?") },
|
||||||
confirmButton = {
|
text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") },
|
||||||
TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") }
|
confirmButton = {
|
||||||
},
|
TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") }
|
||||||
dismissButton = {
|
},
|
||||||
TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") }
|
dismissButton = {
|
||||||
}
|
TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") }
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showZnsLog) {
|
when {
|
||||||
AlertDialog(
|
showZnsLog -> {
|
||||||
onDismissRequest = { showZnsLog = false },
|
AlertDialog(
|
||||||
title = { Text("ZNS Import-Log (letzte 5)") },
|
onDismissRequest = { showZnsLog = false },
|
||||||
text = {
|
title = { Text("ZNS Import-Log (letzte 5)") },
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
text = {
|
||||||
if (znsImportHistory.isEmpty()) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text("Keine Einträge vorhanden.", color = Color.Gray)
|
if (znsImportHistory.isEmpty()) {
|
||||||
} else {
|
Text("Keine Einträge vorhanden.", color = Color.Gray)
|
||||||
znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) ->
|
} else {
|
||||||
val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) ->
|
||||||
Text("• $src – Version $ver – ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp)
|
val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||||
|
Text("• $src – Version $ver – ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
showDatePickerVon -> {
|
||||||
|
val state = rememberDatePickerState()
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showDatePickerVon = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
state.selectedDateMillis?.let {
|
||||||
|
von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
|
||||||
|
}
|
||||||
|
showDatePickerVon = false
|
||||||
|
}) { Text("OK") }
|
||||||
}
|
}
|
||||||
},
|
) { DatePicker(state) }
|
||||||
confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } }
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDatePickerVon) {
|
showDatePickerBis -> {
|
||||||
val state = rememberDatePickerState()
|
val state = rememberDatePickerState()
|
||||||
DatePickerDialog(
|
DatePickerDialog(
|
||||||
onDismissRequest = { showDatePickerVon = false },
|
onDismissRequest = { showDatePickerBis = false },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
state.selectedDateMillis?.let {
|
state.selectedDateMillis?.let {
|
||||||
von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
|
bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
|
||||||
}
|
}
|
||||||
showDatePickerVon = false
|
showDatePickerBis = false
|
||||||
}) { Text("OK") }
|
}) { Text("OK") }
|
||||||
}
|
}
|
||||||
) { DatePicker(state) }
|
) { DatePicker(state) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDatePickerBis) {
|
|
||||||
val state = rememberDatePickerState()
|
|
||||||
DatePickerDialog(
|
|
||||||
onDismissRequest = { showDatePickerBis = false },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
state.selectedDateMillis?.let {
|
|
||||||
bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
|
|
||||||
}
|
|
||||||
showDatePickerBis = false
|
|
||||||
}) { Text("OK") }
|
|
||||||
}
|
|
||||||
) { DatePicker(state) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
7
frontend/shells/meldestelle-desktop/settings.json
Normal file
7
frontend/shells/meldestelle-desktop/settings.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"geraetName": "Meldestelle",
|
||||||
|
"sharedKey": "Meldestelle",
|
||||||
|
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
|
||||||
|
"networkRole": "MASTER",
|
||||||
|
"syncInterval": 20
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import at.mocode.desktop.navigation.DesktopNavigationPort
|
import at.mocode.desktop.navigation.DesktopNavigationPort
|
||||||
import at.mocode.desktop.screens.layout.DesktopMainLayout
|
import at.mocode.desktop.screens.layout.DesktopMainLayout
|
||||||
|
import at.mocode.desktop.screens.onboarding.SettingsManager
|
||||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||||
import at.mocode.frontend.core.auth.presentation.LoginScreen
|
import at.mocode.frontend.core.auth.presentation.LoginScreen
|
||||||
import at.mocode.frontend.core.auth.presentation.LoginViewModel
|
import at.mocode.frontend.core.auth.presentation.LoginViewModel
|
||||||
|
|
@ -34,6 +35,13 @@ fun DesktopApp() {
|
||||||
val currentScreen by nav.currentScreen.collectAsState()
|
val currentScreen by nav.currentScreen.collectAsState()
|
||||||
val loginViewModel: LoginViewModel = koinViewModel()
|
val loginViewModel: LoginViewModel = koinViewModel()
|
||||||
|
|
||||||
|
// Onboarding-Check beim Start
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!SettingsManager.isConfigured()) {
|
||||||
|
nav.navigateToScreen(AppScreen.Onboarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val authState by authTokenManager.authState.collectAsState()
|
val authState by authTokenManager.authState.collectAsState()
|
||||||
|
|
||||||
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
|
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.*
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -16,6 +16,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import at.mocode.desktop.screens.onboarding.OnboardingSettings
|
||||||
|
import at.mocode.desktop.screens.onboarding.SettingsManager
|
||||||
import at.mocode.frontend.core.designsystem.theme.AppColors
|
import at.mocode.frontend.core.designsystem.theme.AppColors
|
||||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||||
import at.mocode.frontend.core.navigation.AppScreen
|
import at.mocode.frontend.core.navigation.AppScreen
|
||||||
|
|
@ -60,9 +62,8 @@ fun DesktopMainLayout(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Onboarding-Eingaben zwischen Navigationswechseln behalten
|
// Onboarding-Daten (On-the-fly geladen oder Default)
|
||||||
var obGeraet by rememberSaveable { mutableStateOf("") }
|
var onboardingSettings by remember { mutableStateOf(SettingsManager.loadSettings() ?: OnboardingSettings()) }
|
||||||
var obKey by rememberSaveable { mutableStateOf("") }
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
// Navigation Rail (Modernere Seitenleiste)
|
// Navigation Rail (Modernere Seitenleiste)
|
||||||
|
|
@ -84,10 +85,8 @@ fun DesktopMainLayout(
|
||||||
currentScreen = currentScreen,
|
currentScreen = currentScreen,
|
||||||
onNavigate = onNavigate,
|
onNavigate = onNavigate,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
obGeraet = obGeraet,
|
onSettingsChange = { onboardingSettings = it },
|
||||||
obKey = obKey,
|
settings = onboardingSettings,
|
||||||
onObGeraetChange = { obGeraet = it },
|
|
||||||
onObKeyChange = { obKey = it },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,15 +150,25 @@ private fun DesktopNavRail(
|
||||||
)
|
)
|
||||||
|
|
||||||
NavRailItem(
|
NavRailItem(
|
||||||
icon = Icons.Default.Settings,
|
icon = Icons.Default.WifiTethering,
|
||||||
label = "Tools",
|
label = "Sync",
|
||||||
selected = currentScreen is AppScreen.Ping,
|
selected = currentScreen is AppScreen.Ping,
|
||||||
onClick = { onNavigate(AppScreen.Ping) }
|
onClick = { onNavigate(AppScreen.Ping) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
NavRailItem(
|
||||||
|
icon = Icons.Default.AppRegistration,
|
||||||
|
label = "Setup",
|
||||||
|
selected = currentScreen is AppScreen.Onboarding,
|
||||||
|
onClick = { onNavigate(AppScreen.Onboarding) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavRailItem(
|
private fun NavRailItem(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
|
|
@ -170,23 +179,35 @@ private fun NavRailItem(
|
||||||
val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent
|
val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent
|
||||||
val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent
|
val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent
|
||||||
|
|
||||||
Surface(
|
TooltipBox(
|
||||||
modifier = Modifier
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
.size(48.dp)
|
positioning = TooltipAnchorPosition.Right
|
||||||
.clickable(onClick = onClick),
|
),
|
||||||
shape = MaterialTheme.shapes.medium,
|
tooltip = {
|
||||||
color = background
|
PlainTooltip {
|
||||||
|
Text(label)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
state = rememberTooltipState()
|
||||||
) {
|
) {
|
||||||
Column(
|
Surface(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center
|
.size(48.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = background
|
||||||
) {
|
) {
|
||||||
Icon(
|
Column(
|
||||||
imageVector = icon,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
contentDescription = label,
|
verticalArrangement = Arrangement.Center
|
||||||
tint = tint,
|
) {
|
||||||
modifier = Modifier.size(Dimens.IconSizeM)
|
Icon(
|
||||||
)
|
imageVector = icon,
|
||||||
|
contentDescription = label,
|
||||||
|
tint = tint,
|
||||||
|
modifier = Modifier.size(Dimens.IconSizeM)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -475,28 +496,20 @@ private fun DesktopContentArea(
|
||||||
currentScreen: AppScreen,
|
currentScreen: AppScreen,
|
||||||
onNavigate: (AppScreen) -> Unit,
|
onNavigate: (AppScreen) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
obGeraet: String,
|
settings: OnboardingSettings,
|
||||||
obKey: String,
|
onSettingsChange: (OnboardingSettings) -> Unit,
|
||||||
onObGeraetChange: (String) -> Unit,
|
|
||||||
onObKeyChange: (String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
// Onboarding ohne Login
|
// Onboarding (Geräte-Setup)
|
||||||
is AppScreen.Onboarding -> {
|
is AppScreen.Onboarding -> {
|
||||||
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject()
|
at.mocode.desktop.v2.OnboardingScreen(
|
||||||
at.mocode.frontend.core.designsystem.theme.AppTheme {
|
settings = settings,
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
onSettingsChange = onSettingsChange,
|
||||||
at.mocode.desktop.v2.OnboardingScreen(
|
onContinue = { finalSettings ->
|
||||||
geraetName = obGeraet,
|
SettingsManager.saveSettings(finalSettings)
|
||||||
secureKey = obKey,
|
onNavigate(AppScreen.VeranstaltungVerwaltung)
|
||||||
onGeraetNameChange = onObGeraetChange,
|
|
||||||
onSecureKeyChange = onObKeyChange,
|
|
||||||
) { _, _ ->
|
|
||||||
authTokenManager.setToken("dummy.jwt.token")
|
|
||||||
onNavigate(AppScreen.VeranstaltungVerwaltung)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Haupt-Zentrale: Veranstaltung-Verwaltung
|
// Haupt-Zentrale: Veranstaltung-Verwaltung
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
package at.mocode.desktop.screens.onboarding
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
enum class ZnsStatus { NONE, LOCAL, SYNCED }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OnboardingScreen(
|
|
||||||
initialName: String = "",
|
|
||||||
initialKey: String = "",
|
|
||||||
initialZns: ZnsStatus = ZnsStatus.NONE,
|
|
||||||
onZnsSync: () -> Unit = {},
|
|
||||||
onZnsUsb: () -> Unit = {},
|
|
||||||
onContinue: (geraetName: String, sharedKey: String, znsStatus: ZnsStatus) -> Unit,
|
|
||||||
) {
|
|
||||||
var geraetName by rememberSaveable { mutableStateOf(initialName) }
|
|
||||||
var sharedKey by rememberSaveable { mutableStateOf(initialKey) }
|
|
||||||
var znsStatus by rememberSaveable { mutableStateOf(initialZns) }
|
|
||||||
var showPassword by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val nameValid = OnboardingValidator.isNameValid(geraetName)
|
|
||||||
val keyValid = OnboardingValidator.isKeyValid(sharedKey)
|
|
||||||
val canContinue = OnboardingValidator.canContinue(geraetName, sharedKey)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text("Onboarding", style = MaterialTheme.typography.headlineSmall)
|
|
||||||
|
|
||||||
Card {
|
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text("Gerätename (Pflicht)", style = MaterialTheme.typography.titleMedium)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = geraetName,
|
|
||||||
onValueChange = { geraetName = it },
|
|
||||||
placeholder = { Text("z. B. Meldestelle") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
isError = !nameValid && geraetName.isNotBlank()
|
|
||||||
)
|
|
||||||
|
|
||||||
Text("Sicherheitsschlüssel (Pflicht)", style = MaterialTheme.typography.titleMedium)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = sharedKey,
|
|
||||||
onValueChange = { sharedKey = it },
|
|
||||||
placeholder = { Text("z. B. Neumarkt2026") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
isError = !keyValid && sharedKey.isNotBlank(),
|
|
||||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
|
||||||
trailingIcon = {
|
|
||||||
val label = if (showPassword) "Verbergen" else "Anzeigen"
|
|
||||||
TextButton(onClick = { showPassword = !showPassword }) {
|
|
||||||
Text(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Text("ZNS-Daten (optional)", style = MaterialTheme.typography.titleMedium)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
AssistChip(onClick = {
|
|
||||||
znsStatus = ZnsStatus.SYNCED
|
|
||||||
onZnsSync()
|
|
||||||
}, label = { Text("Aktualisieren") })
|
|
||||||
AssistChip(onClick = {
|
|
||||||
znsStatus = ZnsStatus.LOCAL
|
|
||||||
onZnsUsb()
|
|
||||||
}, label = { Text("USB-Import") })
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Status: $znsStatus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Button(onClick = { onContinue(geraetName.trim(), sharedKey.trim(), znsStatus) }, enabled = canContinue) {
|
|
||||||
Text("Weiter zu den Veranstaltungen")
|
|
||||||
}
|
|
||||||
if (!canContinue) {
|
|
||||||
Text("Bitte Gerätename (min. 3) und Schlüssel (min. 8) angeben.", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package at.mocode.desktop.screens.onboarding
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class NetworkRole {
|
||||||
|
MASTER,
|
||||||
|
CLIENT
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class OnboardingSettings(
|
||||||
|
val geraetName: String = "",
|
||||||
|
val sharedKey: String = "",
|
||||||
|
val backupPath: String = "",
|
||||||
|
val networkRole: NetworkRole = NetworkRole.CLIENT,
|
||||||
|
val syncInterval: Int = 30, // in Minuten
|
||||||
|
val defaultPrinter: String = ""
|
||||||
|
)
|
||||||
|
|
@ -7,6 +7,8 @@ package at.mocode.desktop.screens.onboarding
|
||||||
* Regeln gemäß Onboarding-Spezifikation:
|
* Regeln gemäß Onboarding-Spezifikation:
|
||||||
* - Gerätename: mindestens 3 Zeichen (nach trim)
|
* - Gerätename: mindestens 3 Zeichen (nach trim)
|
||||||
* - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim)
|
* - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim)
|
||||||
|
* - Backup-Pfad: darf nicht leer sein und muss existieren (Prüfung optional hier)
|
||||||
|
* - Sync-Intervall: zwischen 1 und 60 Minuten
|
||||||
*/
|
*/
|
||||||
object OnboardingValidator {
|
object OnboardingValidator {
|
||||||
|
|
||||||
|
|
@ -16,15 +18,28 @@ object OnboardingValidator {
|
||||||
/** Mindestlänge für den Sicherheitsschlüssel. */
|
/** Mindestlänge für den Sicherheitsschlüssel. */
|
||||||
const val MIN_KEY_LENGTH = 8
|
const val MIN_KEY_LENGTH = 8
|
||||||
|
|
||||||
|
/** Standard-Sync-Intervall in Minuten. */
|
||||||
|
const val DEFAULT_SYNC_INTERVAL = 30
|
||||||
|
|
||||||
/** Gibt `true` zurück, wenn der Gerätename gültig ist. */
|
/** Gibt `true` zurück, wenn der Gerätename gültig ist. */
|
||||||
fun isNameValid(name: String): Boolean = name.trim().length >= MIN_NAME_LENGTH
|
fun isNameValid(name: String): Boolean = name.trim().length >= MIN_NAME_LENGTH
|
||||||
|
|
||||||
/** Gibt `true` zurück, wenn der Sicherheitsschlüssel gültig ist. */
|
/** Gibt `true` zurück, wenn der Sicherheitsschlüssel gültig ist. */
|
||||||
fun isKeyValid(key: String): Boolean = key.trim().length >= MIN_KEY_LENGTH
|
fun isKeyValid(key: String): Boolean = key.trim().length >= MIN_KEY_LENGTH
|
||||||
|
|
||||||
|
/** Gibt `true` zurück, wenn der Backup-Pfad gültig ist. */
|
||||||
|
fun isBackupPathValid(path: String): Boolean = path.isNotBlank()
|
||||||
|
|
||||||
|
/** Gibt `true` zurück, wenn das Sync-Intervall gültig ist. */
|
||||||
|
fun isSyncIntervalValid(interval: Int): Boolean = interval in 1..60
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
|
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
|
||||||
* der „Weiter"-Button aktiviert werden darf.
|
* der „Weiter"-Button aktiviert werden darf.
|
||||||
*/
|
*/
|
||||||
fun canContinue(name: String, key: String): Boolean = isNameValid(name) && isKeyValid(key)
|
fun canContinue(settings: OnboardingSettings): Boolean =
|
||||||
|
isNameValid(settings.geraetName) &&
|
||||||
|
isKeyValid(settings.sharedKey) &&
|
||||||
|
isBackupPathValid(settings.backupPath) &&
|
||||||
|
isSyncIntervalValid(settings.syncInterval)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package at.mocode.desktop.screens.onboarding
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object SettingsManager {
|
||||||
|
private val settingsFile = File("settings.json")
|
||||||
|
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
fun saveSettings(settings: OnboardingSettings) {
|
||||||
|
try {
|
||||||
|
val content = json.encodeToString(settings)
|
||||||
|
settingsFile.writeText(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Fehler beim Speichern der Einstellungen: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSettings(): OnboardingSettings? {
|
||||||
|
if (!settingsFile.exists()) return null
|
||||||
|
return try {
|
||||||
|
val content = settingsFile.readText()
|
||||||
|
json.decodeFromString<OnboardingSettings>(content)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Fehler beim Laden der Einstellungen: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isConfigured(): Boolean {
|
||||||
|
val settings = loadSettings() ?: return false
|
||||||
|
return OnboardingValidator.canContinue(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,136 +5,242 @@ import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material.icons.filled.Search
|
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.*
|
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.desktop.screens.onboarding.NetworkRole
|
||||||
|
import at.mocode.desktop.screens.onboarding.OnboardingSettings
|
||||||
|
import at.mocode.desktop.screens.onboarding.OnboardingValidator
|
||||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
import at.mocode.frontend.core.designsystem.components.MsTextField
|
||||||
|
import javax.print.PrintServiceLookup
|
||||||
|
import javax.swing.JFileChooser
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(
|
fun OnboardingScreen(
|
||||||
geraetName: String,
|
settings: OnboardingSettings,
|
||||||
secureKey: String,
|
onSettingsChange: (OnboardingSettings) -> Unit,
|
||||||
onGeraetNameChange: (String) -> Unit,
|
onContinue: (OnboardingSettings) -> Unit,
|
||||||
onSecureKeyChange: (String) -> Unit,
|
|
||||||
onContinue: (String, String) -> Unit,
|
|
||||||
) {
|
) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(
|
||||||
Text("Onboarding", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Willkommen beim Meldestelle-Biest",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Bitte konfiguriere deine lokale Instanz (Geburtsurkunde).",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
var showPw by remember { mutableStateOf(false) }
|
var showPw by remember { mutableStateOf(false) }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val frName = remember { FocusRequester() }
|
|
||||||
val frKey = remember { FocusRequester() }
|
|
||||||
val frBtn = remember { FocusRequester() }
|
|
||||||
|
|
||||||
MsTextField(
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
value = geraetName,
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
onValueChange = { onGeraetNameChange(it) },
|
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
|
||||||
label = "Gerätename (Pflicht)",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRequester(frName)
|
|
||||||
.onKeyEvent { e ->
|
|
||||||
if (e.type == KeyEventType.KeyUp) {
|
|
||||||
when (e.key) {
|
|
||||||
Key.Tab, Key.Enter -> {
|
|
||||||
focusManager.moveFocus(FocusDirection.Next)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
} else false
|
|
||||||
}
|
|
||||||
,
|
|
||||||
imeAction = ImeAction.Next,
|
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
|
||||||
)
|
|
||||||
MsTextField(
|
|
||||||
value = secureKey,
|
|
||||||
onValueChange = { onSecureKeyChange(it) },
|
|
||||||
label = "Sicherheitsschlüssel (Pflicht)",
|
|
||||||
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
|
||||||
onTrailingIconClick = { showPw = !showPw },
|
|
||||||
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.focusRequester(frKey)
|
|
||||||
.onKeyEvent { e ->
|
|
||||||
if (e.type == KeyEventType.KeyUp) {
|
|
||||||
when (e.key) {
|
|
||||||
Key.Tab -> {
|
|
||||||
focusManager.moveFocus(FocusDirection.Next)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Key.Enter -> {
|
|
||||||
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
|
|
||||||
onContinue(geraetName, secureKey)
|
|
||||||
} else {
|
|
||||||
focusManager.moveFocus(FocusDirection.Next)
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
} else false
|
|
||||||
}
|
|
||||||
,
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
keyboardActions = KeyboardActions(onDone = {
|
|
||||||
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
|
|
||||||
onContinue(geraetName, secureKey)
|
|
||||||
} else {
|
|
||||||
focusManager.moveFocus(FocusDirection.Next)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
val enabled = geraetName.trim().length >= 3 && secureKey.trim().length >= 8
|
MsTextField(
|
||||||
Button(
|
value = settings.geraetName,
|
||||||
onClick = { onContinue(geraetName, secureKey) },
|
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
|
||||||
enabled = enabled,
|
label = "Gerätename (Pflicht)",
|
||||||
modifier = Modifier
|
placeholder = "z. B. Meldestelle-PC-1",
|
||||||
.focusRequester(frBtn)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.onKeyEvent { e ->
|
imeAction = ImeAction.Next,
|
||||||
if (e.type == KeyEventType.KeyUp && (e.key == Key.Enter)) {
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
if (enabled) onContinue(geraetName, secureKey)
|
)
|
||||||
true
|
|
||||||
} else false
|
MsTextField(
|
||||||
}
|
value = settings.sharedKey,
|
||||||
) {
|
onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
|
||||||
Text("Zu den Veranstaltungen")
|
label = "Sicherheitsschlüssel (Pflicht)",
|
||||||
|
placeholder = "Shared Secret für Netzwerk-Sync",
|
||||||
|
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
onTrailingIconClick = { showPw = !showPw },
|
||||||
|
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("⚙️ Lokale Einstellungen", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
MsTextField(
|
||||||
|
value = settings.backupPath,
|
||||||
|
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
|
||||||
|
label = "💾 Datenbank-Sicherungspfad (Backup)",
|
||||||
|
placeholder = "Pfad zum Backup-Verzeichnis",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
trailingIcon = Icons.Default.FolderOpen,
|
||||||
|
onTrailingIconClick = {
|
||||||
|
val chooser = JFileChooser()
|
||||||
|
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
|
chooser.dialogTitle = "Backup-Verzeichnis auswählen"
|
||||||
|
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||||
|
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("🌐 Netzwerk-Rolle", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(
|
||||||
|
selected = settings.networkRole == NetworkRole.MASTER,
|
||||||
|
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Master (Hostet lokale DB)",
|
||||||
|
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) })
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(
|
||||||
|
selected = settings.networkRole == NetworkRole.CLIENT,
|
||||||
|
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Client",
|
||||||
|
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text("📡 Sync-Intervall: ${settings.syncInterval} Minuten", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Slider(
|
||||||
|
value = settings.syncInterval.toFloat(),
|
||||||
|
onValueChange = { onSettingsChange(settings.copy(syncInterval = it.toInt())) },
|
||||||
|
valueRange = 1f..60f,
|
||||||
|
steps = 59,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var showPrinterDialog by remember { mutableStateOf(false) }
|
||||||
|
val availablePrinters = remember {
|
||||||
|
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
MsTextField(
|
||||||
|
value = settings.defaultPrinter,
|
||||||
|
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
|
||||||
|
label = "🖨️ Standard-Drucker",
|
||||||
|
placeholder = "Name des Standard-Druckers",
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
trailingIcon = Icons.Default.Print,
|
||||||
|
onTrailingIconClick = { showPrinterDialog = true },
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
if (OnboardingValidator.canContinue(settings)) onContinue(settings)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (showPrinterDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showPrinterDialog = false },
|
||||||
|
title = { Text("Drucker auswählen") },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
if (availablePrinters.isEmpty()) {
|
||||||
|
Text("Keine Drucker gefunden", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
} else {
|
||||||
|
availablePrinters.forEach { printer ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onSettingsChange(settings.copy(defaultPrinter = printer))
|
||||||
|
showPrinterDialog = false
|
||||||
|
}
|
||||||
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = settings.defaultPrinter == printer,
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(printer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showPrinterDialog = false }) {
|
||||||
|
Text("Schließen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val canContinue = OnboardingValidator.canContinue(settings)
|
||||||
|
Button(
|
||||||
|
onClick = { onContinue(settings) },
|
||||||
|
enabled = canContinue,
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text("Konfiguration speichern & starten")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canContinue) {
|
||||||
|
Text(
|
||||||
|
"Bitte alle Pflichtfelder korrekt ausfüllen (Name min. 3, Key min. 8, Backup-Pfad gesetzt).",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!enabled) Text("Mind. 3 Zeichen für Namen und 8 Zeichen für Schlüssel", color = Color(0xFFB00020))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun OnboardingScreenPreview() {
|
||||||
|
var settings by remember { mutableStateOf(OnboardingSettings()) }
|
||||||
|
OnboardingScreen(
|
||||||
|
settings = settings,
|
||||||
|
onSettingsChange = { settings = it },
|
||||||
|
onContinue = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } }
|
val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } }
|
||||||
if (pferd == null) { Text("Pferd nicht gefunden"); return@DesktopThemeV2 }
|
if (pferd == null) {
|
||||||
|
Text("Pferd nicht gefunden"); return@DesktopThemeV2
|
||||||
|
}
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||||
|
|
@ -144,13 +250,17 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
var editOpen by remember { mutableStateOf(false) }
|
var editOpen by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
|
modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
Text(pferd.name.take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
Text(pferd.name.take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(pferd.name, style = MaterialTheme.typography.titleMedium)
|
Text(pferd.name, style = MaterialTheme.typography.titleMedium)
|
||||||
val l2 = listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ")
|
val l2 =
|
||||||
|
listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ")
|
||||||
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||||
val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ")
|
val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ")
|
||||||
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
||||||
|
|
@ -203,7 +313,9 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } }
|
val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } }
|
||||||
if (r == null) { Text("Reiter nicht gefunden"); return@DesktopThemeV2 }
|
if (r == null) {
|
||||||
|
Text("Reiter nicht gefunden"); return@DesktopThemeV2
|
||||||
|
}
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||||
|
|
@ -213,14 +325,22 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
var editOpen by remember { mutableStateOf(false) }
|
var editOpen by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
val initials = (r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
|
modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val initials =
|
||||||
|
(r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2)
|
||||||
|
.joinToString("")
|
||||||
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text("${r.vorname} ${r.nachname}", style = MaterialTheme.typography.titleMedium)
|
Text("${r.vorname} ${r.nachname}", style = MaterialTheme.typography.titleMedium)
|
||||||
val l2 = listOfNotNull(r.oepsNummer?.let { "OEPS: $it" }, r.feiId?.let { "FEI: $it" }, r.lizenzKlasse.takeIf { it.isNotBlank() } ).joinToString(" · ")
|
val l2 = listOfNotNull(
|
||||||
|
r.oepsNummer?.let { "OEPS: $it" },
|
||||||
|
r.feiId?.let { "FEI: $it" },
|
||||||
|
r.lizenzKlasse.takeIf { it.isNotBlank() }).joinToString(" · ")
|
||||||
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||||
r.verein?.let { Text(it, color = Color(0xFF6B7280)) }
|
r.verein?.let { Text(it, color = Color(0xFF6B7280)) }
|
||||||
}
|
}
|
||||||
|
|
@ -277,7 +397,9 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } }
|
val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } }
|
||||||
if (v == null) { Text("Verein nicht gefunden"); return@DesktopThemeV2 }
|
if (v == null) {
|
||||||
|
Text("Verein nicht gefunden"); return@DesktopThemeV2
|
||||||
|
}
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||||
|
|
@ -287,13 +409,17 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
var editOpen by remember { mutableStateOf(false) }
|
var editOpen by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
|
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(v.name, style = MaterialTheme.typography.titleMedium)
|
Text(v.name, style = MaterialTheme.typography.titleMedium)
|
||||||
val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }.joinToString(" · ")
|
val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }
|
||||||
|
.joinToString(" · ")
|
||||||
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||||
val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
||||||
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
|
||||||
|
|
@ -340,7 +466,12 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||||
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
OutlinedTextField(strasse, { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(
|
||||||
|
strasse,
|
||||||
|
{ strasse = it },
|
||||||
|
label = { Text("Straße / Adresse") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
|
||||||
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
|
||||||
|
|
@ -357,7 +488,9 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
|
fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } }
|
val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } }
|
||||||
if (f == null) { Text("Funktionär nicht gefunden"); return@DesktopThemeV2 }
|
if (f == null) {
|
||||||
|
Text("Funktionär nicht gefunden"); return@DesktopThemeV2
|
||||||
|
}
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||||
|
|
@ -367,14 +500,21 @@ fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
var editOpen by remember { mutableStateOf(false) }
|
var editOpen by remember { mutableStateOf(false) }
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) {
|
Box(
|
||||||
val initials = (f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("")
|
modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
val initials =
|
||||||
|
(f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2)
|
||||||
|
.joinToString("")
|
||||||
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text("${f.vorname} ${f.nachname}", style = MaterialTheme.typography.titleMedium)
|
Text("${f.vorname} ${f.nachname}", style = MaterialTheme.typography.titleMedium)
|
||||||
val l2 = listOfNotNull(f.richterNummer?.let { "Nr. $it" }, f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ")
|
val l2 = listOfNotNull(
|
||||||
|
f.richterNummer?.let { "Nr. $it" },
|
||||||
|
f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ")
|
||||||
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
|
||||||
f.email?.let { Text(it, color = Color(0xFF6B7280)) }
|
f.email?.let { Text(it, color = Color(0xFF6B7280)) }
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +551,12 @@ fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(num, { num = it }, label = { Text("Nummer") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(num, { num = it }, label = { Text("Nummer") }, modifier = Modifier.weight(1f))
|
||||||
OutlinedTextField(qual, { qual = it }, label = { Text("Qualifikation") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(
|
||||||
|
qual,
|
||||||
|
{ qual = it },
|
||||||
|
label = { Text("Qualifikation") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
|
|
@ -500,12 +645,21 @@ fun VeranstalterDetailV2(
|
||||||
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
|
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text((verein.kurzname ?: verein.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
|
Text(
|
||||||
|
(verein.kurzname ?: verein.name).take(2).uppercase(),
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(verein.name, style = MaterialTheme.typography.titleMedium)
|
Text(verein.name, style = MaterialTheme.typography.titleMedium)
|
||||||
val line2 = listOfNotNull("OEPS: ${verein.oepsNummer}", verein.ort, verein.plz, verein.strasse).filter { it.isNotBlank() }.joinToString(" · ")
|
val line2 = listOfNotNull(
|
||||||
|
"OEPS: ${verein.oepsNummer}",
|
||||||
|
verein.ort,
|
||||||
|
verein.plz,
|
||||||
|
verein.strasse
|
||||||
|
).filter { it.isNotBlank() }.joinToString(" · ")
|
||||||
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
|
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
|
||||||
val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
|
||||||
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
|
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
|
||||||
|
|
@ -545,19 +699,59 @@ fun VeranstalterDetailV2(
|
||||||
title = { Text("Veranstalter bearbeiten") },
|
title = { Text("Veranstalter bearbeiten") },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Name") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(value = oeps, onValueChange = { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(
|
||||||
OutlinedTextField(value = logo, onValueChange = { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f))
|
value = oeps,
|
||||||
|
onValueChange = { oeps = it },
|
||||||
|
label = { Text("OEPS-Nummer") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = logo,
|
||||||
|
onValueChange = { logo = it },
|
||||||
|
label = { Text("Logo-URL") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(value = ort, onValueChange = { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(
|
||||||
OutlinedTextField(value = plz, onValueChange = { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
value = ort,
|
||||||
|
onValueChange = { ort = it },
|
||||||
|
label = { Text("Ort") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = plz,
|
||||||
|
onValueChange = { plz = it },
|
||||||
|
label = { Text("PLZ") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
OutlinedTextField(value = strasse, onValueChange = { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth())
|
OutlinedTextField(
|
||||||
|
value = strasse,
|
||||||
|
onValueChange = { strasse = it },
|
||||||
|
label = { Text("Straße / Adresse") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
|
OutlinedTextField(
|
||||||
OutlinedTextField(value = tel, onValueChange = { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text("E-Mail") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = tel,
|
||||||
|
onValueChange = { tel = it },
|
||||||
|
label = { Text("Telefon") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -580,9 +774,9 @@ fun VeranstalterDetailV2(
|
||||||
val q = search.trim()
|
val q = search.trim()
|
||||||
if (q.isEmpty()) events else events.filter {
|
if (q.isEmpty()) events else events.filter {
|
||||||
it.titel.contains(q, ignoreCase = true) ||
|
it.titel.contains(q, ignoreCase = true) ||
|
||||||
it.status.contains(q, ignoreCase = true) ||
|
it.status.contains(q, ignoreCase = true) ||
|
||||||
it.datumVon.contains(q, ignoreCase = true) ||
|
it.datumVon.contains(q, ignoreCase = true) ||
|
||||||
(it.datumBis?.contains(q, ignoreCase = true) == true)
|
(it.datumBis?.contains(q, ignoreCase = true) == true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (filtered.isEmpty()) Text("Keine passenden Veranstaltungen gefunden.", color = Color(0xFF6B7280))
|
if (filtered.isEmpty()) Text("Keine passenden Veranstaltungen gefunden.", color = Color(0xFF6B7280))
|
||||||
|
|
@ -613,7 +807,13 @@ fun VeranstalterDetailV2(
|
||||||
text = { Text("Diese Aktion entfernt die Veranstaltung und alle zugehörigen Turniere im Prototypen.") }
|
text = { Text("Diese Aktion entfernt die Veranstaltung und alle zugehörigen Turniere im Prototypen.") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) }
|
IconButton(onClick = { confirm = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Delete,
|
||||||
|
contentDescription = "Löschen",
|
||||||
|
tint = Color(0xFFDC2626)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,37 +82,54 @@ class OnboardingValidatorTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue false wenn beide Felder leer`() {
|
fun `B2 canContinue false wenn beide Felder leer`() {
|
||||||
assertFalse(OnboardingValidator.canContinue("", ""))
|
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "", sharedKey = "")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue false wenn nur Name gültig`() {
|
fun `B2 canContinue false wenn nur Name gültig`() {
|
||||||
assertFalse(OnboardingValidator.canContinue("Meldestelle", "kurz"))
|
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "kurz")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue false wenn nur Schlüssel gültig`() {
|
fun `B2 canContinue false wenn nur Schlüssel gültig`() {
|
||||||
assertFalse(OnboardingValidator.canContinue("AB", "Neumarkt2026"))
|
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "Neumarkt2026")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue true wenn beide Felder gültig`() {
|
fun `B2 canContinue true wenn beide Felder gültig`() {
|
||||||
assertTrue(OnboardingValidator.canContinue("Meldestelle", "Neumarkt2026"))
|
// Beachte: backupPath muss für true auch gesetzt sein
|
||||||
|
assertTrue(
|
||||||
|
OnboardingValidator.canContinue(
|
||||||
|
OnboardingSettings(
|
||||||
|
geraetName = "Meldestelle",
|
||||||
|
sharedKey = "Neumarkt2026",
|
||||||
|
backupPath = "/tmp"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue false bei Grenzfall Name 2 Zeichen und gültigem Schlüssel`() {
|
fun `B2 canContinue false bei Grenzfall Name 2 Zeichen und gültigem Schlüssel`() {
|
||||||
assertFalse(OnboardingValidator.canContinue("AB", "12345678"))
|
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "12345678")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue false bei gültigem Namen und Grenzfall Schlüssel 7 Zeichen`() {
|
fun `B2 canContinue false bei gültigem Namen und Grenzfall Schlüssel 7 Zeichen`() {
|
||||||
assertFalse(OnboardingValidator.canContinue("Meldestelle", "1234567"))
|
assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "1234567")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue true bei exakten Mindestlängen`() {
|
fun `B2 canContinue true bei exakten Mindestlängen`() {
|
||||||
assertTrue(OnboardingValidator.canContinue("ABC", "12345678"))
|
assertTrue(
|
||||||
|
OnboardingValidator.canContinue(
|
||||||
|
OnboardingSettings(
|
||||||
|
geraetName = "ABC",
|
||||||
|
sharedKey = "12345678",
|
||||||
|
backupPath = "/tmp"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Doppelklick-Schutz (Submit-Guard) ──────────────────────────────────────
|
// ─── Doppelklick-Schutz (Submit-Guard) ──────────────────────────────────────
|
||||||
|
|
@ -120,10 +137,9 @@ class OnboardingValidatorTest {
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
||||||
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
|
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
|
||||||
val name = "Meldestelle"
|
val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
|
||||||
val key = "Neumarkt2026"
|
val first = OnboardingValidator.canContinue(settings)
|
||||||
val first = OnboardingValidator.canContinue(name, key)
|
val second = OnboardingValidator.canContinue(settings)
|
||||||
val second = OnboardingValidator.canContinue(name, key)
|
|
||||||
assertTrue(first)
|
assertTrue(first)
|
||||||
assertTrue(second)
|
assertTrue(second)
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +163,13 @@ class OnboardingValidatorTest {
|
||||||
"Sicherheitsschlüssel muss nach Zurück-Navigation noch gültig sein (rememberSaveable-Fix)"
|
"Sicherheitsschlüssel muss nach Zurück-Navigation noch gültig sein (rememberSaveable-Fix)"
|
||||||
)
|
)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
OnboardingValidator.canContinue(wiederhergestellterName, wiederhergestellterKey),
|
OnboardingValidator.canContinue(
|
||||||
|
OnboardingSettings(
|
||||||
|
geraetName = wiederhergestellterName,
|
||||||
|
sharedKey = wiederhergestellterKey,
|
||||||
|
backupPath = "/tmp"
|
||||||
|
)
|
||||||
|
),
|
||||||
"Weiter-Button muss nach Zurück-Navigation aktiviert bleiben"
|
"Weiter-Button muss nach Zurück-Navigation aktiviert bleiben"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +182,7 @@ class OnboardingValidatorTest {
|
||||||
val nameNachReset = ""
|
val nameNachReset = ""
|
||||||
val keyNachReset = ""
|
val keyNachReset = ""
|
||||||
assertFalse(
|
assertFalse(
|
||||||
OnboardingValidator.canContinue(nameNachReset, keyNachReset),
|
OnboardingValidator.canContinue(OnboardingSettings(geraetName = nameNachReset, sharedKey = keyNachReset)),
|
||||||
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
|
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,18 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import at.mocode.frontend.core.designsystem.theme.AppColors
|
import at.mocode.frontend.core.designsystem.theme.AppColors
|
||||||
import at.mocode.frontend.features.billing.presentation.BillingViewModel
|
import at.mocode.frontend.features.billing.presentation.BillingViewModel
|
||||||
|
import at.mocode.frontend.features.nennung.domain.NennungRemoteRepository
|
||||||
import at.mocode.frontend.features.nennung.presentation.web.OnlineNennungFormular
|
import at.mocode.frontend.features.nennung.presentation.web.OnlineNennungFormular
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun WebMainScreen() {
|
fun WebMainScreen() {
|
||||||
val billingViewModel: BillingViewModel = koinViewModel()
|
val billingViewModel: BillingViewModel = koinViewModel()
|
||||||
|
val nennungRepository: NennungRemoteRepository = koinInject()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
var currentScreen by remember { mutableStateOf<WebScreen>(WebScreen.Landing) }
|
var currentScreen by remember { mutableStateOf<WebScreen>(WebScreen.Landing) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -48,9 +53,15 @@ fun WebMainScreen() {
|
||||||
is WebScreen.Nennung -> OnlineNennungFormular(
|
is WebScreen.Nennung -> OnlineNennungFormular(
|
||||||
turnierNr = screen.turnierId.toString(),
|
turnierNr = screen.turnierId.toString(),
|
||||||
onNennenAbgeschickt = { payload ->
|
onNennenAbgeschickt = { payload ->
|
||||||
// Hier wird später der Mail-Versand oder API-Call integriert
|
scope.launch {
|
||||||
println("Nennung abgeschickt: $payload")
|
val result = nennungRepository.sendeNennung(screen.turnierId.toString(), payload)
|
||||||
currentScreen = WebScreen.Erfolg(payload.email)
|
if (result.isSuccess) {
|
||||||
|
currentScreen = WebScreen.Erfolg(payload.email)
|
||||||
|
} else {
|
||||||
|
// Hier könnte man eine Fehlermeldung anzeigen
|
||||||
|
println("Fehler beim Senden der Nennung: ${result.exceptionOrNull()?.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onBack = { currentScreen = WebScreen.Landing }
|
onBack = { currentScreen = WebScreen.Landing }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user