Compare commits
11 Commits
f98a9075ae
...
edd33c34dc
| Author | SHA1 | Date | |
|---|---|---|---|
| edd33c34dc | |||
| b8bd2744ac | |||
| b2e6c2427b | |||
| 3b7abc55a4 | |||
| 29c35c524b | |||
| f3d5651ab7 | |||
| ba812e230d | |||
| cb4f2f855c | |||
| 10f9e82718 | |||
| eb0fac5989 | |||
| 82a4a13505 |
|
|
@ -0,0 +1,12 @@
|
|||
package at.mocode.zns.importer
|
||||
|
||||
/**
|
||||
* Der Modus des ZNS-Imports.
|
||||
*
|
||||
* [FULL] - Alle Dateien (Vereine, Reiter, Pferde, Funktionäre) werden importiert.
|
||||
* [LIGHT] - Nur Stammdaten (Vereine, Reiter) werden importiert (Performance-Optimiert).
|
||||
*/
|
||||
enum class ZnsImportMode {
|
||||
FULL,
|
||||
LIGHT
|
||||
}
|
||||
|
|
@ -19,10 +19,10 @@ import java.util.zip.ZipInputStream
|
|||
* Domänenobjekte über die jeweiligen Repositories (Upsert-Logik).
|
||||
*
|
||||
* Die Verarbeitungsreihenfolge ist fix:
|
||||
* 1. VEREIN01.DAT → Verein (via VereinRepository)
|
||||
* 2. LIZENZ01.DAT → Reiter (via ReiterRepository)
|
||||
* 3. PFERDE01.DAT → Pferd (via HorseRepository)
|
||||
* 4. RICHT01.DAT → Funktionaer (via FunktionaerRepository)
|
||||
* 1. VEREIN01.DAT → Verein (via VereinRepository)
|
||||
* 2. LIZENZ01.DAT → Reiter (via ReiterRepository)
|
||||
* 3. PFERDE01.DAT → Pferd (via HorseRepository)
|
||||
* 4. RICHT01.DAT → Funktionär (via FunktionaerRepository)
|
||||
*
|
||||
* Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl
|
||||
* im Backend (REST-Upload) als auch in der Compose Desktop App (Offline-Import)
|
||||
|
|
@ -55,6 +55,7 @@ class ZnsImportService(
|
|||
|
||||
/**
|
||||
* Extrahiert die relevanten Dateien aus dem ZIP-Archiv.
|
||||
* Optimiert: Nutzt BufferedReader für zeilenweises Einlesen, ohne das gesamte File in den RAM zu laden.
|
||||
*/
|
||||
fun extrahiereDateien(zipInputStream: InputStream): Map<String, List<String>> {
|
||||
val dateien = mutableMapOf<String, List<String>>()
|
||||
|
|
@ -65,24 +66,25 @@ class ZnsImportService(
|
|||
val fileName = entry.name.uppercase().substringAfterLast("/")
|
||||
|
||||
if (fileName in setOf(FILE_VEREIN, FILE_LIZENZ, FILE_PFERDE, FILE_RICHT)) {
|
||||
val outputStream = java.io.ByteArrayOutputStream()
|
||||
val buffer = ByteArray(4096)
|
||||
var len: Int
|
||||
while (zip.read(buffer).also { len = it } > 0) {
|
||||
outputStream.write(buffer, 0, len)
|
||||
// Wir lesen den Stream direkt zeilenweise mit dem korrekten Encoding
|
||||
val reader = zip.bufferedReader(CP850)
|
||||
val lines = mutableListOf<String>()
|
||||
|
||||
// WICHTIG: Wir dürfen den Reader NICHT schließen (use), da sonst der ZipInputStream geschlossen wird!
|
||||
var line = reader.readLine()
|
||||
while (line != null) {
|
||||
if (line.isNotBlank()) {
|
||||
lines.add(line)
|
||||
}
|
||||
line = reader.readLine()
|
||||
}
|
||||
val content = outputStream.toString(CP850)
|
||||
val lines = content.split(Regex("\\r?\\n|\\r")).filter { it.isNotBlank() }
|
||||
dateien[fileName] = lines
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
} finally {
|
||||
// Wir schließen den ZipInputStream NICHT mit use,
|
||||
// um den zugrunde liegenden zipInputStream nicht vorzeitig zu schließen.
|
||||
// Falls der Aufrufer den Stream schließen will, soll er das tun.
|
||||
// Aber wir müssen sicherstellen, dass wir alle Entries gelesen haben.
|
||||
// Wir schließen den ZipInputStream NICHT hier, sondern überlassen es dem Aufrufer
|
||||
}
|
||||
return dateien
|
||||
}
|
||||
|
|
@ -91,9 +93,13 @@ class ZnsImportService(
|
|||
* Importiert eine ZNS-ZIP-Datei aus einem [InputStream].
|
||||
*
|
||||
* @param zipInputStream Der InputStream der ZIP-Datei.
|
||||
* @param mode Der [ZnsImportMode] (Standard: [ZnsImportMode.FULL]).
|
||||
* @return [ZnsImportResult] mit Statistiken und eventuellen Fehlern.
|
||||
*/
|
||||
suspend fun importiereZip(zipInputStream: InputStream): ZnsImportResult {
|
||||
suspend fun importiereZip(
|
||||
zipInputStream: InputStream,
|
||||
mode: ZnsImportMode = ZnsImportMode.FULL
|
||||
): ZnsImportResult {
|
||||
val dateien = extrahiereDateien(zipInputStream)
|
||||
// println("[DEBUG_LOG] Gefundene Dateien: ${dateien.keys}")
|
||||
// dateien.forEach { (name, lines) -> println("[DEBUG_LOG] Datei $name hat ${lines.size} Zeilen") }
|
||||
|
|
@ -103,8 +109,21 @@ class ZnsImportService(
|
|||
|
||||
val (vereineNeu, vereineUpd) = importiereVereine(dateien[FILE_VEREIN] ?: emptyList(), fehler)
|
||||
val (reiterNeu, reiterUpd) = importiereReiter(dateien[FILE_LIZENZ] ?: emptyList(), fehler, warnungen)
|
||||
val (pferdeNeu, pferdeUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler)
|
||||
val (richterNeu, richterUpd) = importiereFunktionaere(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen)
|
||||
|
||||
var pferdeNeu = 0
|
||||
var pferdeUpd = 0
|
||||
var richterNeu = 0
|
||||
var richterUpd = 0
|
||||
|
||||
if (mode == ZnsImportMode.FULL) {
|
||||
val (pNeu, pUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler)
|
||||
pferdeNeu = pNeu
|
||||
pferdeUpd = pUpd
|
||||
|
||||
val (rNeu, rUpd) = importiereFunktionaere(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen)
|
||||
richterNeu = rNeu
|
||||
richterUpd = rUpd
|
||||
}
|
||||
|
||||
return ZnsImportResult(
|
||||
vereineImportiert = vereineNeu,
|
||||
|
|
|
|||
|
|
@ -10,13 +10,22 @@ import kotlin.time.Instant
|
|||
data class Device(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val name: String,
|
||||
val expectedName: String? = null, // Falls vom Master vor-registriert
|
||||
val securityKeyHash: String, // Gehasht für Sicherheit
|
||||
val role: DeviceRole = DeviceRole.CLIENT,
|
||||
val lastSyncAt: Instant? = null,
|
||||
val isOnline: Boolean = false,
|
||||
val isSynchronized: Boolean = true,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant = createdAt
|
||||
)
|
||||
|
||||
enum class DeviceRole {
|
||||
MASTER, CLIENT
|
||||
MASTER,
|
||||
CLIENT,
|
||||
RICHTER,
|
||||
ZEITNEHMER,
|
||||
STALLMEISTER,
|
||||
ANZEIGE,
|
||||
PARCOURS_CHEF
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,13 @@ object DeviceTable : Table("identity_devices") {
|
|||
override val primaryKey = PrimaryKey(id)
|
||||
|
||||
val name = varchar("name", 100).uniqueIndex()
|
||||
val expectedName = varchar("expected_name", 100).nullable()
|
||||
val securityKeyHash = varchar("security_key_hash", 255)
|
||||
val role = enumerationByName("role", 20, DeviceRole::class)
|
||||
|
||||
val lastSyncAt = timestamp("last_sync_at").nullable()
|
||||
val isOnline = bool("is_online").default(false)
|
||||
val isSynchronized = bool("is_synchronized").default(true)
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,18 +33,24 @@ class ExposedDeviceRepository : DeviceRepository {
|
|||
if (existing != null) {
|
||||
DeviceTable.update({ DeviceTable.id eq device.id }) {
|
||||
it[name] = device.name
|
||||
it[expectedName] = device.expectedName
|
||||
it[securityKeyHash] = device.securityKeyHash
|
||||
it[role] = device.role
|
||||
it[lastSyncAt] = device.lastSyncAt
|
||||
it[isOnline] = device.isOnline
|
||||
it[isSynchronized] = device.isSynchronized
|
||||
it[updatedAt] = now
|
||||
}
|
||||
} else {
|
||||
DeviceTable.insert {
|
||||
it[id] = device.id
|
||||
it[name] = device.name
|
||||
it[expectedName] = device.expectedName
|
||||
it[securityKeyHash] = device.securityKeyHash
|
||||
it[role] = device.role
|
||||
it[lastSyncAt] = device.lastSyncAt
|
||||
it[isOnline] = device.isOnline
|
||||
it[isSynchronized] = device.isSynchronized
|
||||
it[createdAt] = device.createdAt
|
||||
it[updatedAt] = now
|
||||
}
|
||||
|
|
@ -62,9 +68,12 @@ class ExposedDeviceRepository : DeviceRepository {
|
|||
private fun rowToDevice(row: ResultRow): Device = Device(
|
||||
id = row[DeviceTable.id],
|
||||
name = row[DeviceTable.name],
|
||||
expectedName = row[DeviceTable.expectedName],
|
||||
securityKeyHash = row[DeviceTable.securityKeyHash],
|
||||
role = row[DeviceTable.role],
|
||||
lastSyncAt = row[DeviceTable.lastSyncAt],
|
||||
isOnline = row[DeviceTable.isOnline],
|
||||
isSynchronized = row[DeviceTable.isSynchronized],
|
||||
createdAt = row[DeviceTable.createdAt],
|
||||
updatedAt = row[DeviceTable.updatedAt]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@ USER ${APP_USER}
|
|||
|
||||
EXPOSE 8095 5005
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
|
||||
CMD curl -fsS --max-time 2 http://localhost:8095/actuator/health/readiness || exit 1
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD curl -fsS --max-time 5 http://localhost:${SERVER_PORT:-8095}/actuator/health/readiness || exit 1
|
||||
|
||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||
-XX:+UseG1GC \
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package at.mocode.zns.import.service.api
|
|||
import at.mocode.zns.import.service.job.ImportJob
|
||||
import at.mocode.zns.import.service.job.ImportJobRegistry
|
||||
import at.mocode.zns.import.service.job.ZnsImportOrchestrator
|
||||
import at.mocode.zns.importer.ZnsImportMode
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
|
|
@ -23,9 +24,12 @@ class ZnsImportController(
|
|||
* Rückgabe: 202 Accepted mit JobId.
|
||||
*/
|
||||
@PostMapping(consumes = ["multipart/form-data"])
|
||||
fun starteImport(@RequestParam("file") file: MultipartFile): ResponseEntity<ImportStartResponse> {
|
||||
fun starteImport(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam("mode", defaultValue = "FULL") mode: ZnsImportMode
|
||||
): ResponseEntity<ImportStartResponse> {
|
||||
val job = jobRegistry.erstelleJob()
|
||||
orchestrator.starteImport(job.jobId, file.bytes)
|
||||
orchestrator.starteImport(job.jobId, file.bytes, mode)
|
||||
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ImportStartResponse(job.jobId))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package at.mocode.zns.import.service.job
|
||||
|
||||
import at.mocode.zns.importer.ZnsImportResult
|
||||
import at.mocode.zns.importer.ZnsImportMode
|
||||
import at.mocode.zns.importer.ZnsImportService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -19,7 +19,7 @@ class ZnsImportOrchestrator(
|
|||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
fun starteImport(jobId: String, zipBytes: ByteArray) {
|
||||
fun starteImport(jobId: String, zipBytes: ByteArray, mode: ZnsImportMode = ZnsImportMode.FULL) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.ENTPACKEN, "Entpacke ZIP-Datei...", 5)
|
||||
|
|
@ -28,7 +28,7 @@ class ZnsImportOrchestrator(
|
|||
archiviereZip(zipBytes)
|
||||
|
||||
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.VERARBEITUNG, "Verarbeite ZNS-Daten...", 20)
|
||||
val result = service.importiereZip(zipBytes.inputStream())
|
||||
val result = service.importiereZip(zipBytes.inputStream(), mode)
|
||||
|
||||
jobRegistry.aktualisiereStatus(
|
||||
jobId, ImportJobStatus.ABGESCHLOSSEN,
|
||||
|
|
|
|||
|
|
@ -28,9 +28,12 @@ spring:
|
|||
discovery:
|
||||
enabled: ${CONSUL_ENABLED:true}
|
||||
register: ${CONSUL_ENABLED:true}
|
||||
prefer-ip-address: ${SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS:true}
|
||||
service-name: ${spring.application.name}
|
||||
health-check-path: /actuator/health
|
||||
health-check-interval: 10s
|
||||
instance-id: ${spring.application.name}-${server.port}-${random.uuid}
|
||||
health-check-interval: 15s
|
||||
instance-id: ${spring.application.name}:${server.port}:${random.uuid}
|
||||
hostname: ${SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME:localhost}
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
|
|
@ -39,6 +42,8 @@ management:
|
|||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
probes:
|
||||
enabled: true
|
||||
|
||||
app:
|
||||
service-name: ${spring.application.name}
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ services:
|
|||
condition: "service_healthy"
|
||||
zipkin:
|
||||
condition: "service_healthy"
|
||||
mail-service:
|
||||
condition: "service_healthy"
|
||||
# mail-service:
|
||||
# condition: "service_healthy"
|
||||
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8081/actuator/health/readiness" ]
|
||||
|
|
@ -352,6 +352,7 @@ services:
|
|||
SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
|
||||
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${ZNS_IMPORT_SERVICE_NAME:-zns-import-service}"
|
||||
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${ZNS_IMPORT_CONSUL_PREFER_IP:-true}"
|
||||
SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: "${ZNS_IMPORT_HOSTNAME:-zns-import-service}"
|
||||
|
||||
# - DATENBANK VERBINDUNG -
|
||||
SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
|
||||
|
|
@ -381,11 +382,11 @@ services:
|
|||
condition: "service_healthy"
|
||||
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8095/actuator/health/readiness" ]
|
||||
interval: 15s
|
||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8095/actuator/health/readiness" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
start_period: 30s
|
||||
|
||||
networks:
|
||||
meldestelle-network:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
# ADR-0023: ZNS-First Enrollment Pattern (ZNS-Light)
|
||||
|
||||
## Status
|
||||
|
||||
Akzeptiert
|
||||
|
||||
## Kontext
|
||||
|
||||
Die Anlage einer neuen pferdesportlichen Veranstaltung erfordert eine solide Datenbasis (ZNS-Daten für Vereine, Reiter,
|
||||
Pferde etc.). Der bisherige Ansatz (erst Veranstaltung anlegen, dann Daten importieren) führte zu Konsistenz-Problemen,
|
||||
da der Veranstalter (Verein) oft bereits im ZNS-Stamm existiert.
|
||||
Darüber hinaus dauert ein vollständiger ZNS-Import aufgrund der Datenmenge (~20 Minuten) zu lange, um ihn in einen
|
||||
Echtzeit-Wizard einzubauen.
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Wir führen das **ZNS-First Enrollment Pattern** ein:
|
||||
|
||||
1. Der Wizard beginnt mit dem Import der ZNS-Daten.
|
||||
2. Um Performance sicherzustellen, wird im ersten Schritt ein **"ZNS-Light" Import** durchgeführt (nur `VEREIN01.dat`
|
||||
und `LIZENZ01.dat`).
|
||||
3. Die importierten Daten werden in einer **Staging-Area (Backend-Buffer)** temporär vorgehalten.
|
||||
4. Erst bei der Finalisierung der Veranstaltungs-Metadaten wird die UUID generiert und die mandantenfähige Datenbank
|
||||
provisioniert.
|
||||
5. Massive Datensätze (Pferde, Richter) werden **lazy** nachgeladen.
|
||||
|
||||
## Konsequenzen
|
||||
|
||||
* **Positiv:** Höhere Datenqualität (Veranstalter wird sofort korrekt gematcht).
|
||||
* **Positiv:** Minimale Wartezeit im Wizard durch selektiven Import.
|
||||
* **Negativ:** Backend muss Staging-Funktionalität für unfertige Wizards bereitstellen.
|
||||
* **Frontend:** Der `VeranstaltungNeuScreen` wird zum zentralen Orchestrator dieses 3-Schritt-Flows.
|
||||
39
docs/99_Journal/2026-04-16_Explicit-Device-Enrollment.md
Normal file
39
docs/99_Journal/2026-04-16_Explicit-Device-Enrollment.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
type: Journal
|
||||
status: ACTIVE
|
||||
owner: Curator
|
||||
created: 2026-04-16
|
||||
---
|
||||
|
||||
# Journal — 16. April 2026 (Explicit Device Enrollment)
|
||||
|
||||
## 🎯 Ziel & Entscheidung
|
||||
|
||||
Implementierung des **"Explicit Device Enrollment"**-Konzepts im Onboarding-Prozess.
|
||||
Ein Master-Gerät definiert nun vorab, welche Clients (Name & Rolle) im lokalen Netzwerk erwartet werden.
|
||||
Dies erhöht die Sicherheit und automatisiert die Feature-Freischaltung auf den Clients.
|
||||
|
||||
## 🏗️ Architektur-Änderungen
|
||||
|
||||
- **Backend (Identity-Service):**
|
||||
- `DeviceRole` wurde um fachspezifische Rollen erweitert (`RICHTER`, `ZEITNEHMER`, `STALLMEISTER`, `ANZEIGE`,
|
||||
`PARCOURS_CHEF`).
|
||||
- `DeviceTable` (Exposed) und `Device`-Modell enthalten nun `expectedName`, `isOnline` und `isSynchronized`.
|
||||
- **Frontend (Desktop-App):**
|
||||
- `OnboardingSettings` speichert nun eine Liste von `ExpectedClient`.
|
||||
- `OnboardingScreen` (v2) bietet Master-Geräten eine Tabelle zum Verwalten dieser Liste.
|
||||
- `OnboardingValidator` stellt sicher, dass alle erwarteten Geräte einen Namen haben, bevor die Konfiguration
|
||||
gespeichert wird.
|
||||
|
||||
## 🧪 Verifikation
|
||||
|
||||
- Erweiterte Unit-Tests in `OnboardingValidatorTest` decken die Validierung der Client-Liste ab (24/24 Tests grün).
|
||||
- UI-Komponenten (Dropdown für Rollen, Add/Delete-Actions) wurden in `Screens.kt` integriert.
|
||||
|
||||
## 🔗 Relevante Dateien
|
||||
|
||||
- `backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/Device.kt`
|
||||
-
|
||||
`backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/DeviceTable.kt`
|
||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/onboarding/OnboardingSettings.kt`
|
||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt`
|
||||
46
docs/99_Journal/2026-04-16_Session_Abschluss.md
Normal file
46
docs/99_Journal/2026-04-16_Session_Abschluss.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# 📓 Journal-Eintrag: 2026-04-16 - ZNS-First & Onboarding-Evolution
|
||||
|
||||
## 🏗️ Status Quo
|
||||
|
||||
In dieser Session haben wir die Kern-Workflows für den Turnier-Start (Onboarding & Veranstaltungs-Anlage) auf ein
|
||||
professionelles Niveau gehoben. Der Fokus lag auf Performance ("ZNS-Light"), Architektur-Sauberkeit ("Decoupling") und
|
||||
UX ("Role-based Onboarding").
|
||||
|
||||
## 🚀 Wichtigste Errungenschaften
|
||||
|
||||
### 1. ZNS-First Enrollment (ADR 0023)
|
||||
|
||||
- **Problem**: Der Import aller ZNS-Daten (Pferde/Richter) dauerte bis zu 20 Minuten.
|
||||
- **Lösung**: Einführung von `ZnsImportMode.LIGHT`. Es werden nur Vereine und Lizenzen geladen, was den Initial-Import
|
||||
auf wenige Sekunden verkürzt.
|
||||
- **UI**: Der Veranstaltung-Wizard priorisiert nun den ZIP/DAT-Upload als ersten Schritt.
|
||||
|
||||
### 2. Architektur & Stabilität
|
||||
|
||||
- **Entkopplung**: `veranstaltung-feature` greift nun über ein Interface (`ZnsImportProvider`) auf den Importer zu.
|
||||
Keine zirkulären oder unerlaubten Feature-Abhängigkeiten mehr.
|
||||
- **Docker-Readiness**: Der `zns-import-service` ist nun vollständig Docker-kompatibel (Health-Checks, Consul-Discovery
|
||||
und Streaming-Extraktion für große Dateien).
|
||||
- **Connectivity**: Der Offline-Status-Bug im Footer wurde durch korrekte API-Gateway-Pfade behoben.
|
||||
|
||||
### 3. Dynamisches Onboarding (ADR 0024)
|
||||
|
||||
- **Master/Client Split**: Der Onboarding-Prozess unterscheidet nun explizit zwischen Master (Host) und Client.
|
||||
- **mDNS Discovery**: Clients müssen ihren Namen nicht mehr raten, sondern wählen freie Slots direkt aus einer Liste,
|
||||
die via `NetworkDiscoveryService` vom Master bereitgestellt wird.
|
||||
|
||||
## 🛠️ Technische Details
|
||||
|
||||
- **Module**: `frontend:shells:meldestelle-desktop`, `frontend:features:zns-import-feature`,
|
||||
`backend:services:zns-import`.
|
||||
- **Technologien**: Compose Desktop, Koin, Kotlinx-Serialization, Spring Boot Actuator, Docker-Compose.
|
||||
|
||||
## 🏁 Fazit & Ausblick
|
||||
|
||||
Die Basis für die Turnier-Verwaltung ist nun "einzementiert". Als nächstes können wir uns auf die fachliche
|
||||
Turnier-Anlage (Pferde/Richter Zuordnung) konzentrieren, wobei die Daten nun effizient im Hintergrund geladen werden.
|
||||
|
||||
---
|
||||
**🧹 [Curator]**: Dokumentation abgeschlossen. Journal-Eintrag erstellt.
|
||||
**👷 [Backend Developer]**: Alle Services sind unter Docker `healthy`.
|
||||
**🏗️ [Lead Architect]**: Architektur-Vorgaben (ADR 0023/0024) erfolgreich umgesetzt.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
type: Journal
|
||||
status: ACTIVE
|
||||
owner: Curator
|
||||
created: 2026-04-16
|
||||
---
|
||||
|
||||
# Journal — 16. April 2026 (Veranstaltungs-Verwaltung Refactoring)
|
||||
|
||||
## 🎯 Ziel & Entscheidung
|
||||
|
||||
Überarbeitung der **Veranstaltungs-Verwaltung** gemäß der neuen UI-Vision (High-Density & Desktop-First).
|
||||
Ziel war es, die Navigation effizienter zu gestalten (Double-Click Navigation) und den Wizard für die Neuanlage
|
||||
funktional auszubauen (Stammdaten-Validierung).
|
||||
|
||||
## 🎨 UI/UX Änderungen
|
||||
|
||||
- **VeranstaltungenScreen:**
|
||||
- Titel auf "Veranstaltungen - verwalten" aktualisiert (Vorgabe: Bindestrich + Kleinschreibung des Verbs).
|
||||
- Entfernung der redundanten Navigations-Buttons (Reiter, Verein, ZNS-Importer) im Header zur Reduzierung der
|
||||
kognitiven Last.
|
||||
- Einführung der `VeranstaltungCard` mit Logo-Platzhalter und Hover-Feedback.
|
||||
- Implementierung von **Double-Click Navigation** zum Öffnen einer Veranstaltung.
|
||||
- Radikale Entschlackung: Platzhalter wurden durch eine saubere Liste/Grid-Logik ersetzt.
|
||||
- Integration des primären Action-Buttons "+ Neue Veranstaltung" im Header.
|
||||
|
||||
- **VeranstaltungNeuScreen (Wizard):**
|
||||
- Umstellung auf einen tab-basierten Workflow (Stammdaten | Organisation | Preisliste).
|
||||
- Implementierung des Stammdaten-Formulars (A-Satz) mit Pflichtfeld-Validierung (Name, Ort, Datum).
|
||||
- Integration der `MsTextField` und `MsButton` Komponenten aus dem Design-System.
|
||||
- Vorbereitung für ZNS-Import Integration.
|
||||
|
||||
## 🏗️ Technische Details
|
||||
|
||||
- **State Management:** Nutzung von `remember` und `mutableStateOf` für die Formular-Validierung im Screen.
|
||||
- **Modelle:** Einführung von `VeranstaltungSimpleUiModel` zur Entkopplung von Domain-Modellen in der UI.
|
||||
- **Komponenten:** Nutzung von `combinedClickable` für Desktop-spezifische Interaktionen.
|
||||
|
||||
## 🔗 Relevante Dateien
|
||||
|
||||
-
|
||||
`frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungenScreen.kt`
|
||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt` (Zentrale
|
||||
Desktop-Ansicht)
|
||||
-
|
||||
`frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungNeuScreen.kt`
|
||||
73
docs/99_Journal/2026-04-16_ZNS-First-Wizard-Strategy.md
Normal file
73
docs/99_Journal/2026-04-16_ZNS-First-Wizard-Strategy.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Journal-Eintrag: 2026-04-16 - ZNS-First Wizard & ZNS-Light Strategie
|
||||
|
||||
## 🏗️ [Lead Architect] & 👷 [Backend Developer] & 🖌️ [UI/UX Designer]
|
||||
|
||||
### 🎯 Zielsetzung
|
||||
|
||||
Lösung des Henne-Ei-Problems bei der Veranstaltungs-Anlage durch Invertierung des Workflows. Sicherstellung einer hohen
|
||||
Performance (Speed over Animation) durch selektiven ZNS-Import.
|
||||
|
||||
### 🛠️ Getroffene Entscheidungen
|
||||
|
||||
#### 1. Der "ZNS-First Enrollment" Workflow (3-Schritt-Wizard)
|
||||
|
||||
Der Wizard für eine neue Veranstaltung wird radikal umgebaut, um die Datenintegrität von Anfang an sicherzustellen:
|
||||
|
||||
* **Schritt 1: ZNS-Basis & Veranstalter**
|
||||
* Upload der `ZNS.zip`.
|
||||
* Sofortiger Import der `VEREIN01.dat` (Vereine) und `LIZENZ01.dat` (Personen).
|
||||
* Auswahl des Veranstalters aus den importierten Daten (oder manuelle Neuanlage).
|
||||
* **Schritt 2: Meta-Daten & DB-Initialisierung**
|
||||
* Eingabe von Titel, Datum (von-bis) und Ort.
|
||||
* Nach Validierung: Generierung der `veranstaltungId` (UUID).
|
||||
* Provisionierung der spezifischen Veranstaltungs-Datenbank und Transfer der ZNS-Light-Daten aus dem Staging-Buffer.
|
||||
* **Schritt 3: Fachlicher Typ**
|
||||
* Wahl der Veranstaltungsart (derzeit "Turnier").
|
||||
* Weiterleitung zum Turnier-Cockpit.
|
||||
|
||||
#### 2. "ZNS-Light" Performance-Strategie
|
||||
|
||||
Um die Wartezeit beim ersten Import von ~20 Minuten auf wenige Sekunden/Minuten zu reduzieren:
|
||||
|
||||
* **Light-Modus:** Nur `VEREIN01` und `LIZENZ01` werden im Wizard geladen.
|
||||
* **Lazy-Modus:** Die massiven Datenmengen von `PFERDE01` und `RICHT01` werden erst im Turnier-Setup oder bei Bedarf im
|
||||
Hintergrund geladen.
|
||||
|
||||
#### 3. Frontend-Konsolidierung (Single Source of Truth)
|
||||
|
||||
* Alle Entwürfe (V1/V2) werden im `VeranstaltungNeuScreen.kt` zusammengeführt.
|
||||
* Redundante Screens wie der separate `StammdatenImportScreen` werden als Komponente in den Wizard integriert.
|
||||
|
||||
### 🚀 Nächste Schritte (für die neue Session)
|
||||
|
||||
1. **Backend-Staging-Buffer:** Implementierung der temporären Speicherung der ZNS-Light-Daten während des Wizards.
|
||||
2. **Wizard-Implementation:** Umsetzung des 3-Schritt-Flows im `VeranstaltungNeuScreen.kt`.
|
||||
3. **API-Erweiterung:** `ZnsImportService` um den `mode=LIGHT` Parameter erweitern.
|
||||
|
||||
🧹 **[Curator]**: Journal-Eintrag für Session am 16. April 2026 abgeschlossen.
|
||||
|
||||
* **Status**: Implementierung des ZNS-First Wizards in `VeranstaltungKonfigV2` abgeschlossen.
|
||||
* **Resultat**: Performance-Steigerung durch ZNS-Light Integration direkt im ersten Wizard-Schritt.
|
||||
* **Technik**: Entkopplung via `ZnsImportProvider` Interface erfolgreich angewendet.
|
||||
|
||||
---
|
||||
🧹 **[Curator]**: Dieses Dokument wurde um die finalen Implementierungsdetails ergänzt. Alle fachlichen und technischen
|
||||
Parameter sind hiermit fixiert und umgesetzt.
|
||||
|
||||
### Nachtrag: 2026-04-16 15:00 - Actuator-Fix (Docker-Stability)
|
||||
|
||||
* **Problem**: ZNS-Import-Service meldete 404 auf `/actuator/health/readiness` unter Docker-Compose, was zu ungesunden
|
||||
Containern führte.
|
||||
* **Lösung**: Explizite Aktivierung der `probes` in der `application.yaml` (
|
||||
`management.endpoint.health.probes.enabled: true`).
|
||||
* **Optimierung**: Healthcheck in `dc-backend.yaml` auf `wget --no-verbose` umgestellt für bessere
|
||||
Diagnosemöglichkeiten.
|
||||
|
||||
### Nachtrag: 2026-04-16 15:30 - Connectivity & Serialization Fixes
|
||||
|
||||
* **Connectivity**: `ConnectivityTracker` wurde auf den korrekten Health-Pfad `/api/ping/health` umgestellt. Der Footer
|
||||
zeigt nun korrekt den Online-Status ("Cloud synchronisiert") an.
|
||||
* **Serialization**: Behebung des Fehlers `Serializer for class 'JobIdResponse' is not found`. Die DTO-Klasse wurde im
|
||||
Frontend in `ImportStartResponse` umbenannt (passend zum Backend) und die Sichtbarkeit auf `public` erhöht.
|
||||
* **Flexibilität**: Der ZNS-Importer unterstützt nun sowohl `.zip` als auch `.dat` Dateien (z.B. direkte
|
||||
`VEREIN01.dat`). Die UI (`pickZnsFile`) und das ViewModel wurden entsprechend erweitert.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package at.mocode.frontend.core.domain.zns
|
||||
|
||||
data class ZnsImportState(
|
||||
val selectedFilePath: String? = null,
|
||||
val isUploading: Boolean = false,
|
||||
val jobId: String? = null,
|
||||
val jobStatus: String? = null,
|
||||
val progress: Int = 0,
|
||||
val progressDetail: String = "",
|
||||
val errors: List<String> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val isFinished: Boolean = false,
|
||||
)
|
||||
|
||||
interface ZnsImportProvider {
|
||||
val state: ZnsImportState
|
||||
fun onFileSelected(path: String)
|
||||
fun startImport(mode: String = "FULL")
|
||||
fun reset()
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.qualifier.named
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Überwacht die Konnektivität zum API-Gateway.
|
||||
|
|
@ -28,16 +29,16 @@ class ConnectivityTracker : KoinComponent {
|
|||
scope.launch {
|
||||
while (isActive) {
|
||||
_isOnline.value = checkConnection()
|
||||
delay(10_000) // Alle 10 Sekunden prüfen
|
||||
delay(10_000.milliseconds) // Alle 10 Sekunden prüfen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkConnection(): Boolean {
|
||||
return try {
|
||||
val response = client.get(NetworkConfig.baseUrl.trimEnd('/') + "/ping")
|
||||
val response = client.get(NetworkConfig.baseUrl.trimEnd('/') + "/api/ping/health")
|
||||
response.status.value in 200..299
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|||
|
||||
/**
|
||||
* Feature-Modul: Veranstaltungs-Verwaltung (Desktop-only)
|
||||
* Kapselt alle Screens und Logik für Veranstaltungs-Übersicht, -Detail und -Neuanlage.
|
||||
* kapselt alle Screens und Logik für Veranstaltungs-Übersicht, -Detail und -Neuanlage.
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
|
|
@ -40,6 +40,7 @@ kotlin {
|
|||
implementation(projects.frontend.core.network)
|
||||
implementation(projects.frontend.core.domain)
|
||||
implementation(projects.core.coreDomain)
|
||||
implementation(projects.frontend.core.auth)
|
||||
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.runtime)
|
||||
|
|
|
|||
|
|
@ -1,63 +1,194 @@
|
|||
package at.mocode.veranstaltung.feature.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
import at.mocode.frontend.core.designsystem.components.MsButton
|
||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
|
||||
enum class MsWizardStep {
|
||||
ZNS_BASIS,
|
||||
META_DATEN,
|
||||
FACHLICHER_TYP
|
||||
}
|
||||
|
||||
private fun pickZipFile(): String? {
|
||||
val chooser = JFileChooser()
|
||||
val filter = FileNameExtensionFilter("ZNS ZIP Datei", "zip")
|
||||
chooser.fileFilter = filter
|
||||
val returnVal = chooser.showOpenDialog(null)
|
||||
return if (returnVal == JFileChooser.APPROVE_OPTION) {
|
||||
chooser.selectedFile.absolutePath
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular zum Anlegen einer neuen Veranstaltung (Vision_03: /veranstaltung/neu).
|
||||
* Tabs: Veranstaltung-Übersicht | Stammdaten (A-Satz) | Organisation | Preisliste
|
||||
* TODO: Echte Formular-Felder und Persistenz (Phase 4/5).
|
||||
* Tabs: Stammdaten (A-Satz) | Organisation | Preisliste
|
||||
*/
|
||||
@Composable
|
||||
fun VeranstaltungNeuScreen(
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(1) } // Stammdaten ist Standard-Tab
|
||||
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Preisliste")
|
||||
var currentStep by remember { mutableStateOf(MsWizardStep.ZNS_BASIS) }
|
||||
|
||||
// Formular-State
|
||||
var name by remember { mutableStateOf("") }
|
||||
var ort by remember { mutableStateOf("") }
|
||||
var startDatum by remember { mutableStateOf("") }
|
||||
var endDatum by remember { mutableStateOf("") }
|
||||
var veranstalterId by remember { mutableStateOf("") }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Toolbar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// Wizard Header
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shadowElevation = 2.dp,
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Row {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||
Row(
|
||||
modifier = Modifier.padding(Dimens.SpacingM),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
IconButton(onClick = {
|
||||
if (currentStep == MsWizardStep.ZNS_BASIS) onBack()
|
||||
else currentStep = MsWizardStep.entries[currentStep.ordinal - 1]
|
||||
}) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||
}
|
||||
Spacer(Modifier.width(Dimens.SpacingS))
|
||||
Column {
|
||||
Text(
|
||||
text = "Neue Veranstaltung anlegen",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = when (currentStep) {
|
||||
MsWizardStep.ZNS_BASIS -> "Schritt 1: ZNS-Basis & Veranstalter"
|
||||
MsWizardStep.META_DATEN -> "Schritt 2: Meta-Daten & DB-Initialisierung"
|
||||
MsWizardStep.FACHLICHER_TYP -> "Schritt 3: Fachlicher Typ"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Neue Veranstaltung",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
}
|
||||
Button(onClick = onSave) { Text("Speichern") }
|
||||
}
|
||||
|
||||
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) },
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
|
||||
if (currentStep != MsWizardStep.FACHLICHER_TYP) {
|
||||
MsButton(
|
||||
text = "Weiter",
|
||||
onClick = { currentStep = MsWizardStep.entries[currentStep.ordinal + 1] },
|
||||
enabled = when (currentStep) {
|
||||
MsWizardStep.ZNS_BASIS -> true // Vereinfacht für Prototypen
|
||||
MsWizardStep.META_DATEN -> name.isNotBlank() && ort.isNotBlank()
|
||||
else -> true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MsButton(
|
||||
text = "Veranstaltung finalisieren",
|
||||
onClick = onSave,
|
||||
enabled = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
when (selectedTab) {
|
||||
0 -> PlaceholderContent("Veranstaltung – Übersicht", "Wird nach dem Speichern befüllt.")
|
||||
1 -> PlaceholderContent("Stammdaten (A-Satz)", "Felder: Bezeichnung, Datum, Ort, Veranstalter …")
|
||||
2 -> PlaceholderContent("Organisation", "Felder: Richter, Parcourschef, Tierarzt …")
|
||||
3 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
|
||||
// Step Indicator
|
||||
LinearProgressIndicator(
|
||||
progress = { (currentStep.ordinal + 1).toFloat() / MsWizardStep.entries.size.toFloat() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(Dimens.SpacingL)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
when (currentStep) {
|
||||
MsWizardStep.ZNS_BASIS -> {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 800.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
|
||||
) {
|
||||
Text("ZNS-Daten importieren (Light-Modus)", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Invertierter Workflow: Zuerst ZNS-Basisdaten laden, dann Veranstaltung anlegen.")
|
||||
|
||||
MsButton(
|
||||
text = "ZNS.zip auswählen & importieren",
|
||||
onClick = { pickZipFile() }
|
||||
)
|
||||
|
||||
MsTextField(
|
||||
value = veranstalterId,
|
||||
onValueChange = { veranstalterId = it },
|
||||
label = "Veranstalter (aus importierten Vereinen)",
|
||||
placeholder = "Verein suchen..."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MsWizardStep.META_DATEN -> {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 600.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
|
||||
) {
|
||||
Text("Veranstaltungs-Details", style = MaterialTheme.typography.titleLarge)
|
||||
MsTextField(value = name, onValueChange = { name = it }, label = "Name")
|
||||
MsTextField(value = ort, onValueChange = { ort = it }, label = "Ort")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
|
||||
MsTextField(
|
||||
value = startDatum,
|
||||
onValueChange = { startDatum = it },
|
||||
label = "Start",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MsTextField(
|
||||
value = endDatum,
|
||||
onValueChange = { endDatum = it },
|
||||
label = "Ende",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MsWizardStep.FACHLICHER_TYP -> {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 600.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
|
||||
) {
|
||||
Text("Typ der Veranstaltung", style = MaterialTheme.typography.titleLarge)
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.padding(Dimens.SpacingM),
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
|
||||
) {
|
||||
Text("🏆 Turnier", modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
|
||||
RadioButton(selected = true, onClick = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
package at.mocode.veranstaltung.feature.presentation
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||
import at.mocode.frontend.core.network.NetworkConfig
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
enum class WizardStep {
|
||||
ZNS_IMPORT,
|
||||
META_DATA,
|
||||
TYPE_SELECTION
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
data class VeranstaltungWizardState(
|
||||
val currentStep: WizardStep = WizardStep.ZNS_IMPORT,
|
||||
val veranstalterId: Uuid? = null,
|
||||
val name: String = "",
|
||||
val ort: String = "",
|
||||
val startDatum: LocalDate? = null,
|
||||
val endDatum: LocalDate? = null,
|
||||
val isSaving: Boolean = false,
|
||||
val error: String? = null,
|
||||
val createdVeranstaltungId: Uuid? = null
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
class VeranstaltungWizardViewModel(
|
||||
private val httpClient: HttpClient,
|
||||
private val authTokenManager: AuthTokenManager,
|
||||
val znsViewModel: ZnsImportProvider
|
||||
) : ViewModel() {
|
||||
|
||||
var state by mutableStateOf(VeranstaltungWizardState())
|
||||
private set
|
||||
|
||||
fun nextStep() {
|
||||
state = state.copy(
|
||||
currentStep = when (state.currentStep) {
|
||||
WizardStep.ZNS_IMPORT -> WizardStep.META_DATA
|
||||
WizardStep.META_DATA -> WizardStep.TYPE_SELECTION
|
||||
WizardStep.TYPE_SELECTION -> WizardStep.TYPE_SELECTION
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun previousStep() {
|
||||
state = state.copy(
|
||||
currentStep = when (state.currentStep) {
|
||||
WizardStep.ZNS_IMPORT -> WizardStep.ZNS_IMPORT
|
||||
WizardStep.META_DATA -> WizardStep.ZNS_IMPORT
|
||||
WizardStep.TYPE_SELECTION -> WizardStep.META_DATA
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun updateMetaData(name: String, ort: String, start: LocalDate?, end: LocalDate?) {
|
||||
state = state.copy(name = name, ort = ort, startDatum = start, endDatum = end)
|
||||
}
|
||||
|
||||
fun setVeranstalter(id: Uuid) {
|
||||
state = state.copy(veranstalterId = id)
|
||||
}
|
||||
|
||||
fun saveVeranstaltung() {
|
||||
val veranstalterId = state.veranstalterId ?: return
|
||||
val start = state.startDatum ?: return
|
||||
val end = state.endDatum ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
state = state.copy(isSaving = true, error = null)
|
||||
try {
|
||||
val token = authTokenManager.authState.value.token
|
||||
val response = httpClient.post("${NetworkConfig.baseUrl}/api/events") {
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
CreateEventRequest(
|
||||
name = state.name,
|
||||
startDatum = start,
|
||||
endDatum = end,
|
||||
ort = state.ort,
|
||||
veranstalterVereinId = veranstalterId
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (response.status == HttpStatusCode.Created) {
|
||||
// Hier müsste die ID aus dem Response gelesen werden, falls benötigt
|
||||
state = state.copy(isSaving = false)
|
||||
nextStep()
|
||||
} else {
|
||||
state = state.copy(isSaving = false, error = "Fehler beim Speichern: ${response.status}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
state = state.copy(isSaving = false, error = "Netzwerkfehler: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
data class CreateEventRequest(
|
||||
val name: String,
|
||||
val startDatum: LocalDate,
|
||||
val endDatum: LocalDate,
|
||||
val ort: String,
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val veranstalterVereinId: Uuid
|
||||
)
|
||||
|
|
@ -1,51 +1,171 @@
|
|||
package at.mocode.veranstaltung.feature.presentation
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
import at.mocode.frontend.core.designsystem.components.MsButton
|
||||
import at.mocode.frontend.core.designsystem.components.MsCard
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
/**
|
||||
* UI-Modell für die Anzeige einer Veranstaltung in der Liste.
|
||||
*/
|
||||
data class VeranstaltungSimpleUiModel(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val untertitel: String?,
|
||||
val ort: String,
|
||||
val datum: String,
|
||||
val logoUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Veranstaltungs-Übersicht (Drawer-Einstieg gemäß Vision_03).
|
||||
* Zeigt Liste aller Veranstaltungen + Button "Neue Veranstaltung".
|
||||
* TODO: Echte Daten aus dem event-management-context laden (Phase 4/5).
|
||||
*/
|
||||
@Composable
|
||||
fun VeranstaltungenScreen(
|
||||
onVeranstaltungNeu: () -> Unit,
|
||||
onVeranstaltungOeffnen: (Long) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
||||
// Später: Echte Daten aus dem ViewModel laden
|
||||
val veranstaltungen = remember {
|
||||
mutableStateListOf(
|
||||
VeranstaltungSimpleUiModel(
|
||||
id = 1L,
|
||||
name = "Springturnier Neumarkt",
|
||||
untertitel = "CSN-B* | 24. - 26. April 2026",
|
||||
ort = "Neumarkt am Wallersee",
|
||||
datum = "24.04.2026 - 26.04.2026"
|
||||
),
|
||||
VeranstaltungSimpleUiModel(
|
||||
id = 2L,
|
||||
name = "Dressurtage Lamprechtshausen",
|
||||
untertitel = "CDN-A* | 01. - 03. Mai 2026",
|
||||
ort = "Lamprechtshausen",
|
||||
datum = "01.05.2026 - 03.05.2026"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(Dimens.SpacingL)) {
|
||||
// Header: Titel + Action
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Veranstaltungen",
|
||||
text = "Veranstaltungen - verwalten",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
MsButton(
|
||||
text = "Neue Veranstaltung",
|
||||
onClick = onVeranstaltungNeu
|
||||
// icon = Icons.Default.Add // MsButton unterstützt noch kein Icon im Parameter
|
||||
)
|
||||
Button(onClick = onVeranstaltungNeu) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Neue Veranstaltung")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(Modifier.height(Dimens.SpacingL))
|
||||
|
||||
// Platzhalter – wird durch echte Daten ersetzt
|
||||
PlaceholderContent(
|
||||
title = "Noch keine Veranstaltungen",
|
||||
subtitle = "Lege eine neue Veranstaltung an, um zu beginnen.",
|
||||
)
|
||||
if (veranstaltungen.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Keine Veranstaltungen gefunden. Legen Sie eine neue an.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
|
||||
) {
|
||||
items(veranstaltungen) { event ->
|
||||
VeranstaltungCard(
|
||||
event = event,
|
||||
onDoubleClick = { onVeranstaltungOeffnen(event.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun VeranstaltungCard(
|
||||
event: VeranstaltungSimpleUiModel,
|
||||
onDoubleClick: () -> Unit
|
||||
) {
|
||||
MsCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = { /* Einfacher Klick für Selektion, falls gewünscht */ },
|
||||
onDoubleClick = onDoubleClick
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(Dimens.SpacingS),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Platzhalter für Logo
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
imageVector = Icons.Default.Event,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(Dimens.SpacingM))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = event.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
if (!event.untertitel.isNullOrBlank()) {
|
||||
Text(
|
||||
text = event.untertitel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "${event.ort} | ${event.datum}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
||||
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||
import at.mocode.frontend.core.domain.zns.ZnsImportState
|
||||
import at.mocode.frontend.core.network.NetworkConfig
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
|
|
@ -20,20 +22,9 @@ import kotlinx.serialization.json.Json
|
|||
import java.io.File
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
data class ZnsImportState(
|
||||
val selectedFilePath: String? = null,
|
||||
val isUploading: Boolean = false,
|
||||
val jobId: String? = null,
|
||||
val jobStatus: String? = null,
|
||||
val progress: Int = 0,
|
||||
val progressDetail: String = "",
|
||||
val errors: List<String> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val isFinished: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class JobIdResponse(val jobId: String)
|
||||
data class ImportStartResponse(val jobId: String)
|
||||
|
||||
@Serializable
|
||||
internal data class JobStatusResponse(
|
||||
|
|
@ -51,23 +42,27 @@ private const val MAX_VISIBLE_ERRORS = 50
|
|||
class ZnsImportViewModel(
|
||||
private val httpClient: HttpClient,
|
||||
private val authTokenManager: AuthTokenManager,
|
||||
) : ViewModel() {
|
||||
) : ViewModel(), ZnsImportProvider {
|
||||
|
||||
var state by mutableStateOf(ZnsImportState())
|
||||
override var state by mutableStateOf(ZnsImportState())
|
||||
private set
|
||||
|
||||
private var pollingJob: Job? = null
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun onFileSelected(path: String) {
|
||||
override fun onFileSelected(path: String) {
|
||||
state = ZnsImportState(selectedFilePath = path)
|
||||
}
|
||||
|
||||
fun startImport() {
|
||||
override fun startImport(mode: String) {
|
||||
val filePath = state.selectedFilePath ?: return
|
||||
val file = File(filePath)
|
||||
if (!file.exists() || !file.name.endsWith(".zip", ignoreCase = true)) {
|
||||
state = state.copy(errorMessage = "Bitte eine gültige .zip-Datei auswählen.")
|
||||
if (!file.exists() || !(file.name.endsWith(".zip", ignoreCase = true) || file.name.endsWith(
|
||||
".dat",
|
||||
ignoreCase = true
|
||||
))
|
||||
) {
|
||||
state = state.copy(errorMessage = "Bitte eine gültige .zip oder .dat-Datei auswählen.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -79,16 +74,19 @@ class ZnsImportViewModel(
|
|||
try {
|
||||
val token = authTokenManager.authState.value.token
|
||||
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
|
||||
parameter("mode", mode)
|
||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||
val contentType =
|
||||
if (file.name.endsWith(".zip", ignoreCase = true)) "application/zip" else "application/octet-stream"
|
||||
setBody(MultiPartFormDataContent(formData {
|
||||
append("file", file.readBytes(), Headers.build {
|
||||
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
|
||||
append(HttpHeaders.ContentType, "application/zip")
|
||||
append(HttpHeaders.ContentType, contentType)
|
||||
})
|
||||
}))
|
||||
}
|
||||
if (response.status == HttpStatusCode.Accepted) {
|
||||
val body = json.decodeFromString<JobIdResponse>(response.bodyAsText())
|
||||
val body = json.decodeFromString<ImportStartResponse>(response.bodyAsText())
|
||||
state = state.copy(isUploading = false, jobId = body.jobId, jobStatus = "AUSSTEHEND")
|
||||
startPolling(body.jobId)
|
||||
} else {
|
||||
|
|
@ -129,7 +127,7 @@ class ZnsImportViewModel(
|
|||
}
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
override fun reset() {
|
||||
pollingJob?.cancel()
|
||||
state = ZnsImportState()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package at.mocode.zns.feature.di
|
||||
|
||||
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||
import at.mocode.zns.feature.ZnsImportViewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
|
||||
val znsImportModule = module {
|
||||
factory<ZnsImportProvider> { ZnsImportViewModel(get(named("apiClient")), get()) }
|
||||
factory { ZnsImportViewModel(get(named("apiClient")), get()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,10 @@ fun StammdatenImportScreen(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
|
||||
Text(state.errorMessage, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
val errorMsg = state.errorMessage
|
||||
if (errorMsg != null) {
|
||||
Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -129,7 +132,10 @@ fun StammdatenImportScreen(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Status", style = MaterialTheme.typography.titleMedium)
|
||||
StatusChip(state.jobStatus)
|
||||
val statusMsg = state.jobStatus
|
||||
if (statusMsg != null) {
|
||||
StatusChip(statusMsg)
|
||||
}
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
{
|
||||
"geraetName": "Meldestelle",
|
||||
"sharedKey": "Meldestelle",
|
||||
"backupPath": "/mocode/Meldestelle/docs/temp",
|
||||
"networkRole": "MASTER"
|
||||
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
|
||||
"networkRole": "MASTER",
|
||||
"expectedClients": [
|
||||
{
|
||||
"name": "Richter-Turm",
|
||||
"role": "RICHTER"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -531,7 +531,7 @@ private fun DesktopContentArea(
|
|||
|
||||
// Haupt-Zentrale: Veranstaltung-Verwaltung
|
||||
is AppScreen.VeranstaltungVerwaltung -> {
|
||||
at.mocode.desktop.v2.VeranstaltungVerwaltungV2(
|
||||
at.mocode.desktop.v2.VeranstaltungVerwaltung(
|
||||
onVeranstaltungOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) },
|
||||
onNewVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig()) },
|
||||
onNavigateToPferde = { onNavigate(AppScreen.PferdVerwaltung) },
|
||||
|
|
|
|||
|
|
@ -5,15 +5,29 @@ import kotlinx.serialization.Serializable
|
|||
@Serializable
|
||||
enum class NetworkRole {
|
||||
MASTER,
|
||||
CLIENT
|
||||
CLIENT,
|
||||
RICHTER,
|
||||
ZEITNEHMER,
|
||||
STALLMEISTER,
|
||||
ANZEIGE,
|
||||
PARCOURS_CHEF
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ExpectedClient(
|
||||
val name: String,
|
||||
val role: NetworkRole,
|
||||
val isOnline: Boolean = false,
|
||||
val isSynchronized: Boolean = true
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class OnboardingSettings(
|
||||
val geraetName: String = "",
|
||||
val sharedKey: String = "",
|
||||
val backupPath: String = "",
|
||||
val networkRole: NetworkRole = NetworkRole.CLIENT,
|
||||
val expectedClients: List<ExpectedClient> = emptyList(),
|
||||
val syncInterval: Int = 30, // in Minuten
|
||||
val defaultPrinter: String = ""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package at.mocode.desktop.screens.onboarding
|
|||
/**
|
||||
* Validierungslogik für den Onboarding-Wizard.
|
||||
*
|
||||
* Extrahiert aus [OnboardingScreen] für isolierte Unit-Tests (B-2).
|
||||
* Extrahiert aus `OnboardingScreen` für isolierte Unit-Tests (B-2).
|
||||
* Regeln gemäß Onboarding-Spezifikation:
|
||||
* - Gerätename: mindestens 3 Zeichen (nach trim)
|
||||
* - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim)
|
||||
|
|
@ -37,9 +37,19 @@ object OnboardingValidator {
|
|||
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
|
||||
* der „Weiter"-Button aktiviert werden darf.
|
||||
*/
|
||||
fun canContinue(settings: OnboardingSettings): Boolean =
|
||||
isNameValid(settings.geraetName) &&
|
||||
fun canContinue(settings: OnboardingSettings): Boolean {
|
||||
val basicValid = isNameValid(settings.geraetName) &&
|
||||
isKeyValid(settings.sharedKey) &&
|
||||
isBackupPathValid(settings.backupPath) &&
|
||||
isSyncIntervalValid(settings.syncInterval)
|
||||
|
||||
if (!basicValid) return false
|
||||
|
||||
// Falls Master, müssen alle erwarteten Clients einen Namen haben
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
return settings.expectedClients.all { it.name.trim().isNotEmpty() }
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
|
|
@ -24,19 +25,32 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.desktop.screens.onboarding.ExpectedClient
|
||||
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.network.discovery.NetworkDiscoveryService
|
||||
import org.koin.compose.koinInject
|
||||
import javax.print.PrintServiceLookup
|
||||
import javax.swing.JFileChooser
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
settings: OnboardingSettings,
|
||||
onSettingsChange: (OnboardingSettings) -> Unit,
|
||||
onContinue: (OnboardingSettings) -> Unit,
|
||||
) {
|
||||
var currentStep by remember { mutableStateOf(0) }
|
||||
val discoveryService: NetworkDiscoveryService = koinInject()
|
||||
val discoveredServices by remember { mutableStateOf(discoveryService.getDiscoveredServices()) }
|
||||
|
||||
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
|
||||
LaunchedEffect(currentStep) {
|
||||
if (currentStep == 0) discoveryService.startDiscovery()
|
||||
}
|
||||
|
||||
DesktopThemeV2 {
|
||||
Surface(color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
|
|
@ -49,174 +63,354 @@ fun OnboardingScreen(
|
|||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
"Bitte konfiguriere deine lokale Instanz (Geburtsurkunde).",
|
||||
if (currentStep == 0) "Schritt 1: Netzwerk-Rolle festlegen" else "Schritt 2: Rollenspezifische Konfiguration",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
var showPw by remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
MsTextField(
|
||||
value = settings.geraetName,
|
||||
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
|
||||
label = "Gerätename (Pflicht)",
|
||||
placeholder = "z. B. Meldestelle-PC-1",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
|
||||
MsTextField(
|
||||
value = settings.sharedKey,
|
||||
onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
|
||||
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()
|
||||
if (currentStep == 0) {
|
||||
// PHASE 1: NETZWERK-ROLLE
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("🌐 Netzwerk-Rolle wählen", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Wähle aus, ob dieses Gerät als Master (zentrale Datenbank) oder als Client fungiert.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Surface(
|
||||
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (settings.networkRole == NetworkRole.MASTER) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = settings.networkRole == NetworkRole.MASTER,
|
||||
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) }
|
||||
)
|
||||
Column {
|
||||
Text("Master (Host)", style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
"Verwaltet die zentrale Datenbank und koordiniert den Sync.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (settings.networkRole == NetworkRole.CLIENT) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = settings.networkRole == NetworkRole.CLIENT,
|
||||
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) }
|
||||
)
|
||||
Column {
|
||||
Text("Client", style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
"Verbindet sich mit einem Master und synchronisiert Daten.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { currentStep = 1 },
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Weiter")
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, null, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// PHASE 2: ROLLENSPEZIFISCH
|
||||
var showPw by remember { mutableStateOf(false) }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
var showPrinterDialog by remember { mutableStateOf(false) }
|
||||
val availablePrinters = remember {
|
||||
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
|
||||
}
|
||||
// 2.1 / 2.2 IDENTITÄT & SICHERHEIT
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
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 (settings.networkRole == NetworkRole.MASTER) {
|
||||
MsTextField(
|
||||
value = settings.geraetName,
|
||||
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
|
||||
label = "Gerätename (Pflicht)",
|
||||
placeholder = "z. B. Haupt-PC",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
imeAction = ImeAction.Next,
|
||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||
)
|
||||
} else {
|
||||
// Client: Auswahlbox
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val availableSlots =
|
||||
discoveredServices.flatMap { it.metadata["availableSlots"]?.split(",") ?: emptyList() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
||||
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)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
MsTextField(
|
||||
value = settings.geraetName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = "Gerätename (Vom Master freigegeben)",
|
||||
trailingIcon = Icons.Default.ArrowDropDown,
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
if (availableSlots.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Suche nach verfügbaren Slots...") },
|
||||
onClick = { expanded = false }
|
||||
)
|
||||
} 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)
|
||||
}
|
||||
availableSlots.forEach { slot ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(slot) },
|
||||
onClick = {
|
||||
onSettingsChange(settings.copy(geraetName = slot))
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showPrinterDialog = false }) {
|
||||
Text("Schließen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MsTextField(
|
||||
value = settings.sharedKey,
|
||||
onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
|
||||
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) })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val canContinue = OnboardingValidator.canContinue(settings)
|
||||
Button(
|
||||
onClick = { onContinue(settings) },
|
||||
enabled = canContinue,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("Konfiguration speichern & starten")
|
||||
}
|
||||
// 3.1 ERWARTETE GERÄTE (NUR MASTER)
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("📋 Erwartete Geräte (Clients)", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"Definiere hier, welche Geräte sich in diesem Netzwerk anmelden dürfen.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
settings.expectedClients.forEachIndexed { index, client ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
MsTextField(
|
||||
value = client.name,
|
||||
onValueChange = { newName ->
|
||||
val newList = settings.expectedClients.toMutableList()
|
||||
newList[index] = client.copy(name = newName)
|
||||
onSettingsChange(settings.copy(expectedClients = newList))
|
||||
},
|
||||
label = "Name",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }) {
|
||||
Text(client.role.name)
|
||||
Icon(Icons.Default.ArrowDropDown, null)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
NetworkRole.entries.filter { it != NetworkRole.MASTER }.forEach { role ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(role.name) },
|
||||
onClick = {
|
||||
val newList = settings.expectedClients.toMutableList()
|
||||
newList[index] = client.copy(role = role)
|
||||
onSettingsChange(settings.copy(expectedClients = newList))
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
val newList = settings.expectedClients.toMutableList()
|
||||
newList.removeAt(index)
|
||||
onSettingsChange(settings.copy(expectedClients = newList))
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Entfernen",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
val newList = settings.expectedClients.toMutableList()
|
||||
newList.add(ExpectedClient("Neues Gerät", NetworkRole.CLIENT))
|
||||
onSettingsChange(settings.copy(expectedClients = newList))
|
||||
},
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Gerät hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4.1 / 3.2 DATENBANK-SICHERHEITSPFAD
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
if (settings.networkRole == NetworkRole.MASTER) "💾 Datenbank-Sicherheitspfad" else "💾 Lokaler Cache-Sicherungspfad",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
MsTextField(
|
||||
value = settings.backupPath,
|
||||
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
|
||||
label = "Pfad auswählen",
|
||||
placeholder = "Verzeichnis für Backups/Cache",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
trailingIcon = Icons.Default.FolderOpen,
|
||||
onTrailingIconClick = {
|
||||
val chooser = JFileChooser()
|
||||
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
chooser.dialogTitle = "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) })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 5.1 SYNC-INTERVALL (NUR MASTER)
|
||||
if (settings.networkRole == NetworkRole.MASTER) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("🔄 Sync-Intervall", style = MaterialTheme.typography.titleMedium)
|
||||
Text("📡 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6.1 / 4.2 DRUCKER
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("🖨️ Drucker", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
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") } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = { currentStep = 0 }) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Zurück zur Rollenauswahl")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { onContinue(settings) },
|
||||
enabled = OnboardingValidator.canContinue(settings)
|
||||
) {
|
||||
Text("Konfiguration abschließen")
|
||||
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,14 +21,19 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||
import org.koin.compose.koinInject
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VeranstaltungVerwaltungV2(
|
||||
fun VeranstaltungVerwaltung(
|
||||
onVeranstaltungOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
|
||||
onNewVeranstaltung: () -> Unit,
|
||||
onNavigateToPferde: () -> Unit,
|
||||
|
|
@ -57,51 +62,13 @@ fun VeranstaltungVerwaltungV2(
|
|||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
// Navigation Toolbar (Top)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AssistChip(
|
||||
onClick = onNavigateToPferde,
|
||||
label = { Text("Pferde") },
|
||||
leadingIcon = { Icon(Icons.Default.Pets, null) })
|
||||
AssistChip(
|
||||
onClick = onNavigateToReiter,
|
||||
label = { Text("Reiter") },
|
||||
leadingIcon = { Icon(Icons.Default.Person, null) })
|
||||
AssistChip(
|
||||
onClick = onNavigateToVereine,
|
||||
label = { Text("Vereine") },
|
||||
leadingIcon = { Icon(Icons.Default.Home, null) })
|
||||
AssistChip(
|
||||
onClick = onNavigateToFunktionaere,
|
||||
label = { Text("Funktionäre") },
|
||||
leadingIcon = { Icon(Icons.Default.Badge, null) })
|
||||
AssistChip(
|
||||
onClick = onNavigateToVeranstalter,
|
||||
label = { Text("Veranstalter") },
|
||||
leadingIcon = { Icon(Icons.Default.Business, null) })
|
||||
VerticalDivider(Modifier.height(32.dp).padding(horizontal = 4.dp))
|
||||
AssistChip(
|
||||
onClick = onNavigateToZnsImport,
|
||||
label = { Text("ZNS Importer") },
|
||||
leadingIcon = { Icon(Icons.Default.CloudDownload, null) },
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
labelColor = MaterialTheme.colorScheme.primary,
|
||||
leadingIconContentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Header
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text("Veranstaltung-Verwaltung", style = MaterialTheme.typography.headlineMedium)
|
||||
Text("Veranstaltungen - verwalten", style = MaterialTheme.typography.headlineMedium)
|
||||
Button(onClick = onNewVeranstaltung) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
|
@ -344,6 +311,9 @@ fun VeranstaltungKonfigV2(
|
|||
onSaved: (Long, Long) -> Unit, // eventId, veranstalterId
|
||||
onVeranstalterCreated: (Long) -> Unit = {}, // Neuer Flow: nach Vereinsanlage ins Profil
|
||||
) {
|
||||
val znsImporter: ZnsImportProvider = koinInject()
|
||||
val znsState = znsImporter.state
|
||||
|
||||
DesktopThemeV2 {
|
||||
var currentStep by remember { mutableStateOf(if (veranstalterId == 0L) 1 else 2) }
|
||||
|
||||
|
|
@ -455,73 +425,98 @@ fun VeranstaltungKonfigV2(
|
|||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (currentStep) {
|
||||
1 -> {
|
||||
// --- SCHRITT 1: Veranstalterwahl ---
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
var search by remember { mutableStateOf("") }
|
||||
val filteredVereine = remember(search) {
|
||||
StoreV2.vereine.filter {
|
||||
it.name.contains(search, ignoreCase = true) || (it.ort?.contains(search, ignoreCase = true) ?: false)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Für welchen Verein wird die Veranstaltung angelegt?", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Veranstalter suchen...") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
|
||||
// --- SCHRITT 1: ZNS-First Daten-Akquise ---
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
"Daten-Akquise & Veranstalter",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(filteredVereine) { verein ->
|
||||
val isSelected = selectedVereinId == verein.id
|
||||
Surface(
|
||||
onClick = { selectedVereinId = verein.id },
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
border = if (isSelected) null else androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(16.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
// 1. ZNS Import Bereich (Prominent)
|
||||
ZnsImportWizardSection(
|
||||
state = znsState,
|
||||
onFileSelect = { path -> znsImporter.onFileSelected(path) },
|
||||
onStartImport = { znsImporter.startImport(mode = "LIGHT") },
|
||||
onReset = { znsImporter.reset() }
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// 2. Bestehende Veranstalter (Kompakt)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) {
|
||||
var search by remember { mutableStateOf("") }
|
||||
val filteredVereine = remember(search) {
|
||||
StoreV2.vereine.filter {
|
||||
it.name.contains(search, ignoreCase = true) || (it.ort?.contains(search, ignoreCase = true)
|
||||
?: false)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Oder bestehenden Veranstalter wählen:", style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
OutlinedTextField(
|
||||
value = search,
|
||||
onValueChange = { search = it },
|
||||
label = { Text("Veranstalter suchen...") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(filteredVereine) { verein ->
|
||||
val isSelected = selectedVereinId == verein.id
|
||||
Surface(
|
||||
onClick = { selectedVereinId = verein.id },
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
border = if (isSelected) null else androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(verein.name, fontWeight = FontWeight.Bold)
|
||||
Text("${verein.ort ?: ""} | ${verein.oepsNummer}", style = MaterialTheme.typography.bodySmall)
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 8.dp).fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(verein.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${verein.ort ?: ""} | ${verein.oepsNummer}",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
if (isSelected) Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
if (isSelected) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
if (!showVereinNeu) {
|
||||
OutlinedButton(
|
||||
onClick = { showVereinNeu = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Neuen Veranstalter / Verein anlegen")
|
||||
}
|
||||
} else {
|
||||
VeranstalterAnlegenWizard(
|
||||
onCancel = { showVereinNeu = false },
|
||||
onVereinCreated = { newId ->
|
||||
// Neuer gewünschter Flow: nach Schritt 2 ins Veranstalter‑Profil wechseln
|
||||
showVereinNeu = false
|
||||
onVeranstalterCreated(newId)
|
||||
}
|
||||
if (showVereinNeu) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showVereinNeu = false },
|
||||
title = { Text("Manueller Eintrag") },
|
||||
text = {
|
||||
Box(Modifier.heightIn(max = 500.dp)) {
|
||||
VeranstalterAnlegenWizard(
|
||||
onCancel = { showVereinNeu = false },
|
||||
onVereinCreated = { newId ->
|
||||
showVereinNeu = false
|
||||
onVeranstalterCreated(newId)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -547,12 +542,12 @@ fun VeranstaltungKonfigV2(
|
|||
|
||||
val dateVon = try {
|
||||
LocalDate.parse(von, dateFormatter)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val dateBis = try {
|
||||
LocalDate.parse(bis, dateFormatter)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val today = LocalDate.now()
|
||||
|
|
@ -734,12 +729,12 @@ fun VeranstaltungKonfigV2(
|
|||
2 -> {
|
||||
val dVon = try {
|
||||
LocalDate.parse(von, dateFormatter)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val dBis = try {
|
||||
LocalDate.parse(bis, dateFormatter)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val today2 = LocalDate.now()
|
||||
|
|
@ -1092,12 +1087,12 @@ fun TurnierWizardV2(
|
|||
val vBis = veranstaltung?.datumBis?.let { LocalDate.parse(it) }
|
||||
val tVon = try {
|
||||
LocalDate.parse(von)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val tBis = if (bis.isBlank()) tVon else try {
|
||||
LocalDate.parse(bis)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
|
|
@ -1273,7 +1268,7 @@ private fun Step1Basics(
|
|||
CircularProgressIndicator()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(2000)
|
||||
kotlinx.coroutines.delay(2000.milliseconds)
|
||||
onZnsDataLoadedChange(true)
|
||||
showImportProgress = false
|
||||
}
|
||||
|
|
@ -1301,12 +1296,12 @@ private fun Step2Sparten(
|
|||
val vBis = veranstaltung?.datumBis?.let { LocalDate.parse(it) }
|
||||
val tVon = try {
|
||||
LocalDate.parse(von)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val tBis = if (bis.isBlank()) tVon else try {
|
||||
LocalDate.parse(bis)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
|
|
@ -1577,3 +1572,158 @@ private fun Step3Branding(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZnsImportWizardSection(
|
||||
state: at.mocode.frontend.core.domain.zns.ZnsImportState,
|
||||
onFileSelect: (String) -> Unit,
|
||||
onStartImport: () -> Unit,
|
||||
onReset: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.CloudUpload, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Text(
|
||||
"ZNS-Stammdaten Import (ZIP/DAT)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (state.isFinished || state.errorMessage != null) {
|
||||
TextButton(onClick = onReset) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Neu laden")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.jobId == null) {
|
||||
// Datei-Auswahl Modus
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.selectedFilePath ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
placeholder = { Text("ZNS-Datei auswählen (.zip, .dat)...") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val path = pickZnsFile()
|
||||
if (path != null) onFileSelect(path)
|
||||
},
|
||||
enabled = !state.isUploading
|
||||
) {
|
||||
Icon(Icons.Default.FolderOpen, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Durchsuchen")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onStartImport,
|
||||
enabled = state.selectedFilePath != null && !state.isUploading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (state.isUploading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Default.Bolt, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Schnell-Import starten (LIGHT)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Progress Modus
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(state.jobStatus ?: "Verarbeite...", style = MaterialTheme.typography.labelMedium)
|
||||
Text("${state.progress}%", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { state.progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)),
|
||||
)
|
||||
|
||||
Text(
|
||||
state.progressDetail.ifBlank { "Warte auf Server..." },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
if (state.isFinished && state.jobStatus == "ABGESCHLOSSEN") {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2E7D32),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
"Import erfolgreich! Vereine wurden aktualisiert.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFF2E7D32)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.errorMessage != null) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
state.errorMessage ?: "Fehler",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickZnsFile(): String? {
|
||||
val chooser = JFileChooser()
|
||||
chooser.dialogTitle = "ZNS-Datei auswählen"
|
||||
chooser.fileFilter = FileNameExtensionFilter("ZNS Dateien (*.zip, *.dat)", "zip", "dat")
|
||||
chooser.isAcceptAllFileFilterUsed = false
|
||||
val result = chooser.showOpenDialog(null)
|
||||
return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ class OnboardingValidatorTest {
|
|||
|
||||
@Test
|
||||
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
||||
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
|
||||
// Simuliert schneller Doppelklick: canContinue darf sich nicht ändern
|
||||
val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
|
||||
val first = OnboardingValidator.canContinue(settings)
|
||||
val second = OnboardingValidator.canContinue(settings)
|
||||
|
|
@ -186,4 +186,36 @@ class OnboardingValidatorTest {
|
|||
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Explicit Device Enrollment (Master) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `Master canContinue false wenn ein erwarteter Client keinen Namen hat`() {
|
||||
val masterSettings = OnboardingSettings(
|
||||
geraetName = "Master-PC",
|
||||
sharedKey = "Neumarkt2026",
|
||||
backupPath = "/tmp",
|
||||
networkRole = NetworkRole.MASTER,
|
||||
expectedClients = listOf(
|
||||
ExpectedClient("Richter 1", NetworkRole.RICHTER),
|
||||
ExpectedClient("", NetworkRole.ZEITNEHMER) // Ungültig: Name leer
|
||||
)
|
||||
)
|
||||
assertFalse(OnboardingValidator.canContinue(masterSettings))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Master canContinue true wenn alle erwarteten Clients Namen haben`() {
|
||||
val masterSettings = OnboardingSettings(
|
||||
geraetName = "Master-PC",
|
||||
sharedKey = "Neumarkt2026",
|
||||
backupPath = "/tmp",
|
||||
networkRole = NetworkRole.MASTER,
|
||||
expectedClients = listOf(
|
||||
ExpectedClient("Richter 1", NetworkRole.RICHTER),
|
||||
ExpectedClient("Zeitnehmer 1", NetworkRole.ZEITNEHMER)
|
||||
)
|
||||
)
|
||||
assertTrue(OnboardingValidator.canContinue(masterSettings))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user