Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edd33c34dc | |||
| b8bd2744ac | |||
| b2e6c2427b | |||
| 3b7abc55a4 | |||
| 29c35c524b | |||
| f3d5651ab7 | |||
| ba812e230d | |||
| cb4f2f855c | |||
| 10f9e82718 | |||
| eb0fac5989 | |||
| 82a4a13505 |
+12
@@ -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
|
||||||
|
}
|
||||||
+34
-15
@@ -22,7 +22,7 @@ import java.util.zip.ZipInputStream
|
|||||||
* 1. VEREIN01.DAT → Verein (via VereinRepository)
|
* 1. VEREIN01.DAT → Verein (via VereinRepository)
|
||||||
* 2. LIZENZ01.DAT → Reiter (via ReiterRepository)
|
* 2. LIZENZ01.DAT → Reiter (via ReiterRepository)
|
||||||
* 3. PFERDE01.DAT → Pferd (via HorseRepository)
|
* 3. PFERDE01.DAT → Pferd (via HorseRepository)
|
||||||
* 4. RICHT01.DAT → Funktionaer (via FunktionaerRepository)
|
* 4. RICHT01.DAT → Funktionär (via FunktionaerRepository)
|
||||||
*
|
*
|
||||||
* Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl
|
* Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl
|
||||||
* im Backend (REST-Upload) als auch in der Compose Desktop App (Offline-Import)
|
* 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.
|
* 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>> {
|
fun extrahiereDateien(zipInputStream: InputStream): Map<String, List<String>> {
|
||||||
val dateien = mutableMapOf<String, List<String>>()
|
val dateien = mutableMapOf<String, List<String>>()
|
||||||
@@ -65,24 +66,25 @@ class ZnsImportService(
|
|||||||
val fileName = entry.name.uppercase().substringAfterLast("/")
|
val fileName = entry.name.uppercase().substringAfterLast("/")
|
||||||
|
|
||||||
if (fileName in setOf(FILE_VEREIN, FILE_LIZENZ, FILE_PFERDE, FILE_RICHT)) {
|
if (fileName in setOf(FILE_VEREIN, FILE_LIZENZ, FILE_PFERDE, FILE_RICHT)) {
|
||||||
val outputStream = java.io.ByteArrayOutputStream()
|
// Wir lesen den Stream direkt zeilenweise mit dem korrekten Encoding
|
||||||
val buffer = ByteArray(4096)
|
val reader = zip.bufferedReader(CP850)
|
||||||
var len: Int
|
val lines = mutableListOf<String>()
|
||||||
while (zip.read(buffer).also { len = it } > 0) {
|
|
||||||
outputStream.write(buffer, 0, len)
|
// 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
|
dateien[fileName] = lines
|
||||||
}
|
}
|
||||||
zip.closeEntry()
|
zip.closeEntry()
|
||||||
entry = zip.nextEntry
|
entry = zip.nextEntry
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Wir schließen den ZipInputStream NICHT mit use,
|
// Wir schließen den ZipInputStream NICHT hier, sondern überlassen es dem Aufrufer
|
||||||
// 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.
|
|
||||||
}
|
}
|
||||||
return dateien
|
return dateien
|
||||||
}
|
}
|
||||||
@@ -91,9 +93,13 @@ class ZnsImportService(
|
|||||||
* Importiert eine ZNS-ZIP-Datei aus einem [InputStream].
|
* Importiert eine ZNS-ZIP-Datei aus einem [InputStream].
|
||||||
*
|
*
|
||||||
* @param zipInputStream Der InputStream der ZIP-Datei.
|
* @param zipInputStream Der InputStream der ZIP-Datei.
|
||||||
|
* @param mode Der [ZnsImportMode] (Standard: [ZnsImportMode.FULL]).
|
||||||
* @return [ZnsImportResult] mit Statistiken und eventuellen Fehlern.
|
* @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)
|
val dateien = extrahiereDateien(zipInputStream)
|
||||||
// println("[DEBUG_LOG] Gefundene Dateien: ${dateien.keys}")
|
// println("[DEBUG_LOG] Gefundene Dateien: ${dateien.keys}")
|
||||||
// dateien.forEach { (name, lines) -> println("[DEBUG_LOG] Datei $name hat ${lines.size} Zeilen") }
|
// 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 (vereineNeu, vereineUpd) = importiereVereine(dateien[FILE_VEREIN] ?: emptyList(), fehler)
|
||||||
val (reiterNeu, reiterUpd) = importiereReiter(dateien[FILE_LIZENZ] ?: emptyList(), fehler, warnungen)
|
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(
|
return ZnsImportResult(
|
||||||
vereineImportiert = vereineNeu,
|
vereineImportiert = vereineNeu,
|
||||||
|
|||||||
+10
-1
@@ -10,13 +10,22 @@ import kotlin.time.Instant
|
|||||||
data class Device(
|
data class Device(
|
||||||
val id: UUID = UUID.randomUUID(),
|
val id: UUID = UUID.randomUUID(),
|
||||||
val name: String,
|
val name: String,
|
||||||
|
val expectedName: String? = null, // Falls vom Master vor-registriert
|
||||||
val securityKeyHash: String, // Gehasht für Sicherheit
|
val securityKeyHash: String, // Gehasht für Sicherheit
|
||||||
val role: DeviceRole = DeviceRole.CLIENT,
|
val role: DeviceRole = DeviceRole.CLIENT,
|
||||||
val lastSyncAt: Instant? = null,
|
val lastSyncAt: Instant? = null,
|
||||||
|
val isOnline: Boolean = false,
|
||||||
|
val isSynchronized: Boolean = true,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
val updatedAt: Instant = createdAt
|
val updatedAt: Instant = createdAt
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class DeviceRole {
|
enum class DeviceRole {
|
||||||
MASTER, CLIENT
|
MASTER,
|
||||||
|
CLIENT,
|
||||||
|
RICHTER,
|
||||||
|
ZEITNEHMER,
|
||||||
|
STALLMEISTER,
|
||||||
|
ANZEIGE,
|
||||||
|
PARCOURS_CHEF
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -13,10 +13,13 @@ object DeviceTable : Table("identity_devices") {
|
|||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
val name = varchar("name", 100).uniqueIndex()
|
val name = varchar("name", 100).uniqueIndex()
|
||||||
|
val expectedName = varchar("expected_name", 100).nullable()
|
||||||
val securityKeyHash = varchar("security_key_hash", 255)
|
val securityKeyHash = varchar("security_key_hash", 255)
|
||||||
val role = enumerationByName("role", 20, DeviceRole::class)
|
val role = enumerationByName("role", 20, DeviceRole::class)
|
||||||
|
|
||||||
val lastSyncAt = timestamp("last_sync_at").nullable()
|
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 createdAt = timestamp("created_at")
|
||||||
val updatedAt = timestamp("updated_at")
|
val updatedAt = timestamp("updated_at")
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -33,18 +33,24 @@ class ExposedDeviceRepository : DeviceRepository {
|
|||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
DeviceTable.update({ DeviceTable.id eq device.id }) {
|
DeviceTable.update({ DeviceTable.id eq device.id }) {
|
||||||
it[name] = device.name
|
it[name] = device.name
|
||||||
|
it[expectedName] = device.expectedName
|
||||||
it[securityKeyHash] = device.securityKeyHash
|
it[securityKeyHash] = device.securityKeyHash
|
||||||
it[role] = device.role
|
it[role] = device.role
|
||||||
it[lastSyncAt] = device.lastSyncAt
|
it[lastSyncAt] = device.lastSyncAt
|
||||||
|
it[isOnline] = device.isOnline
|
||||||
|
it[isSynchronized] = device.isSynchronized
|
||||||
it[updatedAt] = now
|
it[updatedAt] = now
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
DeviceTable.insert {
|
DeviceTable.insert {
|
||||||
it[id] = device.id
|
it[id] = device.id
|
||||||
it[name] = device.name
|
it[name] = device.name
|
||||||
|
it[expectedName] = device.expectedName
|
||||||
it[securityKeyHash] = device.securityKeyHash
|
it[securityKeyHash] = device.securityKeyHash
|
||||||
it[role] = device.role
|
it[role] = device.role
|
||||||
it[lastSyncAt] = device.lastSyncAt
|
it[lastSyncAt] = device.lastSyncAt
|
||||||
|
it[isOnline] = device.isOnline
|
||||||
|
it[isSynchronized] = device.isSynchronized
|
||||||
it[createdAt] = device.createdAt
|
it[createdAt] = device.createdAt
|
||||||
it[updatedAt] = now
|
it[updatedAt] = now
|
||||||
}
|
}
|
||||||
@@ -62,9 +68,12 @@ class ExposedDeviceRepository : DeviceRepository {
|
|||||||
private fun rowToDevice(row: ResultRow): Device = Device(
|
private fun rowToDevice(row: ResultRow): Device = Device(
|
||||||
id = row[DeviceTable.id],
|
id = row[DeviceTable.id],
|
||||||
name = row[DeviceTable.name],
|
name = row[DeviceTable.name],
|
||||||
|
expectedName = row[DeviceTable.expectedName],
|
||||||
securityKeyHash = row[DeviceTable.securityKeyHash],
|
securityKeyHash = row[DeviceTable.securityKeyHash],
|
||||||
role = row[DeviceTable.role],
|
role = row[DeviceTable.role],
|
||||||
lastSyncAt = row[DeviceTable.lastSyncAt],
|
lastSyncAt = row[DeviceTable.lastSyncAt],
|
||||||
|
isOnline = row[DeviceTable.isOnline],
|
||||||
|
isSynchronized = row[DeviceTable.isSynchronized],
|
||||||
createdAt = row[DeviceTable.createdAt],
|
createdAt = row[DeviceTable.createdAt],
|
||||||
updatedAt = row[DeviceTable.updatedAt]
|
updatedAt = row[DeviceTable.updatedAt]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,8 +92,8 @@ USER ${APP_USER}
|
|||||||
|
|
||||||
EXPOSE 8095 5005
|
EXPOSE 8095 5005
|
||||||
|
|
||||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=60s --retries=5 \
|
||||||
CMD curl -fsS --max-time 2 http://localhost:8095/actuator/health/readiness || exit 1
|
CMD curl -fsS --max-time 5 http://localhost:${SERVER_PORT:-8095}/actuator/health/readiness || exit 1
|
||||||
|
|
||||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||||
-XX:+UseG1GC \
|
-XX:+UseG1GC \
|
||||||
|
|||||||
+6
-2
@@ -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.ImportJob
|
||||||
import at.mocode.zns.import.service.job.ImportJobRegistry
|
import at.mocode.zns.import.service.job.ImportJobRegistry
|
||||||
import at.mocode.zns.import.service.job.ZnsImportOrchestrator
|
import at.mocode.zns.import.service.job.ZnsImportOrchestrator
|
||||||
|
import at.mocode.zns.importer.ZnsImportMode
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
@@ -23,9 +24,12 @@ class ZnsImportController(
|
|||||||
* Rückgabe: 202 Accepted mit JobId.
|
* Rückgabe: 202 Accepted mit JobId.
|
||||||
*/
|
*/
|
||||||
@PostMapping(consumes = ["multipart/form-data"])
|
@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()
|
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))
|
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ImportStartResponse(job.jobId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
package at.mocode.zns.import.service.job
|
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 at.mocode.zns.importer.ZnsImportService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -19,7 +19,7 @@ class ZnsImportOrchestrator(
|
|||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(Dispatchers.IO)
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
fun starteImport(jobId: String, zipBytes: ByteArray) {
|
fun starteImport(jobId: String, zipBytes: ByteArray, mode: ZnsImportMode = ZnsImportMode.FULL) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.ENTPACKEN, "Entpacke ZIP-Datei...", 5)
|
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.ENTPACKEN, "Entpacke ZIP-Datei...", 5)
|
||||||
@@ -28,7 +28,7 @@ class ZnsImportOrchestrator(
|
|||||||
archiviereZip(zipBytes)
|
archiviereZip(zipBytes)
|
||||||
|
|
||||||
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.VERARBEITUNG, "Verarbeite ZNS-Daten...", 20)
|
jobRegistry.aktualisiereStatus(jobId, ImportJobStatus.VERARBEITUNG, "Verarbeite ZNS-Daten...", 20)
|
||||||
val result = service.importiereZip(zipBytes.inputStream())
|
val result = service.importiereZip(zipBytes.inputStream(), mode)
|
||||||
|
|
||||||
jobRegistry.aktualisiereStatus(
|
jobRegistry.aktualisiereStatus(
|
||||||
jobId, ImportJobStatus.ABGESCHLOSSEN,
|
jobId, ImportJobStatus.ABGESCHLOSSEN,
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ spring:
|
|||||||
discovery:
|
discovery:
|
||||||
enabled: ${CONSUL_ENABLED:true}
|
enabled: ${CONSUL_ENABLED:true}
|
||||||
register: ${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-path: /actuator/health
|
||||||
health-check-interval: 10s
|
health-check-interval: 15s
|
||||||
instance-id: ${spring.application.name}-${server.port}-${random.uuid}
|
instance-id: ${spring.application.name}:${server.port}:${random.uuid}
|
||||||
|
hostname: ${SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME:localhost}
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
@@ -39,6 +42,8 @@ management:
|
|||||||
endpoint:
|
endpoint:
|
||||||
health:
|
health:
|
||||||
show-details: always
|
show-details: always
|
||||||
|
probes:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
app:
|
app:
|
||||||
service-name: ${spring.application.name}
|
service-name: ${spring.application.name}
|
||||||
|
|||||||
+6
-5
@@ -77,8 +77,8 @@ services:
|
|||||||
condition: "service_healthy"
|
condition: "service_healthy"
|
||||||
zipkin:
|
zipkin:
|
||||||
condition: "service_healthy"
|
condition: "service_healthy"
|
||||||
mail-service:
|
# mail-service:
|
||||||
condition: "service_healthy"
|
# condition: "service_healthy"
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8081/actuator/health/readiness" ]
|
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8081/actuator/health/readiness" ]
|
||||||
@@ -352,6 +352,7 @@ services:
|
|||||||
SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
|
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_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_PREFER_IP_ADDRESS: "${ZNS_IMPORT_CONSUL_PREFER_IP:-true}"
|
||||||
|
SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: "${ZNS_IMPORT_HOSTNAME:-zns-import-service}"
|
||||||
|
|
||||||
# - DATENBANK VERBINDUNG -
|
# - DATENBANK VERBINDUNG -
|
||||||
SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
|
SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
|
||||||
@@ -381,11 +382,11 @@ services:
|
|||||||
condition: "service_healthy"
|
condition: "service_healthy"
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8095/actuator/health/readiness" ]
|
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8095/actuator/health/readiness" ]
|
||||||
interval: 15s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 40s
|
start_period: 30s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
meldestelle-network:
|
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.
|
||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
+20
@@ -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()
|
||||||
|
}
|
||||||
+4
-3
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Überwacht die Konnektivität zum API-Gateway.
|
* Überwacht die Konnektivität zum API-Gateway.
|
||||||
@@ -28,16 +29,16 @@ class ConnectivityTracker : KoinComponent {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
_isOnline.value = checkConnection()
|
_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 {
|
private suspend fun checkConnection(): Boolean {
|
||||||
return try {
|
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
|
response.status.value in 200..299
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature-Modul: Veranstaltungs-Verwaltung (Desktop-only)
|
* 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 {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
@@ -40,6 +40,7 @@ kotlin {
|
|||||||
implementation(projects.frontend.core.network)
|
implementation(projects.frontend.core.network)
|
||||||
implementation(projects.frontend.core.domain)
|
implementation(projects.frontend.core.domain)
|
||||||
implementation(projects.core.coreDomain)
|
implementation(projects.core.coreDomain)
|
||||||
|
implementation(projects.frontend.core.auth)
|
||||||
|
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
|
|||||||
+162
-31
@@ -1,63 +1,194 @@
|
|||||||
package at.mocode.veranstaltung.feature.presentation
|
package at.mocode.veranstaltung.feature.presentation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import at.mocode.frontend.core.designsystem.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).
|
* Formular zum Anlegen einer neuen Veranstaltung (Vision_03: /veranstaltung/neu).
|
||||||
* Tabs: Veranstaltung-Übersicht | Stammdaten (A-Satz) | Organisation | Preisliste
|
* Tabs: Stammdaten (A-Satz) | Organisation | Preisliste
|
||||||
* TODO: Echte Formular-Felder und Persistenz (Phase 4/5).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun VeranstaltungNeuScreen(
|
fun VeranstaltungNeuScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSave: () -> Unit,
|
onSave: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var selectedTab by remember { mutableIntStateOf(1) } // Stammdaten ist Standard-Tab
|
var currentStep by remember { mutableStateOf(MsWizardStep.ZNS_BASIS) }
|
||||||
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Preisliste")
|
|
||||||
|
// 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()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Toolbar
|
// Wizard Header
|
||||||
Row(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
shadowElevation = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.surface
|
||||||
) {
|
) {
|
||||||
Row {
|
Row(
|
||||||
IconButton(onClick = onBack) {
|
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")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(Dimens.SpacingS))
|
||||||
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "Neue Veranstaltung",
|
text = "Neue Veranstaltung anlegen",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.alignByBaseline(),
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
Text(
|
||||||
Button(onClick = onSave) { Text("Speichern") }
|
text = when (currentStep) {
|
||||||
}
|
MsWizardStep.ZNS_BASIS -> "Schritt 1: ZNS-Basis & Veranstalter"
|
||||||
|
MsWizardStep.META_DATEN -> "Schritt 2: Meta-Daten & DB-Initialisierung"
|
||||||
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
MsWizardStep.FACHLICHER_TYP -> "Schritt 3: Fachlicher Typ"
|
||||||
tabs.forEachIndexed { index, title ->
|
},
|
||||||
Tab(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
selected = selectedTab == index,
|
color = MaterialTheme.colorScheme.primary
|
||||||
onClick = { selectedTab = index },
|
|
||||||
text = { Text(title) },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
|
||||||
when (selectedTab) {
|
if (currentStep != MsWizardStep.FACHLICHER_TYP) {
|
||||||
0 -> PlaceholderContent("Veranstaltung – Übersicht", "Wird nach dem Speichern befüllt.")
|
MsButton(
|
||||||
1 -> PlaceholderContent("Stammdaten (A-Satz)", "Felder: Bezeichnung, Datum, Ort, Veranstalter …")
|
text = "Weiter",
|
||||||
2 -> PlaceholderContent("Organisation", "Felder: Richter, Parcourschef, Tierarzt …")
|
onClick = { currentStep = MsWizardStep.entries[currentStep.ordinal + 1] },
|
||||||
3 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
@@ -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
|
||||||
|
)
|
||||||
+138
-18
@@ -1,51 +1,171 @@
|
|||||||
package at.mocode.veranstaltung.feature.presentation
|
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.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Event
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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 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).
|
* Veranstaltungs-Übersicht (Drawer-Einstieg gemäß Vision_03).
|
||||||
* Zeigt Liste aller Veranstaltungen + Button "Neue Veranstaltung".
|
* Zeigt Liste aller Veranstaltungen + Button "Neue Veranstaltung".
|
||||||
* TODO: Echte Daten aus dem event-management-context laden (Phase 4/5).
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun VeranstaltungenScreen(
|
fun VeranstaltungenScreen(
|
||||||
onVeranstaltungNeu: () -> Unit,
|
onVeranstaltungNeu: () -> Unit,
|
||||||
onVeranstaltungOeffnen: (Long) -> 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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Veranstaltungen",
|
text = "Veranstaltungen - verwalten",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
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
|
if (veranstaltungen.isEmpty()) {
|
||||||
PlaceholderContent(
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
title = "Noch keine Veranstaltungen",
|
Text(
|
||||||
subtitle = "Lege eine neue Veranstaltung an, um zu beginnen.",
|
"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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+19
-21
@@ -6,6 +6,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import at.mocode.frontend.core.auth.data.AuthTokenManager
|
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 at.mocode.frontend.core.network.NetworkConfig
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
@@ -20,20 +22,9 @@ import kotlinx.serialization.json.Json
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
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
|
@Serializable
|
||||||
internal data class JobIdResponse(val jobId: String)
|
data class ImportStartResponse(val jobId: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
internal data class JobStatusResponse(
|
internal data class JobStatusResponse(
|
||||||
@@ -51,23 +42,27 @@ private const val MAX_VISIBLE_ERRORS = 50
|
|||||||
class ZnsImportViewModel(
|
class ZnsImportViewModel(
|
||||||
private val httpClient: HttpClient,
|
private val httpClient: HttpClient,
|
||||||
private val authTokenManager: AuthTokenManager,
|
private val authTokenManager: AuthTokenManager,
|
||||||
) : ViewModel() {
|
) : ViewModel(), ZnsImportProvider {
|
||||||
|
|
||||||
var state by mutableStateOf(ZnsImportState())
|
override var state by mutableStateOf(ZnsImportState())
|
||||||
private set
|
private set
|
||||||
|
|
||||||
private var pollingJob: Job? = null
|
private var pollingJob: Job? = null
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
fun onFileSelected(path: String) {
|
override fun onFileSelected(path: String) {
|
||||||
state = ZnsImportState(selectedFilePath = path)
|
state = ZnsImportState(selectedFilePath = path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startImport() {
|
override fun startImport(mode: String) {
|
||||||
val filePath = state.selectedFilePath ?: return
|
val filePath = state.selectedFilePath ?: return
|
||||||
val file = File(filePath)
|
val file = File(filePath)
|
||||||
if (!file.exists() || !file.name.endsWith(".zip", ignoreCase = true)) {
|
if (!file.exists() || !(file.name.endsWith(".zip", ignoreCase = true) || file.name.endsWith(
|
||||||
state = state.copy(errorMessage = "Bitte eine gültige .zip-Datei auswählen.")
|
".dat",
|
||||||
|
ignoreCase = true
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
state = state.copy(errorMessage = "Bitte eine gültige .zip oder .dat-Datei auswählen.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,16 +74,19 @@ class ZnsImportViewModel(
|
|||||||
try {
|
try {
|
||||||
val token = authTokenManager.authState.value.token
|
val token = authTokenManager.authState.value.token
|
||||||
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
|
val response: HttpResponse = httpClient.post("${NetworkConfig.baseUrl}/api/v1/import/zns") {
|
||||||
|
parameter("mode", mode)
|
||||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
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 {
|
setBody(MultiPartFormDataContent(formData {
|
||||||
append("file", file.readBytes(), Headers.build {
|
append("file", file.readBytes(), Headers.build {
|
||||||
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
|
append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"")
|
||||||
append(HttpHeaders.ContentType, "application/zip")
|
append(HttpHeaders.ContentType, contentType)
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (response.status == HttpStatusCode.Accepted) {
|
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")
|
state = state.copy(isUploading = false, jobId = body.jobId, jobStatus = "AUSSTEHEND")
|
||||||
startPolling(body.jobId)
|
startPolling(body.jobId)
|
||||||
} else {
|
} else {
|
||||||
@@ -129,7 +127,7 @@ class ZnsImportViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
override fun reset() {
|
||||||
pollingJob?.cancel()
|
pollingJob?.cancel()
|
||||||
state = ZnsImportState()
|
state = ZnsImportState()
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -1,9 +1,11 @@
|
|||||||
package at.mocode.zns.feature.di
|
package at.mocode.zns.feature.di
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||||
import at.mocode.zns.feature.ZnsImportViewModel
|
import at.mocode.zns.feature.ZnsImportViewModel
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val znsImportModule = module {
|
val znsImportModule = module {
|
||||||
|
factory<ZnsImportProvider> { ZnsImportViewModel(get(named("apiClient")), get()) }
|
||||||
factory { ZnsImportViewModel(get(named("apiClient")), get()) }
|
factory { ZnsImportViewModel(get(named("apiClient")), get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -114,7 +114,10 @@ fun StammdatenImportScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error)
|
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,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text("Status", style = MaterialTheme.typography.titleMedium)
|
Text("Status", style = MaterialTheme.typography.titleMedium)
|
||||||
StatusChip(state.jobStatus)
|
val statusMsg = state.jobStatus
|
||||||
|
if (statusMsg != null) {
|
||||||
|
StatusChip(statusMsg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"geraetName": "Meldestelle",
|
"geraetName": "Meldestelle",
|
||||||
"sharedKey": "Meldestelle",
|
"sharedKey": "Meldestelle",
|
||||||
"backupPath": "/mocode/Meldestelle/docs/temp",
|
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
|
||||||
"networkRole": "MASTER"
|
"networkRole": "MASTER",
|
||||||
|
"expectedClients": [
|
||||||
|
{
|
||||||
|
"name": "Richter-Turm",
|
||||||
|
"role": "RICHTER"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -531,7 +531,7 @@ private fun DesktopContentArea(
|
|||||||
|
|
||||||
// Haupt-Zentrale: Veranstaltung-Verwaltung
|
// Haupt-Zentrale: Veranstaltung-Verwaltung
|
||||||
is AppScreen.VeranstaltungVerwaltung -> {
|
is AppScreen.VeranstaltungVerwaltung -> {
|
||||||
at.mocode.desktop.v2.VeranstaltungVerwaltungV2(
|
at.mocode.desktop.v2.VeranstaltungVerwaltung(
|
||||||
onVeranstaltungOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) },
|
onVeranstaltungOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungProfil(vId, eId)) },
|
||||||
onNewVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig()) },
|
onNewVeranstaltung = { onNavigate(AppScreen.VeranstaltungKonfig()) },
|
||||||
onNavigateToPferde = { onNavigate(AppScreen.PferdVerwaltung) },
|
onNavigateToPferde = { onNavigate(AppScreen.PferdVerwaltung) },
|
||||||
|
|||||||
+15
-1
@@ -5,15 +5,29 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
enum class NetworkRole {
|
enum class NetworkRole {
|
||||||
MASTER,
|
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
|
@Serializable
|
||||||
data class OnboardingSettings(
|
data class OnboardingSettings(
|
||||||
val geraetName: String = "",
|
val geraetName: String = "",
|
||||||
val sharedKey: String = "",
|
val sharedKey: String = "",
|
||||||
val backupPath: String = "",
|
val backupPath: String = "",
|
||||||
val networkRole: NetworkRole = NetworkRole.CLIENT,
|
val networkRole: NetworkRole = NetworkRole.CLIENT,
|
||||||
|
val expectedClients: List<ExpectedClient> = emptyList(),
|
||||||
val syncInterval: Int = 30, // in Minuten
|
val syncInterval: Int = 30, // in Minuten
|
||||||
val defaultPrinter: String = ""
|
val defaultPrinter: String = ""
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-3
@@ -3,7 +3,7 @@ package at.mocode.desktop.screens.onboarding
|
|||||||
/**
|
/**
|
||||||
* Validierungslogik für den Onboarding-Wizard.
|
* 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:
|
* Regeln gemäß Onboarding-Spezifikation:
|
||||||
* - Gerätename: mindestens 3 Zeichen (nach trim)
|
* - Gerätename: mindestens 3 Zeichen (nach trim)
|
||||||
* - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim)
|
* - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim)
|
||||||
@@ -37,9 +37,19 @@ object OnboardingValidator {
|
|||||||
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
|
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
|
||||||
* der „Weiter"-Button aktiviert werden darf.
|
* der „Weiter"-Button aktiviert werden darf.
|
||||||
*/
|
*/
|
||||||
fun canContinue(settings: OnboardingSettings): Boolean =
|
fun canContinue(settings: OnboardingSettings): Boolean {
|
||||||
isNameValid(settings.geraetName) &&
|
val basicValid = isNameValid(settings.geraetName) &&
|
||||||
isKeyValid(settings.sharedKey) &&
|
isKeyValid(settings.sharedKey) &&
|
||||||
isBackupPathValid(settings.backupPath) &&
|
isBackupPathValid(settings.backupPath) &&
|
||||||
isSyncIntervalValid(settings.syncInterval)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+243
-49
@@ -10,6 +10,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -24,19 +25,32 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
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.NetworkRole
|
||||||
import at.mocode.desktop.screens.onboarding.OnboardingSettings
|
import at.mocode.desktop.screens.onboarding.OnboardingSettings
|
||||||
import at.mocode.desktop.screens.onboarding.OnboardingValidator
|
import at.mocode.desktop.screens.onboarding.OnboardingValidator
|
||||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
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.print.PrintServiceLookup
|
||||||
import javax.swing.JFileChooser
|
import javax.swing.JFileChooser
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(
|
fun OnboardingScreen(
|
||||||
settings: OnboardingSettings,
|
settings: OnboardingSettings,
|
||||||
onSettingsChange: (OnboardingSettings) -> Unit,
|
onSettingsChange: (OnboardingSettings) -> Unit,
|
||||||
onContinue: (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 {
|
DesktopThemeV2 {
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
Column(
|
Column(
|
||||||
@@ -49,27 +63,136 @@ fun OnboardingScreen(
|
|||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Bitte konfiguriere deine lokale Instanz (Geburtsurkunde).",
|
if (currentStep == 0) "Schritt 1: Netzwerk-Rolle festlegen" else "Schritt 2: Rollenspezifische Konfiguration",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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) }
|
var showPw by remember { mutableStateOf(false) }
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
|
// 2.1 / 2.2 IDENTITÄT & SICHERHEIT
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
|
Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
if (settings.networkRole == NetworkRole.MASTER) {
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = settings.geraetName,
|
value = settings.geraetName,
|
||||||
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
|
onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
|
||||||
label = "Gerätename (Pflicht)",
|
label = "Gerätename (Pflicht)",
|
||||||
placeholder = "z. B. Meldestelle-PC-1",
|
placeholder = "z. B. Haupt-PC",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
imeAction = ImeAction.Next,
|
imeAction = ImeAction.Next,
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.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() }
|
||||||
|
|
||||||
|
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 {
|
||||||
|
availableSlots.forEach { slot ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(slot) },
|
||||||
|
onClick = {
|
||||||
|
onSettingsChange(settings.copy(geraetName = slot))
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = settings.sharedKey,
|
value = settings.sharedKey,
|
||||||
@@ -86,21 +209,104 @@ fun OnboardingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3.1 ERWARTETE GERÄTE (NUR MASTER)
|
||||||
|
if (settings.networkRole == NetworkRole.MASTER) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text("⚙️ Lokale Einstellungen", style = MaterialTheme.typography.titleMedium)
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
MsTextField(
|
||||||
value = settings.backupPath,
|
value = settings.backupPath,
|
||||||
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
|
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
|
||||||
label = "💾 Datenbank-Sicherungspfad (Backup)",
|
label = "Pfad auswählen",
|
||||||
placeholder = "Pfad zum Backup-Verzeichnis",
|
placeholder = "Verzeichnis für Backups/Cache",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
trailingIcon = Icons.Default.FolderOpen,
|
trailingIcon = Icons.Default.FolderOpen,
|
||||||
onTrailingIconClick = {
|
onTrailingIconClick = {
|
||||||
val chooser = JFileChooser()
|
val chooser = JFileChooser()
|
||||||
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
chooser.dialogTitle = "Backup-Verzeichnis auswählen"
|
chooser.dialogTitle = "Verzeichnis auswählen"
|
||||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
||||||
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
|
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
|
||||||
}
|
}
|
||||||
@@ -108,31 +314,15 @@ fun OnboardingScreen(
|
|||||||
imeAction = ImeAction.Next,
|
imeAction = ImeAction.Next,
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.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 {
|
// 5.1 SYNC-INTERVALL (NUR MASTER)
|
||||||
Text("📡 Sync-Intervall: ${settings.syncInterval} Minuten", style = MaterialTheme.typography.labelLarge)
|
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(
|
Slider(
|
||||||
value = settings.syncInterval.toFloat(),
|
value = settings.syncInterval.toFloat(),
|
||||||
onValueChange = { onSettingsChange(settings.copy(syncInterval = it.toInt())) },
|
onValueChange = { onSettingsChange(settings.copy(syncInterval = it.toInt())) },
|
||||||
@@ -141,6 +331,13 @@ fun OnboardingScreen(
|
|||||||
modifier = Modifier.fillMaxWidth()
|
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) }
|
var showPrinterDialog by remember { mutableStateOf(false) }
|
||||||
val availablePrinters = remember {
|
val availablePrinters = remember {
|
||||||
@@ -150,7 +347,7 @@ fun OnboardingScreen(
|
|||||||
MsTextField(
|
MsTextField(
|
||||||
value = settings.defaultPrinter,
|
value = settings.defaultPrinter,
|
||||||
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
|
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
|
||||||
label = "🖨️ Standard-Drucker",
|
label = "Standard-Drucker",
|
||||||
placeholder = "Name des Standard-Druckers",
|
placeholder = "Name des Standard-Druckers",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
trailingIcon = Icons.Default.Print,
|
trailingIcon = Icons.Default.Print,
|
||||||
@@ -181,10 +378,7 @@ fun OnboardingScreen(
|
|||||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
RadioButton(
|
RadioButton(selected = settings.defaultPrinter == printer, onClick = null)
|
||||||
selected = settings.defaultPrinter == printer,
|
|
||||||
onClick = null
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(printer)
|
Text(printer)
|
||||||
}
|
}
|
||||||
@@ -192,31 +386,31 @@ fun OnboardingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = { TextButton(onClick = { showPrinterDialog = false }) { Text("Schließen") } }
|
||||||
TextButton(onClick = { showPrinterDialog = false }) {
|
|
||||||
Text("Schließen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val canContinue = OnboardingValidator.canContinue(settings)
|
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(
|
Button(
|
||||||
onClick = { onContinue(settings) },
|
onClick = { onContinue(settings) },
|
||||||
enabled = canContinue,
|
enabled = OnboardingValidator.canContinue(settings)
|
||||||
modifier = Modifier.align(Alignment.End)
|
|
||||||
) {
|
) {
|
||||||
Text("Konfiguration speichern & starten")
|
Text("Konfiguration abschließen")
|
||||||
|
Icon(Icons.Default.Check, null, Modifier.padding(start = 8.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+224
-74
@@ -21,14 +21,19 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import javax.swing.JFileChooser
|
||||||
|
import javax.swing.filechooser.FileNameExtensionFilter
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun VeranstaltungVerwaltungV2(
|
fun VeranstaltungVerwaltung(
|
||||||
onVeranstaltungOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
|
onVeranstaltungOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
|
||||||
onNewVeranstaltung: () -> Unit,
|
onNewVeranstaltung: () -> Unit,
|
||||||
onNavigateToPferde: () -> Unit,
|
onNavigateToPferde: () -> Unit,
|
||||||
@@ -57,51 +62,13 @@ fun VeranstaltungVerwaltungV2(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
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
|
// Header
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("Veranstaltung-Verwaltung", style = MaterialTheme.typography.headlineMedium)
|
Text("Veranstaltungen - verwalten", style = MaterialTheme.typography.headlineMedium)
|
||||||
Button(onClick = onNewVeranstaltung) {
|
Button(onClick = onNewVeranstaltung) {
|
||||||
Icon(Icons.Default.Add, contentDescription = null)
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
@@ -344,6 +311,9 @@ fun VeranstaltungKonfigV2(
|
|||||||
onSaved: (Long, Long) -> Unit, // eventId, veranstalterId
|
onSaved: (Long, Long) -> Unit, // eventId, veranstalterId
|
||||||
onVeranstalterCreated: (Long) -> Unit = {}, // Neuer Flow: nach Vereinsanlage ins Profil
|
onVeranstalterCreated: (Long) -> Unit = {}, // Neuer Flow: nach Vereinsanlage ins Profil
|
||||||
) {
|
) {
|
||||||
|
val znsImporter: ZnsImportProvider = koinInject()
|
||||||
|
val znsState = znsImporter.state
|
||||||
|
|
||||||
DesktopThemeV2 {
|
DesktopThemeV2 {
|
||||||
var currentStep by remember { mutableStateOf(if (veranstalterId == 0L) 1 else 2) }
|
var currentStep by remember { mutableStateOf(if (veranstalterId == 0L) 1 else 2) }
|
||||||
|
|
||||||
@@ -455,34 +425,54 @@ fun VeranstaltungKonfigV2(
|
|||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
when (currentStep) {
|
when (currentStep) {
|
||||||
1 -> {
|
1 -> {
|
||||||
// --- SCHRITT 1: Veranstalterwahl ---
|
// --- SCHRITT 1: ZNS-First Daten-Akquise ---
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"Daten-Akquise & Veranstalter",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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("") }
|
var search by remember { mutableStateOf("") }
|
||||||
val filteredVereine = remember(search) {
|
val filteredVereine = remember(search) {
|
||||||
StoreV2.vereine.filter {
|
StoreV2.vereine.filter {
|
||||||
it.name.contains(search, ignoreCase = true) || (it.ort?.contains(search, ignoreCase = true) ?: false)
|
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)
|
Text("Oder bestehenden Veranstalter wählen:", style = MaterialTheme.typography.titleSmall)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = search,
|
value = search,
|
||||||
onValueChange = { search = it },
|
onValueChange = { search = it },
|
||||||
label = { Text("Veranstalter suchen...") },
|
label = { Text("Veranstalter suchen...") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
singleLine = true
|
||||||
)
|
)
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
items(filteredVereine) { verein ->
|
items(filteredVereine) { verein ->
|
||||||
val isSelected = selectedVereinId == verein.id
|
val isSelected = selectedVereinId == verein.id
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { selectedVereinId = verein.id },
|
onClick = { selectedVereinId = verein.id },
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.small,
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||||
border = if (isSelected) null else androidx.compose.foundation.BorderStroke(
|
border = if (isSelected) null else androidx.compose.foundation.BorderStroke(
|
||||||
1.dp,
|
1.dp,
|
||||||
@@ -490,40 +480,45 @@ fun VeranstaltungKonfigV2(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(16.dp).fillMaxWidth(),
|
Modifier.padding(horizontal = 12.dp, vertical = 8.dp).fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(verein.name, fontWeight = FontWeight.Bold)
|
Text(verein.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
Text("${verein.ort ?: ""} | ${verein.oepsNummer}", style = MaterialTheme.typography.bodySmall)
|
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) {
|
||||||
|
AlertDialog(
|
||||||
if (!showVereinNeu) {
|
onDismissRequest = { showVereinNeu = false },
|
||||||
OutlinedButton(
|
title = { Text("Manueller Eintrag") },
|
||||||
onClick = { showVereinNeu = true },
|
text = {
|
||||||
modifier = Modifier.fillMaxWidth()
|
Box(Modifier.heightIn(max = 500.dp)) {
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Add, contentDescription = null)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
|
||||||
Text("Neuen Veranstalter / Verein anlegen")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VeranstalterAnlegenWizard(
|
VeranstalterAnlegenWizard(
|
||||||
onCancel = { showVereinNeu = false },
|
onCancel = { showVereinNeu = false },
|
||||||
onVereinCreated = { newId ->
|
onVereinCreated = { newId ->
|
||||||
// Neuer gewünschter Flow: nach Schritt 2 ins Veranstalter‑Profil wechseln
|
|
||||||
showVereinNeu = false
|
showVereinNeu = false
|
||||||
onVeranstalterCreated(newId)
|
onVeranstalterCreated(newId)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,12 +542,12 @@ fun VeranstaltungKonfigV2(
|
|||||||
|
|
||||||
val dateVon = try {
|
val dateVon = try {
|
||||||
LocalDate.parse(von, dateFormatter)
|
LocalDate.parse(von, dateFormatter)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val dateBis = try {
|
val dateBis = try {
|
||||||
LocalDate.parse(bis, dateFormatter)
|
LocalDate.parse(bis, dateFormatter)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val today = LocalDate.now()
|
val today = LocalDate.now()
|
||||||
@@ -734,12 +729,12 @@ fun VeranstaltungKonfigV2(
|
|||||||
2 -> {
|
2 -> {
|
||||||
val dVon = try {
|
val dVon = try {
|
||||||
LocalDate.parse(von, dateFormatter)
|
LocalDate.parse(von, dateFormatter)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val dBis = try {
|
val dBis = try {
|
||||||
LocalDate.parse(bis, dateFormatter)
|
LocalDate.parse(bis, dateFormatter)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val today2 = LocalDate.now()
|
val today2 = LocalDate.now()
|
||||||
@@ -1092,12 +1087,12 @@ fun TurnierWizardV2(
|
|||||||
val vBis = veranstaltung?.datumBis?.let { LocalDate.parse(it) }
|
val vBis = veranstaltung?.datumBis?.let { LocalDate.parse(it) }
|
||||||
val tVon = try {
|
val tVon = try {
|
||||||
LocalDate.parse(von)
|
LocalDate.parse(von)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val tBis = if (bis.isBlank()) tVon else try {
|
val tBis = if (bis.isBlank()) tVon else try {
|
||||||
LocalDate.parse(bis)
|
LocalDate.parse(bis)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,7 +1268,7 @@ private fun Step1Basics(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
kotlinx.coroutines.delay(2000)
|
kotlinx.coroutines.delay(2000.milliseconds)
|
||||||
onZnsDataLoadedChange(true)
|
onZnsDataLoadedChange(true)
|
||||||
showImportProgress = false
|
showImportProgress = false
|
||||||
}
|
}
|
||||||
@@ -1301,12 +1296,12 @@ private fun Step2Sparten(
|
|||||||
val vBis = veranstaltung?.datumBis?.let { LocalDate.parse(it) }
|
val vBis = veranstaltung?.datumBis?.let { LocalDate.parse(it) }
|
||||||
val tVon = try {
|
val tVon = try {
|
||||||
LocalDate.parse(von)
|
LocalDate.parse(von)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
val tBis = if (bis.isBlank()) tVon else try {
|
val tBis = if (bis.isBlank()) tVon else try {
|
||||||
LocalDate.parse(bis)
|
LocalDate.parse(bis)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
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
|
||||||
|
}
|
||||||
|
|||||||
+33
-1
@@ -136,7 +136,7 @@ class OnboardingValidatorTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
|
||||||
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
|
// Simuliert schneller Doppelklick: canContinue darf sich nicht ändern
|
||||||
val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
|
val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
|
||||||
val first = OnboardingValidator.canContinue(settings)
|
val first = OnboardingValidator.canContinue(settings)
|
||||||
val second = OnboardingValidator.canContinue(settings)
|
val second = OnboardingValidator.canContinue(settings)
|
||||||
@@ -186,4 +186,36 @@ class OnboardingValidatorTest {
|
|||||||
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
|
"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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user