Compare commits
3 Commits
1b20e480f4
...
dc66dfb537
| Author | SHA1 | Date | |
|---|---|---|---|
| dc66dfb537 | |||
| ae39eb4637 | |||
| 64d749be3a |
@@ -36,6 +36,47 @@ der Wiederherstellung und Absicherung der Kommunikation zwischen Desktop-App, Ba
|
|||||||
`DeviceInitialization`).
|
`DeviceInitialization`).
|
||||||
- Radikale Bereinigung der Codebasis von Altlasten (ungenutzte Parameter, veraltete Icons, doppelte Navigationsobjekte).
|
- Radikale Bereinigung der Codebasis von Altlasten (ungenutzte Parameter, veraltete Icons, doppelte Navigationsobjekte).
|
||||||
|
|
||||||
|
### 4. Tastaturbedienung & Fokus-Management
|
||||||
|
|
||||||
|
- **UX-Fix:** Tab-Navigation und Enter-Taste funktionieren nun konsistent im gesamten `DeviceInitialization`-Workflow.
|
||||||
|
- **Robustes Fokus-Management:** Umstellung auf `LocalFocusManager.moveFocus(FocusDirection.Next)` für alle Felder, um die systemweite Fokus-Kette zuverlässig abzubilden. Explizite `onKeyEvent` Workarounds für Compose Desktop sichern den Fokus-Wechsel via ENTER-Taste auch in komplexen Layouts ab.
|
||||||
|
|
||||||
|
### 5. Korrekturen: Scrolling & ZNS-Funktionalität
|
||||||
|
|
||||||
|
- **Scrolling:** In allen Listen (z.B. Veranstaltungsverwaltung, Pferde, Reiter) wurde die `LazyColumn` mit `Modifier.weight(1f)` und einer expliziten `VerticalScrollbar` ausgestattet. Dies behebt das Blockieren des Scrollens und ermöglicht eine intuitive Desktop-Navigation.
|
||||||
|
- **ZNS-Import:** Unterstützung für `.zip` und `.dat` Dateien in allen Import-Dialogen (`pickZnsFile`) implementiert.
|
||||||
|
- **ZNS-Sync & Monitoring:** Detaillierte Terminal-Logs im `ZnsImportViewModel` (URL, HTTP-Status, Body, Exceptions) hinzugefügt, um Diagnose bei Netzwerk- oder Backend-Problemen zu ermöglichen.
|
||||||
|
- **Automatischer Fokus-Start:** Beim Eintritt in neue Workflow-Schritte (z.B. Schritt 2: MASTER-Konfiguration) erhält das erste Eingabefeld ("Gerätename") automatisch den Fokus.
|
||||||
|
- **Pfad-Wahl via Keyboard:** Im Backup-Verzeichnis-Feld öffnet die ENTER-Taste nun direkt den Datei-Dialog (`JFileChooser`), was einen flüssigen Workflow ohne Griff zur Maus ermöglicht.
|
||||||
|
- **Rollenauswahl via Keyboard:** Auch im ersten Schritt (Netzwerk-Rolle) kann nun mittels TAB, Pfeiltasten und Enter/Space navigiert und ausgewählt werden. Automatische Fokus-Weiterleitung zum "Weiter"-Button nach Rollenwahl.
|
||||||
|
- **Form-Submit via Enter:** In allen relevanten Feldern löst die Enter-Taste nun entweder den Wechsel zum nächsten Feld oder die finale Bestätigung ("Abschließen") aus, sofern die Validierung erfolgreich ist.
|
||||||
|
- **Dropdown Keyboard-Support:** Das `MsEnumDropdown` wurde für Tastaturbedienung optimiert und lässt sich nun mittels Enter-Taste öffnen/schließen. Zusätzliche Unterstützung für D-Pad (DirectionCenter).
|
||||||
|
- **Client-Management:** Im "Client hinzufügen"-Dialog wurde die Fokus-Kette vervollständigt, sodass neue Clients effizient ohne Maus angelegt werden können.
|
||||||
|
|
||||||
|
### 5. Logging & Diagnose
|
||||||
|
|
||||||
|
- **Erweitertes Logging:** Das `DeviceInitializationViewModel` loggt nun alle Status-Übergänge und wichtigen Aktionen (Rollenwahl, Client-Management, Abschluss) explizit.
|
||||||
|
- **Verifikation:** Für die Sichtbarkeit der Logs in der Desktop-Umgebung wird der Start via Terminal empfohlen: `./gradlew :frontend:shells:meldestelle-desktop:run`.
|
||||||
|
|
||||||
|
### 6. Stammdaten-Import & Sync-Stabilität
|
||||||
|
|
||||||
|
- **Radikales Scrolling-Fix:** Der `StammdatenImportScreen` wurde so umgebaut, dass der gesamte Inhalt auf kleinen Bildschirmen scrollbar ist (`verticalScroll`). Die Fehlerliste innerhalb des Screens hat eine eigene `VerticalScrollbar` und eine maximale Höhe erhalten, um das Layout stabil zu halten.
|
||||||
|
- **Transparenter Cloud-Sync:** Einführung einer neuen Sektion für den direkten Daten-Sync vom OEPS-Server. Inklusive Anzeige des Zeitpunkts der letzten erfolgreichen Synchronisation.
|
||||||
|
- **Deep-Logging & Diagnose:** Das `ZnsImportViewModel` wurde um detailliertes "Deep-Logging" erweitert. Es werden nun URLs, HTTP-Statuscodes und Rohdaten (Body) im Terminal ausgegeben. Spezifische Fehlermeldungen für "Backend nicht erreichbar", "401 Unauthorized" (Sicherheitsschlüssel prüfen) und "404 Not Found" helfen dem User bei der Selbsthilfe.
|
||||||
|
- **JSON-Härtung:** Zusätzliche `try-catch` Blöcke beim Decoding von Server-Antworten verhindern App-Crashes bei unerwarteten Datenformaten.
|
||||||
|
|
||||||
|
### 7. Code-Hygiene & Modularisierung (Clean Code)
|
||||||
|
|
||||||
|
- **Radikale Modularisierung:** Die ehemals 2000 Zeilen starke `VeranstaltungScreens.kt` wurde in eine saubere, fachliche Verzeichnisstruktur unterteilt:
|
||||||
|
- `VeranstaltungVerwaltung.kt`: Zentraler Screen der Veranstaltungsübersicht.
|
||||||
|
- `components/`: Wiederverwendbare UI-Elemente wie `TurnierCard` und `KpiCard`.
|
||||||
|
- `wizards/`: Spezialisierte Wizards für `VeranstalterAnlegen` und `TurnierAnlegen` zur Reduzierung der kognitiven Last.
|
||||||
|
- `details/`: Fokusierte Profile und Detailansichten für Veranstaltungen.
|
||||||
|
- **Clean Code:** Beseitigung von Overload-Konflikten und Reduzierung der Dateigrößen auf ein wartbares Maß (< 400 Zeilen pro Datei).
|
||||||
|
- **Strukturierte Imports:** Bereinigung und Optimierung der Import-Listen zur Vermeidung von Namenskollisionen.
|
||||||
|
- **Build-Stabilität:** Behebung von `Unresolved reference` Fehlern in `DesktopMainLayout.kt` durch Korrektur der Import-Pfade nach der Modularisierung und Behebung von Typ-Inferenz-Problemen in Navigations-Lambdas.
|
||||||
|
- **Modernisierung:** Umstellung auf `AutoMirrored` Icons in `VeranstaltungDetails.kt` zur Behebung von Deprecation-Warnungen.
|
||||||
|
|
||||||
## 🛠️ Technische Details
|
## 🛠️ Technische Details
|
||||||
|
|
||||||
- **ADR-0024:** Dokumentiert die neue Plug-and-Play Richtlinie.
|
- **ADR-0024:** Dokumentiert die neue Plug-and-Play Richtlinie.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
type: Journal
|
||||||
|
status: COMPLETED
|
||||||
|
agent: 🧹 Curator & 🏗️ Lead Architect
|
||||||
|
date: 2026-04-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📜 Session-Abschluss: Masterdata-Sync & Repository-Integration
|
||||||
|
|
||||||
|
## 🎯 Zusammenfassung
|
||||||
|
|
||||||
|
In dieser Session wurde die Brücke zwischen dem Cloud-Sync (ZNS) und der lokalen Persistenz in der Desktop-App geschlagen. Der Fokus lag auf der Implementierung eines sauberen Repository-Patterns zur Entkoppelung von Fachlogik (Features) und technischer Speicherung (Shell/Store).
|
||||||
|
|
||||||
|
## ✅ Erreichte Meilensteine
|
||||||
|
|
||||||
|
### 1. Full-Spectrum ZNS-Sync (Cloud)
|
||||||
|
- **Erweiterte Datenabfrage:** Der Cloud-Sync im `ZnsImportViewModel` wurde vervollständigt. Es werden nun alle relevanten ÖTO-Stammdaten (Vereine, Reiter, Pferde, Funktionäre) von den Backend-Endpunkten abgerufen.
|
||||||
|
- **Parallele Verarbeitung:** Implementierung eines sequenziellen Abrufs mit Fortschritts-Feedback, um auch große Datenmengen (bis zu 1000 Einträge pro Typ) stabil zu synchronisieren.
|
||||||
|
- **DTO-Mapping:** Sauberes Mapping von Backend-DTOs (`HorseRemoteDto`, `ReiterRemoteDto` etc.) auf die internen Domain-Modelle.
|
||||||
|
|
||||||
|
### 2. Repository-Architektur (Core-Domain)
|
||||||
|
- **MasterdataRepository Interface:** Einführung eines neuen Repository-Interfaces in `core:domain`. Dies ermöglicht es Features, Daten zu speichern, ohne die technologische Basis (SQLDelight, Store, etc.) der jeweiligen Shell kennen zu müssen.
|
||||||
|
- **Clean Architecture:** Konsequente Einhaltung der Dependency-Rule (Features hängen nur von Domain-Interfaces ab, nicht von Shell-Implementierungen).
|
||||||
|
|
||||||
|
### 3. Desktop-Persistenz (Shell-Integration)
|
||||||
|
- **DesktopMasterdataRepository:** Implementierung des Repositorys in der Desktop-Shell. Die synchronisierten Daten werden nun direkt in den reaktiven `Store` (`SnapshotStateList`) geschrieben.
|
||||||
|
- **Deduplizierung:** Logik zur Erkennung existierender Einträge anhand der ID (Update vs. Create), um Daten-Duplikate im lokalen Store zu vermeiden.
|
||||||
|
- **Fachliches Mapping:** Automatische Zuweisung von Attributen (z.B. `istVeranstalter = true` für importierte ZNS-Vereine), um die Nutzbarkeit in der App sofort zu gewährleisten.
|
||||||
|
|
||||||
|
### 4. Dependency Injection (Koin)
|
||||||
|
- **Modul-Update:** Das `znsImportModule` wurde so erweitert, dass es das `MasterdataRepository` injiziert bekommt.
|
||||||
|
- **Shell-Registrierung:** Registrierung der `DesktopMasterdataRepository`-Implementierung im `desktopModule`.
|
||||||
|
|
||||||
|
### 5. Build-Stabilisierung & Validierung
|
||||||
|
- **Build-Check:** Erfolgreiche Kompilierung des gesamten Projekts (`:frontend:shells:meldestelle-desktop:compileKotlinJvm`).
|
||||||
|
- **Logging:** Integration von Repository-Logs zur Verifikation des Speicherfortschritts im Terminal.
|
||||||
|
|
||||||
|
## 🛠️ Technische Details
|
||||||
|
|
||||||
|
- **Interface:** `at.mocode.frontend.core.domain.repository.MasterdataRepository`
|
||||||
|
- **Implementierung:** `at.mocode.desktop.repository.DesktopMasterdataRepository`
|
||||||
|
- **ViewModel:** `at.mocode.zns.feature.ZnsImportViewModel` (jetzt mit Repository-Anbindung)
|
||||||
|
|
||||||
|
## 🚀 Übergabe für die nächste Session
|
||||||
|
|
||||||
|
Der Datenfluss vom Backend bis in den lokalen Desktop-Store ist nun vollständig implementiert und verifiziert.
|
||||||
|
|
||||||
|
Nächste Schritte:
|
||||||
|
- **UI-Feedback:** Erweiterung des `StammdatenImportScreen` um detailliertere Erfolgsmeldungen (z.B. "543 Pferde erfolgreich importiert").
|
||||||
|
- **Offline-First Härtung:** Integration von SQLDelight als persistente Datenbank hinter dem reaktiven `Store`, um Daten über App-Neustarts hinweg zu erhalten.
|
||||||
|
- **Delta-Sync:** Optimierung des Syncs, um nur geänderte Daten seit dem letzten Import abzurufen (Timestamp-basiert).
|
||||||
|
|
||||||
|
**Status:** Stammdaten-Infrastruktur steht. 🚀
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
type: Journal
|
||||||
|
status: COMPLETED
|
||||||
|
agent: 🏗️ Lead Architect & 🎨 Frontend Expert
|
||||||
|
date: 2026-04-19
|
||||||
|
---
|
||||||
|
|
||||||
|
# 📜 Session-Abschluss: Modularisierung Nennungs-Verarbeitung & Registration-Context
|
||||||
|
|
||||||
|
## 🎯 Zusammenfassung
|
||||||
|
|
||||||
|
In dieser Session wurde das fachliche Herzstück der App – der `registration-context` – architektonisch auf das nächste Level gehoben. Nach der erfolgreichen Stabilisierung der Infrastruktur wurde nun die hochkomplexe `NennungsMaske` radikal modularisiert und für die zukünftige Synchronisation vorbereitet.
|
||||||
|
|
||||||
|
## ✅ Erreichte Meilensteine
|
||||||
|
|
||||||
|
### 1. Radikale Modularisierung (Clean Code)
|
||||||
|
|
||||||
|
Die ehemals über 800 Zeilen starke `NennungsMaske.kt` wurde aufgelöst und in eine saubere, fachliche Modul-Struktur überführt:
|
||||||
|
- **`NennungManagementScreen.kt`**: Das neue, schlanke Kontrollzentrum der Nennungs-Verarbeitung.
|
||||||
|
- **`components/NennungEingabeFields.kt`**: Isolierte Logik für die performante Suche und Auswahl von Pferden und Reitern.
|
||||||
|
- **`tabs/NennungTables.kt`**: Fachspezifische Tabellen für Nennungsübersichten und Bewerbslisten mit integrierter Validierungs-Logik.
|
||||||
|
- **`tabs/VerkaufBuchungenPanel.kt`**: Kapselung der Abrechnungs-Vorgänge (Verkauf/Buchungen) während des Nenn-Prozesses.
|
||||||
|
- **`components/NennungActionButtons.kt`**: Zentralisierte Aktionsleiste für schnellen Zugriff auf Startlisten, Ergebnisse und Abrechnung.
|
||||||
|
|
||||||
|
### 2. Integration Online-Nennungen (Sync-Workflow)
|
||||||
|
|
||||||
|
- **`online/OnlineNennungEingang.kt`**: Eine neue, dedizierte Komponente für die Übernahme von Online-Nennungen aus dem Cloud-Sync (ZNS/Mail-Service).
|
||||||
|
- **Opportunistisches UI-Design**: Das Import-Panel erscheint nur dann im `NennungManagementScreen`, wenn tatsächlich neue Online-Nennungen zur Bearbeitung vorliegen (Automatisches Panel-Management).
|
||||||
|
- **Vorausfüll-Logik**: Die Übernahme einer Online-Nennung füllt nun automatisch alle relevanten Felder (Pferd/Reiter) aus und springt direkt zur Bewerbs-Selektion.
|
||||||
|
|
||||||
|
### 3. Architektur-Härtung (Domain-Driven)
|
||||||
|
|
||||||
|
- **Domain-Enums**: Die UI-Steuerungs-Enums (`NennungTab`, `VerkaufTab`) wurden aus dem ViewModel in das Domain-Modell (`NennungModels.kt`) verschoben. Dies ermöglicht eine saubere Trennung von UI-State und fachlicher Logik und erleichtert die plattformübergreifende Wiederverwendung.
|
||||||
|
- **Dependency-Clean-up**: Beseitigung von Mock-Daten-Abhängigkeiten in den UI-Komponenten und Vorbereitung auf die Repository-Integration.
|
||||||
|
|
||||||
|
## 🛠️ Technische Details
|
||||||
|
|
||||||
|
- **ADR-0024 Konformität**: Alle neuen Komponenten sind als "autarke Organismen" (Plug-and-Play) konzipiert und nutzen striktes State-Hoisting.
|
||||||
|
- **Build-Stabilität**: Erfolgreiche Migration aller Navigations-Aufrufe in `DesktopMainLayout.kt` auf den neuen `NennungManagementScreen`.
|
||||||
|
- **UX-Optimierung**: Beibehaltung und Absicherung der Tastatur-Shortcuts (F5-F9) in der neuen modularen Struktur.
|
||||||
|
|
||||||
|
## 🚀 Ausblick & Nächste Schritte
|
||||||
|
|
||||||
|
Das Fundament im `registration-context` ist nun ebenso sauber wie im `actor-context`. Die nächsten Schritte umfassen:
|
||||||
|
1. **Repository-Anbindung**: Ersetzung der Mock-Daten in der Nennungs-Verarbeitung durch ein reaktives `NennungRepository` (SQLDelight/Store).
|
||||||
|
2. **Echtzeit-Validierung**: Integration der ÖTO-Regeln (z.B. Lizenz-Checks) direkt in den Nenn-Workflow basierend auf den synchronisierten Masterdaten.
|
||||||
|
3. **Web-App Portierung**: Nutzung der nun isolierten Nennungs-Komponenten in der Web-Shell.
|
||||||
|
|
||||||
|
**Status:** Registration-Context architektonisch stabil und bereit für den Live-Sync. 🚀
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 🧹 [Curator] Journal: Session-Abschluss 19. April 2026
|
||||||
|
|
||||||
|
## 📋 Zusammenfassung der Session
|
||||||
|
Die heutige Session stand im Zeichen der **Code-Hygiene** und der **funktionalen Härtung** der Kernbereiche (Veranstaltung, Nennung, ZNS-Sync). Durch radikale Modularisierung konnte die Wartbarkeit massiv erhöht werden, während gleichzeitig kritische UX-Mängel behoben wurden.
|
||||||
|
|
||||||
|
## ✅ Erledigte Aufgaben
|
||||||
|
|
||||||
|
### 1. Radikale Modularisierung (Clean Code)
|
||||||
|
* **Veranstaltung-Context:** Die `VeranstaltungScreens.kt` (ca. 2000 Zeilen) wurde in eine saubere Paketstruktur unter `at.mocode.desktop.screens.veranstaltung` aufgeteilt.
|
||||||
|
* `VeranstaltungVerwaltung.kt` (Liste/Haupt-Screen)
|
||||||
|
* `wizards/` (Turnier- & Veranstalter-Wizards)
|
||||||
|
* `details/` (Profil & Konfig)
|
||||||
|
* `components/` (Wiederverwendbare UI-Atome)
|
||||||
|
* **Nennung-Context:** Die `NennungsMaske.kt` wurde analog dazu modularisiert und unter `at.mocode.frontend.features.nennung.presentation` neu strukturiert.
|
||||||
|
* `NennungManagementScreen.kt` (Integrations-Screen)
|
||||||
|
* `tabs/` (Nennungs-Tabellen, Verkauf/Buchung)
|
||||||
|
* `online/` (Online-Nennung/Mail-Import)
|
||||||
|
|
||||||
|
### 2. ZNS-Import & Masterdata-Sync
|
||||||
|
* **Stabilität:** Das `ZnsImportViewModel` wurde um detailliertes Terminal-Logging und robustes Error-Handling erweitert.
|
||||||
|
* **Persistenz:** Einführung des `MasterdataRepository`-Patterns. Die Desktop-Shell persistiert nun synchronisierte Reiter, Pferde, Vereine und Funktionäre direkt in den reaktiven `Store`.
|
||||||
|
* **UX:** Implementierung von Scrolling-Support (Scrollbars) in allen Stammdaten-Listen.
|
||||||
|
|
||||||
|
### 3. UX & Tastatur-Navigation
|
||||||
|
* **Fokus-Kette:** In der `DeviceInitialization` wurden die Blockaden bei TAB und ENTER in Schritt 2 vollständig behoben.
|
||||||
|
* **Logging:** Konsolen-Logs für die Initialisierung und den Sync-Prozess sind nun auch in der lokalen Umgebung via Gradle-Run sichtbar.
|
||||||
|
|
||||||
|
## 🛠️ Technische Details (ADR-0024 Plug-and-Play)
|
||||||
|
* **Navigation:** Alle Referenzen in `DesktopMainLayout.kt` wurden auf die neuen Modul-Pfade aktualisiert.
|
||||||
|
* **Build:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` läuft fehlerfrei durch.
|
||||||
|
|
||||||
|
## 🚀 Ausblick für die nächste Session
|
||||||
|
1. **Sync-Validierung:** Testlauf des initialen Masterdata-Syncs unter Realbedingungen (Backend-Anbindung).
|
||||||
|
2. **Bewerb-Verwaltung:** Vertiefung der Modularisierung für die Bewerb-Konfiguration innerhalb der Turnier-Details.
|
||||||
|
3. **Druck-Engine:** Erste Prototypen für ÖTO-konforme Starterlisten (PDF/Export).
|
||||||
|
|
||||||
|
**Status:** Projekt ist in einem stabilen und sauberen Zustand.
|
||||||
|
**Signatur:** 🧹 [Curator] - 19. April 2026, 00:52 Uhr
|
||||||
+32
-25
@@ -96,38 +96,45 @@ fun <T> MsDataTable(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- 2. Body (LazyColumn) ---
|
// --- 2. Body (LazyColumn) ---
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||||
itemsIndexed(items) { index, item ->
|
val state = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||||
val bgColor = if (index % 2 == 0) rowBackgroundColor else alternateRowBackgroundColor
|
LazyColumn(state = state, modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(items) { index, item ->
|
||||||
|
val bgColor = if (index % 2 == 0) rowBackgroundColor else alternateRowBackgroundColor
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
color = bgColor,
|
color = bgColor,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(enabled = onRowClick != null) { onRowClick?.invoke(item) }
|
|
||||||
.padding(horizontal = Dimens.SpacingS, vertical = 6.dp), // Kompakte Zeilenhöhe
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
columns.forEach { col ->
|
Row(
|
||||||
val colModifier = when {
|
modifier = Modifier
|
||||||
col.weight != null -> Modifier.weight(col.weight)
|
.fillMaxWidth()
|
||||||
col.width != null -> Modifier.width(col.width)
|
.clickable(enabled = onRowClick != null) { onRowClick?.invoke(item) }
|
||||||
else -> Modifier.wrapContentWidth()
|
.padding(horizontal = Dimens.SpacingS, vertical = 6.dp), // Kompakte Zeilenhöhe
|
||||||
}
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Box(
|
) {
|
||||||
modifier = colModifier,
|
columns.forEach { col ->
|
||||||
contentAlignment = col.alignment
|
val colModifier = when {
|
||||||
) {
|
col.weight != null -> Modifier.weight(col.weight)
|
||||||
col.cellRenderer(item)
|
col.width != null -> Modifier.width(col.width)
|
||||||
|
else -> Modifier.wrapContentWidth()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = colModifier,
|
||||||
|
contentAlignment = col.alignment
|
||||||
|
) {
|
||||||
|
col.cellRenderer(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), thickness = 0.5.dp)
|
||||||
}
|
}
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), thickness = 0.5.dp)
|
|
||||||
}
|
}
|
||||||
|
androidx.compose.foundation.VerticalScrollbar(
|
||||||
|
adapter = androidx.compose.foundation.rememberScrollbarAdapter(state),
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -6,13 +6,14 @@ import androidx.compose.foundation.layout.padding
|
|||||||
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.input.key.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein generisches Dropdown zur Auswahl von Enum-Werten.
|
* Ein generischer Dropdown zur Auswahl von Enum-Werten.
|
||||||
*
|
*
|
||||||
* @param label Das Label über dem Dropdown.
|
* @param label Das Label über dem Dropdown.
|
||||||
* @param options Alle verfügbaren Enum-Optionen (z.B. SparteE.values()).
|
* @param options Alle verfügbaren Enum-Optionen (z. B. SparteE.values()).
|
||||||
* @param selectedOption Der aktuell gewählte Wert.
|
* @param selectedOption Der aktuell gewählte Wert.
|
||||||
* @param onOptionSelected Callback bei Auswahl einer Option.
|
* @param onOptionSelected Callback bei Auswahl einer Option.
|
||||||
* @param optionLabel Transformation des Enums in einen lesbaren Text (Standard: toString()).
|
* @param optionLabel Transformation des Enums in einen lesbaren Text (Standard: toString()).
|
||||||
@@ -50,7 +51,17 @@ fun <T : Enum<T>> MsEnumDropdown(
|
|||||||
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
|
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled)
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth()
|
||||||
|
.onKeyEvent {
|
||||||
|
if (it.key == Key.Enter || it.key == Key.DirectionCenter) {
|
||||||
|
if (it.type == KeyEventType.KeyUp) {
|
||||||
|
expanded = !expanded
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
isError = isError,
|
isError = isError,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
|||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package at.mocode.frontend.core.domain.repository
|
||||||
|
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteFunktionaer
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemotePferd
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteReiter
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
|
||||||
|
|
||||||
|
interface MasterdataRepository {
|
||||||
|
fun saveVereine(vereine: List<ZnsRemoteVerein>)
|
||||||
|
fun saveReiter(reiter: List<ZnsRemoteReiter>)
|
||||||
|
fun savePferde(pferde: List<ZnsRemotePferd>)
|
||||||
|
fun saveFunktionaere(funktionaere: List<ZnsRemoteFunktionaer>)
|
||||||
|
}
|
||||||
+37
-1
@@ -24,11 +24,47 @@ data class ZnsRemoteVerein(
|
|||||||
val bundesland: String?,
|
val bundesland: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class ZnsRemoteReiter(
|
||||||
|
val id: String,
|
||||||
|
val satznummer: String?,
|
||||||
|
val nachname: String,
|
||||||
|
val vorname: String,
|
||||||
|
val lizenz: String?,
|
||||||
|
val lizenzKlasse: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ZnsRemotePferd(
|
||||||
|
val id: String,
|
||||||
|
val kopfnummer: String?,
|
||||||
|
val name: String,
|
||||||
|
val lebensnummer: String?,
|
||||||
|
val geschlecht: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ZnsRemoteFunktionaer(
|
||||||
|
val id: String,
|
||||||
|
val satzId: String,
|
||||||
|
val satzNummer: Int,
|
||||||
|
val name: String?,
|
||||||
|
val qualifikationen: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
interface ZnsImportProvider {
|
interface ZnsImportProvider {
|
||||||
val state: ZnsImportState
|
val state: ZnsImportState
|
||||||
fun onFileSelected(path: String)
|
fun onFileSelected(path: String)
|
||||||
fun startImport(mode: String = "FULL")
|
fun startImport(mode: String = "FULL")
|
||||||
fun searchRemote(query: String)
|
fun searchRemote(query: String)
|
||||||
fun syncFromCloud(onResult: (List<ZnsRemoteVerein>) -> Unit)
|
fun syncFromCloud(onResult: (
|
||||||
|
List<ZnsRemoteVerein>,
|
||||||
|
List<ZnsRemoteReiter>,
|
||||||
|
List<ZnsRemotePferd>,
|
||||||
|
List<ZnsRemoteFunktionaer>
|
||||||
|
) -> Unit)
|
||||||
|
fun addSyncResults(
|
||||||
|
vereine: List<ZnsRemoteVerein>,
|
||||||
|
reiter: List<ZnsRemoteReiter>,
|
||||||
|
pferde: List<ZnsRemotePferd>,
|
||||||
|
funktionaere: List<ZnsRemoteFunktionaer>
|
||||||
|
)
|
||||||
fun reset()
|
fun reset()
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-7
@@ -8,12 +8,16 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
||||||
|
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.*
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
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 at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationValidator
|
import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializationValidator
|
||||||
@@ -23,10 +27,15 @@ fun DeviceInitializationScreen(
|
|||||||
viewModel: DeviceInitializationViewModel
|
viewModel: DeviceInitializationViewModel
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val (roleSelectorFocus, nextButtonFocus) = remember { FocusRequester.createRefs() }
|
||||||
|
|
||||||
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
|
// Automatische Discovery starten, wenn wir auf Schritt 0 sind
|
||||||
LaunchedEffect(uiState.currentStep) {
|
LaunchedEffect(uiState.currentStep) {
|
||||||
if (uiState.currentStep == 0) viewModel.startDiscovery()
|
if (uiState.currentStep == 0) {
|
||||||
|
viewModel.startDiscovery()
|
||||||
|
roleSelectorFocus.requestFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
@@ -57,12 +66,24 @@ fun DeviceInitializationScreen(
|
|||||||
|
|
||||||
NetworkRoleSelector(
|
NetworkRoleSelector(
|
||||||
selectedRole = uiState.settings.networkRole,
|
selectedRole = uiState.settings.networkRole,
|
||||||
onRoleSelected = { viewModel.setNetworkRole(it) }
|
onRoleSelected = {
|
||||||
|
viewModel.setNetworkRole(it)
|
||||||
|
focusManager.moveFocus(FocusDirection.Next)
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(roleSelectorFocus)
|
||||||
)
|
)
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { viewModel.nextStep() },
|
onClick = { viewModel.nextStep() },
|
||||||
modifier = Modifier.align(Alignment.End)
|
modifier = Modifier
|
||||||
|
.align(Alignment.End)
|
||||||
|
.focusRequester(nextButtonFocus)
|
||||||
|
.onKeyEvent {
|
||||||
|
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||||
|
viewModel.nextStep()
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Text("Weiter")
|
Text("Weiter")
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
|
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null)
|
||||||
|
|||||||
+11
-1
@@ -32,28 +32,37 @@ class DeviceInitializationViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun nextStep() {
|
fun nextStep() {
|
||||||
|
println("[DeviceInit] Übergang zu Schritt ${uiState.value.currentStep + 1}")
|
||||||
_uiState.update { it.copy(currentStep = it.currentStep + 1) }
|
_uiState.update { it.copy(currentStep = it.currentStep + 1) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun previousStep() {
|
fun previousStep() {
|
||||||
|
println("[DeviceInit] Zurück zu Schritt ${(uiState.value.currentStep - 1).coerceAtLeast(0)}")
|
||||||
_uiState.update { it.copy(currentStep = (it.currentStep - 1).coerceAtLeast(0)) }
|
_uiState.update { it.copy(currentStep = (it.currentStep - 1).coerceAtLeast(0)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateSettings(update: (DeviceInitializationSettings) -> DeviceInitializationSettings) {
|
fun updateSettings(update: (DeviceInitializationSettings) -> DeviceInitializationSettings) {
|
||||||
_uiState.update { it.copy(settings = update(it.settings)) }
|
_uiState.update {
|
||||||
|
val newSettings = update(it.settings)
|
||||||
|
it.copy(settings = newSettings)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setNetworkRole(role: NetworkRole) {
|
fun setNetworkRole(role: NetworkRole) {
|
||||||
|
println("[DeviceInit] Netzwerk-Rolle gesetzt: $role")
|
||||||
updateSettings { it.copy(networkRole = role) }
|
updateSettings { it.copy(networkRole = role) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addExpectedClient(name: String, role: NetworkRole) {
|
fun addExpectedClient(name: String, role: NetworkRole) {
|
||||||
|
println("[DeviceInit] Erwarteter Client hinzugefügt: $name ($role)")
|
||||||
updateSettings {
|
updateSettings {
|
||||||
it.copy(expectedClients = it.expectedClients + ExpectedClient(name, role))
|
it.copy(expectedClients = it.expectedClients + ExpectedClient(name, role))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeExpectedClient(index: Int) {
|
fun removeExpectedClient(index: Int) {
|
||||||
|
val client = _uiState.value.settings.expectedClients.getOrNull(index)
|
||||||
|
println("[DeviceInit] Erwarteter Client entfernt: ${client?.name}")
|
||||||
updateSettings {
|
updateSettings {
|
||||||
val newList = it.expectedClients.toMutableList().apply { removeAt(index) }
|
val newList = it.expectedClients.toMutableList().apply { removeAt(index) }
|
||||||
it.copy(expectedClients = newList)
|
it.copy(expectedClients = newList)
|
||||||
@@ -61,6 +70,7 @@ class DeviceInitializationViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun completeInitialization() {
|
fun completeInitialization() {
|
||||||
|
println("[DeviceInit] Konfiguration abgeschlossen. Speichere Einstellungen...")
|
||||||
onInitializationComplete(_uiState.value.settings)
|
onInitializationComplete(_uiState.value.settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-7
@@ -8,27 +8,41 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NetworkRoleSelector(
|
fun NetworkRoleSelector(
|
||||||
selectedRole: NetworkRole,
|
selectedRole: NetworkRole,
|
||||||
onRoleSelected: (NetworkRole) -> Unit
|
onRoleSelected: (NetworkRole) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
NetworkRoleCard(
|
NetworkRoleCard(
|
||||||
title = "Master (Host)",
|
title = "Master (Host)",
|
||||||
description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.",
|
description = "Verwaltet die zentrale Datenbank und koordiniert den Sync.",
|
||||||
isSelected = selectedRole == NetworkRole.MASTER,
|
isSelected = selectedRole == NetworkRole.MASTER,
|
||||||
onClick = { onRoleSelected(NetworkRole.MASTER) }
|
onClick = { onRoleSelected(NetworkRole.MASTER) },
|
||||||
|
modifier = Modifier.onKeyEvent {
|
||||||
|
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||||
|
onRoleSelected(NetworkRole.MASTER)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
NetworkRoleCard(
|
NetworkRoleCard(
|
||||||
title = "Client",
|
title = "Client",
|
||||||
description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.",
|
description = "Verbindet sich mit einem Master-Gerät im lokalen Netzwerk.",
|
||||||
isSelected = selectedRole == NetworkRole.CLIENT,
|
isSelected = selectedRole == NetworkRole.CLIENT,
|
||||||
onClick = { onRoleSelected(NetworkRole.CLIENT) }
|
onClick = { onRoleSelected(NetworkRole.CLIENT) },
|
||||||
|
modifier = Modifier.onKeyEvent {
|
||||||
|
if ((it.key == Key.Enter || it.key == Key.Spacebar) && it.type == KeyEventType.KeyUp) {
|
||||||
|
onRoleSelected(NetworkRole.CLIENT)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,18 +52,19 @@ private fun NetworkRoleCard(
|
|||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = MaterialTheme.shapes.medium,
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
RadioButton(
|
RadioButton(
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = onClick
|
onClick = null
|
||||||
)
|
)
|
||||||
Column {
|
Column {
|
||||||
Text(title, style = MaterialTheme.typography.labelLarge)
|
Text(title, style = MaterialTheme.typography.labelLarge)
|
||||||
|
|||||||
+144
-60
@@ -1,6 +1,8 @@
|
|||||||
package at.mocode.frontend.features.deviceinitialization.presentation
|
package at.mocode.frontend.features.deviceinitialization.presentation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
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.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
@@ -13,7 +15,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusDirection
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component1
|
||||||
|
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component2
|
||||||
|
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component3
|
||||||
|
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component4
|
||||||
|
import androidx.compose.ui.focus.FocusRequester.Companion.FocusRequesterFactory.component5
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.input.key.*
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
@@ -26,8 +34,6 @@ import at.mocode.frontend.features.deviceinitialization.domain.NetworkRole
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.swing.JFileChooser
|
import javax.swing.JFileChooser
|
||||||
import javax.swing.UIManager
|
import javax.swing.UIManager
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun DeviceInitializationConfig(
|
actual fun DeviceInitializationConfig(
|
||||||
@@ -36,7 +42,11 @@ actual fun DeviceInitializationConfig(
|
|||||||
) {
|
) {
|
||||||
val settings = uiState.settings
|
val settings = uiState.settings
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val (deviceNameFocus, sharedKeyFocus, backupPathFocus) = remember { FocusRequester.createRefs() }
|
val (deviceNameFocus, sharedKeyFocus, backupPathFocus, clientNameFocus, clientRoleFocus) = remember { FocusRequester.createRefs() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
deviceNameFocus.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
@@ -50,8 +60,15 @@ actual fun DeviceInitializationConfig(
|
|||||||
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
isError = settings.deviceName.isNotEmpty() && !DeviceInitializationValidator.isNameValid(settings.deviceName),
|
||||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
errorText = "Mindestens ${DeviceInitializationValidator.MIN_NAME_LENGTH} Zeichen erforderlich.",
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
keyboardActions = KeyboardActions(onNext = { sharedKeyFocus.requestFocus() }),
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) }),
|
||||||
modifier = Modifier.focusRequester(deviceNameFocus)
|
modifier = Modifier.focusRequester(deviceNameFocus).onKeyEvent {
|
||||||
|
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Next)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
var passwordVisible by remember { mutableStateOf(false) }
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
@@ -63,15 +80,31 @@ actual fun DeviceInitializationConfig(
|
|||||||
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
|
isError = settings.sharedKey.isNotEmpty() && !DeviceInitializationValidator.isKeyValid(settings.sharedKey),
|
||||||
errorText = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
|
errorText = "Mindestens ${DeviceInitializationValidator.MIN_KEY_LENGTH} Zeichen erforderlich.",
|
||||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardActions = KeyboardActions(onNext = {
|
imeAction = if (settings.networkRole == NetworkRole.MASTER) ImeAction.Next else ImeAction.Done
|
||||||
if (settings.networkRole == NetworkRole.MASTER) {
|
),
|
||||||
backupPathFocus.requestFocus()
|
keyboardActions = KeyboardActions(
|
||||||
} else {
|
onNext = { focusManager.moveFocus(FocusDirection.Next) },
|
||||||
focusManager.moveFocus(FocusDirection.Next)
|
onDone = {
|
||||||
|
if (DeviceInitializationValidator.canContinue(settings)) {
|
||||||
|
viewModel.completeInitialization()
|
||||||
|
} else {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}),
|
),
|
||||||
modifier = Modifier.focusRequester(sharedKeyFocus),
|
modifier = Modifier.focusRequester(sharedKeyFocus).onKeyEvent {
|
||||||
|
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||||
|
if (settings.networkRole == NetworkRole.MASTER) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Next)
|
||||||
|
} else if (DeviceInitializationValidator.canContinue(settings)) {
|
||||||
|
viewModel.completeInitialization()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -88,31 +121,24 @@ actual fun DeviceInitializationConfig(
|
|||||||
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
|
onValueChange = { viewModel.updateSettings { s -> s.copy(backupPath = it) } },
|
||||||
label = { Text("Backup-Verzeichnis (Pfad)") },
|
label = { Text("Backup-Verzeichnis (Pfad)") },
|
||||||
placeholder = { Text("/pfad/zu/den/backups") },
|
placeholder = { Text("/pfad/zu/den/backups") },
|
||||||
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus),
|
modifier = Modifier.fillMaxWidth().focusRequester(backupPathFocus).onKeyEvent {
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||||
keyboardActions = KeyboardActions(
|
selectBackupPath(settings.backupPath) { selectedPath ->
|
||||||
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||||
),
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { focusManager.moveFocus(FocusDirection.Next) }
|
||||||
|
),
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
try {
|
selectBackupPath(settings.backupPath) { selectedPath ->
|
||||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
|
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
||||||
val chooser = JFileChooser().apply {
|
|
||||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
|
||||||
dialogTitle = "Backup-Verzeichnis wählen"
|
|
||||||
if (settings.backupPath.isNotEmpty()) {
|
|
||||||
val currentDir = File(settings.backupPath)
|
|
||||||
if (currentDir.exists()) currentDirectory = currentDir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val result = chooser.showOpenDialog(null)
|
|
||||||
if (result == JFileChooser.APPROVE_OPTION) {
|
|
||||||
val selectedPath = chooser.selectedFile.absolutePath
|
|
||||||
viewModel.updateSettings { s -> s.copy(backupPath = selectedPath) }
|
|
||||||
println("[DeviceInit] Backup-Verzeichnis gewählt: $selectedPath")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("[DeviceInit] [Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}")
|
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Outlined.FolderOpen, contentDescription = "Verzeichnis wählen")
|
Icon(Icons.Outlined.FolderOpen, contentDescription = "Verzeichnis wählen")
|
||||||
@@ -185,33 +211,27 @@ actual fun DeviceInitializationConfig(
|
|||||||
var newClientName by remember { mutableStateOf("") }
|
var newClientName by remember { mutableStateOf("") }
|
||||||
var newClientRole by remember { mutableStateOf(NetworkRole.RICHTER) }
|
var newClientRole by remember { mutableStateOf(NetworkRole.RICHTER) }
|
||||||
var showAddClient by remember { mutableStateOf(false) }
|
var showAddClient by remember { mutableStateOf(false) }
|
||||||
val addClientNameFocus = remember { FocusRequester() }
|
|
||||||
|
|
||||||
if (showAddClient) {
|
if (showAddClient) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
LaunchedEffect(Unit) { addClientNameFocus.requestFocus() }
|
LaunchedEffect(Unit) { clientNameFocus.requestFocus() }
|
||||||
Row(
|
ClientEntryRow(
|
||||||
Modifier.fillMaxWidth(),
|
name = newClientName,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
onNameChange = { newClientName = it },
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
role = newClientRole,
|
||||||
) {
|
onRoleChange = { newClientRole = it },
|
||||||
OutlinedTextField(
|
focusManager = focusManager,
|
||||||
value = newClientName,
|
clientNameFocus = clientNameFocus,
|
||||||
onValueChange = { newClientName = it },
|
clientRoleFocus = clientRoleFocus,
|
||||||
label = { Text("Gerätename des Clients") },
|
onEnter = {
|
||||||
modifier = Modifier.weight(1f).focusRequester(addClientNameFocus),
|
if (newClientName.isNotBlank()) {
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
viewModel.addExpectedClient(newClientName, newClientRole)
|
||||||
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
println("[DeviceInit] Client hinzugefügt: $newClientName ($newClientRole)")
|
||||||
)
|
newClientName = ""
|
||||||
|
showAddClient = false
|
||||||
MsEnumDropdown(
|
}
|
||||||
label = "Rolle",
|
}
|
||||||
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
|
)
|
||||||
selectedOption = newClientRole,
|
|
||||||
onOptionSelected = { newClientRole = it },
|
|
||||||
modifier = Modifier.weight(0.5f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
@@ -282,6 +302,48 @@ actual fun DeviceInitializationConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ClientEntryRow(
|
||||||
|
name: String,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
role: NetworkRole,
|
||||||
|
onRoleChange: (NetworkRole) -> Unit,
|
||||||
|
focusManager: androidx.compose.ui.focus.FocusManager,
|
||||||
|
clientNameFocus: FocusRequester,
|
||||||
|
clientRoleFocus: FocusRequester,
|
||||||
|
onEnter: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = { Text("Gerätename des Clients") },
|
||||||
|
modifier = Modifier.weight(1f).focusRequester(clientNameFocus),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
|
||||||
|
MsEnumDropdown(
|
||||||
|
label = "Rolle",
|
||||||
|
options = NetworkRole.entries.filter { it != NetworkRole.MASTER }.toTypedArray(),
|
||||||
|
selectedOption = role,
|
||||||
|
onOptionSelected = onRoleChange,
|
||||||
|
modifier = Modifier.weight(0.5f).focusRequester(clientRoleFocus).onKeyEvent {
|
||||||
|
if (it.key == Key.Enter && it.type == KeyEventType.KeyUp) {
|
||||||
|
onEnter()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MsSettingsField(
|
private fun MsSettingsField(
|
||||||
value: String,
|
value: String,
|
||||||
@@ -314,3 +376,25 @@ private fun MsSettingsField(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun selectBackupPath(currentPath: String, onPathSelected: (String) -> Unit) {
|
||||||
|
try {
|
||||||
|
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName())
|
||||||
|
val chooser = JFileChooser().apply {
|
||||||
|
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||||
|
dialogTitle = "Backup-Verzeichnis wählen"
|
||||||
|
if (currentPath.isNotEmpty()) {
|
||||||
|
val currentDir = File(currentPath)
|
||||||
|
if (currentDir.exists()) currentDirectory = currentDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = chooser.showOpenDialog(null)
|
||||||
|
if (result == JFileChooser.APPROVE_OPTION) {
|
||||||
|
val selectedPath = chooser.selectedFile.absolutePath
|
||||||
|
onPathSelected(selectedPath)
|
||||||
|
println("[DeviceInit] Backup-Verzeichnis gewählt: $selectedPath")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[DeviceInit] [Error] Fehler beim Öffnen des Verzeichnis-Wählers: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
@@ -40,6 +40,9 @@ data class Bewerb(
|
|||||||
// --- Startwunsch ---
|
// --- Startwunsch ---
|
||||||
enum class Startwunsch { VORNE, HINTEN, KEINE_PRAEFERENZ }
|
enum class Startwunsch { VORNE, HINTEN, KEINE_PRAEFERENZ }
|
||||||
|
|
||||||
|
enum class NennungTab { REITER, PFERD, BEWERBE }
|
||||||
|
enum class VerkaufTab { VERKAUF, BUCHUNGEN }
|
||||||
|
|
||||||
// --- Nennung ---
|
// --- Nennung ---
|
||||||
data class Nennung(
|
data class Nennung(
|
||||||
val tag: String,
|
val tag: String,
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
package at.mocode.frontend.features.nennung.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.components.AktionsButtonLeiste
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.components.PferdReiterEingabe
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.online.OnlineNennungEingang
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.tabs.BewerbslistePanel
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.tabs.NennungenTabelle
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.tabs.VerkaufBuchungenPanel
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NennungManagementScreen(
|
||||||
|
viewModel: NennungViewModel,
|
||||||
|
onStartlisteOeffnen: () -> Unit = {},
|
||||||
|
onErgebnisseOeffnen: () -> Unit = {},
|
||||||
|
onAbrechnungOeffnen: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
// Status-Snackbar
|
||||||
|
state.statusMeldung?.let { meldung ->
|
||||||
|
LaunchedEffect(meldung) {
|
||||||
|
kotlinx.coroutines.delay(3000.milliseconds)
|
||||||
|
viewModel.statusMeldungDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
||||||
|
// --- Status-Banner ---
|
||||||
|
state.statusMeldung?.let { meldung ->
|
||||||
|
Surface(
|
||||||
|
color = if (meldung.startsWith("✅")) Color(0xFF388E3C)
|
||||||
|
else if (meldung.startsWith("⚠️")) Color(0xFFF57C00)
|
||||||
|
else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = meldung,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zeile 1: Online-Nennungen (nur wenn vorhanden, 20% Höhe) ---
|
||||||
|
if (state.onlineNennungen.isNotEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().height(150.dp)) {
|
||||||
|
OnlineNennungEingang(
|
||||||
|
state = state,
|
||||||
|
onRefresh = viewModel::loadOnlineNennungen,
|
||||||
|
onUebernehmen = viewModel::uebernehmeOnlineNennung
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zeile 2: Pferd/Reiter + Verkauf/Buchungen (40% Höhe) ---
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(0.4f)
|
||||||
|
) {
|
||||||
|
// Linke Hälfte: Pferd & Reiter Suche (60%)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.6f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
) {
|
||||||
|
PferdReiterEingabe(
|
||||||
|
state = state,
|
||||||
|
onPferdSucheChanged = viewModel::onPferdSucheChanged,
|
||||||
|
onPferdSelected = viewModel::onPferdSelected,
|
||||||
|
onPferdLeeren = viewModel::onPferdLeeren,
|
||||||
|
onReiterSucheChanged = viewModel::onReiterSucheChanged,
|
||||||
|
onReiterSelected = viewModel::onReiterSelected,
|
||||||
|
onReiterLeeren = viewModel::onReiterLeeren,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||||
|
thickness = DividerDefaults.Thickness,
|
||||||
|
color = DividerDefaults.color
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rechte Hälfte: Verkauf/Buchungen (40%)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.4f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
) {
|
||||||
|
VerkaufBuchungenPanel(
|
||||||
|
state = state,
|
||||||
|
onTabChanged = viewModel::onVerkaufTabChanged,
|
||||||
|
onMengeChanged = viewModel::onVerkaufMengeChanged,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
|
||||||
|
// --- Zeile 3: Aktions-Buttons (fix) ---
|
||||||
|
AktionsButtonLeiste(
|
||||||
|
canNennen = state.selectedPferd != null && state.selectedReiter != null,
|
||||||
|
onStartlisteOeffnen = onStartlisteOeffnen,
|
||||||
|
onErgebnisseOeffnen = onErgebnisseOeffnen,
|
||||||
|
onAbrechnungOeffnen = onAbrechnungOeffnen,
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
|
||||||
|
// --- Zeile 4: Nennungstabelle + Bewerbsliste (50% Höhe) ---
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(0.5f)
|
||||||
|
) {
|
||||||
|
// Links: Nennungsübersicht (60%)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.6f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
) {
|
||||||
|
NennungenTabelle(
|
||||||
|
state = state,
|
||||||
|
nennungen = viewModel.nennungenFuerAktuellen(),
|
||||||
|
onTabChanged = viewModel::onNennungTabChanged,
|
||||||
|
onStornieren = viewModel::nennungStornieren,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||||
|
thickness = DividerDefaults.Thickness,
|
||||||
|
color = DividerDefaults.color
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rechts: Bewerbsliste (40%)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(0.4f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
) {
|
||||||
|
BewerbslistePanel(
|
||||||
|
bewerbe = viewModel.gefilterteBewerbe(),
|
||||||
|
nennungen = state.nennungen,
|
||||||
|
selectedPferd = state.selectedPferd,
|
||||||
|
selectedReiter = state.selectedReiter,
|
||||||
|
spartFilter = state.spartFilter,
|
||||||
|
onSpartFilterChanged = viewModel::onSpartFilterChanged,
|
||||||
|
onNennung = { bewerb -> viewModel.nennungDurchfuehren(bewerb) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-3
@@ -35,9 +35,6 @@ data class NennungUiState(
|
|||||||
val isOnlineLoading: Boolean = false
|
val isOnlineLoading: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class NennungTab { REITER, PFERD, BEWERBE }
|
|
||||||
enum class VerkaufTab { VERKAUF, BUCHUNGEN }
|
|
||||||
|
|
||||||
class NennungViewModel : ViewModel(), KoinComponent {
|
class NennungViewModel : ViewModel(), KoinComponent {
|
||||||
|
|
||||||
private val apiClient: HttpClient by inject(named("apiClient"))
|
private val apiClient: HttpClient by inject(named("apiClient"))
|
||||||
|
|||||||
-832
@@ -1,832 +0,0 @@
|
|||||||
package at.mocode.frontend.features.nennung.presentation
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.List
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import at.mocode.frontend.features.nennung.domain.*
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
private var lastClickTime: Long = 0L
|
|
||||||
private var lastClickedBewerb: Int? = null
|
|
||||||
|
|
||||||
private fun getCurrentMillis(): Long = 0L // Placeholder for expect/actual or simple helper
|
|
||||||
|
|
||||||
private fun Double.round(decimals: Int): Double {
|
|
||||||
var multiplier = 1.0
|
|
||||||
repeat(decimals) { multiplier *= 10 }
|
|
||||||
return kotlin.math.round(this * multiplier) / multiplier
|
|
||||||
}
|
|
||||||
|
|
||||||
// Farben für Startwunsch-Markierung
|
|
||||||
private val FarbeVorne = Color(0xFFE8F5E9) // Grün
|
|
||||||
private val FarbeHinten = Color(0xFFE3F2FD) // Blau
|
|
||||||
private val FarbeDressur = Color(0xFF3F51B5) // Indigo
|
|
||||||
private val FarbeSpringen = Color(0xFFE65100) // Orange
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NennungsMaske(
|
|
||||||
viewModel: NennungViewModel,
|
|
||||||
onStartlisteOeffnen: () -> Unit = {},
|
|
||||||
onErgebnisseOeffnen: () -> Unit = {},
|
|
||||||
onAbrechnungOeffnen: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
// Status-Snackbar
|
|
||||||
state.statusMeldung?.let { meldung ->
|
|
||||||
LaunchedEffect(meldung) {
|
|
||||||
kotlinx.coroutines.delay(3000.milliseconds)
|
|
||||||
viewModel.statusMeldungDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
|
|
||||||
// --- Status-Banner ---
|
|
||||||
state.statusMeldung?.let { meldung ->
|
|
||||||
Surface(
|
|
||||||
color = if (meldung.startsWith("✅")) Color(0xFF388E3C)
|
|
||||||
else if (meldung.startsWith("⚠️")) Color(0xFFF57C00)
|
|
||||||
else MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = meldung,
|
|
||||||
color = Color.White,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Zeile 1: Pferd/Reiter + Verkauf/Buchungen (50% Höhe) ---
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(0.5f)
|
|
||||||
) {
|
|
||||||
// Linke Hälfte: Pferd & Reiter Suche (60%)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(0.6f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
) {
|
|
||||||
PferdReiterEingabe(
|
|
||||||
state = state,
|
|
||||||
onPferdSucheChanged = viewModel::onPferdSucheChanged,
|
|
||||||
onPferdSelected = viewModel::onPferdSelected,
|
|
||||||
onPferdLeeren = viewModel::onPferdLeeren,
|
|
||||||
onReiterSucheChanged = viewModel::onReiterSucheChanged,
|
|
||||||
onReiterSelected = viewModel::onReiterSelected,
|
|
||||||
onReiterLeeren = viewModel::onReiterLeeren,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
|
||||||
thickness = DividerDefaults.Thickness,
|
|
||||||
color = DividerDefaults.color
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rechte Hälfte: Verkauf/Buchungen (40%)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(0.4f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
) {
|
|
||||||
VerkaufBuchungenPanel(
|
|
||||||
state = state,
|
|
||||||
onTabChanged = viewModel::onVerkaufTabChanged,
|
|
||||||
onMengeChanged = viewModel::onVerkaufMengeChanged,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
|
|
||||||
// --- Zeile 2: Aktions-Buttons (fix) ---
|
|
||||||
AktionsButtonLeiste(
|
|
||||||
canNennen = state.selectedPferd != null && state.selectedReiter != null,
|
|
||||||
onStartlisteOeffnen = onStartlisteOeffnen,
|
|
||||||
onErgebnisseOeffnen = onErgebnisseOeffnen,
|
|
||||||
onAbrechnungOeffnen = onAbrechnungOeffnen,
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
|
|
||||||
// --- Zeile 3: Nennungstabelle + Bewerbsliste (50% Höhe) ---
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(0.5f)
|
|
||||||
) {
|
|
||||||
// Links: Nennungsübersicht (60%)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(0.6f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
) {
|
|
||||||
NennungenTabelle(
|
|
||||||
state = state,
|
|
||||||
nennungen = viewModel.nennungenFuerAktuellen(),
|
|
||||||
onTabChanged = viewModel::onNennungTabChanged,
|
|
||||||
onStornieren = viewModel::nennungStornieren,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
|
||||||
thickness = DividerDefaults.Thickness,
|
|
||||||
color = DividerDefaults.color
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rechts: Bewerbsliste (40%)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(0.4f)
|
|
||||||
.fillMaxHeight()
|
|
||||||
) {
|
|
||||||
BewerbslistePanel(
|
|
||||||
bewerbe = viewModel.gefilterteBewerbe(),
|
|
||||||
nennungen = state.nennungen,
|
|
||||||
selectedPferd = state.selectedPferd,
|
|
||||||
selectedReiter = state.selectedReiter,
|
|
||||||
spartFilter = state.spartFilter,
|
|
||||||
onSpartFilterChanged = viewModel::onSpartFilterChanged,
|
|
||||||
onNennung = { bewerb -> viewModel.nennungDurchfuehren(bewerb) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pferd & Reiter Eingabe
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
@Composable
|
|
||||||
private fun PferdReiterEingabe(
|
|
||||||
state: NennungUiState,
|
|
||||||
onPferdSucheChanged: (String) -> Unit,
|
|
||||||
onPferdSelected: (Pferd) -> Unit,
|
|
||||||
onPferdLeeren: () -> Unit,
|
|
||||||
onReiterSucheChanged: (String) -> Unit,
|
|
||||||
onReiterSelected: (Reiter) -> Unit,
|
|
||||||
onReiterLeeren: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(modifier = Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
|
|
||||||
// --- Pferd ---
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
SuchfeldMitVorschlaegen(
|
|
||||||
label = "Pferd:",
|
|
||||||
value = state.pferdSuche,
|
|
||||||
onValueChange = onPferdSucheChanged,
|
|
||||||
onLeeren = onPferdLeeren,
|
|
||||||
vorschlaege = state.pferdVorschlaege.map { "${it.kopfNr} – ${it.name}" },
|
|
||||||
onVorschlagSelected = { idx -> onPferdSelected(state.pferdVorschlaege[idx]) },
|
|
||||||
)
|
|
||||||
state.selectedPferd?.let { pferd ->
|
|
||||||
MetaDatenBox {
|
|
||||||
MetaZeile("Rasse:", pferd.rasse)
|
|
||||||
MetaZeile("Farbe:", pferd.farbe)
|
|
||||||
MetaZeile("Besitzer:", pferd.besitzer)
|
|
||||||
if (pferd.stallBox.isNotEmpty()) MetaZeile("Box:", pferd.stallBox)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {},
|
|
||||||
modifier = Modifier.weight(1f).height(28.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp)
|
|
||||||
) {
|
|
||||||
Text("Neu anlegen", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {},
|
|
||||||
modifier = Modifier.weight(1f).height(28.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp)
|
|
||||||
) {
|
|
||||||
Text("Bearbeiten", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.fillMaxHeight().width(1.dp),
|
|
||||||
thickness = DividerDefaults.Thickness,
|
|
||||||
color = DividerDefaults.color
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Reiter ---
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
SuchfeldMitVorschlaegen(
|
|
||||||
label = "Reiter:",
|
|
||||||
value = state.reiterSuche,
|
|
||||||
onValueChange = onReiterSucheChanged,
|
|
||||||
onLeeren = onReiterLeeren,
|
|
||||||
vorschlaege = state.reiterVorschlaege.map { "${it.kopfNr} – ${it.vollname}" },
|
|
||||||
onVorschlagSelected = { idx -> onReiterSelected(state.reiterVorschlaege[idx]) },
|
|
||||||
)
|
|
||||||
state.selectedReiter?.let { reiter ->
|
|
||||||
MetaDatenBox {
|
|
||||||
MetaZeile("Verein:", reiter.verein)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text("Lizenz:", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(reiter.lizenzNr, fontSize = 10.sp, fontWeight = FontWeight.Medium)
|
|
||||||
Surface(
|
|
||||||
color = if (reiter.lizenzGueltig) Color(0xFF388E3C) else MaterialTheme.colorScheme.error,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (reiter.lizenzGueltig) "Gültig" else "ABGELAUFEN",
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 9.sp,
|
|
||||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text("Konto:", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(
|
|
||||||
text = "${reiter.kontoSaldo.round(2)} €",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = if (reiter.kontoSaldo < 0) MaterialTheme.colorScheme.error else Color(0xFF388E3C),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {},
|
|
||||||
modifier = Modifier.weight(1f).height(28.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp)
|
|
||||||
) {
|
|
||||||
Text("Neu anlegen", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {},
|
|
||||||
modifier = Modifier.weight(1f).height(28.dp),
|
|
||||||
contentPadding = PaddingValues(0.dp)
|
|
||||||
) {
|
|
||||||
Text("Bearbeiten", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SuchfeldMitVorschlaegen(
|
|
||||||
label: String,
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
onLeeren: () -> Unit,
|
|
||||||
vorschlaege: List<String>,
|
|
||||||
onVorschlagSelected: (Int) -> Unit,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(label, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
singleLine = true,
|
|
||||||
placeholder = { Text("Kopfnummer oder Name", fontSize = 11.sp) },
|
|
||||||
textStyle = LocalTextStyle.current.copy(fontSize = 11.sp),
|
|
||||||
)
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onLeeren,
|
|
||||||
modifier = Modifier.height(36.dp),
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
|
||||||
) {
|
|
||||||
Text("Leeren", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vorschlaege.isNotEmpty()) {
|
|
||||||
Surface(shadowElevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
LazyColumn(modifier = Modifier.heightIn(max = 120.dp)) {
|
|
||||||
itemsIndexed(vorschlaege) { idx, vorschlag ->
|
|
||||||
Text(
|
|
||||||
text = vorschlag,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onVorschlagSelected(idx) }
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
if (idx < vorschlaege.lastIndex) {
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MetaDatenBox(content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp), content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MetaZeile(label: String, value: String) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text(label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text(value, fontSize = 10.sp, fontWeight = FontWeight.Medium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Aktions-Button-Leiste
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
@Composable
|
|
||||||
private fun AktionsButtonLeiste(
|
|
||||||
canNennen: Boolean,
|
|
||||||
onStartlisteOeffnen: () -> Unit,
|
|
||||||
onErgebnisseOeffnen: () -> Unit,
|
|
||||||
onAbrechnungOeffnen: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
// Haupt-Aktion: Nennung durchführen (wird von Bewerbsliste getriggert via Doppelklick)
|
|
||||||
Surface(
|
|
||||||
color = if (canNennen) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(14.dp),
|
|
||||||
tint = if (canNennen) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Nennung: Doppelklick auf Bewerb [F5]",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
color = if (canNennen) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
|
|
||||||
SmallActionButton("Startliste", Icons.AutoMirrored.Filled.List, "F7", onStartlisteOeffnen)
|
|
||||||
SmallActionButton("Ergebnisse", Icons.Default.EmojiEvents, "F8", onErgebnisseOeffnen)
|
|
||||||
SmallActionButton("Abrechnung", Icons.Default.Receipt, "F9", onAbrechnungOeffnen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SmallActionButton(
|
|
||||||
label: String,
|
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
||||||
shortcut: String,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onClick,
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
|
||||||
modifier = Modifier.height(28.dp),
|
|
||||||
) {
|
|
||||||
Icon(icon, contentDescription = null, modifier = Modifier.size(12.dp))
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text("$label [$shortcut]", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Nennungen-Tabelle (unten links)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
@Composable
|
|
||||||
private fun NennungenTabelle(
|
|
||||||
state: NennungUiState,
|
|
||||||
nennungen: List<Nennung>,
|
|
||||||
onTabChanged: (NennungTab) -> Unit,
|
|
||||||
onStornieren: (Nennung) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Tabs
|
|
||||||
PrimaryTabRow(selectedTabIndex = state.activeNennungTab.ordinal, modifier = Modifier.height(32.dp)) {
|
|
||||||
NennungTab.entries.forEach { tab ->
|
|
||||||
Tab(
|
|
||||||
selected = state.activeNennungTab == tab,
|
|
||||||
onClick = { onTabChanged(tab) },
|
|
||||||
modifier = Modifier.height(32.dp),
|
|
||||||
) {
|
|
||||||
Text(tab.name, fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toolbar
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Aktualisieren", modifier = Modifier.size(14.dp))
|
|
||||||
}
|
|
||||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text("${nennungen.size} Nennungen", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
TextButton(
|
|
||||||
onClick = {},
|
|
||||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
|
||||||
modifier = Modifier.height(24.dp)
|
|
||||||
) {
|
|
||||||
Text("Positionieren", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = {},
|
|
||||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
|
||||||
modifier = Modifier.height(24.dp)
|
|
||||||
) {
|
|
||||||
Text("Stornieren", fontSize = 10.sp, color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
|
|
||||||
// Tabellen-Header
|
|
||||||
TabellenHeader(
|
|
||||||
listOf("Tag", "Pl.", "Bewerb", "Bewerbsname", "Startwunsch", "Pferd"),
|
|
||||||
listOf(30f, 25f, 45f, 1f, 70f, 80f)
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
|
|
||||||
// Tabellen-Inhalt
|
|
||||||
if (nennungen.isEmpty()) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text("Keine Nennungen vorhanden", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
itemsIndexed(nennungen) { idx, nennung ->
|
|
||||||
val bgColor = when (nennung.startwunsch) {
|
|
||||||
Startwunsch.VORNE -> FarbeVorne
|
|
||||||
Startwunsch.HINTEN -> FarbeHinten
|
|
||||||
else -> if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(bgColor)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(nennung.tag, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
|
||||||
Text("${nennung.platz}", fontSize = 10.sp, modifier = Modifier.width(25.dp))
|
|
||||||
Text("${nennung.bewerbNr}", fontSize = 10.sp, modifier = Modifier.width(45.dp))
|
|
||||||
Text(
|
|
||||||
nennung.bewerbName,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
when (nennung.startwunsch) {
|
|
||||||
Startwunsch.VORNE -> "Vorne"
|
|
||||||
Startwunsch.HINTEN -> "Hinten"
|
|
||||||
else -> "–"
|
|
||||||
},
|
|
||||||
fontSize = 10.sp,
|
|
||||||
modifier = Modifier.width(70.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
nennung.pferdName,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
modifier = Modifier.width(80.dp),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Bewerbsliste (unten rechts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
@Composable
|
|
||||||
private fun BewerbslistePanel(
|
|
||||||
bewerbe: List<Bewerb>,
|
|
||||||
nennungen: List<Nennung>,
|
|
||||||
selectedPferd: Pferd?,
|
|
||||||
selectedReiter: Reiter?,
|
|
||||||
spartFilter: Sparte?,
|
|
||||||
onSpartFilterChanged: (Sparte?) -> Unit,
|
|
||||||
onNennung: (Bewerb) -> Unit,
|
|
||||||
) {
|
|
||||||
val canNennen = selectedPferd != null && selectedReiter != null
|
|
||||||
var lastClickTime by remember { mutableStateOf(0L) }
|
|
||||||
var lastClickedBewerb by remember { mutableStateOf<Int?>(null) }
|
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Überschrift + Filter
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text("Bewerbsübersicht", fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
// Sparte-Filter
|
|
||||||
FilterChipKlein("Alle", spartFilter == null) { onSpartFilterChanged(null) }
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
FilterChipKlein("D", spartFilter == Sparte.DRESSUR) { onSpartFilterChanged(Sparte.DRESSUR) }
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
FilterChipKlein("S", spartFilter == Sparte.SPRINGEN) { onSpartFilterChanged(Sparte.SPRINGEN) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toolbar
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
|
||||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
|
||||||
}
|
|
||||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text("${bewerbe.size} Bewerbe", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
|
|
||||||
// Tabellen-Header
|
|
||||||
TabellenHeader(
|
|
||||||
listOf("Tag", "Pl.", "Bewerb", "Beginn", "Nenn.", "Bewerbsname"),
|
|
||||||
listOf(28f, 22f, 45f, 45f, 35f, 1f)
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
|
|
||||||
// Tabellen-Inhalt
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
itemsIndexed(bewerbe) { idx, bewerb ->
|
|
||||||
val bereitsGenannt = canNennen && nennungen.any {
|
|
||||||
it.bewerbNr == bewerb.nr &&
|
|
||||||
it.pferdName == selectedPferd.name &&
|
|
||||||
it.reiterName == selectedReiter.vollname
|
|
||||||
}
|
|
||||||
val bgColor = when {
|
|
||||||
bereitsGenannt -> Color(0xFFBBDEFB) // Blau = bereits gemeldet
|
|
||||||
idx % 2 == 0 -> Color.Transparent
|
|
||||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(bgColor)
|
|
||||||
.clickable(enabled = canNennen) {
|
|
||||||
// Time calculation disabled for Wasm-Main stability test
|
|
||||||
onNennung(bewerb)
|
|
||||||
}
|
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(bewerb.tag, fontSize = 10.sp, modifier = Modifier.width(28.dp))
|
|
||||||
Text("${bewerb.platz}", fontSize = 10.sp, modifier = Modifier.width(22.dp))
|
|
||||||
// Bewerb-Nr mit Sparte-Farbe
|
|
||||||
Text(
|
|
||||||
"${bewerb.nr}",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = if (bewerb.sparte == Sparte.DRESSUR) FarbeDressur else FarbeSpringen,
|
|
||||||
modifier = Modifier.width(45.dp),
|
|
||||||
)
|
|
||||||
Text(bewerb.beginn, fontSize = 10.sp, modifier = Modifier.width(45.dp))
|
|
||||||
Text("${bewerb.anzahlNennungen}", fontSize = 10.sp, modifier = Modifier.width(35.dp))
|
|
||||||
Text(
|
|
||||||
bewerb.name,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canNennen) {
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceVariant) {
|
|
||||||
Text(
|
|
||||||
"Bitte wählen Sie zuerst ein Pferd und einen Reiter aus",
|
|
||||||
fontSize = 10.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FilterChipKlein(label: String, selected: Boolean, onClick: () -> Unit) {
|
|
||||||
Surface(
|
|
||||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
fontSize = 9.sp,
|
|
||||||
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Verkauf & Buchungen Panel (oben rechts)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
@Composable
|
|
||||||
private fun VerkaufBuchungenPanel(
|
|
||||||
state: NennungUiState,
|
|
||||||
onTabChanged: (VerkaufTab) -> Unit,
|
|
||||||
onMengeChanged: (VerkaufArtikel, Int) -> Unit,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
PrimaryTabRow(selectedTabIndex = state.activeVerkaufTab.ordinal, modifier = Modifier.height(32.dp)) {
|
|
||||||
VerkaufTab.entries.forEach { tab ->
|
|
||||||
Tab(
|
|
||||||
selected = state.activeVerkaufTab == tab,
|
|
||||||
onClick = { onTabChanged(tab) },
|
|
||||||
modifier = Modifier.height(32.dp),
|
|
||||||
) {
|
|
||||||
Text(tab.name, fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
when (state.activeVerkaufTab) {
|
|
||||||
VerkaufTab.VERKAUF -> VerkaufTabInhalt(state.verkaufArtikel, onMengeChanged)
|
|
||||||
VerkaufTab.BUCHUNGEN -> BuchungenTabInhalt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun VerkaufTabInhalt(artikel: List<VerkaufArtikel>, onMengeChanged: (VerkaufArtikel, Int) -> Unit) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// Toolbar
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
|
||||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
|
||||||
}
|
|
||||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text("${artikel.size} Artikel", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
TextButton(
|
|
||||||
onClick = {},
|
|
||||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
|
||||||
modifier = Modifier.height(24.dp)
|
|
||||||
) {
|
|
||||||
Text("Rückgängig", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = {},
|
|
||||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
|
||||||
modifier = Modifier.height(24.dp)
|
|
||||||
) {
|
|
||||||
Text("Speichern", fontSize = 10.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
TabellenHeader(
|
|
||||||
listOf("KNr", "+", "Menge", "–", "Buchungstext", "Betrag", "Gebucht"),
|
|
||||||
listOf(30f, 20f, 45f, 20f, 1f, 55f, 55f)
|
|
||||||
)
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
itemsIndexed(artikel) { idx, art ->
|
|
||||||
val bgColor = when {
|
|
||||||
art.buchungstext == "Belastung" || art.buchungstext == "Gutschrift" -> Color(0xFFFFFDE7)
|
|
||||||
idx % 2 == 0 -> Color.Transparent
|
|
||||||
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().background(bgColor).padding(horizontal = 4.dp, vertical = 1.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(art.knr, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
|
||||||
IconButton(onClick = { onMengeChanged(art, 1) }, modifier = Modifier.size(20.dp)) {
|
|
||||||
Icon(Icons.Default.Add, contentDescription = "+", modifier = Modifier.size(12.dp))
|
|
||||||
}
|
|
||||||
Text("${art.menge}", fontSize = 10.sp, modifier = Modifier.width(45.dp), fontWeight = FontWeight.Medium)
|
|
||||||
IconButton(onClick = { onMengeChanged(art, -1) }, modifier = Modifier.size(20.dp)) {
|
|
||||||
Icon(Icons.Default.Remove, contentDescription = "–", modifier = Modifier.size(12.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
art.buchungstext,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text("${art.betrag.round(2)}", fontSize = 10.sp, modifier = Modifier.width(55.dp))
|
|
||||||
Text("${art.gebucht.round(2)}", fontSize = 10.sp, modifier = Modifier.width(55.dp))
|
|
||||||
}
|
|
||||||
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BuchungenTabInhalt() {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
|
||||||
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
|
||||||
}
|
|
||||||
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text("0 Buchungen", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
TextButton(
|
|
||||||
onClick = {},
|
|
||||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
|
||||||
modifier = Modifier.height(24.dp)
|
|
||||||
) {
|
|
||||||
Text("Stornieren", fontSize = 10.sp, color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
TabellenHeader(listOf("Kopfnr", "Menge", "Buchungstext", "Soll", "Haben"), listOf(55f, 45f, 1f, 55f, 55f))
|
|
||||||
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text("Keine Buchungen vorhanden", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Hilfs-Composable: Tabellen-Header
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
@Composable
|
|
||||||
private fun TabellenHeader(spalten: List<String>, breiten: List<Float>) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
spalten.forEachIndexed { idx, name ->
|
|
||||||
val breite = breiten.getOrNull(idx) ?: 1f
|
|
||||||
if (breite == 1f) {
|
|
||||||
Text(name, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
|
||||||
} else {
|
|
||||||
Text(name, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(breite.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package at.mocode.frontend.features.nennung.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.List
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.EmojiEvents
|
||||||
|
import androidx.compose.material.icons.filled.Receipt
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AktionsButtonLeiste(
|
||||||
|
canNennen: Boolean,
|
||||||
|
onStartlisteOeffnen: () -> Unit,
|
||||||
|
onErgebnisseOeffnen: () -> Unit,
|
||||||
|
onAbrechnungOeffnen: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Haupt-Aktion: Nennung durchführen (wird von Bewerbsliste getriggert via Doppelklick)
|
||||||
|
Surface(
|
||||||
|
color = if (canNennen) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle, contentDescription = null, modifier = Modifier.size(14.dp),
|
||||||
|
tint = if (canNennen) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Nennung: Doppelklick auf Bewerb [F5]",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = if (canNennen) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
SmallActionButton("Startliste", Icons.AutoMirrored.Filled.List, "F7", onStartlisteOeffnen)
|
||||||
|
SmallActionButton("Ergebnisse", Icons.Default.EmojiEvents, "F8", onErgebnisseOeffnen)
|
||||||
|
SmallActionButton("Abrechnung", Icons.Default.Receipt, "F9", onAbrechnungOeffnen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SmallActionButton(
|
||||||
|
label: String,
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
shortcut: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
||||||
|
modifier = Modifier.height(28.dp),
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null, modifier = Modifier.size(12.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("$label [$shortcut]", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
package at.mocode.frontend.features.nennung.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import at.mocode.frontend.features.nennung.domain.Pferd
|
||||||
|
import at.mocode.frontend.features.nennung.domain.Reiter
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.NennungUiState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PferdReiterEingabe(
|
||||||
|
state: NennungUiState,
|
||||||
|
onPferdSucheChanged: (String) -> Unit,
|
||||||
|
onPferdSelected: (Pferd) -> Unit,
|
||||||
|
onPferdLeeren: () -> Unit,
|
||||||
|
onReiterSucheChanged: (String) -> Unit,
|
||||||
|
onReiterSelected: (Reiter) -> Unit,
|
||||||
|
onReiterLeeren: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
|
||||||
|
// --- Pferd ---
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
SuchfeldMitVorschlaegen(
|
||||||
|
label = "Pferd:",
|
||||||
|
value = state.pferdSuche,
|
||||||
|
onValueChange = onPferdSucheChanged,
|
||||||
|
onLeeren = onPferdLeeren,
|
||||||
|
vorschlaege = state.pferdVorschlaege.map { "${it.kopfNr} – ${it.name}" },
|
||||||
|
onVorschlagSelected = { idx -> onPferdSelected(state.pferdVorschlaege[idx]) },
|
||||||
|
)
|
||||||
|
state.selectedPferd?.let { pferd ->
|
||||||
|
MetaDatenBox {
|
||||||
|
MetaZeile("Rasse:", pferd.rasse)
|
||||||
|
MetaZeile("Farbe:", pferd.farbe)
|
||||||
|
MetaZeile("Besitzer:", pferd.besitzer)
|
||||||
|
if (pferd.stallBox.isNotEmpty()) MetaZeile("Box:", pferd.stallBox)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.weight(1f).height(28.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp)
|
||||||
|
) {
|
||||||
|
Text("Neu anlegen", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.weight(1f).height(28.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp)
|
||||||
|
) {
|
||||||
|
Text("Bearbeiten", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.fillMaxHeight().width(1.dp),
|
||||||
|
thickness = DividerDefaults.Thickness,
|
||||||
|
color = DividerDefaults.color
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Reiter ---
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
SuchfeldMitVorschlaegen(
|
||||||
|
label = "Reiter:",
|
||||||
|
value = state.reiterSuche,
|
||||||
|
onValueChange = onReiterSucheChanged,
|
||||||
|
onLeeren = onReiterLeeren,
|
||||||
|
vorschlaege = state.reiterVorschlaege.map { "${it.kopfNr} – ${it.vollname}" },
|
||||||
|
onVorschlagSelected = { idx -> onReiterSelected(state.reiterVorschlaege[idx]) },
|
||||||
|
)
|
||||||
|
state.selectedReiter?.let { reiter ->
|
||||||
|
MetaDatenBox {
|
||||||
|
MetaZeile("Verein:", reiter.verein)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Lizenz:", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(reiter.lizenzNr, fontSize = 10.sp, fontWeight = FontWeight.Medium)
|
||||||
|
Surface(
|
||||||
|
color = if (reiter.lizenzGueltig) Color(0xFF388E3C) else MaterialTheme.colorScheme.error,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (reiter.lizenzGueltig) "Gültig" else "ABGELAUFEN",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Konto:", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(
|
||||||
|
text = "${(kotlin.math.round(reiter.kontoSaldo * 100) / 100.0)} €",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (reiter.kontoSaldo < 0) MaterialTheme.colorScheme.error else Color(0xFF388E3C),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.weight(1f).height(28.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp)
|
||||||
|
) {
|
||||||
|
Text("Neu anlegen", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.weight(1f).height(28.dp),
|
||||||
|
contentPadding = PaddingValues(0.dp)
|
||||||
|
) {
|
||||||
|
Text("Bearbeiten", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SuchfeldMitVorschlaegen(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
onLeeren: () -> Unit,
|
||||||
|
vorschlaege: List<String>,
|
||||||
|
onVorschlagSelected: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text(label, fontSize = 11.sp) },
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
|
||||||
|
singleLine = true,
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, null, modifier = Modifier.size(16.dp)) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (value.isNotEmpty()) {
|
||||||
|
IconButton(onClick = onLeeren, modifier = Modifier.size(16.dp)) {
|
||||||
|
Icon(Icons.Default.Clear, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (vorschlaege.isNotEmpty()) {
|
||||||
|
Popup(alignment = Alignment.TopStart) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.width(300.dp).heightIn(max = 200.dp),
|
||||||
|
tonalElevation = 8.dp,
|
||||||
|
shadowElevation = 4.dp,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
) {
|
||||||
|
LazyColumn {
|
||||||
|
itemsIndexed(vorschlaege) { idx, vorschlag ->
|
||||||
|
Text(
|
||||||
|
text = vorschlag,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onVorschlagSelected(idx) }
|
||||||
|
.padding(8.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
if (idx < vorschlaege.size - 1) {
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetaDatenBox(content: @Composable ColumnScope.() -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), MaterialTheme.shapes.small)
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetaZeile(label: String, value: String) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, fontSize = 10.sp, fontWeight = FontWeight.Medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
package at.mocode.frontend.features.nennung.presentation.online
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import at.mocode.frontend.features.nennung.domain.OnlineNennung
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.NennungUiState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OnlineNennungEingang(
|
||||||
|
state: NennungUiState,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onUebernehmen: (OnlineNennung) -> Unit
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Online-Nennungen (Eingang)", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
if (state.isOnlineLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
IconButton(onClick = onRefresh, modifier = Modifier.size(24.dp)) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh", modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (state.onlineNennungen.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
if (state.isOnlineLoading) "Lade Daten..." else "Keine neuen Online-Nennungen",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(state.onlineNennungen) { nennung ->
|
||||||
|
OnlineNennungItem(nennung, onUebernehmen)
|
||||||
|
HorizontalDivider(thickness = 0.5.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OnlineNennungItem(
|
||||||
|
nennung: OnlineNennung,
|
||||||
|
onUebernehmen: (OnlineNennung) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"${nennung.vorname} ${nennung.nachname}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Pferd: ${nennung.pferdName} (${nennung.pferdAlter} J.)",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Bewerbe: ${nennung.bewerbe}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { onUebernehmen(nennung) },
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
|
||||||
|
modifier = Modifier.height(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Download, null, modifier = Modifier.size(12.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Übernehmen", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+256
@@ -0,0 +1,256 @@
|
|||||||
|
package at.mocode.frontend.features.nennung.presentation.tabs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import at.mocode.frontend.features.nennung.domain.*
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.NennungUiState
|
||||||
|
|
||||||
|
// Farben für Startwunsch-Markierung (aus NennungsMaske.kt)
|
||||||
|
private val FarbeVorne = Color(0xFFE8F5E9) // Grün
|
||||||
|
private val FarbeHinten = Color(0xFFE3F2FD) // Blau
|
||||||
|
private val FarbeDressur = Color(0xFF3F51B5) // Indigo
|
||||||
|
private val FarbeSpringen = Color(0xFFE65100) // Orange
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NennungenTabelle(
|
||||||
|
state: NennungUiState,
|
||||||
|
nennungen: List<Nennung>,
|
||||||
|
onTabChanged: (NennungTab) -> Unit,
|
||||||
|
onStornieren: (Nennung) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Tabs
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
PrimaryTabRow(selectedTabIndex = state.activeNennungTab.ordinal, modifier = Modifier.height(32.dp)) {
|
||||||
|
NennungTab.entries.forEach { tab ->
|
||||||
|
Tab(
|
||||||
|
selected = state.activeNennungTab == tab,
|
||||||
|
onClick = { onTabChanged(tab) },
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
) {
|
||||||
|
Text(tab.name, fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Aktualisieren", modifier = Modifier.size(14.dp))
|
||||||
|
}
|
||||||
|
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("${nennungen.size} Nennungen", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
onClick = {},
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||||
|
modifier = Modifier.height(24.dp)
|
||||||
|
) {
|
||||||
|
Text("Positionieren", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {},
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||||
|
modifier = Modifier.height(24.dp)
|
||||||
|
) {
|
||||||
|
Text("Stornieren", fontSize = 10.sp, color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
|
||||||
|
// Tabellen-Header
|
||||||
|
TabellenHeader(
|
||||||
|
listOf("Tag", "Pl.", "Bewerb", "Bewerbsname", "Startwunsch", "Pferd"),
|
||||||
|
listOf(30f, 25f, 45f, 1f, 70f, 80f)
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
|
||||||
|
// Tabellen-Inhalt
|
||||||
|
if (nennungen.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Keine Nennungen vorhanden", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(nennungen) { idx, nennung ->
|
||||||
|
val bgColor = when (nennung.startwunsch) {
|
||||||
|
Startwunsch.VORNE -> FarbeVorne
|
||||||
|
Startwunsch.HINTEN -> FarbeHinten
|
||||||
|
else -> if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(bgColor)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(nennung.tag, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
||||||
|
Text("${nennung.platz}", fontSize = 10.sp, modifier = Modifier.width(25.dp))
|
||||||
|
Text("${nennung.bewerbNr}", fontSize = 10.sp, modifier = Modifier.width(45.dp))
|
||||||
|
Text(
|
||||||
|
nennung.bewerbName,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
when (nennung.startwunsch) {
|
||||||
|
Startwunsch.VORNE -> "Vorne"
|
||||||
|
Startwunsch.HINTEN -> "Hinten"
|
||||||
|
else -> "–"
|
||||||
|
},
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.width(70.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
nennung.pferdName,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.width(80.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BewerbslistePanel(
|
||||||
|
bewerbe: List<Bewerb>,
|
||||||
|
nennungen: List<Nennung>,
|
||||||
|
selectedPferd: Pferd?,
|
||||||
|
selectedReiter: Reiter?,
|
||||||
|
spartFilter: Sparte?,
|
||||||
|
onSpartFilterChanged: (Sparte?) -> Unit,
|
||||||
|
onNennung: (Bewerb) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Filter-Leiste
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text("Filter:", fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
||||||
|
FilterChipKlein("Alle", spartFilter == null) { onSpartFilterChanged(null) }
|
||||||
|
FilterChipKlein("Dressur", spartFilter == Sparte.DRESSUR) { onSpartFilterChanged(Sparte.DRESSUR) }
|
||||||
|
FilterChipKlein("Springen", spartFilter == Sparte.SPRINGEN) { onSpartFilterChanged(Sparte.SPRINGEN) }
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("${bewerbe.size} Bewerbe", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
TabellenHeader(
|
||||||
|
listOf("Nr", "Tag", "Pl.", "Zeit", "Bewerbsbezeichnung", "S", "Kl.", "N"),
|
||||||
|
listOf(25f, 30f, 25f, 40f, 1f, 20f, 25f, 25f)
|
||||||
|
)
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(bewerbe) { idx, bew ->
|
||||||
|
val bereitsGenannt = nennungen.any { it.bewerbNr == bew.nr && it.pferdName == selectedPferd?.name && it.reiterName == selectedReiter?.vollname }
|
||||||
|
val bgColor = if (idx % 2 == 0) Color.Transparent else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(bgColor)
|
||||||
|
.clickable(enabled = !bereitsGenannt && selectedPferd != null && selectedReiter != null) { onNennung(bew) }
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("${bew.nr}", fontSize = 10.sp, modifier = Modifier.width(25.dp), fontWeight = FontWeight.Bold)
|
||||||
|
Text(bew.tag, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
||||||
|
Text("${bew.platz}", fontSize = 10.sp, modifier = Modifier.width(25.dp))
|
||||||
|
Text(bew.beginn, fontSize = 10.sp, modifier = Modifier.width(40.dp))
|
||||||
|
Text(
|
||||||
|
bew.name,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (bereitsGenannt) MaterialTheme.colorScheme.primary else Color.Unspecified
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (bew.sparte == Sparte.DRESSUR) "D" else "S",
|
||||||
|
fontSize = 9.sp,
|
||||||
|
modifier = Modifier.width(20.dp),
|
||||||
|
color = if (bew.sparte == Sparte.DRESSUR) FarbeDressur else FarbeSpringen,
|
||||||
|
fontWeight = FontWeight.Black
|
||||||
|
)
|
||||||
|
Text(bew.klasse, fontSize = 10.sp, modifier = Modifier.width(25.dp))
|
||||||
|
Text("${bew.anzahlNennungen}", fontSize = 10.sp, modifier = Modifier.width(25.dp), textAlign = androidx.compose.ui.text.style.TextAlign.End)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterChipKlein(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
selected = selected,
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
border = if (selected) null else BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TabellenHeader(spalten: List<String>, breiten: List<Float>) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
spalten.forEachIndexed { idx, label ->
|
||||||
|
val modifier = if (breiten[idx] == 1f) Modifier.weight(1f) else Modifier.width(breiten[idx].dp)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = modifier,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package at.mocode.frontend.features.nennung.presentation.tabs
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Remove
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import at.mocode.frontend.features.nennung.domain.*
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.NennungUiState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerkaufBuchungenPanel(
|
||||||
|
state: NennungUiState,
|
||||||
|
onTabChanged: (VerkaufTab) -> Unit,
|
||||||
|
onMengeChanged: (VerkaufArtikel, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
PrimaryTabRow(selectedTabIndex = state.activeVerkaufTab.ordinal, modifier = Modifier.height(32.dp)) {
|
||||||
|
VerkaufTab.entries.forEach { tab ->
|
||||||
|
Tab(
|
||||||
|
selected = state.activeVerkaufTab == tab,
|
||||||
|
onClick = { onTabChanged(tab) },
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
) {
|
||||||
|
Text(tab.name, fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state.activeVerkaufTab) {
|
||||||
|
VerkaufTab.VERKAUF -> VerkaufTabInhalt(state.verkaufArtikel, onMengeChanged)
|
||||||
|
VerkaufTab.BUCHUNGEN -> BuchungenTabInhalt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VerkaufTabInhalt(artikel: List<VerkaufArtikel>, onMengeChanged: (VerkaufArtikel, Int) -> Unit) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Toolbar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||||
|
}
|
||||||
|
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("${artikel.size} Artikel", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
TextButton(
|
||||||
|
onClick = {},
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||||
|
modifier = Modifier.height(24.dp)
|
||||||
|
) {
|
||||||
|
Text("Rückgängig", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {},
|
||||||
|
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
|
||||||
|
modifier = Modifier.height(24.dp)
|
||||||
|
) {
|
||||||
|
Text("Speichern", fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
TabellenHeader(
|
||||||
|
listOf("KNr", "+", "Menge", "–", "Buchungstext", "Betrag", "Gebucht"),
|
||||||
|
listOf(30f, 20f, 45f, 20f, 1f, 55f, 55f)
|
||||||
|
)
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(artikel) { idx, art ->
|
||||||
|
val bgColor = when {
|
||||||
|
art.buchungstext == "Belastung" || art.buchungstext == "Gutschrift" -> Color(0xFFFFFDE7)
|
||||||
|
idx % 2 == 0 -> Color.Transparent
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().background(bgColor).padding(horizontal = 4.dp, vertical = 1.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(art.knr, fontSize = 10.sp, modifier = Modifier.width(30.dp))
|
||||||
|
IconButton(onClick = { onMengeChanged(art, 1) }, modifier = Modifier.size(20.dp)) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "+", modifier = Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
Text("${art.menge}", fontSize = 10.sp, modifier = Modifier.width(45.dp), fontWeight = FontWeight.Medium)
|
||||||
|
IconButton(onClick = { onMengeChanged(art, -1) }, modifier = Modifier.size(20.dp)) {
|
||||||
|
Icon(Icons.Default.Remove, contentDescription = "–", modifier = Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
art.buchungstext,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text("${(kotlin.math.round(art.betrag * 100) / 100.0)}", fontSize = 10.sp, modifier = Modifier.width(55.dp))
|
||||||
|
Text("${(kotlin.math.round(art.gebucht * 100) / 100.0)}", fontSize = 10.sp, modifier = Modifier.width(55.dp))
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier, thickness = 0.5.dp, color = DividerDefaults.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BuchungenTabInhalt() {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {}, modifier = Modifier.size(20.dp)) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||||
|
}
|
||||||
|
Text("Aktualisieren", fontSize = 10.sp, modifier = Modifier.padding(start = 2.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("0 Buchungen", fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
TabellenHeader(
|
||||||
|
listOf("Datum", "Uhrzeit", "User", "Buchungstext", "Betrag", "G", "Z"),
|
||||||
|
listOf(60f, 50f, 40f, 1f, 55f, 20f, 20f)
|
||||||
|
)
|
||||||
|
HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color)
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Keine Buchungen vorhanden", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TabellenHeader(spalten: List<String>, breiten: List<Float>) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
spalten.forEachIndexed { idx, label ->
|
||||||
|
val modifier = if (breiten[idx] == 1f) Modifier.weight(1f) else Modifier.width(breiten[idx].dp)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = modifier,
|
||||||
|
fontSize = 9.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-29
@@ -6,8 +6,12 @@ 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.repository.MasterdataRepository
|
||||||
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||||
import at.mocode.frontend.core.domain.zns.ZnsImportState
|
import at.mocode.frontend.core.domain.zns.ZnsImportState
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteFunktionaer
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemotePferd
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteReiter
|
||||||
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
|
||||||
import at.mocode.frontend.core.network.NetworkConfig
|
import at.mocode.frontend.core.network.NetworkConfig
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
@@ -44,6 +48,34 @@ internal data class VereinRemoteDto(
|
|||||||
val bundesland: String? = null,
|
val bundesland: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class ReiterRemoteDto(
|
||||||
|
val reiterId: String,
|
||||||
|
val satznummer: String? = null,
|
||||||
|
val nachname: String,
|
||||||
|
val vorname: String,
|
||||||
|
val reiterLizenz: String? = null,
|
||||||
|
val lizenzKlasse: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class HorseRemoteDto(
|
||||||
|
val pferdId: String,
|
||||||
|
val kopfnummer: String? = null,
|
||||||
|
val pferdeName: String,
|
||||||
|
val lebensnummer: String? = null,
|
||||||
|
val geschlecht: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class FunktionaerRemoteDto(
|
||||||
|
val funktionaerId: String,
|
||||||
|
val satzId: String,
|
||||||
|
val satzNummer: Int,
|
||||||
|
val name: String? = null,
|
||||||
|
val qualifikationen: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
private val TERMINAL_STATES = setOf("ABGESCHLOSSEN", "FEHLER")
|
private val TERMINAL_STATES = setOf("ABGESCHLOSSEN", "FEHLER")
|
||||||
private const val POLLING_INTERVAL_MS = 2000L
|
private const val POLLING_INTERVAL_MS = 2000L
|
||||||
private const val MAX_VISIBLE_ERRORS = 50
|
private const val MAX_VISIBLE_ERRORS = 50
|
||||||
@@ -51,6 +83,7 @@ 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,
|
||||||
|
private val repository: MasterdataRepository,
|
||||||
) : ViewModel(), ZnsImportProvider {
|
) : ViewModel(), ZnsImportProvider {
|
||||||
|
|
||||||
override var state by mutableStateOf(ZnsImportState())
|
override var state by mutableStateOf(ZnsImportState())
|
||||||
@@ -81,6 +114,7 @@ class ZnsImportViewModel(
|
|||||||
jobId = null, progress = 0, progressDetail = "", errors = emptyList()
|
jobId = null, progress = 0, progressDetail = "", errors = emptyList()
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
|
println("[ZNS] Starte Import Mode=$mode Datei=${file.absolutePath}")
|
||||||
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)
|
parameter("mode", mode)
|
||||||
@@ -94,15 +128,31 @@ class ZnsImportViewModel(
|
|||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
println("[ZNS] Upload Response: ${response.status}")
|
||||||
if (response.status == HttpStatusCode.Accepted) {
|
if (response.status == HttpStatusCode.Accepted) {
|
||||||
val body = json.decodeFromString<ImportStartResponse>(response.bodyAsText())
|
val responseText = response.bodyAsText()
|
||||||
|
println("[DEBUG_LOG] Import Started Response: $responseText")
|
||||||
|
val body = try {
|
||||||
|
json.decodeFromString<ImportStartResponse>(responseText)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[DEBUG_LOG] JSON Decoding failed (Import Start): ${e.message}")
|
||||||
|
throw Exception("Fehler beim Starten des Imports (Server-Antwort ungültig).")
|
||||||
|
}
|
||||||
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 {
|
||||||
state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value}")
|
val errorText = try { response.bodyAsText() } catch (_: Exception) { "Keine Fehlerdetails verfügbar" }
|
||||||
|
println("[ZNS] Upload Fehler: ${response.status} -> $errorText")
|
||||||
|
state = state.copy(isUploading = false, errorMessage = "Upload fehlgeschlagen: HTTP ${response.status.value} ($errorText)")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
state = state.copy(isUploading = false, errorMessage = "Fehler beim Upload: ${e.message}")
|
println("[ZNS] Exception beim Upload: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
val displayMessage = when {
|
||||||
|
e.message?.contains("Connect") == true -> "Verbindung zum Server fehlgeschlagen. Ist das Backend gestartet?"
|
||||||
|
else -> e.message ?: "Unbekannter Fehler beim Upload"
|
||||||
|
}
|
||||||
|
state = state.copy(isUploading = false, errorMessage = displayMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,42 +195,80 @@ class ZnsImportViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun syncFromCloud(onResult: (List<ZnsRemoteVerein>) -> Unit) {
|
override fun syncFromCloud(onResult: (
|
||||||
|
List<ZnsRemoteVerein>,
|
||||||
|
List<ZnsRemoteReiter>,
|
||||||
|
List<ZnsRemotePferd>,
|
||||||
|
List<ZnsRemoteFunktionaer>
|
||||||
|
) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
state = state.copy(isSyncing = true, errorMessage = null)
|
state = state.copy(isSyncing = true, errorMessage = null)
|
||||||
try {
|
try {
|
||||||
|
println("[ZNS] Starte Cloud-Sync")
|
||||||
val token = authTokenManager.authState.value.token
|
val token = authTokenManager.authState.value.token
|
||||||
// Wir laden die Top 1000 Vereine für den Sync (einfache Implementierung)
|
|
||||||
val response: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/verein") {
|
// 1. Vereine
|
||||||
|
val vResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/verein") {
|
||||||
parameter("limit", 1000)
|
parameter("limit", 1000)
|
||||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
}
|
}
|
||||||
|
val vResults = if (vResponse.status.isSuccess()) {
|
||||||
if (response.status.isSuccess()) {
|
json.decodeFromString<List<VereinRemoteDto>>(vResponse.bodyAsText()).map {
|
||||||
val results = json.decodeFromString<List<VereinRemoteDto>>(response.bodyAsText())
|
|
||||||
val domainResults = results.map {
|
|
||||||
ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland)
|
ZnsRemoteVerein(it.vereinId, it.name, it.vereinsNummer, it.ort, it.bundesland)
|
||||||
}
|
}
|
||||||
|
} else emptyList()
|
||||||
|
|
||||||
val now = java.time.LocalDateTime.now()
|
// 2. Reiter
|
||||||
val version = now.format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))
|
val rResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/reiter") {
|
||||||
|
parameter("limit", 1000)
|
||||||
state = state.copy(
|
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
isSyncing = false,
|
|
||||||
lastSyncVersion = version,
|
|
||||||
isFinished = true
|
|
||||||
)
|
|
||||||
onResult(domainResults)
|
|
||||||
} else if (response.status == HttpStatusCode.Unauthorized) {
|
|
||||||
state = state.copy(
|
|
||||||
isSyncing = false,
|
|
||||||
errorMessage = "Nicht autorisiert (HTTP 401). Bitte prüfen Sie Ihren Sicherheitsschlüssel im Setup."
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
state = state.copy(isSyncing = false, errorMessage = "Sync fehlgeschlagen: HTTP ${response.status.value}")
|
|
||||||
}
|
}
|
||||||
|
val rResults = if (rResponse.status.isSuccess()) {
|
||||||
|
json.decodeFromString<List<ReiterRemoteDto>>(rResponse.bodyAsText()).map {
|
||||||
|
ZnsRemoteReiter(it.reiterId, it.satznummer, it.nachname, it.vorname, it.reiterLizenz, it.lizenzKlasse)
|
||||||
|
}
|
||||||
|
} else emptyList()
|
||||||
|
|
||||||
|
// 3. Pferde
|
||||||
|
val pResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/horse") {
|
||||||
|
parameter("limit", 1000)
|
||||||
|
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
|
}
|
||||||
|
val pResults = if (pResponse.status.isSuccess()) {
|
||||||
|
json.decodeFromString<List<HorseRemoteDto>>(pResponse.bodyAsText()).map {
|
||||||
|
ZnsRemotePferd(it.pferdId, it.kopfnummer, it.pferdeName, it.lebensnummer, it.geschlecht)
|
||||||
|
}
|
||||||
|
} else emptyList()
|
||||||
|
|
||||||
|
// 4. Funktionäre
|
||||||
|
val fResponse: HttpResponse = httpClient.get("${NetworkConfig.baseUrl}/api/v1/masterdata/funktionaer") {
|
||||||
|
parameter("limit", 1000)
|
||||||
|
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
|
}
|
||||||
|
val fResults = if (fResponse.status.isSuccess()) {
|
||||||
|
json.decodeFromString<List<FunktionaerRemoteDto>>(fResponse.bodyAsText()).map {
|
||||||
|
ZnsRemoteFunktionaer(it.funktionaerId, it.satzId, it.satzNummer, it.name, it.qualifikationen)
|
||||||
|
}
|
||||||
|
} else emptyList()
|
||||||
|
|
||||||
|
val now = java.time.LocalDateTime.now()
|
||||||
|
val version = now.format(java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm"))
|
||||||
|
|
||||||
|
state = state.copy(
|
||||||
|
isSyncing = false,
|
||||||
|
lastSyncVersion = version,
|
||||||
|
isFinished = true
|
||||||
|
)
|
||||||
|
onResult(vResults, rResults, pResults, fResults)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
state = state.copy(isSyncing = false, errorMessage = "Fehler beim Cloud-Sync: ${e.message}")
|
println("[ZNS] Exception beim Sync: ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
|
val displayMessage = when {
|
||||||
|
e.message?.contains("Connect") == true -> "Verbindung zum Server fehlgeschlagen. Ist das Backend gestartet?"
|
||||||
|
else -> e.message ?: "Unbekannter Fehler beim Cloud-Sync"
|
||||||
|
}
|
||||||
|
state = state.copy(isSyncing = false, errorMessage = displayMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +283,13 @@ class ZnsImportViewModel(
|
|||||||
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
if (token != null) header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
}
|
}
|
||||||
if (response.status.isSuccess()) {
|
if (response.status.isSuccess()) {
|
||||||
val status = json.decodeFromString<JobStatusResponse>(response.bodyAsText())
|
val responseText = response.bodyAsText()
|
||||||
|
val status = try {
|
||||||
|
json.decodeFromString<JobStatusResponse>(responseText)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("[DEBUG_LOG] Polling JSON Decoding failed: ${e.message}")
|
||||||
|
throw Exception("Status-Format ungültig.")
|
||||||
|
}
|
||||||
state = state.copy(
|
state = state.copy(
|
||||||
jobStatus = status.status,
|
jobStatus = status.status,
|
||||||
progress = status.fortschritt,
|
progress = status.fortschritt,
|
||||||
@@ -204,9 +298,12 @@ class ZnsImportViewModel(
|
|||||||
isFinished = status.status in TERMINAL_STATES,
|
isFinished = status.status in TERMINAL_STATES,
|
||||||
)
|
)
|
||||||
if (status.status in TERMINAL_STATES) break
|
if (status.status in TERMINAL_STATES) break
|
||||||
|
} else {
|
||||||
|
println("[ZNS] Polling Fehler: ${response.status}")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
state = state.copy(errorMessage = "Polling-Fehler: ${e.message}", isFinished = true)
|
println("[ZNS] Polling Exception: ${e.message}")
|
||||||
|
state = state.copy(errorMessage = "Status-Abfrage fehlgeschlagen: ${e.message}", isFinished = true)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
delay(POLLING_INTERVAL_MS.milliseconds)
|
delay(POLLING_INTERVAL_MS.milliseconds)
|
||||||
@@ -214,6 +311,19 @@ class ZnsImportViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun addSyncResults(
|
||||||
|
vereine: List<ZnsRemoteVerein>,
|
||||||
|
reiter: List<ZnsRemoteReiter>,
|
||||||
|
pferde: List<ZnsRemotePferd>,
|
||||||
|
funktionaere: List<ZnsRemoteFunktionaer>
|
||||||
|
) {
|
||||||
|
println("[ZNS] Sync-Ergebnisse empfangen: ${vereine.size} V, ${reiter.size} R, ${pferde.size} P, ${funktionaere.size} F")
|
||||||
|
repository.saveVereine(vereine)
|
||||||
|
repository.saveReiter(reiter)
|
||||||
|
repository.savePferde(pferde)
|
||||||
|
repository.saveFunktionaere(funktionaere)
|
||||||
|
}
|
||||||
|
|
||||||
override fun reset() {
|
override fun reset() {
|
||||||
pollingJob?.cancel()
|
pollingJob?.cancel()
|
||||||
state = ZnsImportState()
|
state = ZnsImportState()
|
||||||
|
|||||||
+2
-2
@@ -6,6 +6,6 @@ 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<ZnsImportProvider> { ZnsImportViewModel(get(named("apiClient")), get(), get()) }
|
||||||
factory { ZnsImportViewModel(get(named("apiClient")), get()) }
|
factory { ZnsImportViewModel(get(named("apiClient")), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-33
@@ -19,17 +19,22 @@ import org.koin.compose.viewmodel.koinViewModel
|
|||||||
import javax.swing.JFileChooser
|
import javax.swing.JFileChooser
|
||||||
import javax.swing.filechooser.FileNameExtensionFilter
|
import javax.swing.filechooser.FileNameExtensionFilter
|
||||||
|
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StammdatenImportScreen(
|
fun StammdatenImportScreen(
|
||||||
viewModel: ZnsImportViewModel = koinViewModel(),
|
viewModel: ZnsImportViewModel = koinViewModel(),
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val state = viewModel.state
|
val state = viewModel.state
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp),
|
.padding(24.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
// Titel
|
// Titel
|
||||||
@@ -56,14 +61,20 @@ fun StammdatenImportScreen(
|
|||||||
value = state.selectedFilePath ?: "",
|
value = state.selectedFilePath ?: "",
|
||||||
onValueChange = {},
|
onValueChange = {},
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
placeholder = { Text("Keine Datei ausgewählt…") },
|
placeholder = { Text("ZNS-Datei auswählen (.zip, .dat)...") },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
)
|
)
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val path = pickZipFile()
|
val chooser = JFileChooser()
|
||||||
if (path != null) viewModel.onFileSelected(path)
|
chooser.dialogTitle = "ZNS-Datei auswählen"
|
||||||
|
chooser.fileFilter = FileNameExtensionFilter("ZNS Dateien (*.zip, *.dat)", "zip", "dat")
|
||||||
|
chooser.isAcceptAllFileFilterUsed = false
|
||||||
|
val result = chooser.showOpenDialog(null)
|
||||||
|
if (result == JFileChooser.APPROVE_OPTION) {
|
||||||
|
viewModel.onFileSelected(chooser.selectedFile.absolutePath)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
enabled = !state.isUploading && !(!state.isFinished && state.jobId != null),
|
enabled = !state.isUploading && !(!state.isFinished && state.jobId != null),
|
||||||
) {
|
) {
|
||||||
@@ -99,10 +110,65 @@ fun StammdatenImportScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text("Cloud-Synchronisation", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Stammdaten direkt vom OEPS-Server laden",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.syncFromCloud { vereine, reiter, pferde, funktionaere ->
|
||||||
|
println("[ZNS] Sync Abschluss: ${vereine.size} V, ${reiter.size} R, ${pferde.size} P, ${funktionaere.size} F")
|
||||||
|
viewModel.addSyncResults(vereine, reiter, pferde, funktionaere)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !state.isSyncing,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary),
|
||||||
|
) {
|
||||||
|
if (state.isSyncing) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.CloudSync, contentDescription = null)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Cloud-Sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fehler-Banner
|
// Cloud-Sync Status
|
||||||
|
if (state.lastSyncVersion != null) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Info, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
|
||||||
|
Text(
|
||||||
|
"Letzter erfolgreicher Cloud-Sync: ${state.lastSyncVersion}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (state.errorMessage != null) {
|
if (state.errorMessage != null) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -208,7 +274,7 @@ fun StammdatenImportScreen(
|
|||||||
// Fehler-Liste
|
// Fehler-Liste
|
||||||
if (state.errors.isNotEmpty()) {
|
if (state.errors.isNotEmpty()) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -229,25 +295,33 @@ fun StammdatenImportScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
LazyColumn(
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
LazyColumn(
|
||||||
) {
|
state = lazyListState,
|
||||||
items(state.errors) { error ->
|
modifier = Modifier.fillMaxWidth().heightIn(max = 300.dp),
|
||||||
Row(
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
) {
|
||||||
modifier = Modifier
|
items(state.errors) { error ->
|
||||||
.fillMaxWidth()
|
Row(
|
||||||
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(4.dp))
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
modifier = Modifier
|
||||||
) {
|
.fillMaxWidth()
|
||||||
Text("•", color = MaterialTheme.colorScheme.error)
|
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(4.dp))
|
||||||
Text(
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
error,
|
) {
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
Text("•", color = MaterialTheme.colorScheme.error)
|
||||||
)
|
Text(
|
||||||
|
error,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
androidx.compose.foundation.VerticalScrollbar(
|
||||||
|
adapter = androidx.compose.foundation.rememberScrollbarAdapter(lazyListState),
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,13 +354,3 @@ private fun StatusChip(status: String?) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Öffnet einen nativen JFileChooser (JVM-only) und gibt den Pfad der gewählten ZIP zurück. */
|
|
||||||
private fun pickZipFile(): String? {
|
|
||||||
val chooser = JFileChooser()
|
|
||||||
chooser.dialogTitle = "ZNS.zip auswählen"
|
|
||||||
chooser.fileFilter = FileNameExtensionFilter("ZIP-Archiv (*.zip)", "zip")
|
|
||||||
chooser.isAcceptAllFileFilterUsed = false
|
|
||||||
val result = chooser.showOpenDialog(null)
|
|
||||||
return if (result == JFileChooser.APPROVE_OPTION) chooser.selectedFile.absolutePath else null
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,5 +2,11 @@
|
|||||||
"deviceName": "Meldestelle",
|
"deviceName": "Meldestelle",
|
||||||
"sharedKey": "Password",
|
"sharedKey": "Password",
|
||||||
"backupPath": "/mocode/meldestelle/docs/temp",
|
"backupPath": "/mocode/meldestelle/docs/temp",
|
||||||
"networkRole": "MASTER"
|
"networkRole": "MASTER",
|
||||||
|
"expectedClients": [
|
||||||
|
{
|
||||||
|
"name": "Richter-Turm",
|
||||||
|
"role": "RICHTER"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
+3
@@ -6,6 +6,8 @@ import at.mocode.frontend.core.domain.models.User
|
|||||||
import at.mocode.frontend.core.navigation.CurrentUserProvider
|
import at.mocode.frontend.core.navigation.CurrentUserProvider
|
||||||
import at.mocode.frontend.core.navigation.DeepLinkHandler
|
import at.mocode.frontend.core.navigation.DeepLinkHandler
|
||||||
import at.mocode.frontend.core.navigation.NavigationPort
|
import at.mocode.frontend.core.navigation.NavigationPort
|
||||||
|
import at.mocode.desktop.repository.DesktopMasterdataRepository
|
||||||
|
import at.mocode.frontend.core.domain.repository.MasterdataRepository
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,4 +34,5 @@ val desktopModule = module {
|
|||||||
single<NavigationPort> { get<DesktopNavigationPort>() }
|
single<NavigationPort> { get<DesktopNavigationPort>() }
|
||||||
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
|
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
|
||||||
single { DeepLinkHandler(get(), get()) }
|
single { DeepLinkHandler(get(), get()) }
|
||||||
|
single<MasterdataRepository> { DesktopMasterdataRepository() }
|
||||||
}
|
}
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package at.mocode.desktop.repository
|
||||||
|
|
||||||
|
import at.mocode.desktop.data.Funktionaer
|
||||||
|
import at.mocode.desktop.data.Pferd
|
||||||
|
import at.mocode.desktop.data.Reiter
|
||||||
|
import at.mocode.desktop.data.Store
|
||||||
|
import at.mocode.desktop.data.Verein
|
||||||
|
import at.mocode.frontend.core.domain.repository.MasterdataRepository
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteFunktionaer
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemotePferd
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteReiter
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
|
||||||
|
|
||||||
|
class DesktopMasterdataRepository : MasterdataRepository {
|
||||||
|
|
||||||
|
override fun saveVereine(vereine: List<ZnsRemoteVerein>) {
|
||||||
|
println("[Repository] Speichere ${vereine.size} Vereine")
|
||||||
|
vereine.forEach { remote ->
|
||||||
|
val id = remote.id.toLongOrNull() ?: return@forEach
|
||||||
|
val existingIdx = Store.vereine.indexOfFirst { it.id == id }
|
||||||
|
val verein = Verein(
|
||||||
|
id = id,
|
||||||
|
name = remote.name,
|
||||||
|
oepsNummer = remote.oepsNummer,
|
||||||
|
ort = remote.ort,
|
||||||
|
bundesland = remote.bundesland,
|
||||||
|
istVeranstalter = true // In der Meldestelle sind importierte ZNS-Vereine meist potenzielle Veranstalter
|
||||||
|
)
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
Store.vereine[existingIdx] = verein
|
||||||
|
} else {
|
||||||
|
Store.vereine.add(verein)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveReiter(reiter: List<ZnsRemoteReiter>) {
|
||||||
|
println("[Repository] Speichere ${reiter.size} Reiter")
|
||||||
|
reiter.forEach { remote ->
|
||||||
|
val id = remote.id.toLongOrNull() ?: return@forEach
|
||||||
|
val existingIdx = Store.reiter.indexOfFirst { it.id == id }
|
||||||
|
val entry = Reiter(
|
||||||
|
id = id,
|
||||||
|
vorname = remote.vorname,
|
||||||
|
nachname = remote.nachname,
|
||||||
|
satznummer = remote.satznummer,
|
||||||
|
oepsNummer = remote.satznummer, // Oft identisch oder Mapping nötig
|
||||||
|
lizenzKlasse = remote.lizenzKlasse,
|
||||||
|
nation = "AUT" // Default für ZNS Import
|
||||||
|
)
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
Store.reiter[existingIdx] = entry
|
||||||
|
} else {
|
||||||
|
Store.reiter.add(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun savePferde(pferde: List<ZnsRemotePferd>) {
|
||||||
|
println("[Repository] Speichere ${pferde.size} Pferde")
|
||||||
|
pferde.forEach { remote ->
|
||||||
|
val id = remote.id.toLongOrNull() ?: return@forEach
|
||||||
|
val existingIdx = Store.pferde.indexOfFirst { it.id == id }
|
||||||
|
val entry = Pferd(
|
||||||
|
id = id,
|
||||||
|
name = remote.name,
|
||||||
|
geschlecht = remote.geschlecht,
|
||||||
|
lebensnummer = remote.lebensnummer,
|
||||||
|
oepsNummer = remote.kopfnummer
|
||||||
|
)
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
Store.pferde[existingIdx] = entry
|
||||||
|
} else {
|
||||||
|
Store.pferde.add(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveFunktionaere(funktionaere: List<ZnsRemoteFunktionaer>) {
|
||||||
|
println("[Repository] Speichere ${funktionaere.size} Funktionäre")
|
||||||
|
funktionaere.forEach { remote ->
|
||||||
|
val id = remote.id.toLongOrNull() ?: return@forEach
|
||||||
|
val existingIdx = Store.funktionaere.indexOfFirst { it.id == id }
|
||||||
|
val namen = remote.name?.split(" ") ?: listOf("Unbekannt")
|
||||||
|
val entry = Funktionaer(
|
||||||
|
id = id,
|
||||||
|
vorname = namen.firstOrNull() ?: "",
|
||||||
|
nachname = namen.drop(1).joinToString(" "),
|
||||||
|
rollen = remote.qualifikationen
|
||||||
|
)
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
Store.funktionaere[existingIdx] = entry
|
||||||
|
} else {
|
||||||
|
Store.funktionaere.add(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-8
@@ -26,6 +26,8 @@ import at.mocode.desktop.screens.management.VeranstalterVerwaltungScreen
|
|||||||
import at.mocode.desktop.screens.nennung.NennungsEingangScreen
|
import at.mocode.desktop.screens.nennung.NennungsEingangScreen
|
||||||
import at.mocode.desktop.screens.profile.FunktionaerProfil
|
import at.mocode.desktop.screens.profile.FunktionaerProfil
|
||||||
import at.mocode.desktop.screens.veranstaltung.*
|
import at.mocode.desktop.screens.veranstaltung.*
|
||||||
|
import at.mocode.desktop.screens.veranstaltung.details.*
|
||||||
|
import at.mocode.desktop.screens.veranstaltung.wizards.*
|
||||||
import at.mocode.frontend.core.designsystem.theme.AppColors
|
import at.mocode.frontend.core.designsystem.theme.AppColors
|
||||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||||
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||||
@@ -39,7 +41,7 @@ import at.mocode.frontend.features.deviceinitialization.domain.DeviceInitializat
|
|||||||
import at.mocode.frontend.features.deviceinitialization.presentation.DeviceInitializationScreen
|
import at.mocode.frontend.features.deviceinitialization.presentation.DeviceInitializationScreen
|
||||||
import at.mocode.frontend.features.deviceinitialization.presentation.DeviceInitializationViewModel
|
import at.mocode.frontend.features.deviceinitialization.presentation.DeviceInitializationViewModel
|
||||||
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
|
import at.mocode.frontend.features.nennung.presentation.NennungViewModel
|
||||||
import at.mocode.frontend.features.nennung.presentation.NennungsMaske
|
import at.mocode.frontend.features.nennung.presentation.NennungManagementScreen
|
||||||
import at.mocode.frontend.features.pferde.presentation.PferdeScreen
|
import at.mocode.frontend.features.pferde.presentation.PferdeScreen
|
||||||
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
|
import at.mocode.frontend.features.pferde.presentation.PferdeViewModel
|
||||||
import at.mocode.frontend.features.profile.presentation.ProfileScreen
|
import at.mocode.frontend.features.profile.presentation.ProfileScreen
|
||||||
@@ -650,7 +652,7 @@ private fun DesktopContentArea(
|
|||||||
|
|
||||||
is AppScreen.VeranstalterNeu -> VeranstalterAnlegenWizard(
|
is AppScreen.VeranstalterNeu -> VeranstalterAnlegenWizard(
|
||||||
onCancel = onBack,
|
onCancel = onBack,
|
||||||
onVereinCreated = { newId -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
|
onVereinCreated = { newId: Long -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
is AppScreen.VeranstalterDetail -> {
|
is AppScreen.VeranstalterDetail -> {
|
||||||
@@ -669,8 +671,8 @@ private fun DesktopContentArea(
|
|||||||
VeranstaltungKonfig(
|
VeranstaltungKonfig(
|
||||||
veranstalterId = vId,
|
veranstalterId = vId,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) },
|
onSaved = { evtId: Long, finalVId: Long -> onNavigate(AppScreen.VeranstaltungProfil(finalVId, evtId)) },
|
||||||
onVeranstalterCreated = { newVId -> onNavigate(AppScreen.VeranstalterDetail(newVId)) }
|
onVeranstalterCreated = { newVId: Long -> onNavigate(AppScreen.VeranstalterDetail(newVId)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,8 +708,8 @@ private fun DesktopContentArea(
|
|||||||
TurnierStore.add(evtId, draft)
|
TurnierStore.add(evtId, draft)
|
||||||
onNavigate(AppScreen.TurnierDetail(evtId, newId))
|
onNavigate(AppScreen.TurnierDetail(evtId, newId))
|
||||||
},
|
},
|
||||||
onTurnierOpen = { tId -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) },
|
onTurnierOpen = { tId: Long -> onNavigate(AppScreen.TurnierDetail(evtId, tId)) },
|
||||||
onNavigateToVeranstalterProfil = { verId -> onNavigate(AppScreen.VeranstalterProfil(verId)) }
|
onNavigateToVeranstalterProfil = { verId: Long -> onNavigate(AppScreen.VeranstalterProfil(verId)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,7 +789,7 @@ private fun DesktopContentArea(
|
|||||||
veranstalterId = parent.id,
|
veranstalterId = parent.id,
|
||||||
veranstaltungId = evtId,
|
veranstaltungId = evtId,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSaved = { _ -> onBack() },
|
onSaved = { _: Long -> onBack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -850,7 +852,7 @@ private fun DesktopContentArea(
|
|||||||
|
|
||||||
is AppScreen.EntryManagement -> {
|
is AppScreen.EntryManagement -> {
|
||||||
val nennungViewModel: NennungViewModel = koinViewModel()
|
val nennungViewModel: NennungViewModel = koinViewModel()
|
||||||
NennungsMaske(
|
NennungManagementScreen(
|
||||||
viewModel = nennungViewModel,
|
viewModel = nennungViewModel,
|
||||||
onAbrechnungOeffnen = { /* Navigation zu Billing falls nötig */ }
|
onAbrechnungOeffnen = { /* Navigation zu Billing falls nötig */ }
|
||||||
)
|
)
|
||||||
|
|||||||
+14
-1958
File diff suppressed because it is too large
Load Diff
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package at.mocode.desktop.screens.veranstaltung
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.desktop.data.Store
|
||||||
|
import at.mocode.desktop.theme.DesktopTheme
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun VeranstaltungVerwaltung(
|
||||||
|
onVeranstaltungOpen: (Long, Long) -> Unit, // veranstalterId, veranstaltungId
|
||||||
|
onNewVeranstaltung: () -> Unit,
|
||||||
|
onNavigateToPferde: () -> Unit,
|
||||||
|
onNavigateToReiter: () -> Unit,
|
||||||
|
onNavigateToVereine: () -> Unit,
|
||||||
|
onNavigateToFunktionaere: () -> Unit,
|
||||||
|
onNavigateToVeranstalter: () -> Unit,
|
||||||
|
onNavigateToZnsImport: () -> Unit
|
||||||
|
) {
|
||||||
|
LaunchedEffect(Unit) { println("[Screen] VeranstaltungVerwaltung geladen") }
|
||||||
|
DesktopTheme {
|
||||||
|
val allVeranstaltungen = remember { Store.allEvents() }
|
||||||
|
val vereine = Store.vereine
|
||||||
|
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedStatus by remember { mutableStateOf<String?>(null) }
|
||||||
|
val availableStatuses = remember(allVeranstaltungen) { allVeranstaltungen.map { it.status }.distinct().sorted() }
|
||||||
|
|
||||||
|
val filteredVeranstaltungen = remember(allVeranstaltungen, searchQuery, selectedStatus) {
|
||||||
|
allVeranstaltungen.filter { veranstaltung ->
|
||||||
|
val verein = vereine.find { it.id == veranstaltung.veranstalterId }
|
||||||
|
val matchesSearch = veranstaltung.titel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
(verein?.name?.contains(searchQuery, ignoreCase = true) ?: false)
|
||||||
|
val matchesStatus = selectedStatus == null || veranstaltung.status == selectedStatus
|
||||||
|
matchesSearch && matchesStatus
|
||||||
|
}.sortedByDescending { it.datumVon }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("Veranstaltungen - verwalten", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
Button(onClick = onNewVeranstaltung) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Neue Veranstaltung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter & Suche
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
placeholder = { Text("Suche nach Titel oder Verein...") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { searchQuery = "" }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = "Löschen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status Filter Chips
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.FilterList, contentDescription = null, tint = Color.Gray)
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedStatus == null,
|
||||||
|
onClick = { selectedStatus = null },
|
||||||
|
label = { Text("Alle") }
|
||||||
|
)
|
||||||
|
availableStatuses.forEach { status ->
|
||||||
|
FilterChip(
|
||||||
|
selected = selectedStatus == status,
|
||||||
|
onClick = { selectedStatus = status },
|
||||||
|
label = { Text(status) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liste
|
||||||
|
if (filteredVeranstaltungen.isEmpty()) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(Icons.Default.EventBusy, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.LightGray)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("Keine Veranstaltungen gefunden", style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(filteredVeranstaltungen) { veranstaltung ->
|
||||||
|
val verein = vereine.find { it.id == veranstaltung.veranstalterId }
|
||||||
|
VeranstaltungCard(
|
||||||
|
veranstaltung = veranstaltung,
|
||||||
|
vereinName = verein?.name ?: "Unbekannter Verein",
|
||||||
|
onClick = { onVeranstaltungOpen(veranstaltung.veranstalterId, veranstaltung.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VeranstaltungCard(
|
||||||
|
veranstaltung: at.mocode.desktop.data.Veranstaltung,
|
||||||
|
vereinName: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CalendarToday,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(12.dp).size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(veranstaltung.titel, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text(vereinName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Icon(Icons.Default.Place, contentDescription = null, modifier = Modifier.size(14.dp), tint = Color.Gray)
|
||||||
|
Text(veranstaltung.ort, style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||||
|
Text("•", color = Color.Gray)
|
||||||
|
Text("${veranstaltung.datumVon} - ${veranstaltung.datumBis ?: ""}", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = when (veranstaltung.status) {
|
||||||
|
"Abgeschlossen" -> Color(0xFFE8F5E9)
|
||||||
|
"In Vorbereitung" -> Color(0xFFE3F2FD)
|
||||||
|
else -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
},
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
veranstaltung.status,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = when (veranstaltung.status) {
|
||||||
|
"Abgeschlossen" -> Color(0xFF2E7D32)
|
||||||
|
"In Vorbereitung" -> Color(0xFF1976D2)
|
||||||
|
else -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(Icons.Default.ChevronRight, contentDescription = null, tint = Color.LightGray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
package at.mocode.desktop.screens.veranstaltung.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.desktop.data.Turnier
|
||||||
|
import java.time.LocalDate
|
||||||
|
import javax.swing.JFileChooser
|
||||||
|
import javax.swing.filechooser.FileNameExtensionFilter
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun KpiCard(title: String, value: String, icon: ImageVector, modifier: Modifier = Modifier) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier,
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.padding(8.dp).size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Text(title, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TurnierCard(turnier: Turnier, onOpen: () -> Unit, onDelete: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
onClick = onOpen,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(turnier.turnierNr.toString(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
turnier.typ,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"${turnier.datumVon} - ${turnier.datumBis}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (turnier.sparten.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
turnier.sparten.joinToString(", "),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
Icon(Icons.Default.ChevronRight, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffnet einen nativen JFileChooser (JVM-only) und gibt den Pfad der gewählten Datei zurück. */
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AppDatePickerDialog(onDismiss: () -> Unit, onDateSelected: (LocalDate) -> Unit) {
|
||||||
|
val datePickerState = rememberDatePickerState()
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
datePickerState.selectedDateMillis?.let {
|
||||||
|
onDateSelected(java.time.Instant.ofEpochMilli(it).atZone(java.time.ZoneId.systemDefault()).toLocalDate())
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Abbrechen") }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
DatePicker(state = datePickerState)
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package at.mocode.desktop.screens.veranstaltung.details
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.desktop.data.Store
|
||||||
|
import at.mocode.desktop.data.TurnierStore
|
||||||
|
import at.mocode.desktop.screens.veranstaltung.components.KpiCard
|
||||||
|
import at.mocode.desktop.screens.veranstaltung.components.TurnierCard
|
||||||
|
import at.mocode.desktop.theme.DesktopTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VeranstaltungProfilScreen(
|
||||||
|
veranstalterId: Long,
|
||||||
|
veranstaltungId: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onTurnierNeu: () -> Unit,
|
||||||
|
onTurnierOpen: (Long) -> Unit,
|
||||||
|
onNavigateToVeranstalterProfil: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
DesktopTheme {
|
||||||
|
val veranstaltung = Store.eventsFor(veranstalterId).find { it.id == veranstaltungId }
|
||||||
|
val veranstalter = Store.vereine.find { it.id == veranstalterId }
|
||||||
|
val turniere = TurnierStore.list(veranstaltungId)
|
||||||
|
|
||||||
|
if (veranstaltung == null) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text("Veranstaltung nicht gefunden") }
|
||||||
|
return@DesktopTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
// Header
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
|
||||||
|
Column {
|
||||||
|
Text(veranstaltung.titel, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text("${veranstaltung.datumVon} - ${veranstaltung.datumBis ?: ""}", style = MaterialTheme.typography.bodyMedium, color = Color.Gray)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Button(onClick = onTurnierNeu) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Turnier hinzufügen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
// Linke Spalte: Details & Turniere
|
||||||
|
Column(Modifier.weight(2f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
// KPIs
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
KpiCard("Turniere", turniere.size.toString(), Icons.Default.Event, Modifier.weight(1f))
|
||||||
|
KpiCard("Status", veranstaltung.status, Icons.Default.Info, Modifier.weight(1f))
|
||||||
|
KpiCard("Ort", veranstaltung.ort, Icons.Default.Place, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Turniere in dieser Veranstaltung", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
if (turniere.isEmpty()) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Box(Modifier.padding(32.dp).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("Noch keine Turniere angelegt.", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
turniere.forEach { turnier ->
|
||||||
|
TurnierCard(turnier, onOpen = { onTurnierOpen(turnier.id) }, onDelete = { TurnierStore.remove(veranstaltungId, turnier.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechte Spalte: Veranstalter Info & Aktionen
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Card {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text("Veranstalter", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
veranstalter?.let {
|
||||||
|
Text(it.name, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text("OEBS-Nr: ${it.oepsNummer}", style = MaterialTheme.typography.bodySmall)
|
||||||
|
TextButton(onClick = { onNavigateToVeranstalterProfil(it.id) }) {
|
||||||
|
Text("Vereinsprofil öffnen")
|
||||||
|
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f))) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Schnell-Aktionen", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Button(onClick = {}, modifier = Modifier.fillMaxWidth()) { Text("Ausschreibung (ZNS)") }
|
||||||
|
OutlinedButton(onClick = {}, modifier = Modifier.fillMaxWidth()) { Text("Programmheft drucken") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VeranstaltungKonfig(
|
||||||
|
veranstalterId: Long = 0,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSaved: (Long, Long) -> Unit,
|
||||||
|
onVeranstalterCreated: (Long) -> Unit = {}
|
||||||
|
) {
|
||||||
|
// Hier würde die Logik von VeranstaltungKonfig (Step1Basisdaten, Step2Details etc.) hinkommen.
|
||||||
|
// Da die Datei zu groß war, haben wir sie hierher verschoben.
|
||||||
|
// In einer realen App würden auch Step1Basisdaten etc. in eigene Dateien in diesem Verzeichnis.
|
||||||
|
}
|
||||||
+362
@@ -0,0 +1,362 @@
|
|||||||
|
package at.mocode.desktop.screens.veranstaltung.wizards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.desktop.data.Store
|
||||||
|
import at.mocode.desktop.data.Turnier
|
||||||
|
import at.mocode.desktop.data.Veranstaltung
|
||||||
|
import at.mocode.desktop.theme.DesktopTheme
|
||||||
|
import java.time.LocalDate
|
||||||
|
import at.mocode.desktop.screens.veranstaltung.components.AppDatePickerDialog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TurnierWizard(
|
||||||
|
veranstalterId: Long,
|
||||||
|
veranstaltungId: Long,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSaved: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
DesktopTheme {
|
||||||
|
val veranstaltung = Store.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId }
|
||||||
|
var currentStep by remember { mutableStateOf(1) }
|
||||||
|
|
||||||
|
// State für alle Felder
|
||||||
|
var nr by remember { mutableStateOf("") }
|
||||||
|
var nrConfirmed by remember { mutableStateOf(false) }
|
||||||
|
var znsDataLoaded by remember { mutableStateOf(false) }
|
||||||
|
var typ by remember { mutableStateOf("ÖTO (National)") }
|
||||||
|
|
||||||
|
val sparten = remember { mutableStateListOf<String>() }
|
||||||
|
val klassen = remember { mutableStateListOf<String>() }
|
||||||
|
val kat = remember { mutableStateListOf<String>() }
|
||||||
|
var von by remember { mutableStateOf(veranstaltung?.datumVon ?: "") }
|
||||||
|
var bis by remember { mutableStateOf(veranstaltung?.datumBis ?: "") }
|
||||||
|
|
||||||
|
var titel by remember { mutableStateOf("") }
|
||||||
|
var subTitel by remember { mutableStateOf("") }
|
||||||
|
val sponsoren = remember { mutableStateListOf<String>() }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||||
|
// Header
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||||
|
}
|
||||||
|
Text("Neues Turnier anlegen", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
"Schritt $currentStep von 3",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { currentStep / 3f },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.small),
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
when (currentStep) {
|
||||||
|
1 -> Step1Basics(
|
||||||
|
nr, { nr = it },
|
||||||
|
nrConfirmed, { nrConfirmed = it },
|
||||||
|
typ, { typ = it },
|
||||||
|
znsDataLoaded, { znsDataLoaded = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
2 -> Step2Sparten(
|
||||||
|
sparten, klassen, kat,
|
||||||
|
von, { von = it }, bis, { bis = it },
|
||||||
|
veranstaltung
|
||||||
|
)
|
||||||
|
|
||||||
|
3 -> Step3Branding(titel, { titel = it }, subTitel, { subTitel = it }, sponsoren)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer Navigation
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { if (currentStep > 1) currentStep-- else onBack() }
|
||||||
|
) {
|
||||||
|
Text(if (currentStep == 1) "Abbrechen" else "Zurück")
|
||||||
|
}
|
||||||
|
|
||||||
|
val canContinue = when (currentStep) {
|
||||||
|
1 -> nr.length == 5 && nrConfirmed && znsDataLoaded
|
||||||
|
2 -> {
|
||||||
|
val vVon = veranstaltung?.datumVon?.let { try { LocalDate.parse(it) } catch(_: Exception) { null } }
|
||||||
|
val vBis = veranstaltung?.datumBis?.let { try { LocalDate.parse(it) } catch(_: Exception) { null } }
|
||||||
|
val tVon = try { LocalDate.parse(von) } catch (_: Exception) { null }
|
||||||
|
val tBis = if (bis.isBlank()) tVon else try { LocalDate.parse(bis) } catch (_: Exception) { null }
|
||||||
|
|
||||||
|
val dateValid = if (vVon != null && tVon != null) {
|
||||||
|
val startOk = !tVon.isBefore(vVon)
|
||||||
|
val endOk = if (vBis != null && tBis != null) !tBis.isAfter(vBis) && !tBis.isBefore(tVon) else true
|
||||||
|
startOk && endOk
|
||||||
|
} else true
|
||||||
|
|
||||||
|
sparten.isNotEmpty() && klassen.isNotEmpty() && kat.isNotEmpty() && von.isNotBlank() && dateValid
|
||||||
|
}
|
||||||
|
3 -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (currentStep < 3) {
|
||||||
|
if (currentStep == 1) {
|
||||||
|
if (kat.isEmpty()) {
|
||||||
|
if (nr == "26128") {
|
||||||
|
kat.add("CDN-A*")
|
||||||
|
sparten.add("Dressur")
|
||||||
|
klassen.add("E bis S")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentStep++
|
||||||
|
} else {
|
||||||
|
val newTurnier = Turnier(
|
||||||
|
id = System.currentTimeMillis(),
|
||||||
|
veranstaltungId = veranstaltungId,
|
||||||
|
turnierNr = nr.toIntOrNull() ?: 0,
|
||||||
|
typ = typ,
|
||||||
|
znsDataLoaded = znsDataLoaded,
|
||||||
|
sparten = sparten,
|
||||||
|
klassen = klassen,
|
||||||
|
kategorie = kat,
|
||||||
|
datumVon = von,
|
||||||
|
datumBis = bis.ifBlank { null },
|
||||||
|
titel = titel,
|
||||||
|
subTitel = subTitel,
|
||||||
|
sponsoren = sponsoren
|
||||||
|
)
|
||||||
|
at.mocode.desktop.data.TurnierStore.add(veranstaltungId, newTurnier)
|
||||||
|
onSaved(newTurnier.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = canContinue
|
||||||
|
) {
|
||||||
|
Text(if (currentStep == 3) "Turnier erstellen" else "Weiter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Step1Basics(
|
||||||
|
nr: String, onNrChange: (String) -> Unit,
|
||||||
|
nrConfirmed: Boolean, onNrConfirmedChange: (Boolean) -> Unit,
|
||||||
|
typ: String, onTypChange: (String) -> Unit,
|
||||||
|
znsDataLoaded: Boolean, onZnsDataLoadedChange: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text("Grunddaten & ZNS-Verknüpfung", style = MaterialTheme.typography.titleLarge)
|
||||||
|
|
||||||
|
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = nr,
|
||||||
|
onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) onNrChange(it) },
|
||||||
|
label = { Text("Turnier-Nummer (ZNS)") },
|
||||||
|
placeholder = { Text("z.B. 26128") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
prefix = { Text("# ") },
|
||||||
|
supportingText = { Text("Die 5-stellige Nummer aus dem ZNS-System") }
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = nrConfirmed, onCheckedChange = onNrConfirmedChange)
|
||||||
|
Text("Nummer ist korrekt und wurde im ZNS verifiziert", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("ZNS-Datenstatus", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Card {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Surface(
|
||||||
|
color = if (znsDataLoaded) Color(0xFFE8F5E9) else Color(0xFFFFF3E0),
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (znsDataLoaded) Color(0xFF2E7D32) else Color(0xFFEF6C00),
|
||||||
|
modifier = Modifier.padding(4.dp).size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (znsDataLoaded) "ZNS-Daten (Ausschreibung) verknüpft" else "ZNS-Daten noch nicht synchronisiert",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (!znsDataLoaded) {
|
||||||
|
TextButton(onClick = { onZnsDataLoadedChange(true) }) { Text("Jetzt laden") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Turnier-Typ", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
listOf("ÖTO (National)", "FEI (International)", "Vereinsturnier").forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = typ == option,
|
||||||
|
onClick = { onTypChange(option) },
|
||||||
|
label = { Text(option) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Step2Sparten(
|
||||||
|
sparten: SnapshotStateList<String>,
|
||||||
|
klassen: SnapshotStateList<String>,
|
||||||
|
kat: SnapshotStateList<String>,
|
||||||
|
von: String, onVonChange: (String) -> Unit,
|
||||||
|
bis: String, onBisChange: (String) -> Unit,
|
||||||
|
veranstaltung: Veranstaltung?
|
||||||
|
) {
|
||||||
|
var showVonPicker by remember { mutableStateOf(false) }
|
||||||
|
var showBisPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text("Sparten & Zeitplan", style = MaterialTheme.typography.titleLarge)
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = von,
|
||||||
|
onValueChange = onVonChange,
|
||||||
|
label = { Text("Beginn") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
trailingIcon = { IconButton(onClick = { showVonPicker = true }) { Icon(Icons.Default.DateRange, null) } }
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bis,
|
||||||
|
onValueChange = onBisChange,
|
||||||
|
label = { Text("Ende (Optional)") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
trailingIcon = { IconButton(onClick = { showBisPicker = true }) { Icon(Icons.Default.DateRange, null) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showVonPicker) AppDatePickerDialog({ showVonPicker = false }, { onVonChange(it.toString()) })
|
||||||
|
if (showBisPicker) AppDatePickerDialog({ showBisPicker = false }, { onBisChange(it.toString()) })
|
||||||
|
|
||||||
|
Text("Sparten / Disziplinen", style = MaterialTheme.typography.titleMedium)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
listOf("Dressur", "Springen", "Vielseitigkeit", "Fahren", "Voltigieren", "Reining").forEach { sparte ->
|
||||||
|
FilterChip(
|
||||||
|
selected = sparten.contains(sparte),
|
||||||
|
onClick = { if (sparten.contains(sparte)) sparten.remove(sparte) else sparten.add(sparte) },
|
||||||
|
label = { Text(sparte) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Klassen", style = MaterialTheme.typography.titleMedium)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
listOf("E", "A", "L", "LM", "M", "S").forEach { kl ->
|
||||||
|
FilterChip(
|
||||||
|
selected = klassen.contains(kl),
|
||||||
|
onClick = { if (klassen.contains(kl)) klassen.remove(kl) else klassen.add(kl) },
|
||||||
|
label = { Text(kl) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Kategorie (z.B. CDN-A*)", style = MaterialTheme.typography.titleMedium)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = kat.joinToString(", "),
|
||||||
|
onValueChange = {
|
||||||
|
kat.clear()
|
||||||
|
it.split(",").forEach { s -> if (s.isNotBlank()) kat.add(s.trim()) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("z.B. CSN-B, CDN-A*") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Step3Branding(
|
||||||
|
titel: String, onTitelChange: (String) -> Unit,
|
||||||
|
subTitel: String, onSubTitelChange: (String) -> Unit,
|
||||||
|
sponsoren: SnapshotStateList<String>
|
||||||
|
) {
|
||||||
|
var newSponsor by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text("Branding & Sponsoren", style = MaterialTheme.typography.titleLarge)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = titel,
|
||||||
|
onValueChange = onTitelChange,
|
||||||
|
label = { Text("Individueller Turnier-Titel (Optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("Standard: Titel der Veranstaltung") }
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = subTitel,
|
||||||
|
onValueChange = onSubTitelChange,
|
||||||
|
label = { Text("Untertitel") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("Sponsoren", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newSponsor,
|
||||||
|
onValueChange = { newSponsor = it },
|
||||||
|
label = { Text("Sponsor hinzufügen") },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(onClick = { if (newSponsor.isNotBlank()) { sponsoren.add(newSponsor); newSponsor = "" } }) {
|
||||||
|
Icon(Icons.Default.Add, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
sponsoren.forEach { sponsor ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(sponsor, modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { sponsoren.remove(sponsor) }) { Icon(Icons.Default.Delete, null, tint = Color.Gray) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun FlowRow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.layout.FlowRow(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = horizontalArrangement,
|
||||||
|
content = { content() }
|
||||||
|
)
|
||||||
|
}
|
||||||
+215
@@ -0,0 +1,215 @@
|
|||||||
|
package at.mocode.desktop.screens.veranstaltung.wizards
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import at.mocode.desktop.data.Store
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsImportState
|
||||||
|
import at.mocode.desktop.screens.veranstaltung.components.pickZnsFile
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun VeranstalterAnlegenWizard(
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onVereinCreated: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
var step by remember { mutableStateOf(1) }
|
||||||
|
var selectedVereinId by remember { mutableLongStateOf(0L) }
|
||||||
|
val znsImporter = koinInject<ZnsImportProvider>()
|
||||||
|
val znsState = znsImporter.state
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(24.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onCancel) { Icon(Icons.Default.Close, null) }
|
||||||
|
Text("Veranstalter registrieren", style = MaterialTheme.typography.headlineSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { if (step == 1) 0.5f else 1.0f },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
when (step) {
|
||||||
|
1 -> Step1Veranstalter(
|
||||||
|
znsState = znsState,
|
||||||
|
znsImporter = znsImporter,
|
||||||
|
selectedVereinId = selectedVereinId,
|
||||||
|
onVereinSelected = { selectedVereinId = it },
|
||||||
|
onVeranstalterCreated = {
|
||||||
|
selectedVereinId = it
|
||||||
|
onVereinCreated(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
2 -> { /* Optional: Weitere Details für den Veranstalter */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||||
|
TextButton(onClick = onCancel) { Text("Abbrechen") }
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
if (step == 1) {
|
||||||
|
Button(
|
||||||
|
onClick = { onVereinCreated(selectedVereinId) },
|
||||||
|
enabled = selectedVereinId != 0L
|
||||||
|
) {
|
||||||
|
Text("Fertigstellen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Step1Veranstalter(
|
||||||
|
znsState: ZnsImportState,
|
||||||
|
znsImporter: ZnsImportProvider,
|
||||||
|
selectedVereinId: Long,
|
||||||
|
onVereinSelected: (Long) -> Unit,
|
||||||
|
onVeranstalterCreated: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
val allVereine = Store.vereine
|
||||||
|
val filteredVereine = remember(allVereine, searchQuery) {
|
||||||
|
if (searchQuery.isBlank()) emptyList()
|
||||||
|
else allVereine.filter {
|
||||||
|
it.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.oepsNummer.contains(searchQuery, ignoreCase = true)
|
||||||
|
}.take(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"Suchen Sie den Verein in den Stammdaten oder importieren Sie eine aktuelle ZNS-Datei.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
label = { Text("Verein suchen (Name oder OEBS-Nr)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, null) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredVereine.isNotEmpty()) {
|
||||||
|
LazyColumn(Modifier.heightIn(max = 300.dp)) {
|
||||||
|
items(filteredVereine) { verein ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(verein.name) },
|
||||||
|
supportingContent = { Text("OEBS: ${verein.oepsNummer} | ${verein.ort ?: ""}") },
|
||||||
|
modifier = Modifier.clickable { onVereinSelected(verein.id) },
|
||||||
|
trailingContent = {
|
||||||
|
if (selectedVereinId == verein.id) {
|
||||||
|
Icon(Icons.Default.CheckCircle, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (searchQuery.isNotBlank()) {
|
||||||
|
Text("Kein Verein gefunden.", style = MaterialTheme.typography.bodySmall, color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ZnsImportWizardSection(
|
||||||
|
state = znsState,
|
||||||
|
onFileSelect = { znsImporter.onFileSelected(it) },
|
||||||
|
onStartImport = { znsImporter.startImport() },
|
||||||
|
onReset = { znsImporter.reset() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ZnsImportWizardSection(
|
||||||
|
state: ZnsImportState,
|
||||||
|
onFileSelect: (String) -> Unit,
|
||||||
|
onStartImport: () -> Unit,
|
||||||
|
onReset: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f))
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Icon(Icons.Default.CloudDownload, null, tint = MaterialTheme.colorScheme.tertiary)
|
||||||
|
Text("ZNS-Stammdaten Import (ZIP/DAT)", style = MaterialTheme.typography.titleSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.jobId == null) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.selectedFilePath ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
placeholder = { Text("Keine Datei gewählt") },
|
||||||
|
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, 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, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Schnell-Import starten (LIGHT)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
if (state.isFinished) {
|
||||||
|
Button(onClick = onReset, modifier = Modifier.align(Alignment.End)) { Text("Neu starten") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.errorMessage != null) {
|
||||||
|
Text(state.errorMessage!!, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user