diff --git a/docs/06_Frontend/ErgebnisMaske/Ergebnisliste.PNG b/docs/06_Frontend/ErgebnisMaske/Ergebnisliste.PNG new file mode 100644 index 00000000..7941d1bb Binary files /dev/null and b/docs/06_Frontend/ErgebnisMaske/Ergebnisliste.PNG differ diff --git a/docs/06_Frontend/ErgebnisMaske/ergebnismaske-v01.html b/docs/06_Frontend/ErgebnisMaske/ergebnismaske-v01.html new file mode 100644 index 00000000..c1e4e9af --- /dev/null +++ b/docs/06_Frontend/ErgebnisMaske/ergebnismaske-v01.html @@ -0,0 +1,409 @@ + + + + + + Ergebnisliste + + + + +
+
+ Ergebnisliste +
+
_
+
+
x
+
+
+ +
+
+
+
1
2
3
4
5
5
+
6
7
8
9
10
+
11
12
+
+
+ +
+
+ Aktualisieren + Platzierte + Suchen ▼ + 0 gefunden + Bearbeiten +
+
+ + + + + + +
ErgebnisNr.KopfNrPferdReiterKQPlatziertWertungPGpZGpGesamtnoteGeldpreisPl. EPl. HPl. CPl. M
+
+
+ +
+
+ + + + +
+ +
+
+
+ +
Fehler
+
+
+ +
Zeit
+
+
+
+ +
+ +
+ +
+ + + +
+
+ +
+
+ Aktualisieren + Starter + Suchen ▼ + Bearbeiten +
+
+ + + + + + +
Pos.Nr.KopfNrPferdReiterKQBemerkung
+
+
+
+ +
+
+
Platzierung & Geldpreis:
+
+ Anzahl Platzierte: +
+ + + +
+
+
+ Geldpreis: + --- +
+
+ Kaderreiter Extra: +
+ +
+
+
+ Anzahl platzierte Kaderreiter: + +
+
+ Geldpreis für Kaderreiter: + --- +
+
+ Summe Geldpreise: + --- +
+
+ +
+
Bewerb:
+
+ + +
+
+ +
+
Ergebnisliste:
+
+ + + + +
+
+
+
+
+ + + diff --git a/docs/06_Frontend/Nennmaske/Desktop-Nennmaske-Entwurf_2026-03-21_11-53.png b/docs/06_Frontend/Nennmaske/Desktop-Nennmaske-Entwurf_2026-03-21_11-53.png new file mode 100644 index 00000000..e28ce4fa Binary files /dev/null and b/docs/06_Frontend/Nennmaske/Desktop-Nennmaske-Entwurf_2026-03-21_11-53.png differ diff --git a/docs/06_Frontend/Nennmaske/nennmaske-v01.html b/docs/06_Frontend/Nennmaske/nennmaske-v01.html new file mode 100644 index 00000000..75cd39f8 --- /dev/null +++ b/docs/06_Frontend/Nennmaske/nennmaske-v01.html @@ -0,0 +1,323 @@ + + + + + + Nennmaske Entwurf + + + + +
+ +
+
+
+ Pferd: + + + +
+
+
Keine Ergebnisse
+
+ +
+ +
+
+ Reiter: + + + +
+
+
Keine Ergebnisse
+
+ +
+ +
+
+
VERKAUF
+
BUCHUNGEN
+
+
+ 11 Artikel +
+ Rückgängig + Speichern +
+
+
+ + + + + + + + + + + + + + + + +
KNr+/-MengeBuchungstextBetrag
+Belastung0.00
+Gutschrift0.00
+Boxenpauschale0.00
+Ansage0.00
+
+
+
+ +
+ + + +
+ +
+
+
+
REITER
+
PFERD
+
BEWERBE
+
+
+ 0 Nennungen +
+ Positionieren + Stornieren +
+
+
+ + + + + + + + + + + +
TagPl.BewerbBewerbsnameBemerkungPferd
+
Keine Nennungen vorhanden
+
+
+ +
+
+ Bewerbsübersicht +
+
+ 12 Bewerbe + Filtern +
+
+ + + + + + + + + + + + + + + + + +
TagPl.Bew.BeginnNenn.Bewerbsname
So1108:000Dressurreiterprüfung Ratepass
So1208:200Dressurreiterprüfung Katecnadel
So1308:400Dressurreiterprüfung Idf.
So1409:000Dressurprüfung Idf.
+
+
+ Bitte wählen Sie zuerst ein Pferd und einen Reiter aus +
+
+
+ +
+ + + diff --git a/docs/06_Frontend/flow-fehler.png b/docs/06_Frontend/flow-fehler.png new file mode 100644 index 00000000..80b898d9 Binary files /dev/null and b/docs/06_Frontend/flow-fehler.png differ diff --git a/docs/06_Frontend/flow-wechsel.png b/docs/06_Frontend/flow-wechsel.png new file mode 100644 index 00000000..10f766a0 Binary files /dev/null and b/docs/06_Frontend/flow-wechsel.png differ diff --git a/docs/06_Frontend/verlauf-neueVeranstaltungTurnier-anlegen.png b/docs/06_Frontend/verlauf-neueVeranstaltungTurnier-anlegen.png new file mode 100644 index 00000000..5de8e6d5 Binary files /dev/null and b/docs/06_Frontend/verlauf-neueVeranstaltungTurnier-anlegen.png differ diff --git a/docs/99_Journal/2026-04-01_Session_Log_Frontend_Flow_Entscheidungen.md b/docs/99_Journal/2026-04-01_Session_Log_Frontend_Flow_Entscheidungen.md new file mode 100644 index 00000000..f92ab471 --- /dev/null +++ b/docs/99_Journal/2026-04-01_Session_Log_Frontend_Flow_Entscheidungen.md @@ -0,0 +1,47 @@ +# 🧹 Curator – Session Log (2026-04-01) + +## Zusammenfassung +- Flow-Entscheidung bestätigt: Grüner Pfad aktiv, roter Pfad verworfen. "+ Neues Turnier" führt direkt zum Tab „STAMMDATEN“ v2 mit Turnier‑Nr.-Gatekeeping. +- Keine Codeänderungen in dieser Sitzung; Build zuletzt grün. Entscheidungen und nächste Schritte dokumentiert. + +## Beschlossene UI/Flow-Regeln +- Turnieranlage + - Einstieg: "+ Neues Turnier" → direkt „Turnier Detail v2“ Tab „STAMMDATEN“. + - Gatekeeping: 5‑stellige Turnier‑Nr. eingeben + Bestätigungsdialog (danach immutable). + - Save-Enable-Matrix: aktiv nur wenn (Nr bestätigt ∧ ZNS geladen ∧ Datum gültig). +- ZNS-Status + - Panel immer sichtbar, zeigt Quelle, `payloadVersion`, Zeitstempel. + - „Import‑Log“ Dialog mit den letzten 5 Einträgen (Erfolg/Fehler, Kurzmeldung). +- Kategorien & Pony + - Mehrfach-Kategorien wie vormittags vereinbart; Pony über Kategorien‑Suffix „P“ (kein separater Switch). + - Kategorien-UI wird gruppiert (z. B. Dressur/Springen). +- Datum/Ort + - Datum im zulässigen Veranstaltungszeitraum; Hinweis: „Muss zwischen [von–bis] liegen“. + - Abweichender Turnier‑Ort: Soft‑Warnung (kein Hard‑Block). +- Branding + - Feld „Titel“ optional. Default‑Vorschlag: „[Kategorien] [Verein‑Ort] [Bundesland]“ (Fallback über Veranstalterdaten). + - „Turnier‑Logo“ optional; Fallback = Veranstalter‑Logo. + +## Veranstalter-Flow +- Nach „Schritt 2: Vereinsdaten bestätigen“ → Weiterleitung zum „Veranstalter‑Profil“. +- Veranstalter‑Profil: minimale Felder (Logo‑URL, Ansprechpartner, E‑Mail, Telefon, Adresse), CTA „+ Neue Veranstaltung“. +- Von dort → Veranstaltung‑Wizard Schritt 2 („Basisdaten“). Feld „Veranstaltungs‑Logo“ optional; Fallbacks: Veranstaltungs‑Logo → Veranstalter‑Logo → Default. + +## Footer-Onboarding +- Online/Offline‑Status anzeigen. +- Geräte‑Verbindung (z. B. „Richter‑Turm“) anzeigen, klickbar für Details. +- Chat‑Trigger anzeigen, wenn mindestens ein weiteres Gerät verbunden ist. + +## Nächste Schritte (To‑Do) +- Routing final auf Stammdaten v2 festziehen; alte Pfade entfernen. +- Save‑Enable‑Matrix implementieren; ZNS‑Panel inkl. Import‑Log. +- Kategorien‑UI konsolidieren und gruppieren; Default‑Titel generieren; Ort‑Softwarnung. +- Veranstalter‑Profil & ‑Übersicht finalisieren; CTA‑Flow prüfen. +- Footer‑Onboarding integrieren (Status, Geräte, Chat‑Trigger). + +## Artefakte/Referenzen +- docs/06_Frontend/flow-wechsel.png (neuer Flow – grüner Pfeil) +- docs/06_Frontend/flow-fehler.png (Bruchstellen im alten Flow) +- docs/99_Journal/2026-03-31_Session_Log_Event_First_Workflow.md +- docs/99_Journal/2026-03-30_Session_Log_ZNS_Documentation.md +- docs/99_Journal/2026-03-30_Session_Log_Masterdata_OETO_Consolidation.md diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt index 3e40f90f..66dabdba 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierDetailScreen.kt @@ -33,6 +33,13 @@ fun TurnierDetailScreen( veranstaltungId: Long, turnierId: Long, onBack: () -> Unit, + eventVon: String? = null, + eventBis: String? = null, + eventOrt: String? = null, + veranstalterName: String? = null, + veranstalterOrt: String? = null, + veranstalterBundesland: String? = null, + veranstalterLogoUrl: String? = null, ) { var selectedTab by remember { mutableIntStateOf(0) } @@ -80,7 +87,16 @@ fun TurnierDetailScreen( // Tab-Inhalte Box(modifier = Modifier.fillMaxSize()) { when (selectedTab) { - 0 -> StammdatenTabContent(turnierId = turnierId) + 0 -> StammdatenTabContent( + turnierId = turnierId, + eventVon = eventVon, + eventBis = eventBis, + eventOrt = eventOrt, + veranstalterName = veranstalterName, + veranstalterOrt = veranstalterOrt, + veranstalterBundesland = veranstalterBundesland, + veranstalterLogoUrl = veranstalterLogoUrl, + ) 1 -> OrganisationTabContent() 2 -> BewerbeTabContent() 3 -> ArtikelTabContent() diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt index 5d14ca32..69b3163c 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt @@ -29,13 +29,26 @@ private val AccentBlue = Color(0xFF3B82F6) */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun StammdatenTabContent(turnierId: Long) { +fun StammdatenTabContent( + turnierId: Long, + eventVon: String? = null, + eventBis: String? = null, + eventOrt: String? = null, + veranstalterName: String? = null, + veranstalterOrt: String? = null, + veranstalterBundesland: String? = null, + veranstalterLogoUrl: String? = null, +) { // In einer echten App würden wir diese Daten aus einem ViewModel laden. // Hier simulieren wir den State basierend auf den Anforderungen. var turnierNr by remember { mutableStateOf("") } var nrConfirmed by remember { mutableStateOf(false) } + var showNrConfirm by remember { mutableStateOf(false) } var znsDataLoaded by remember { mutableStateOf(false) } + var znsPayloadVersion by remember { mutableStateOf(null) } + var znsImportedAt by remember { mutableStateOf(null) } + val znsImportHistory = remember { mutableStateListOf>() } // (source, payloadVersion, ok) var typ by remember { mutableStateOf("ÖTO (National)") } val sparten = remember { mutableStateListOf() } @@ -48,9 +61,11 @@ fun StammdatenTabContent(turnierId: Long) { var titel by remember { mutableStateOf("") } var subTitel by remember { mutableStateOf("") } + var turnierLogoUrl by remember { mutableStateOf("") } val sponsoren = remember { mutableStateListOf() } var showZnsDialog by remember { mutableStateOf(false) } + var showZnsLog by remember { mutableStateOf(false) } // Hilfs-States für DatePicker var showDatePickerVon by remember { mutableStateOf(false) } @@ -79,7 +94,7 @@ fun StammdatenTabContent(turnierId: Long) { ) if (!nrConfirmed) { Button( - onClick = { nrConfirmed = true }, + onClick = { showNrConfirm = true }, enabled = turnierNr.length == 5, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) ) { @@ -88,9 +103,9 @@ fun StammdatenTabContent(turnierId: Long) { } else { InputChip( selected = true, - onClick = { nrConfirmed = false }, + onClick = { }, label = { Text("Bestätigt") }, - trailingIcon = { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) } + trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) } ) } } @@ -108,11 +123,13 @@ fun StammdatenTabContent(turnierId: Long) { FilterChip( selected = typ == "ÖTO (National)", onClick = { typ = "ÖTO (National)" }, + enabled = nrConfirmed, label = { Text("ÖTO (National)") } ) FilterChip( selected = typ == "FEI (International)", onClick = { typ = "FEI (International)" }, + enabled = nrConfirmed, label = { Text("FEI (International)") } ) } @@ -123,16 +140,18 @@ fun StammdatenTabContent(turnierId: Long) { Button( onClick = { showZnsDialog = true }, colors = ButtonDefaults.buttonColors(containerColor = AccentBlue) + , enabled = nrConfirmed ) { Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text("Import via Internet") } - OutlinedButton(onClick = { showZnsDialog = true }) { + OutlinedButton(onClick = { showZnsDialog = true }, enabled = nrConfirmed) { Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text("Import via USB") } + TextButton(onClick = { showZnsLog = true }, enabled = nrConfirmed) { Text("Import-Log anzeigen…") } } val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error @@ -149,6 +168,17 @@ fun StammdatenTabContent(turnierId: Long) { fontWeight = FontWeight.Bold, fontSize = 13.sp ) + if (znsDataLoaded) { + Spacer(Modifier.width(8.dp)) + Text( + listOfNotNull( + znsPayloadVersion?.let { "Version: $it" }, + znsImportedAt?.let { "Zeit: $it" }, + ).joinToString(" • "), + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 12.sp + ) + } } } } @@ -160,11 +190,13 @@ fun StammdatenTabContent(turnierId: Long) { FilterChip( selected = sparten.contains("Dressur"), onClick = { if (sparten.contains("Dressur")) sparten.remove("Dressur") else sparten.add("Dressur") }, + enabled = nrConfirmed, label = { Text("Dressur") } ) FilterChip( selected = sparten.contains("Springen"), onClick = { if (sparten.contains("Springen")) sparten.remove("Springen") else sparten.add("Springen") }, + enabled = nrConfirmed, label = { Text("Springen") } ) } @@ -177,6 +209,7 @@ fun StammdatenTabContent(turnierId: Long) { FilterChip( selected = klassen.contains(k), onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) }, + enabled = nrConfirmed, label = { Text(k) } ) } @@ -197,64 +230,112 @@ fun StammdatenTabContent(turnierId: Long) { if (suggested.isEmpty()) { Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp) } else { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - suggested.forEach { c -> - InputChip( - selected = kat.contains(c), - onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) }, - label = { Text(c) } - ) + // Gruppiere nach Sparte (CDN/CSN) + val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" } + grouped.forEach { (gruppe, eintraege) -> + Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) + Spacer(Modifier.height(4.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + eintraege.sorted().forEach { c -> + InputChip( + selected = kat.contains(c), + onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) }, + enabled = nrConfirmed, + label = { Text(c) } + ) + } } + Spacer(Modifier.height(8.dp)) } } } FormRow("Zeitraum:") { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + val vonMod = if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp) OutlinedTextField( value = von, onValueChange = {}, label = { Text("Von") }, - modifier = Modifier.width(160.dp).clickable { showDatePickerVon = true }, + modifier = vonMod, readOnly = true, + enabled = nrConfirmed, trailingIcon = { Icon(Icons.Default.DateRange, null) } ) Text("bis") + val bisMod = if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp) OutlinedTextField( value = bis, onValueChange = {}, label = { Text("Bis") }, - modifier = Modifier.width(160.dp).clickable { showDatePickerBis = true }, + modifier = bisMod, readOnly = true, + enabled = nrConfirmed, trailingIcon = { Icon(Icons.Default.DateRange, null) } ) } - Text("Hinweis: Muss innerhalb des Veranstaltungs-Zeitraums liegen.", fontSize = 11.sp, color = Color.Gray) - } - - FormRow("Ort:") { - OutlinedTextField( - value = ort, - onValueChange = { ort = it }, - label = { Text("Austragungsort") }, - modifier = Modifier.fillMaxWidth(), - supportingText = { Text("Muss mit Veranstaltungsort übereinstimmen.") } - ) + val rangeText = if (eventVon != null && eventBis != null) "Muss zwischen $eventVon – $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen." + Text(rangeText, fontSize = 11.sp, color = Color.Gray) } } // ── Branding (Schritt 3 Logik) ─────────────────────────────────────── SectionCard(title = "Turnier-Branding") { + // Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland] + val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) { + val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ") + listOfNotNull(cats.ifBlank { null }, + listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ") + .takeIf { it.isNotBlank() } + ).joinToString(" ") + } OutlinedTextField( value = titel, onValueChange = { titel = it }, label = { Text("Titel") }, + placeholder = { if (defaultTitle.isNotBlank()) Text(defaultTitle) }, + enabled = nrConfirmed, modifier = Modifier.fillMaxWidth() ) OutlinedTextField( value = subTitel, onValueChange = { subTitel = it }, label = { Text("Sub-Titel") }, + enabled = nrConfirmed, + modifier = Modifier.fillMaxWidth() + ) + + // Ort im Branding-Bereich platzieren (mit Soft-Warnung bei Abweichung zum Veranstaltungsort) + FormRow("Ort:") { + OutlinedTextField( + value = ort, + onValueChange = { ort = it }, + label = { Text("Austragungsort") }, + enabled = nrConfirmed, + modifier = Modifier.fillMaxWidth(), + supportingText = { + if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Warning, contentDescription = null, tint = Color(0xFFF59E0B), modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(4.dp)) + Text("Abweichung zum Veranstaltungsort ($eventOrt) – bitte prüfen.", color = Color(0xFFF59E0B)) + } + } else { + Text("Muss mit Veranstaltungsort übereinstimmen.") + } + } + ) + } + + // Turnier-Logo mit Fallback auf Veranstalterlogo + OutlinedTextField( + value = turnierLogoUrl, + onValueChange = { turnierLogoUrl = it }, + label = { Text("Turnier-Logo (URL/Pfad)") }, + enabled = nrConfirmed, + supportingText = { + Text("Wenn leer: verwende Veranstalter-Logo${if (!veranstalterLogoUrl.isNullOrBlank()) " ($veranstalterLogoUrl)" else ""}.") + }, modifier = Modifier.fillMaxWidth() ) @@ -264,11 +345,12 @@ fun StammdatenTabContent(turnierId: Long) { InputChip( selected = true, onClick = { sponsoren.remove(s) }, + enabled = nrConfirmed, label = { Text(s) }, trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) } ) } - TextButton(onClick = { sponsoren.add("Neuer Sponsor") }) { + TextButton(onClick = { sponsoren.add("Neuer Sponsor") }, enabled = nrConfirmed) { Text("+ Hinzufügen") } } @@ -276,10 +358,46 @@ fun StammdatenTabContent(turnierId: Long) { } // ── Footer ────────────────────────────────────────────────────────── - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + // Save-Enable-Matrix (kleine Checkliste) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = { + Icon(if (nrConfirmed) Icons.Default.Check else Icons.Default.Close, null, tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error) + }) + AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = { + Icon(if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close, null, tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error) + }) + val dateOk = remember(von, bis, eventVon, eventBis) { + try { + if (eventVon == null || eventBis == null || von.isBlank()) true else { + val evV = LocalDate.parse(eventVon) + val evB = LocalDate.parse(eventBis) + val tV = LocalDate.parse(von) + val tB = if (bis.isBlank()) tV else LocalDate.parse(bis) + !tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV) + } + } catch (e: Exception) { false } + } + AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = { + Icon(if (dateOk) Icons.Default.Check else Icons.Default.Close, null, tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error) + }) + } + Button( onClick = { /* Speichern */ }, - enabled = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() && titel.isNotBlank(), + enabled = run { + val base = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() + val dateValid = try { + if (eventVon == null || eventBis == null || von.isBlank()) true else { + val evV = LocalDate.parse(eventVon) + val evB = LocalDate.parse(eventBis) + val tV = LocalDate.parse(von) + val tB = if (bis.isBlank()) tV else LocalDate.parse(bis) + !tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV) + } + } catch (e: Exception) { false } + base && dateValid + }, colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), modifier = Modifier.padding(bottom = 24.dp) ) { @@ -297,7 +415,13 @@ fun StammdatenTabContent(turnierId: Long) { title = { Text("ZNS Import") }, text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") }, confirmButton = { - TextButton(onClick = { znsDataLoaded = true; showZnsDialog = false }) { Text("Importieren") } + TextButton(onClick = { + znsDataLoaded = true + znsPayloadVersion = "v2.4" + znsImportedAt = java.time.Instant.now().toString() + znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true)) + showZnsDialog = false + }) { Text("Importieren") } }, dismissButton = { TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") } @@ -305,6 +429,40 @@ fun StammdatenTabContent(turnierId: Long) { ) } + if (showNrConfirm) { + AlertDialog( + onDismissRequest = { showNrConfirm = false }, + title = { Text("Turnier-Nummer bestätigen?") }, + text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") }, + confirmButton = { + TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") } + }, + dismissButton = { + TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") } + } + ) + } + + if (showZnsLog) { + AlertDialog( + onDismissRequest = { showZnsLog = false }, + title = { Text("ZNS Import-Log (letzte 5)") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + if (znsImportHistory.isEmpty()) { + Text("Keine Einträge vorhanden.", color = Color.Gray) + } else { + znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) -> + val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error + Text("• $src – Version $ver – ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp) + } + } + } + }, + confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } } + ) + } + if (showDatePickerVon) { val state = rememberDatePickerState() DatePickerDialog( diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt index 79761011..420be3fc 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt @@ -5,10 +5,17 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material3.Button import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -57,11 +64,14 @@ fun DesktopMainLayout( onNavigate = onNavigate, onLogout = onLogout, ) - Box(modifier = Modifier.fillMaxSize()) { - DesktopContentArea( - currentScreen = currentScreen, - onNavigate = onNavigate, - ) + Column(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + DesktopContentArea( + currentScreen = currentScreen, + onNavigate = onNavigate, + ) + } + DesktopFooterBar() } } } @@ -259,6 +269,20 @@ private fun DesktopTopBar( } } +// Hilfsfunktion: OEPS-Bundeslandcode → Abkürzung +private fun mapOepsToBundesland(code: String): String = when (code.uppercase()) { + "OOE" -> "OÖ" + "NOE" -> "NÖ" + "ST" -> "Stmk." + "W" -> "Wien" + "BGLD", "B" -> "Bgld." + "K" -> "Ktn." + "S" -> "Sbg." + "T" -> "Tirol" + "V" -> "Vbg." + else -> code +} + @Composable private fun BreadcrumbSeparator() { Text( @@ -344,7 +368,8 @@ private fun DesktopContentArea( if (vId == 0L) onNavigate(AppScreen.Veranstaltungen) else onNavigate(AppScreen.VeranstalterDetail(vId)) }, - onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungUebersicht(finalVId, evtId)) } + onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungUebersicht(finalVId, evtId)) }, + onVeranstalterCreated = { newVId -> onNavigate(AppScreen.VeranstalterDetail(newVId)) } ) } is AppScreen.VeranstaltungUebersicht -> { @@ -365,7 +390,20 @@ private fun DesktopContentArea( veranstalterId = vId, veranstaltungId = evtId, onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) }, - onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(evtId)) }, + onTurnierNeu = { + val veranstaltung = at.mocode.desktop.v2.StoreV2.eventsFor(vId).firstOrNull { it.id == evtId } + val list = at.mocode.desktop.v2.TurnierStoreV2.list(evtId) + val newId = (list.maxOfOrNull { it.id } ?: 0L) + 1L + val draft = at.mocode.desktop.v2.TurnierV2( + id = newId, + veranstaltungId = evtId, + turnierNr = 0, + datumVon = veranstaltung?.datumVon ?: "", + datumBis = veranstaltung?.datumBis, + ) + at.mocode.desktop.v2.TurnierStoreV2.add(evtId, draft) + onNavigate(AppScreen.TurnierDetail(evtId, newId)) + }, onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) }, ) } @@ -375,7 +413,23 @@ private fun DesktopContentArea( is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen( veranstaltungId = currentScreen.id, onBack = { onNavigate(AppScreen.Veranstaltungen) }, - onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) }, + onTurnierNeu = { + val v = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { vv -> + at.mocode.desktop.v2.StoreV2.eventsFor(vv.id).any { it.id == currentScreen.id } + } + val veranstaltung = v?.let { at.mocode.desktop.v2.StoreV2.eventsFor(it.id).firstOrNull { e -> e.id == currentScreen.id } } + val list = at.mocode.desktop.v2.TurnierStoreV2.list(currentScreen.id) + val newId = (list.maxOfOrNull { it.id } ?: 0L) + 1L + val draft = at.mocode.desktop.v2.TurnierV2( + id = newId, + veranstaltungId = currentScreen.id, + turnierNr = 0, + datumVon = veranstaltung?.datumVon ?: "", + datumBis = veranstaltung?.datumBis, + ) + at.mocode.desktop.v2.TurnierStoreV2.add(currentScreen.id, draft) + onNavigate(AppScreen.TurnierDetail(currentScreen.id, newId)) + }, onTurnierOeffnen = { tid -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, tid)) }, ) is AppScreen.VeranstaltungNeu -> VeranstaltungNeuScreen( @@ -395,10 +449,20 @@ private fun DesktopContentArea( onBack = { onNavigate(AppScreen.Veranstaltungen) } ) } else { + val veranstaltung = at.mocode.desktop.v2.StoreV2.eventsFor(parent.id).firstOrNull { it.id == evtId } + val blCode = parent.oepsNummer.split("-").getOrNull(1) ?: "" + val bundesland = mapOepsToBundesland(blCode) TurnierDetailScreen( veranstaltungId = evtId, turnierId = currentScreen.turnierId, onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) }, + eventVon = veranstaltung?.datumVon, + eventBis = veranstaltung?.datumBis, + eventOrt = veranstaltung?.ort, + veranstalterName = parent.name, + veranstalterOrt = parent.ort, + veranstalterBundesland = bundesland, + veranstalterLogoUrl = veranstaltung?.logoUrl, ) } } @@ -455,3 +519,48 @@ private fun DesktopContentArea( ) } } + +@Composable +private fun DesktopFooterBar() { + // Stub-Status für MVP + val online = remember { mutableStateOf(true) } + val deviceConnected = remember { mutableStateOf(true) } + val deviceName = "Richter-Turm" + + Row( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .background(Color(0xFFF3F4F6)) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (online.value) Icons.Filled.Wifi else Icons.Filled.WifiOff, + contentDescription = null, + tint = if (online.value) Color(0xFF059669) else Color(0xFFDC2626) + ) + Spacer(Modifier.width(6.dp)) + Text(if (online.value) "Online" else "Offline", color = Color(0xFF374151), fontSize = 12.sp) + Spacer(Modifier.width(16.dp)) + Icon(Icons.Filled.Devices, contentDescription = null, tint = if (deviceConnected.value) Color(0xFF2563EB) else Color(0xFF9CA3AF)) + Spacer(Modifier.width(6.dp)) + Text( + if (deviceConnected.value) "Verbunden: $deviceName" else "Kein Gerät verbunden", + color = Color(0xFF374151), + fontSize = 12.sp + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (deviceConnected.value) { + OutlinedButton(onClick = { /* öffne Chat-Panel */ }, contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp)) { + Icon(Icons.Filled.Chat, contentDescription = null, tint = Color(0xFF2563EB)) + Spacer(Modifier.width(6.dp)) + Text("Chat", color = Color(0xFF2563EB), fontSize = 12.sp) + } + } + } + } +} diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt index b212f758..678667ff 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt @@ -125,6 +125,57 @@ fun VeranstalterDetailV2( Button(onClick = onNeuVeranstaltung) { Text("+ Neue Veranstaltung") } } + // Profil-Bereich (Logo URL, Ansprechpartner, Kontakt, Adresse) + val verein = remember(veranstalterId) { StoreV2.vereine.firstOrNull { it.id == veranstalterId } } + if (verein != null) { + Card { + Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Veranstalter‑Profil", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = verein.logoUrl ?: "", + onValueChange = { verein.logoUrl = it.ifBlank { null } }, + label = { Text("Logo‑URL (optional)") }, + modifier = Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = verein.ansprechpartner ?: "", + onValueChange = { verein.ansprechpartner = it.ifBlank { null } }, + label = { Text("Ansprechpartner (optional)") }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = verein.telefon ?: "", + onValueChange = { verein.telefon = it.ifBlank { null } }, + label = { Text("Telefon (optional)") }, + modifier = Modifier.weight(1f) + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = verein.email ?: "", + onValueChange = { verein.email = it.ifBlank { null } }, + label = { Text("E‑Mail (optional)") }, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = verein.oepsNummer, + onValueChange = { verein.oepsNummer = it }, + label = { Text("OEPS‑Nummer") }, + modifier = Modifier.weight(1f) + ) + } + OutlinedTextField( + value = verein.adresse ?: "", + onValueChange = { verein.adresse = it.ifBlank { null } }, + label = { Text("Adresse (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2 + ) + } + } + } + val events = StoreV2.eventsFor(veranstalterId) if (events.isEmpty()) Text("Noch keine Veranstaltungen angelegt.", color = Color(0xFF6B7280)) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt index 1a13b234..10fab167 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt @@ -5,9 +5,15 @@ import androidx.compose.runtime.snapshots.SnapshotStateList data class Verein( val id: Long, - val name: String, - val oepsNummer: String, - val ort: String, + var name: String, + var oepsNummer: String, + var ort: String, + // Profil-Felder (minimal laut Abstimmung) + var logoUrl: String? = null, + var ansprechpartner: String? = null, + var email: String? = null, + var telefon: String? = null, + var adresse: String? = null, ) data class VeranstaltungV2( diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt index de59e303..3a405019 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt @@ -297,6 +297,7 @@ fun VeranstaltungKonfigV2( veranstalterId: Long = 0, onBack: () -> Unit, onSaved: (Long, Long) -> Unit, // eventId, veranstalterId + onVeranstalterCreated: (Long) -> Unit = {}, // Neuer Flow: nach Vereinsanlage ins Profil ) { DesktopThemeV2 { var currentStep by remember { mutableStateOf(if (veranstalterId == 0L) 1 else 2) } @@ -466,9 +467,9 @@ fun VeranstaltungKonfigV2( VeranstalterAnlegenWizard( onCancel = { showVereinNeu = false }, onVereinCreated = { newId -> - selectedVereinId = newId + // Neuer gewünschter Flow: nach Schritt 2 ins Veranstalter‑Profil wechseln showVereinNeu = false - currentStep = 2 + onVeranstalterCreated(newId) } ) }