Compare commits
16 Commits
8f5df00a98
...
f44b2c8126
| Author | SHA1 | Date | |
|---|---|---|---|
| f44b2c8126 | |||
| 496e801943 | |||
| 1699c24875 | |||
| 683ef956fc | |||
| 6bbf6dc966 | |||
| 94306329c9 | |||
| 659e699c33 | |||
| 96c9abb264 | |||
| ad529f7395 | |||
| b2a0883388 | |||
| 442caa59ff | |||
| 340f341594 | |||
| 5980fbe14f | |||
| 2d532eb41c | |||
| c20899752a | |||
| 5065febca2 |
+3
@@ -40,7 +40,10 @@ data class Veranstaltung(
|
||||
|
||||
// Basic Information
|
||||
var name: String,
|
||||
var untertitel: String? = null,
|
||||
var beschreibung: String? = null,
|
||||
var logoUrl: String? = null,
|
||||
var sponsoren: String? = null, // JSON string or comma-separated for now
|
||||
|
||||
// Dates
|
||||
@Serializable(with = KotlinLocalDateSerializer::class)
|
||||
|
||||
+6
@@ -153,7 +153,10 @@ class VeranstaltungRepositoryImpl : VeranstaltungRepository {
|
||||
return Veranstaltung(
|
||||
veranstaltungId = row[VeranstaltungTable.id].value,
|
||||
name = row[VeranstaltungTable.name],
|
||||
untertitel = row[VeranstaltungTable.untertitel],
|
||||
beschreibung = row[VeranstaltungTable.beschreibung],
|
||||
logoUrl = row[VeranstaltungTable.logoUrl],
|
||||
sponsoren = row[VeranstaltungTable.sponsoren],
|
||||
startDatum = row[VeranstaltungTable.startDatum],
|
||||
endDatum = row[VeranstaltungTable.endDatum],
|
||||
ort = row[VeranstaltungTable.ort],
|
||||
@@ -173,7 +176,10 @@ class VeranstaltungRepositoryImpl : VeranstaltungRepository {
|
||||
*/
|
||||
private fun veranstaltungToStatement(statement: UpdateBuilder<*>, veranstaltung: Veranstaltung) {
|
||||
statement[VeranstaltungTable.name] = veranstaltung.name
|
||||
statement[VeranstaltungTable.untertitel] = veranstaltung.untertitel
|
||||
statement[VeranstaltungTable.beschreibung] = veranstaltung.beschreibung
|
||||
statement[VeranstaltungTable.logoUrl] = veranstaltung.logoUrl
|
||||
statement[VeranstaltungTable.sponsoren] = veranstaltung.sponsoren
|
||||
statement[VeranstaltungTable.startDatum] = veranstaltung.startDatum
|
||||
statement[VeranstaltungTable.endDatum] = veranstaltung.endDatum
|
||||
statement[VeranstaltungTable.ort] = veranstaltung.ort
|
||||
|
||||
+3
@@ -16,7 +16,10 @@ object VeranstaltungTable : UUIDTable("veranstaltungen") {
|
||||
|
||||
// Basic Information
|
||||
val name = varchar("name", 255)
|
||||
val untertitel = varchar("untertitel", 255).nullable()
|
||||
val beschreibung = text("beschreibung").nullable()
|
||||
val logoUrl = varchar("logo_url", 255).nullable()
|
||||
val sponsoren = text("sponsoren").nullable() // JSON array of Sponsor data
|
||||
|
||||
// Dates
|
||||
val startDatum = date("start_datum")
|
||||
|
||||
+1
@@ -67,6 +67,7 @@ data class DomVerein(
|
||||
|
||||
// Status & Verwaltung
|
||||
var istAktiv: Boolean = true,
|
||||
var logoUrl: String? = null,
|
||||
var bemerkungen: String? = null,
|
||||
var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
|
||||
|
||||
|
||||
+4
@@ -34,6 +34,7 @@ class ExposedVereinRepository : VereinRepository {
|
||||
oepsRegionNummer = row[VereinTable.oepsRegionNummer],
|
||||
istVeranstalter = row[VereinTable.istVeranstalter],
|
||||
istAktiv = row[VereinTable.istAktiv],
|
||||
logoUrl = row[VereinTable.logoUrl],
|
||||
bemerkungen = row[VereinTable.bemerkungen],
|
||||
datenQuelle = DatenQuelleE.valueOf(row[VereinTable.datenQuelle]),
|
||||
createdAt = row[VereinTable.createdAt],
|
||||
@@ -106,6 +107,7 @@ class ExposedVereinRepository : VereinRepository {
|
||||
it[oepsRegionNummer] = verein.oepsRegionNummer
|
||||
it[istVeranstalter] = verein.istVeranstalter
|
||||
it[istAktiv] = verein.istAktiv
|
||||
it[logoUrl] = verein.logoUrl
|
||||
it[bemerkungen] = verein.bemerkungen
|
||||
it[datenQuelle] = verein.datenQuelle.name
|
||||
it[updatedAt] = verein.updatedAt
|
||||
@@ -127,6 +129,7 @@ class ExposedVereinRepository : VereinRepository {
|
||||
it[oepsRegionNummer] = verein.oepsRegionNummer
|
||||
it[istVeranstalter] = verein.istVeranstalter
|
||||
it[istAktiv] = verein.istAktiv
|
||||
it[logoUrl] = verein.logoUrl
|
||||
it[bemerkungen] = verein.bemerkungen
|
||||
it[datenQuelle] = verein.datenQuelle.name
|
||||
it[createdAt] = verein.createdAt
|
||||
@@ -169,6 +172,7 @@ class ExposedVereinRepository : VereinRepository {
|
||||
it[oepsRegionNummer] = toUpdate.oepsRegionNummer
|
||||
it[istVeranstalter] = toUpdate.istVeranstalter
|
||||
it[istAktiv] = toUpdate.istAktiv
|
||||
it[logoUrl] = toUpdate.logoUrl
|
||||
it[bemerkungen] = toUpdate.bemerkungen
|
||||
it[datenQuelle] = toUpdate.datenQuelle.name
|
||||
it[updatedAt] = toUpdate.updatedAt
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.masterdata.infrastructure.persistence
|
||||
|
||||
import org.jetbrains.exposed.v1.core.Table
|
||||
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
|
||||
import org.jetbrains.exposed.v1.datetime.timestamp
|
||||
|
||||
/**
|
||||
* Verknüpfungstabelle zwischen Verein und Reiter (Ansprechpersonen/Funktionäre).
|
||||
*/
|
||||
object VereinAnsprechpersonTable : Table("verein_ansprechperson") {
|
||||
val vereinId = uuid("verein_id").references(VereinTable.id)
|
||||
val reiterId = uuid("reiter_id").references(ReiterTable.id)
|
||||
val rolle = varchar("rolle", 100).nullable() // z.B. "Obmann", "Meldestelle", "Sportwart"
|
||||
val istHauptkontakt = bool("ist_hauptkontakt").default(false)
|
||||
|
||||
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
|
||||
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp)
|
||||
|
||||
override val primaryKey = PrimaryKey(vereinId, reiterId)
|
||||
}
|
||||
+1
@@ -24,6 +24,7 @@ object VereinTable : Table("verein") {
|
||||
val oepsRegionNummer = varchar("oeps_region_nummer", 10).nullable()
|
||||
val istVeranstalter = bool("ist_veranstalter").default(false)
|
||||
val istAktiv = bool("ist_aktiv").default(true)
|
||||
val logoUrl = varchar("logo_url", 255).nullable()
|
||||
val bemerkungen = text("bemerkungen").nullable()
|
||||
val datenQuelle = varchar("daten_quelle", 50)
|
||||
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Frontend-Komponenten Roadmap: Meldestelle-Biest
|
||||
|
||||
🏗️ **[Lead Architect]** | 31. März 2026
|
||||
|
||||
Diese Roadmap definiert den Weg von der aktuellen Prototypen-Phase hin zu einer professionellen, konsistenten und
|
||||
performanten Desktop-App. Wir setzen auf einen komponentengetriebenen Ansatz (High-Density UI), um die komplexe
|
||||
Datenverwaltung der Turniermeldestelle effizient abzubilden.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Cleanup & Konsolidierung (Das Fundament) ✅ [ABGESCHLOSSEN]
|
||||
|
||||
Bevor wir neue Features bauen, räumen wir die bestehenden Entwürfe auf, um Redundanzen zu vermeiden.
|
||||
|
||||
* [x] **Design-System Refactoring:**
|
||||
* [x] `Buttons.kt` (DenseButton) in `MsButton.kt` überführt.
|
||||
* [x] Einheitliches Naming: Alle Basis-Komponenten erhalten das Präfix `Ms` (z.B. `MsButton.kt`, `MsTextField.kt`).
|
||||
* [x] Redundante Placeholder-Dateien entfernt oder in `core/design-system/models/` bündeln.
|
||||
* [x] **Theme-Check:**
|
||||
* [x] Sicherstellen, dass alle Farben aus `AppColors` kommen und nicht hart codiert sind.
|
||||
* [x] Typografie-Skalen für High-Density optimieren (LabelSmall für Tabellen).
|
||||
* [x] **Build-Fixes:**
|
||||
* [x] Referenzen in `ping-feature` korrigiert.
|
||||
* [x] Referenzen in `profile-feature` korrigiert.
|
||||
|
||||
## Phase 2: Daten-Visualisierungs-Komponenten (Das Herzstück) ✅ [ABGESCHLOSSEN]
|
||||
|
||||
Turniermanagement bedeutet Arbeit mit Listen. Wir benötigen mächtige, aber kompakte Anzeige-Komponenten.
|
||||
|
||||
* [x] **`MsDataTable`:**
|
||||
* [x] KMP-kompatible Tabelle mit Sticky Header.
|
||||
* [x] Generische Spaltendefinition mit Custom Cell Renderern.
|
||||
* [x] Zeilen-Selektion (Einzel-Klick) und gestreiftes Zeilen-Design.
|
||||
* [x] **`MsStatusBadge`:**
|
||||
* [x] Farbliche Kodierung für Nennungsstatus, Lizenzstatus und Prüfungsstatus.
|
||||
* [x] Kompaktes Design für die Nutzung innerhalb von Tabellenzellen.
|
||||
* [x] **`MsFilterBar`:**
|
||||
* [x] Suchfeld mit Debounce-Unterstützung (Pattern-basiert).
|
||||
* [x] Filter-Chips für schnelle Status-Wechsel.
|
||||
* [x] Anzeige der Trefferanzahl (Result Count).
|
||||
|
||||
## Phase 3: Formular- & Eingabe-System (Die Datenerfassung) ✅ [ABGESCHLOSSEN]
|
||||
|
||||
Eingabe von Stammdaten muss schnell und fehlerfrei erfolgen.
|
||||
|
||||
* [x] **`MsEnumDropdown`:** Automatisches Mapping von Backend-Enums (ÖTO) auf UI-Auswahl.
|
||||
* [x] **`MsValidationWrapper`:** Konsistente Anzeige von Fehlern und Warnungen (z.B. ÖTO-Validierungsregeln).
|
||||
* [x] **`MsSearchableSelect`:** Für die Verknüpfung von Reitern/Pferden (Autocomplete-Suche).
|
||||
|
||||
## Phase 4: Layout-Patterns & Navigation ✅ [ABGESCHLOSSEN]
|
||||
|
||||
Hier bringen wir alles zusammen, bevor das finale Routing implementiert wird.
|
||||
|
||||
* [x] **`MsMasterDetailLayout`:** Standard-Layout für alle Stammdaten-Screens (Liste & Editor).
|
||||
* [x] **`MsActionToolbar`:** Einheitliche Platzierung von Hauptaktionen (Neu, Speichern, Drucken).
|
||||
* [x] **`MsDialogShell`:** Standardisierter Rahmen für Modale und Bestätigungsdialoge.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Routing & Screen-Komposition ✅ [ABGESCHLOSSEN]
|
||||
|
||||
In dieser Phase werden die Komponenten zu echten Features zusammengebaut.
|
||||
|
||||
* [x] **Reiter-Verwaltung (MVP):** Erster Screen mit `MsMasterDetailLayout`, `MsDataTable` und Editor.
|
||||
* [x] **Pferde-Verwaltung (MVP):** Analog zur Reiter-Verwaltung (Fertiggestellt).
|
||||
* [x] **Layout-Refactoring:** Umstellung auf Event-First Workflow (Login-Skip).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Vernetzung & Inter-App Kommunikation 🔵 [IN ARBEIT]
|
||||
|
||||
Nachdem die UI-Bausteine stehen, vernetzen wir die Desktop-Apps im LAN.
|
||||
|
||||
* [x] **Konzept & ADR:** ADR-0020 (LAN-Communication & Isolation) erstellt.
|
||||
* [ ] **Discovery:** mDNS Integration für automatische Gerätefindung.
|
||||
* [ ] **Sync:** WebSocket-basierte Echtzeit-Synchronisation zwischen Meldestelle und Richter.
|
||||
* [ ] **Chat:** Implementierung des veranstaltungsweiten Chat-Fensters.
|
||||
|
||||
---
|
||||
|
||||
## Erfolgs-Metriken
|
||||
|
||||
* **Wiederverwendbarkeit:** > 80% der UI besteht aus `Ms`-Komponenten.
|
||||
* **Density:** Alle relevanten Daten eines Reiters/Pferdes sind ohne Scrollen in der Detailansicht sichtbar.
|
||||
* **Performance:** `MsDataTable` rendert 500+ Zeilen flüssig auf ARM64 (Zora/Mac/Linux).
|
||||
|
||||
---
|
||||
🧹 **[Curator]** | 2026-03-31
|
||||
*Dieses Dokument dient als Single Source of Truth für die Frontend-Entwicklung.*
|
||||
@@ -186,7 +186,18 @@ und über definierte Schnittstellen kommunizieren.
|
||||
|
||||
## 4. Geplante Phasen
|
||||
|
||||
### PHASE 7: Series-Context & Erweiterungen 🔵 PHASE 2+
|
||||
### PHASE 7: Desktop-Vernetzung & Event-First Workflow 🔵 IN ARBEIT
|
||||
|
||||
*Ziel: LAN-Kommunikation zwischen Apps und Fokus auf Veranstaltungs-Verwaltung.*
|
||||
|
||||
* [x] **Konzept:** LAN-Discovery (mDNS) und Echtzeit-Sync (WebSockets) entworfen.
|
||||
* [x] **ADR:** ADR-0020 (Lokale Netzwerk-Kommunikation) erstellt.
|
||||
* [ ] **Discovery:** Implementierung des mDNS-Service für die Geräte-Suche.
|
||||
* [ ] **Transport:** Aufbau der WebSocket-Infrastruktur für P2P-Sync.
|
||||
* [ ] **Event-First:** Umstellung des App-Startpunkts auf die Veranstaltungs-Liste (Login-Skip).
|
||||
* [ ] **Wizard:** Implementierung des `VeranstaltungNeuWizard` zur Neuanlage.
|
||||
|
||||
### PHASE 8: Series-Context & Erweiterungen 🔵 PHASE 2+
|
||||
|
||||
*Ziel: Cups, Serien und Meisterschaften mit konfigurierbaren Reglements.*
|
||||
|
||||
@@ -213,7 +224,8 @@ und über definierte Schnittstellen kommunizieren.
|
||||
| 11 | Masterdata: Importer-Einbettung als Worker | ✅ | ADR-0017 |
|
||||
| 12 | Masterdata: Rule-Versionierung (Regulation-as-Data) | ✅ | ADR-0018 |
|
||||
| 13 | Masterdata: API-Schichten (REST vs. Ingestion) | ✅ | ADR-0019 |
|
||||
| 14 | Masterdata: Observability & Operations | ✅ | masterdata-ops.md, CHANGELOG |
|
||||
| 14 | Lokale Netzwerk-Kommunikation und Daten-Isolierung | ✅ | ADR-0020 |
|
||||
| 15 | Masterdata: Observability & Operations | ✅ | masterdata-ops.md, CHANGELOG |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
type: ADR
|
||||
status: ACCEPTED
|
||||
owner: Lead Architect
|
||||
---
|
||||
|
||||
# ADR-0020: Lokale Netzwerk-Kommunikation und Daten-Isolierung
|
||||
|
||||
## Status
|
||||
|
||||
Akzeptiert (März 2026)
|
||||
|
||||
## Kontext
|
||||
|
||||
Die Desktop-Anwendung ("Meldestelle-Biest") muss in einer lokalen Netzwerkumgebung (LAN) auf Reitturnieren
|
||||
funktionieren, oft ohne zuverlässige Internetverbindung. Dabei müssen verschiedene Instanzen der App (z.B. "
|
||||
Meldestelle-Desk-App" und "Richter-Desk-App") in Echtzeit miteinander kommunizieren.
|
||||
|
||||
Herausforderungen:
|
||||
|
||||
1. Wie finden sich die Geräte automatisch im LAN?
|
||||
2. Wie wird die Sicherheit der Datenübertragung ohne zentrale Cloud-Infrastruktur gewährleistet?
|
||||
3. Wie können Daten zwischen verschiedenen Turnieren innerhalb einer Veranstaltung sauber isoliert werden, während ein
|
||||
veranstaltungsweiter Austausch (z.B. Chat) möglich bleibt?
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Wir implementieren ein Peer-to-Peer (P2P) Kommunikationsmodell basierend auf folgenden Säulen:
|
||||
|
||||
1. **Discovery (Geräte finden):** Einsatz von `mDNS` (Multicast DNS / ZeroConf), damit sich Instanzen im lokalen
|
||||
Netzwerk ohne manuelle IP-Konfiguration finden können.
|
||||
2. **Transport & Echtzeit:** Nutzung von verschlüsselten **WebSockets** für die bidirektionale Datenübertragung in
|
||||
Echtzeit.
|
||||
3. **Sicherheit:** Verwendung eines **Shared Security Keys** (Sicherheitsschlüssel), der pro Veranstaltung vergeben
|
||||
wird. Dieser dient als Basis für die Verschlüsselung und Authentifizierung der Peers.
|
||||
4. **Daten-Isolierung (Namespacing):**
|
||||
- Alle fachlichen Datenpakete (Nennungen, Ergebnisse) müssen zwingend eine `turnierId` im Header führen.
|
||||
- Clients abonnieren spezifische Turnier-Streams. Daten anderer Turniere werden auf Protokollebene verworfen.
|
||||
5. **Veranstaltungsweiter Chat:**
|
||||
- Einführung eines globalen Kanals basierend auf der `veranstaltungId`.
|
||||
- Nachrichten in diesem Kanal haben keine Turnier-Bindung und sind für alle Geräte der Veranstaltung sichtbar.
|
||||
- Implementierung eines lokalen Nachrichtenspeichers (Buffering), um Offline-Phasen zu überbrücken.
|
||||
|
||||
## Konsequenzen
|
||||
|
||||
### Positiv
|
||||
|
||||
- **Offline-First:** Volle Funktionalität im LAN ohne Internetzwang.
|
||||
- **Benutzerfreundlichkeit:** Automatische Geräteerkennung spart Zeit beim Setup vor Ort.
|
||||
- **Datenintegrität:** Strikte Trennung der Turniere verhindert Fehlbuchungen.
|
||||
- **Flexibilität:** Richter und Meldestelle können nahtlos zusammenarbeiten.
|
||||
|
||||
### Negativ/Herausforderungen
|
||||
|
||||
- **Netzwerk-Komplexität:** Handhabung von Firewall-Regeln und MDNS-Einschränkungen in manchen Router-Konfigurationen.
|
||||
- **Synchronisation:** Konfliktauflösung bei gleichzeitigen Änderungen (Eventual Consistency) muss implementiert werden.
|
||||
- **Ressourcen:** Laufende WebSocket-Verbindungen und MDNS-Services benötigen (geringe) zusätzliche Systemressourcen.
|
||||
|
||||
## Betrachtete Alternativen
|
||||
|
||||
- **Zentraler lokaler Server:** Erfordert ein dediziertes "Server-Gerät" und erhöht die Komplexität des Deployments. (
|
||||
Verworfen zugunsten von P2P/Master-Client Hybrid).
|
||||
- **HTTP Polling:** Zu langsam für Echtzeit-Richter-Eingaben und verursacht unnötigen Overhead. (Verworfen zugunsten von
|
||||
WebSockets).
|
||||
- **Manuelle IP-Eingabe:** Zu fehleranfällig für Endanwender unter Zeitdruck. (Verworfen zugunsten von mDNS).
|
||||
|
||||
## Referenzen
|
||||
|
||||
- Vision_03: Desktop-App Architektur
|
||||
- [ADR-0004: Event-driven Communication](0004-event-driven-communication-de.md)
|
||||
- [ADR-0008: Multiplatform Client Applications](0008-multiplatform-client-applications-de.md)
|
||||
@@ -17,5 +17,6 @@ Namensschema: ADR-XXX-title.md mit fortlaufender Nummerierung.
|
||||
- ADR-0014 Bounded Context Mapping & Aggregate Roots
|
||||
- ADR-0015 Context Map & Integration Patterns
|
||||
- ADR-0016 API-Design & Anti-Corruption Layer (ACL)
|
||||
- ADR-0020 Lokale Netzwerk-Kommunikation und Daten-Isolierung
|
||||
|
||||
Siehe Template: ADR-000-template.md.
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
## Nachtrag 31.03.2026 17:35
|
||||
|
||||
- **Validierung & Konsistenz im Turnier-Workflow:**
|
||||
- **Veranstaltung anlegen:** In `VeranstaltungKonfigV2` wurde eine Sperre für Vergangenheits-Daten implementiert. Das
|
||||
Startdatum darf nicht vor dem aktuellen Tag liegen. Entsprechende UI-Fehlermeldungen und eine Button-Deaktivierung
|
||||
wurden hinzugefügt.
|
||||
- **Turnier-Stammdaten (Bearbeiten):** Der Tab "STAMMDATEN" im `TurnierDetailScreen` wurde vollständig überarbeitet
|
||||
und spiegelt nun die Logik des `TurnierWizardV2` (Option 1) wider.
|
||||
- **Validierung:** 5-stellige Turnier-Nr. muss explizit bestätigt werden.
|
||||
- **ZNS-Import:** Statusanzeige (geladen/nicht geladen) und interaktive Import-Buttons (Internet/USB) wurden
|
||||
integriert.
|
||||
- **Regelwerk:** Dynamische Generierung von Kategorien (inkl. Pony-Kategorien) basierend auf Sparten- und
|
||||
Klassenauswahl via Filter-Chips.
|
||||
- **Datum & Ort:** Integration von Material 3 DatePickern. Hinweise auf die erforderliche Übereinstimmung mit dem
|
||||
Veranstaltungszeitraum und -ort wurden hinzugefügt.
|
||||
- **Branding:** Unterstützung für Titel, Sub-Titel und dynamische Sponsoren-Chips direkt im Stammdaten-Tab.
|
||||
- **UI/UX:** Einsatz von `FlowRow`, `InputChip` und `SectionCard` für ein aufgeräumtes, konsistentes Erscheinungsbild
|
||||
über alle Turnier-Screens hinweg.
|
||||
|
||||
## Nachtrag 31.03.2026 17:15
|
||||
|
||||
- **`TurnierWizardV2` - Regelwerks-Kategorien & Pony-Logik:**
|
||||
- **Refactoring Kategorien:** Turniere unterstützen nun mehrere gleichberechtigte Kategorien (z.B. "CDN-C NEU" und "
|
||||
CDNP-C NEU") gleichzeitig. Dies ist entscheidend für die korrekte Anwendung der Regelwerke (z.B. Nationales
|
||||
Dressur-Turnier vs. Nationales Dressur-Turnier Pony).
|
||||
- **Integration Pony-Status:** Der separate Switch für "Pony-Bewerbe" wurde entfernt. Stattdessen werden
|
||||
Pony-Kategorien (Suffix "P") nun direkt als auswählbare Optionen in den Kategorien-Vorschlägen angeboten, sofern
|
||||
eine Sparte und Klasse gewählt wurde.
|
||||
- **Datenmodell `TurnierV2`:** Das Feld `isPony` wurde entfernt, da der Status nun implizit über die gewählten
|
||||
Kategorien definiert ist.
|
||||
- **Automatisierung:** Bei Eingabe der Turnier-Nummern für Neumarkt (26128, 26129) werden nun automatisch sowohl die
|
||||
Standard- als auch die Pony-Kategorie vorselektiert.
|
||||
- **Seed-Daten:** Die Testdaten in `Stores.kt` wurden aktualisiert, um Turniere mit mehreren Kategorien (CDN + CDNP)
|
||||
abzubilden.
|
||||
|
||||
## Nachtrag 31.03.2026 17:10
|
||||
|
||||
- **`TurnierWizardV2` - Klassen & Pony-Bewerbe:**
|
||||
- **Klassen-Auswahl:** Umstellung auf ein modernes Chip-basiertes Layout (Grid). Die Klassen (C-NEU bis S) werden nun
|
||||
als `FilterChip` dargestellt, was die Mehrfachauswahl intuitiver macht.
|
||||
- **Pony-Bewerbe:** Integration einer neuen "Pony-Bewerbe" Option (Switch) in Schritt 2. Diese Option steuert die
|
||||
sportfachliche Kennzeichnung des Turniers.
|
||||
- **Kategorien-Logik (CDNP/CSNP):** Die automatische Generierung der Kategorien-Vorschläge berücksichtigt nun den
|
||||
Pony-Status. Bei aktiviertem Switch wird automatisch das Suffix "P" (z.B. CDNP statt CDN) verwendet.
|
||||
- **UI/UX Refinement:**
|
||||
- Einsatz von `InputChip` für die Kategorien-Auswahl mit Checkmark-Indikator.
|
||||
- Gruppierung der Optionen (Sparten, Pony, Klassen) in einer übersichtlichen Spalten/Zeilen-Struktur mit
|
||||
verbesserten Abständen.
|
||||
- Manuelle Korrekturmöglichkeit der Kategorie im `OutlinedTextField` mit `leadingIcon`.
|
||||
- **Datenmodell & Seed:** Erweiterung von `TurnierV2` um das Feld `isPony` und Aktualisierung der Seed-Daten für "
|
||||
Neumarkt 2026" auf den neuen Pony-Status.
|
||||
|
||||
## Nachtrag 31.03.2026 16:45
|
||||
|
||||
- **`TurnierWizardV2` - ZNS-Import & Regelwerks-Logik:**
|
||||
- **Schritt 1 (Basics):** Überarbeitung der Turnier-Nr. Erfassung mit explizitem Bestätigungs-Button und Validierung (
|
||||
5 Stellen).
|
||||
- **ZNS-Import:** Implementierung von zwei Import-Wegen (Internet / USB). Ein interaktiver Fortschritts-Dialog
|
||||
simuliert die Datenverarbeitung und setzt den `znsDataLoaded`-Status.
|
||||
- **ZNS-Statusanzeige:** Ein markantes Status-Panel (Grün/Rot) zeigt an, ob die Pflicht-Stammdaten geladen wurden.
|
||||
Der "Weiter"-Button ist erst nach erfolgreichem Import aktiv.
|
||||
- **Schritt 2 (Sparten & Klassen):** Erweiterung der Klassen-Auswahl (C-NEU bis S) in einem übersichtlichen
|
||||
Spalten-Layout.
|
||||
- **Intelligente Kategorien-Vorschläge:** Implementierung einer Logik, die basierend auf den gewählten Sparten und
|
||||
Klassen passende Turnier-Kategorien (z.B. CSN-C-NEU, CDN-A) als Filter-Chips vorschlägt.
|
||||
- **Modell-Update:** `TurnierV2` um `znsDataLoaded` erweitert und die Sprach-Auswahl gemäß Benutzerwunsch entfernt.
|
||||
|
||||
## Nachtrag 31.03.2026 16:30
|
||||
|
||||
- **`TurnierWizardV2` - "Meta"-Daten & Stammdaten:**
|
||||
- Der Wizard zur Neuanlage eines Turniers wurde gemäß den Benutzervorgaben (Screenshots `Turnier-Stammdaten_01/02`)
|
||||
umfassend erweitert und in drei Phasen unterteilt.
|
||||
- **Schritt 1: Basiskonfiguration:** Erfassung der 5-stelligen Turnier-Nr., des Typs (ÖTO National / FEI
|
||||
International), Sprache (Deutsch/Englisch) und Integration von Platzhalter-Buttons für den ZNS-Daten-Import (
|
||||
Internet/USB) inkl. Informations-Dialog.
|
||||
- **Schritt 2: Sparten & Klassen:** Auswahl der Disziplinen (Dressur, Springen) und Klassen (C, B, A). Die Kategorien
|
||||
werden basierend auf der Auswahl freigeschaltet und bei bekannten Nummern (z.B. 26128) automatisch vorbelegt.
|
||||
- **Schritt 3: Branding & Sponsoren:** Erfassung von Turnier-Titel, Sub-Titel und einer dynamisch erweiterbaren
|
||||
Sponsorenliste (analog zum Veranstaltungs-Wizard).
|
||||
- **Datenmodell `TurnierV2`:** Erweiterung um alle neuen Felder (`typ`, `sprache`, `sparten`, `klassen`, `titel`,
|
||||
`subTitel`, `sponsoren`) unter Nutzung von `SnapshotStateList` für reaktive UI-Updates.
|
||||
- **UI/UX Polish:** Nutzung von `LinearProgressIndicator`, `RadioButton`-Gruppen, `Checkbox`-Listen und
|
||||
`verticalScroll` für eine flüssige Bedienung auf kleineren Bildschirmen.
|
||||
|
||||
## Nachtrag 31.03.2026 16:15
|
||||
|
||||
- **Event-Cockpit-Optimierung:**
|
||||
- **`VeranstaltungUebersichtV2`:** Umfassendes UI-Update für das Veranstaltungs-Cockpit.
|
||||
- **KPI-Dashboard:** Integration von vier KPI-Karten (Turniere, Nennungen, Reiter, Pferde) für eine schnelle Übersicht
|
||||
des Event-Status.
|
||||
- **Turnier-Liste:** Umstellung auf ein modernes Card-Layout mit `OutlinedCard`, `SuggestionChip` für Kategorien und
|
||||
verbesserten Action-Buttons (Öffnen/Löschen).
|
||||
- **Turnier-Wizard:** Die Validierung der 5-stelligen Turnier-Nummer wurde durch `supportingText` im Textfeld
|
||||
verbessert.
|
||||
|
||||
- **Navigation & Routing:**
|
||||
- **`DesktopMainLayout.kt`:** Die Navigation für `AppScreen.TurnierDetail` und `AppScreen.TurnierNeu` wurde
|
||||
vollständig auf den `v2`-Store und die neuen Screens (`VeranstaltungUebersichtV2`, `TurnierWizardV2`) umgestellt.
|
||||
- **Back-Navigation:** "Zurück"-Buttons in den Turnier-Screens führen nun logisch zurück zur
|
||||
`VeranstaltungUebersichtV2` anstatt zu veralteten Screens.
|
||||
|
||||
- **Demonstrations-Daten:**
|
||||
- Für das Beispiel-Event "Neumarkt 2026" (ID 100) wurden realistische Platzhalter-Werte in die KPIs integriert (z.B.
|
||||
248 Nennungen), um das finale Look-and-Feel zu demonstrieren.
|
||||
|
||||
## Nachtrag 31.03.2026 15:45
|
||||
|
||||
- **Fehlerbehebung Desktop-Shell Build:**
|
||||
- **`VereinViewModel.kt`:** Das ViewModel erbt nun korrekt von `androidx.lifecycle.ViewModel`. Dies behebt einen "
|
||||
Intersection Type" Fehler in `DesktopMainLayout.kt`, der beim Aufruf von `koinViewModel()` auftrat.
|
||||
- **`VereinFeatureModule.kt`:** Die Koin-Konfiguration wurde wieder auf den Standard `viewModelOf(::VereinViewModel)`
|
||||
umgestellt, da das ViewModel nun die korrekte Basisklasse besitzt.
|
||||
- **Verifikation:** Die Desktop-Shell (`:frontend:shells:meldestelle-desktop`) kompiliert nun wieder fehlerfrei.
|
||||
|
||||
## Nachtrag 31.03.2026 15:30
|
||||
|
||||
- **Fehlerbehebung `verein-feature`:**
|
||||
- **`VereinScreens.kt`:** Korrektur des `MsFilterBar`-Aufrufs. Der Parameter `onAddClick` wurde durch einen `actions`
|
||||
Block mit einer `MsButton`-Komponente ersetzt, um dem Design-System zu entsprechen.
|
||||
- **Verifikation:** Erfolgreicher Build des Moduls via `./gradlew :frontend:features:verein-feature:compileKotlinJvm`.
|
||||
|
||||
# Session Log: Event-First Workflow & UX-Polish (Initialer Schliff)
|
||||
|
||||
## Kontext
|
||||
|
||||
Im Rahmen der MVP-Phase wurde der Fokus auf den "Event-First" Workflow gelegt. Ziel ist es, dass die App direkt mit der
|
||||
Turnierverwaltung (Offline-First) startet, ohne den Nutzer durch ein separates Onboarding oder Login zu zwingen, solange
|
||||
er lokal arbeitet. Zudem wurde eine konsistente Vereinsverwaltung gefordert, analog zu Reitern und Pferden.
|
||||
|
||||
## Durchgeführte Änderungen
|
||||
|
||||
### 1. Navigation & App-Start
|
||||
|
||||
- **Direkter Einstieg:** Die App startet nun direkt im Screen `AppScreen.Veranstaltungen`.
|
||||
- **Anpassung DesktopApp.kt:** Das Login-Gate wurde so erweitert, dass alle für den Turnier-Workflow relevanten
|
||||
Screens (Veranstaltungen, Veranstalter, Turniere, Vereine) auch ohne Authentifizierung zugänglich sind.
|
||||
|
||||
### 2. Veranstaltungen-Übersicht (Gesamtliste)
|
||||
|
||||
- **Neuer Screen `VeranstaltungenUebersichtV2`:** Implementierung einer zentralen Übersicht, die alle im lokalen Store
|
||||
vorhandenen Veranstaltungen über alle Veranstalter hinweg anzeigt.
|
||||
- **KPI-Kacheln:** Erweiterung um eine Kachel "VEREINE", die als Schnelleinstieg zur Vereinsverwaltung dient.
|
||||
|
||||
### 3. Vereins-Feature (Neu)
|
||||
|
||||
- **Neues Modul `verein-feature`:** Analog zu `reiter-feature` und `pferde-feature` wurde ein dediziertes Modul für die
|
||||
Vereinsverwaltung erstellt.
|
||||
- **Funktionalität:**
|
||||
- **Domain:** Modell `Verein` mit Feldern für Name, Langname, OePS-Nr, Ort, PLZ und Status.
|
||||
- **Presentation:** `VereinViewModel` (mit Such- und Filterlogik) und `VereinScreen` (Master-Detail-Layout).
|
||||
- **Integration:** Koin-Modul `vereinFeatureModule` registriert und Navigation in `DesktopMainLayout.kt` integriert (
|
||||
inkl. Breadcrumbs).
|
||||
|
||||
### 4. Integriertes Onboarding (Wizard)
|
||||
|
||||
- **Wizard-Erweiterung:** Das Geräte-Onboarding (Name & Sicherheitsschlüssel) wurde direkt in den
|
||||
`VeranstaltungKonfigV2`-Wizard integriert. Nutzer müssen die Hardware-Informationen erst angeben, wenn sie die erste
|
||||
Veranstaltung anlegen wollen.
|
||||
|
||||
### 4. Testdaten (Seed)
|
||||
|
||||
- **StoreV2.seed():** Es wurden realistische Testdaten für "Neumarkt 2026" und "Linz 2026" inklusive zugehöriger
|
||||
Turniere angelegt, um den Workflow sofort testbar zu machen.
|
||||
- **Stammdaten:** Hinzufügen von `oepsStammdaten` (Mock-Vereine) im `StoreV2` für die Suche im Anlage-Prozess.
|
||||
|
||||
## Betroffene Dateien
|
||||
|
||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/DesktopApp.kt`
|
||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/screens/layout/DesktopMainLayout.kt`
|
||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt` (Neu:
|
||||
`VeranstaltungenUebersichtV2`, `VeranstalterAnlegenWizard`)
|
||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Stores.kt` (Neu: `allEvents()`, `seed()`,
|
||||
`oepsStammdaten`)
|
||||
- `frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/main.kt` (Aufruf `seed()`)
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
- [ ] Verifikation der Detail-Ansicht für Turniere.
|
||||
- [ ] Implementierung der mDNS Discovery für die lokale Vernetzung.
|
||||
- [ ] ADR für das PDF-Rendering entwerfen.
|
||||
|
||||
## Nachtrag 31.03.2026 14:55
|
||||
|
||||
- **Datumswahl-Optimierung:** In `VeranstaltungKonfigV2` wurden die Textfelder für das Start- und Enddatum durch
|
||||
Material 3 `DatePickerDialoge` ersetzt.
|
||||
- **Interaktion:** Die Felder sind nun schreibgeschützt und öffnen bei Klick (oder Klick auf das Kalender-Icon) einen
|
||||
grafischen Kalender.
|
||||
- **Validierung:** Eine Logik wurde implementiert, die sicherstellt, dass das Enddatum nicht vor dem Startdatum liegen
|
||||
kann. Falls dies der Fall ist, wird das Feld rot markiert und eine Fehlermeldung angezeigt.
|
||||
- **Button-Status:** Der "Weiter"-Button in Schritt 2 ist nur aktiv, wenn Titel und Startdatum gesetzt sind und der
|
||||
Datumsbereich gültig ist.
|
||||
- **Technik:** Nutzung von `java.time.LocalDate` und `DateTimeFormatter.ISO_LOCAL_DATE` für konsistente
|
||||
Datumsverarbeitung auf der JVM.
|
||||
|
||||
## Nachtrag 31.03.2026 14:45
|
||||
|
||||
- **Neuer Wizard "Veranstalter anlegen":** Ein 2-stufiger Prozess zur Erfassung neuer Vereine.
|
||||
- **Schritt 1: Stammdaten-Suche:** Suche in `oepsStammdaten` nach Name, Ort oder OEPS-Nummer.
|
||||
- **Schritt 2: Datenbestätigung:** Übernahme der Daten aus den Stammdaten oder manuelle Erfassung/Korrektur.
|
||||
- **Flow-Optimierung:** Nach dem Anlegen eines neuen Veranstalters im `VeranstaltungKonfigV2`-Wizard springt die App nun
|
||||
automatisch zu "Schritt 2: Basisdaten der Veranstaltung".
|
||||
- **UI-Cleanup:** Import von `Icons.Default.Close` für den Abbrechen-Button im neuen Wizard.
|
||||
|
||||
## Nachtrag 31.03.2026 14:15
|
||||
|
||||
- **Neuer Wizard "Veranstaltung anlegen":** Der Prozess wurde in einen 3-stufigen Wizard umgewandelt.
|
||||
- **Schritt 1: Veranstalterwahl:** Suche in bestehenden Vereinen oder Neuanlage eines Vereins direkt im Wizard.
|
||||
- **Schritt 2: Basisdaten:** Titel, Untertitel, Datum von/bis und Austragungsort.
|
||||
- **Schritt 3: Zusatzdaten & Branding:** Logo-URL/Pfad und Sponsoren-Liste (mit Live-Vorschau der Chips).
|
||||
- **Modell-Erweiterung:** `VeranstaltungV2` wurde um `ort`, `untertitel`, `logoUrl` und eine reaktive Liste von
|
||||
`sponsoren` erweitert.
|
||||
- **Navigation:** Die `VeranstaltungKonfig` in `AppScreen` erlaubt nun eine optionale `veranstalterId`. Falls keine
|
||||
übergeben wird (Aufruf aus Cockpit), startet der Wizard bei Schritt 1 (Veranstalterwahl).
|
||||
- **UI-Polish:** Einsatz von `LinearProgressIndicator` für den Fortschritt und `Surface`-Karten für die Vereinsauswahl.
|
||||
|
||||
## Nachtrag 31.03.2026 13:55
|
||||
|
||||
- **Suche & Filter:** In der `VeranstaltungenUebersichtV2` wurde eine Suchfunktion (Titel/Verein) und ein
|
||||
Status-Filter (via Filter-Chips) implementiert.
|
||||
- **Datenmodell:** `VeranstaltungV2` wurde um ein Feld `beschreibung` erweitert.
|
||||
- **UI-Anpassung:** Die Beschreibung wird nun in der Liste unter dem Titel/Verein angezeigt, um mehr Kontext zu bieten.
|
||||
Status-Badges wurden für bessere Lesbarkeit auf `Surface` mit `primaryContainer` umgestellt.
|
||||
|
||||
## Nachtrag 31.03.2026 13:45
|
||||
|
||||
- **TopBar-Anpassung:** Der Root-Link "🏠 Admin - Verwaltung" wurde in "Veranstaltungen" umbenannt.
|
||||
- **UI-Cleanup:** Der Logout-Button wurde aus der TopBar entfernt, da die App primär im Offline-First/Lokal-Modus
|
||||
betrieben wird.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
-44
@@ -1,44 +0,0 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
/**
|
||||
* Ein kompakter Button für unsere High-Density UI.
|
||||
*
|
||||
* Warum ein eigener Button?
|
||||
* Der Standard Material3 Button ist sehr hoch (40dp+) und hat viel Padding.
|
||||
* Das verschwendet Platz in Tabellen oder Toolbars.
|
||||
* Unser 'DenseButton' ist fix 32dp hoch- und hat weniger Innenabstand.
|
||||
*/
|
||||
@Composable
|
||||
fun DenseButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
containerColor: Color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.height(32.dp), // Fixe, kompakte Höhe
|
||||
shape = MaterialTheme.shapes.small, // Nutzt unsere 4dp Rundung
|
||||
colors = ButtonDefaults.buttonColors(containerColor = containerColor),
|
||||
contentPadding = PaddingValues(horizontal = Dimens.SpacingM, vertical = 0.dp) // Wenig Padding
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium // Kleinere Schrift
|
||||
)
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
/**
|
||||
* Eine einheitliche Aktionsleiste für Editoren und Detail-Ansichten.
|
||||
*
|
||||
* @param onSave Callback für "Speichern".
|
||||
* @param onCancel Callback für "Abbrechen".
|
||||
* @param onDelete Callback für "Löschen" (Optional).
|
||||
* @param onAdd Callback für "Neu" (Optional).
|
||||
* @param extraActions Zusätzliche Aktionen (Optional, z.B. Drucken).
|
||||
* @param isSaving Zeigt den Ladezustand beim Speichern an.
|
||||
* @param canSave Steuert die Aktivierung des Speichern-Buttons.
|
||||
*/
|
||||
@Composable
|
||||
fun MsActionToolbar(
|
||||
onSave: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
onAdd: (() -> Unit)? = null,
|
||||
extraActions: @Composable (RowScope.() -> Unit)? = null,
|
||||
isSaving: Boolean = false,
|
||||
canSave: Boolean = true,
|
||||
title: String? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// --- 1. Titel-Bereich (Optional) ---
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.width(1.dp))
|
||||
}
|
||||
|
||||
// --- 2. Button-Bereich ---
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)
|
||||
) {
|
||||
// Extra Aktionen (z.B. Drucken)
|
||||
extraActions?.invoke(this)
|
||||
|
||||
if (extraActions != null && (onAdd != null || onDelete != null)) {
|
||||
VerticalDivider(modifier = Modifier.height(24.dp).padding(horizontal = 4.dp))
|
||||
}
|
||||
|
||||
// Neu / Löschen
|
||||
if (onAdd != null) {
|
||||
MsButton(
|
||||
text = "Neu",
|
||||
onClick = onAdd,
|
||||
variant = ButtonVariant.OUTLINE,
|
||||
size = ButtonSize.SMALL
|
||||
)
|
||||
}
|
||||
|
||||
if (onDelete != null) {
|
||||
MsButton(
|
||||
text = "Löschen",
|
||||
onClick = onDelete,
|
||||
variant = ButtonVariant.TEXT,
|
||||
size = ButtonSize.SMALL
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider(modifier = Modifier.height(24.dp).padding(horizontal = 4.dp))
|
||||
|
||||
// Hauptaktionen: Abbrechen & Speichern
|
||||
MsButton(
|
||||
text = "Abbrechen",
|
||||
onClick = onCancel,
|
||||
variant = ButtonVariant.TEXT,
|
||||
size = ButtonSize.SMALL
|
||||
)
|
||||
|
||||
MsButton(
|
||||
text = "Speichern",
|
||||
onClick = onSave,
|
||||
enabled = canSave,
|
||||
isLoading = isSaving,
|
||||
size = ButtonSize.SMALL
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
enum class ButtonVariant {
|
||||
@@ -17,7 +18,7 @@ enum class ButtonSize {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MeldestelleButton(
|
||||
fun MsButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -25,7 +26,8 @@ fun MeldestelleButton(
|
||||
size: ButtonSize = ButtonSize.MEDIUM,
|
||||
enabled: Boolean = true,
|
||||
isLoading: Boolean = false,
|
||||
fullWidth: Boolean = false
|
||||
fullWidth: Boolean = false,
|
||||
containerColor: Color? = null
|
||||
) {
|
||||
val buttonModifier = modifier.then(
|
||||
if (fullWidth) Modifier.fillMaxWidth() else Modifier
|
||||
@@ -41,7 +43,8 @@ fun MeldestelleButton(
|
||||
ButtonVariant.PRIMARY -> Button(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
enabled = enabled && !isLoading
|
||||
enabled = enabled && !isLoading,
|
||||
colors = if (containerColor != null) ButtonDefaults.buttonColors(containerColor = containerColor) else ButtonDefaults.buttonColors()
|
||||
) {
|
||||
ButtonContent(text = text, isLoading = isLoading)
|
||||
}
|
||||
@@ -49,7 +52,8 @@ fun MeldestelleButton(
|
||||
ButtonVariant.SECONDARY -> FilledTonalButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
enabled = enabled && !isLoading
|
||||
enabled = enabled && !isLoading,
|
||||
colors = if (containerColor != null) ButtonDefaults.filledTonalButtonColors(containerColor = containerColor) else ButtonDefaults.filledTonalButtonColors()
|
||||
) {
|
||||
ButtonContent(text = text, isLoading = isLoading)
|
||||
}
|
||||
@@ -96,7 +100,7 @@ fun PrimaryButton(
|
||||
enabled: Boolean = true,
|
||||
isLoading: Boolean = false,
|
||||
fullWidth: Boolean = false
|
||||
) = MeldestelleButton(
|
||||
) = MsButton(
|
||||
text = text,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
@@ -115,7 +119,7 @@ fun SecondaryButton(
|
||||
enabled: Boolean = true,
|
||||
isLoading: Boolean = false,
|
||||
fullWidth: Boolean = false
|
||||
) = MeldestelleButton(
|
||||
) = MsButton(
|
||||
text = text,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
+16
-1
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -20,7 +21,7 @@ import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
* Im Enterprise-Kontext sind flache Cards mit dünnem Border (1px) oft sauberer.
|
||||
*/
|
||||
@Composable
|
||||
fun DashboardCard(
|
||||
fun MsCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
@@ -40,3 +41,17 @@ fun DashboardCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preview für IDE (muss in jvmMain liegen um in IDEA gerendert zu werden,
|
||||
// oder hier bleiben als Dokumentation)
|
||||
@Composable
|
||||
fun MsCardPreviewContent() {
|
||||
MaterialTheme {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
MsCard {
|
||||
Text("Dies ist eine MsCard", style = MaterialTheme.typography.bodyMedium)
|
||||
Text("Mit High-Density Content.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
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.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
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.dp
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
/**
|
||||
* Definition einer Spalte für die [MsDataTable].
|
||||
*
|
||||
* @param title Die Beschriftung im Header.
|
||||
* @param width Die Breite der Spalte.
|
||||
* @param weight Wenn gesetzt, dehnt sich die Spalte flexibel aus.
|
||||
* @param alignment Ausrichtung des Inhalts (Start, Center, End).
|
||||
* @param cellRenderer Eigener Renderer für den Inhalt der Zelle.
|
||||
*/
|
||||
data class MsColumnDefinition<T>(
|
||||
val title: String,
|
||||
val width: Dp? = null,
|
||||
val weight: Float? = null,
|
||||
val alignment: Alignment = Alignment.CenterStart,
|
||||
val cellRenderer: @Composable (T) -> Unit = { item ->
|
||||
Text(
|
||||
text = item.toString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Eine performante, hochdichte Datentabelle für Desktop-Anwendungen.
|
||||
*
|
||||
* Warum?
|
||||
* Standard-Material-Tabellen sind oft zu großzügig mit Padding.
|
||||
* In der Meldestelle müssen wir viele Daten auf einen Blick sehen.
|
||||
*
|
||||
* @param items Die anzuzeigenden Datenobjekte.
|
||||
* @param columns Die Definitionen der Spalten.
|
||||
* @param onRowClick Callback, wenn eine Zeile angeklickt wird.
|
||||
* @param modifier Der Modifier für die gesamte Tabelle.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> MsDataTable(
|
||||
items: List<T>,
|
||||
columns: List<MsColumnDefinition<T>>,
|
||||
onRowClick: ((T) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
headerBackgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
rowBackgroundColor: Color = MaterialTheme.colorScheme.surface,
|
||||
alternateRowBackgroundColor: Color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
// --- 1. Header (Sticky) ---
|
||||
Surface(
|
||||
color = headerBackgroundColor,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Dimens.SpacingS, vertical = Dimens.SpacingXS),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
columns.forEach { col ->
|
||||
val colModifier = when {
|
||||
col.weight != null -> Modifier.weight(col.weight)
|
||||
col.width != null -> Modifier.width(col.width)
|
||||
else -> Modifier.wrapContentWidth()
|
||||
}
|
||||
Box(
|
||||
modifier = colModifier,
|
||||
contentAlignment = col.alignment
|
||||
) {
|
||||
Text(
|
||||
text = col.title.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Body (LazyColumn) ---
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(items) { index, item ->
|
||||
val bgColor = if (index % 2 == 0) rowBackgroundColor else alternateRowBackgroundColor
|
||||
|
||||
Surface(
|
||||
color = bgColor,
|
||||
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 ->
|
||||
val colModifier = when {
|
||||
col.weight != null -> Modifier.weight(col.weight)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
/**
|
||||
* Ein einheitlicher Rahmen für modale Dialoge.
|
||||
*
|
||||
* @param title Die Überschrift des Dialogs.
|
||||
* @param onDismissRequest Callback, wenn der Dialog geschlossen werden soll (z.B. Klick außerhalb).
|
||||
* @param confirmButton Die primäre Aktion (z.B. OK, Speichern).
|
||||
* @param dismissButton Die sekundäre Aktion (z.B. Abbrechen).
|
||||
* @param modifier Modifier für das Surface des Dialogs.
|
||||
* @param content Der eigentliche Inhalt des Dialogs.
|
||||
*/
|
||||
@Composable
|
||||
fun MsDialogShell(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
confirmButton: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dismissButton: @Composable (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.wrapContentHeight(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 6.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(Dimens.SpacingM)
|
||||
) {
|
||||
// --- 1. Titel-Bereich ---
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = Dimens.SpacingM)
|
||||
)
|
||||
|
||||
// --- 2. Content-Bereich ---
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(weight = 1f, fill = false)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Dimens.SpacingM))
|
||||
|
||||
// --- 3. Button-Leiste ---
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (dismissButton != null) {
|
||||
dismissButton()
|
||||
Spacer(modifier = Modifier.width(Dimens.SpacingS))
|
||||
}
|
||||
confirmButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfs-Funktion für einen Standard-Bestätigungsdialog.
|
||||
*/
|
||||
@Composable
|
||||
fun MsConfirmDialog(
|
||||
title: String,
|
||||
message: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
confirmText: String = "Bestätigen",
|
||||
dismissText: String = "Abbrechen",
|
||||
isDestructive: Boolean = false
|
||||
) {
|
||||
MsDialogShell(
|
||||
title = title,
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
MsButton(
|
||||
text = confirmText,
|
||||
onClick = onConfirm,
|
||||
variant = if (isDestructive) ButtonVariant.PRIMARY else ButtonVariant.PRIMARY,
|
||||
// Bei destruktiven Aktionen könnten wir hier später eine rote Farbe erzwingen
|
||||
containerColor = if (isDestructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
size = ButtonSize.SMALL
|
||||
)
|
||||
},
|
||||
dismissButton = {
|
||||
MsButton(
|
||||
text = dismissText,
|
||||
onClick = onDismiss,
|
||||
variant = ButtonVariant.TEXT,
|
||||
size = ButtonSize.SMALL
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Ein generisches Dropdown zur Auswahl von Enum-Werten.
|
||||
*
|
||||
* @param label Das Label über dem Dropdown.
|
||||
* @param options Alle verfügbaren Enum-Optionen (z.B. SparteE.values()).
|
||||
* @param selectedOption Der aktuell gewählte Wert.
|
||||
* @param onOptionSelected Callback bei Auswahl einer Option.
|
||||
* @param optionLabel Transformation des Enums in einen lesbaren Text (Standard: toString()).
|
||||
* @param modifier Modifier für die gesamte Komponente.
|
||||
* @param enabled Ob das Dropdown bearbeitbar ist.
|
||||
* @param isError Ob ein Fehler vorliegt.
|
||||
* @param errorMessage Die anzuzeigende Fehlermeldung.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T : Enum<T>> MsEnumDropdown(
|
||||
label: String,
|
||||
options: Array<T>,
|
||||
selectedOption: T?,
|
||||
onOptionSelected: (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
optionLabel: (T) -> String = { it.name },
|
||||
enabled: Boolean = true,
|
||||
isError: Boolean = false,
|
||||
errorMessage: String? = null
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { if (enabled) expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedOption?.let { optionLabel(it) } ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label, style = MaterialTheme.typography.bodySmall) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
|
||||
modifier = Modifier
|
||||
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable, enabled)
|
||||
.fillMaxWidth(),
|
||||
isError = isError,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = optionLabel(option),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
onOptionSelected(option)
|
||||
expanded = false
|
||||
},
|
||||
contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isError && errorMessage != null) {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
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.vector.ImageVector
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
/**
|
||||
* Eine einheitliche Filterzeile für alle Stammdaten-Listen.
|
||||
*
|
||||
* @param searchQuery Der aktuelle Suchbegriff.
|
||||
* @param onSearchQueryChange Callback bei Änderung der Suche.
|
||||
* @param searchPlaceholder Platzhalter im Suchfeld.
|
||||
* @param filters Sektion für Filter-Chips (Optional).
|
||||
* @param actions Sektion für zusätzliche Aktionen am Ende (Optional).
|
||||
* @param resultCount Anzahl der gefundenen Einträge (Optional).
|
||||
*/
|
||||
@Composable
|
||||
fun MsFilterBar(
|
||||
searchQuery: String,
|
||||
onSearchQueryChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
searchPlaceholder: String = "Suchen...",
|
||||
filters: @Composable (RowScope.() -> Unit)? = null,
|
||||
actions: @Composable (RowScope.() -> Unit)? = null,
|
||||
resultCount: Int? = null
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = Dimens.SpacingS, vertical = Dimens.SpacingXS),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// --- 1. Suchfeld (Kompakt) ---
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = onSearchQueryChange,
|
||||
modifier = Modifier
|
||||
.width(300.dp)
|
||||
.height(40.dp), // Fixe Höhe für High-Density
|
||||
placeholder = { Text(searchPlaceholder, style = MaterialTheme.typography.bodySmall) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
trailingIcon = if (searchQuery.isNotEmpty()) {
|
||||
{
|
||||
IconButton(onClick = { onSearchQueryChange("") }) {
|
||||
Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
} else null,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodySmall,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(Dimens.SpacingM))
|
||||
|
||||
// --- 2. Filter-Chips ---
|
||||
if (filters != null) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
filters()
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
|
||||
// --- 3. Result Count ---
|
||||
if (resultCount != null) {
|
||||
Text(
|
||||
text = "$resultCount Einträge",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.padding(horizontal = Dimens.SpacingM)
|
||||
)
|
||||
}
|
||||
|
||||
// --- 4. Aktionen ---
|
||||
if (actions != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
|
||||
) {
|
||||
actions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein kompakter Filter-Chip für die [MsFilterBar].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MsFilterChip(
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
leadingIcon: ImageVector? = null
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
label = { Text(label, style = MaterialTheme.typography.labelSmall) },
|
||||
modifier = modifier.height(28.dp), // Kompakte Höhe
|
||||
leadingIcon = leadingIcon?.let {
|
||||
{ Icon(it, contentDescription = null, modifier = Modifier.size(14.dp)) }
|
||||
},
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun AppFooter() {
|
||||
fun MsFooter() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
+1
-1
@@ -12,7 +12,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun AppHeader(
|
||||
fun MsHeader(
|
||||
isAuthenticated: Boolean,
|
||||
username: String?,
|
||||
onNavigateToLogin: (() -> Unit)? = null,
|
||||
+7
-4
@@ -1,7 +1,10 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -13,7 +16,7 @@ enum class LoadingSize {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingIndicator(
|
||||
fun MsLoadingIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
size: LoadingSize = LoadingSize.MEDIUM,
|
||||
message: String? = null
|
||||
@@ -61,7 +64,7 @@ fun FullScreenLoading(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator(
|
||||
MsLoadingIndicator(
|
||||
size = LoadingSize.LARGE,
|
||||
message = message
|
||||
)
|
||||
@@ -79,7 +82,7 @@ fun InlineLoading(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
LoadingIndicator(
|
||||
MsLoadingIndicator(
|
||||
size = LoadingSize.SMALL,
|
||||
message = message
|
||||
)
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
/**
|
||||
* Ein standardisiertes Master-Detail Layout für die Stammdaten-Verwaltung.
|
||||
*
|
||||
* @param master Der Bereich auf der linken Seite (z.B. MsDataTable mit MsFilterBar).
|
||||
* @param detail Der Bereich auf der rechten Seite (z.B. ein Editor-Formular).
|
||||
* @param modifier Der Modifier für das gesamte Layout.
|
||||
* @param masterWeight Der Anteil des Master-Bereichs (Standard: 0.4).
|
||||
* @param detailHeader Optionaler Header für den Detail-Bereich (z.B. MsActionToolbar).
|
||||
*/
|
||||
@Composable
|
||||
fun MsMasterDetailLayout(
|
||||
master: @Composable BoxScope.() -> Unit,
|
||||
detail: @Composable BoxScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
masterWeight: Float = 0.4f,
|
||||
detailHeader: @Composable (RowScope.() -> Unit)? = null
|
||||
) {
|
||||
Row(modifier = modifier.fillMaxSize()) {
|
||||
// --- 1. Master-Bereich (z.B. Liste) ---
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(masterWeight)
|
||||
.fillMaxHeight()
|
||||
.padding(Dimens.SpacingS)
|
||||
) {
|
||||
master()
|
||||
}
|
||||
|
||||
// --- 2. Trennlinie (Vertikal) ---
|
||||
VerticalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
// --- 3. Detail-Bereich (z.B. Editor) ---
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f - masterWeight)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
// Optionaler Header für Aktionen
|
||||
if (detailHeader != null) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 1.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Dimens.SpacingM, vertical = Dimens.SpacingS),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
detailHeader()
|
||||
}
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(Dimens.SpacingM)
|
||||
) {
|
||||
detail()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -10,13 +10,13 @@ import androidx.compose.ui.Modifier
|
||||
@Suppress("unused")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppScaffold(
|
||||
fun MsScaffold(
|
||||
header: @Composable () -> Unit = {
|
||||
AppHeader(isAuthenticated = false, username = null)
|
||||
MsHeader(isAuthenticated = false, username = null)
|
||||
},
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
footer: @Composable () -> Unit = {
|
||||
AppFooter()
|
||||
MsFooter()
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
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.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
/**
|
||||
* Eine Komponente zur Suche und Auswahl von Objekten aus einer Liste.
|
||||
* Ideal für die Auswahl von Reitern, Pferden oder Vereinen.
|
||||
*
|
||||
* @param label Das Label über dem Auswahlfeld.
|
||||
* @param selectedOption Das aktuell gewählte Objekt.
|
||||
* @param onOptionSelected Callback bei Auswahl eines Objekts.
|
||||
* @param onSearchQueryChange Callback bei Änderung des Suchbegriffs (für API-Calls).
|
||||
* @param options Die aktuell verfügbaren Optionen (Suchergebnisse).
|
||||
* @param optionLabel Transformation des Objekts in einen lesbaren Text.
|
||||
* @param modifier Modifier für die gesamte Komponente.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> MsSearchableSelect(
|
||||
label: String,
|
||||
selectedOption: T?,
|
||||
onOptionSelected: (T) -> Unit,
|
||||
onSearchQueryChange: (String) -> Unit,
|
||||
options: List<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
optionLabel: (T) -> String = { it.toString() },
|
||||
enabled: Boolean = true,
|
||||
placeholder: String = "Suchen & Auswählen..."
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
// --- 1. Das Anzeige-Feld (sieht aus wie ein TextField, öffnet aber den Dialog) ---
|
||||
OutlinedTextField(
|
||||
value = selectedOption?.let { optionLabel(it) } ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(label, style = MaterialTheme.typography.bodySmall) },
|
||||
placeholder = { Text(placeholder, style = MaterialTheme.typography.bodySmall) },
|
||||
trailingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled) { showDialog = true },
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
|
||||
// --- 2. Der Such-Dialog (Desktop-zentriert) ---
|
||||
if (showDialog) {
|
||||
Dialog(onDismissRequest = { showDialog = false }) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 8.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.8f)
|
||||
.fillMaxHeight(0.7f)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
// Internes Suchfeld im Dialog
|
||||
OutlinedTextField(
|
||||
value = searchText,
|
||||
onValueChange = {
|
||||
searchText = it
|
||||
onSearchQueryChange(it)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Suchbegriff eingeben...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.small
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Ergebnisliste
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(options) { option ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = optionLabel(option),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
onOptionSelected(option)
|
||||
showDialog = false
|
||||
searchText = ""
|
||||
}
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = { showDialog = false }) {
|
||||
Text("Abbrechen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Ein kompakter Badge zur Anzeige von Status-Informationen.
|
||||
*
|
||||
* @param text Der anzuzeigende Text.
|
||||
* @param containerColor Die Hintergrundfarbe des Badges.
|
||||
* @param contentColor Die Textfarbe des Badges.
|
||||
*/
|
||||
@Composable
|
||||
fun MsStatusBadge(
|
||||
text: String,
|
||||
containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(color = containerColor, shape = RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = contentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vordefinierte Status-Typen für eine konsistente UX.
|
||||
*/
|
||||
object MsStatusDefaults {
|
||||
@Composable
|
||||
fun Success(text: String, modifier: Modifier = Modifier) = MsStatusBadge(
|
||||
text = text,
|
||||
containerColor = Color(0xFFE8F5E9),
|
||||
contentColor = Color(0xFF2E7D32),
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Warning(text: String, modifier: Modifier = Modifier) = MsStatusBadge(
|
||||
text = text,
|
||||
containerColor = Color(0xFFFFF3E0),
|
||||
contentColor = Color(0xFFEF6C00),
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Error(text: String, modifier: Modifier = Modifier) = MsStatusBadge(
|
||||
text = text,
|
||||
containerColor = Color(0xFFFFEBEE),
|
||||
contentColor = Color(0xFFC62828),
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun Info(text: String, modifier: Modifier = Modifier) = MsStatusBadge(
|
||||
text = text,
|
||||
containerColor = Color(0xFFE3F2FD),
|
||||
contentColor = Color(0xFF1565C0),
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -16,7 +16,7 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MeldestelleTextField(
|
||||
fun MsTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -109,7 +109,7 @@ fun MeldestellePasswordField(
|
||||
) {
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
MeldestelleTextField(
|
||||
MsTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
@@ -152,7 +152,7 @@ fun MeldestelleEmailField(
|
||||
imeAction: ImeAction = ImeAction.Next,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default
|
||||
) {
|
||||
MeldestelleTextField(
|
||||
MsTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package at.mocode.frontend.core.designsystem.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.WarningAmber
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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
|
||||
|
||||
/**
|
||||
* Die Schwere einer Validierungsmeldung.
|
||||
*/
|
||||
enum class ValidationSeverity {
|
||||
ERROR, // Blokierend (z.B. fehlende Pflichtfelder)
|
||||
WARNING, // Hinweisend (z.B. § 39 ÖTO - Abteilungstrennung steht bevor)
|
||||
INFO // Informativ (z.B. Reiter hat heute Geburtstag)
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine einzelne Validierungsmeldung.
|
||||
*/
|
||||
data class ValidationMessage(
|
||||
val message: String,
|
||||
val severity: ValidationSeverity = ValidationSeverity.ERROR
|
||||
)
|
||||
|
||||
/**
|
||||
* Ein Wrapper für Eingabekomponenten, um Validierungsergebnisse (ÖTO-Regeln) anzuzeigen.
|
||||
*
|
||||
* @param messages Liste der anzuzeigenden Meldungen.
|
||||
* @param modifier Der Modifier für den äußeren Container.
|
||||
* @param content Die Eingabekomponente (z.B. MsTextField, MsEnumDropdown).
|
||||
*/
|
||||
@Composable
|
||||
fun MsValidationWrapper(
|
||||
messages: List<ValidationMessage>,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
// Die eigentliche Eingabekomponente
|
||||
content()
|
||||
|
||||
// Validierungsmeldungen unterhalb
|
||||
if (messages.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp, start = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
messages.forEach { msg ->
|
||||
ValidationRow(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine einzelne Zeile für eine Validierungsmeldung mit passendem Icon und Farbe.
|
||||
*/
|
||||
@Composable
|
||||
private fun ValidationRow(msg: ValidationMessage) {
|
||||
val (color, icon) = when (msg.severity) {
|
||||
ValidationSeverity.ERROR -> MaterialTheme.colorScheme.error to Icons.Default.ErrorOutline
|
||||
ValidationSeverity.WARNING -> Color(0xFFEF6C00) to Icons.Default.WarningAmber // Warmer Orange-Ton
|
||||
ValidationSeverity.INFO -> MaterialTheme.colorScheme.primary to Icons.Default.Info
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = color
|
||||
)
|
||||
Text(
|
||||
text = msg.message,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -24,8 +24,10 @@ sealed class AppScreen(val route: String) {
|
||||
data object VeranstalterAuswahl : AppScreen("/veranstalter/auswahl")
|
||||
data object VeranstalterNeu : AppScreen("/veranstalter/neu")
|
||||
data class VeranstalterDetail(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId")
|
||||
// Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail → "+ Neue Veranstaltung")
|
||||
data class VeranstaltungKonfig(val veranstalterId: Long) : AppScreen("/veranstalter/$veranstalterId/veranstaltung/neu")
|
||||
|
||||
// Neue Veranstaltungs-Konfig-Seite (aus Veranstalter-Detail oder direkt aus Cockpit)
|
||||
data class VeranstaltungKonfig(val veranstalterId: Long = 0) :
|
||||
AppScreen("/veranstalter/$veranstalterId/veranstaltung/neu")
|
||||
data class VeranstaltungUebersicht(val veranstalterId: Long, val veranstaltungId: Long) :
|
||||
AppScreen("/veranstalter/$veranstalterId/veranstaltung/$veranstaltungId")
|
||||
|
||||
@@ -37,6 +39,7 @@ sealed class AppScreen(val route: String) {
|
||||
data class TurnierNeu(val veranstaltungId: Long) : AppScreen("/veranstaltung/$veranstaltungId/turnier/neu")
|
||||
data object Reiter : AppScreen("/reiter")
|
||||
data object Pferde : AppScreen("/pferde")
|
||||
data object Vereine : AppScreen("/vereine")
|
||||
data object Funktionaere : AppScreen("/funktionaere")
|
||||
data object Meisterschaften : AppScreen("/meisterschaften")
|
||||
data object Cups : AppScreen("/cups")
|
||||
@@ -68,6 +71,7 @@ sealed class AppScreen(val route: String) {
|
||||
"/veranstaltung/neu" -> VeranstaltungNeu
|
||||
"/reiter" -> Reiter
|
||||
"/pferde" -> Pferde
|
||||
"/vereine" -> Vereine
|
||||
"/funktionaere" -> Funktionaere
|
||||
"/meisterschaften" -> Meisterschaften
|
||||
"/cups" -> Cups
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Feature-Modul: Pferde-Verwaltung (Desktop-only)
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
group = "at.mocode.clients"
|
||||
version = "1.0.0"
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
sourceSets {
|
||||
jvmMain.dependencies {
|
||||
implementation(projects.frontend.core.designSystem)
|
||||
implementation(projects.frontend.core.domain)
|
||||
implementation(projects.frontend.core.navigation)
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.bundles.kmp.common)
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package at.mocode.frontend.features.pferde.domain
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* UI-Modell für ein Pferd.
|
||||
*/
|
||||
data class Pferd(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val lebensnummer: String,
|
||||
val geschlecht: Geschlecht = Geschlecht.WALLACH,
|
||||
val farbe: String = "",
|
||||
val geburtsjahr: Int? = null,
|
||||
val status: PferdeStatus = PferdeStatus.AKTIV
|
||||
)
|
||||
|
||||
enum class Geschlecht(val label: String) {
|
||||
WALLACH("Wallach"),
|
||||
STUTE("Stute"),
|
||||
HENGST("Hengst")
|
||||
}
|
||||
|
||||
enum class PferdeStatus(val label: String, val color: Color) {
|
||||
AKTIV("Aktiv", Color(0xFF2E7D32)),
|
||||
INAKTIV("Inaktiv", Color(0xFF757575)),
|
||||
GESTOKEN("Gestorben", Color(0xFFC62828)),
|
||||
VERKAUFT("Verkauft", Color(0xFF0277BD))
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package at.mocode.frontend.features.pferde.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.*
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
import at.mocode.frontend.features.pferde.domain.Geschlecht
|
||||
import at.mocode.frontend.features.pferde.domain.Pferd
|
||||
import at.mocode.frontend.features.pferde.domain.PferdeStatus
|
||||
|
||||
@Composable
|
||||
fun PferdeScreen(
|
||||
viewModel: PferdeViewModel = PferdeViewModel()
|
||||
) {
|
||||
val uiState = viewModel.uiState
|
||||
|
||||
MsMasterDetailLayout(
|
||||
master = {
|
||||
PferdeListContent(
|
||||
uiState = uiState,
|
||||
onSearchChange = viewModel::onSearchQueryChange,
|
||||
onPferdSelected = viewModel::selectPferd
|
||||
)
|
||||
},
|
||||
detail = {
|
||||
if (uiState.isEditing) {
|
||||
PferdeEditorContent(
|
||||
uiState = uiState,
|
||||
onNameChange = viewModel::onEditNameChange,
|
||||
onLebensnummerChange = viewModel::onEditLebensnummerChange,
|
||||
onGeschlechtChange = viewModel::onEditGeschlechtChange,
|
||||
onFarbeChange = viewModel::onEditFarbeChange,
|
||||
onGeburtsjahrChange = viewModel::onEditGeburtsjahrChange,
|
||||
onStatusChange = viewModel::onEditStatusChange,
|
||||
onSave = viewModel::onSave,
|
||||
onCancel = viewModel::onCancel
|
||||
)
|
||||
} else {
|
||||
PlaceholderContent(
|
||||
title = "Kein Pferd ausgewählt",
|
||||
subtitle = "Wählen Sie ein Pferd aus der Liste aus oder legen Sie ein neues an."
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PferdeListContent(
|
||||
uiState: PferdeUiState,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onPferdSelected: (Pferd) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsFilterBar(
|
||||
searchQuery = uiState.searchQuery,
|
||||
onSearchQueryChange = onSearchChange,
|
||||
resultCount = uiState.searchResults.size
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
MsDataTable(
|
||||
items = uiState.searchResults,
|
||||
columns = listOf(
|
||||
MsColumnDefinition(
|
||||
title = "Name",
|
||||
weight = 1f,
|
||||
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Lebensnummer",
|
||||
width = 150.dp,
|
||||
cellRenderer = { Text(it.lebensnummer, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Status",
|
||||
width = 100.dp,
|
||||
cellRenderer = {
|
||||
MsStatusBadge(
|
||||
text = it.status.label,
|
||||
containerColor = it.status.color.copy(alpha = 0.1f),
|
||||
contentColor = it.status.color
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
onRowClick = onPferdSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PferdeEditorContent(
|
||||
uiState: PferdeUiState,
|
||||
onNameChange: (String) -> Unit,
|
||||
onLebensnummerChange: (String) -> Unit,
|
||||
onGeschlechtChange: (Geschlecht) -> Unit,
|
||||
onFarbeChange: (String) -> Unit,
|
||||
onGeburtsjahrChange: (String) -> Unit,
|
||||
onStatusChange: (PferdeStatus) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsActionToolbar(
|
||||
title = "Pferde Details",
|
||||
onSave = onSave,
|
||||
onCancel = onCancel
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
MsTextField(
|
||||
value = uiState.editName,
|
||||
onValueChange = onNameChange,
|
||||
label = "Name",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editLebensnummer,
|
||||
onValueChange = onLebensnummerChange,
|
||||
label = "Lebensnummer",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
MsEnumDropdown(
|
||||
label = "Geschlecht",
|
||||
options = Geschlecht.entries.toTypedArray(),
|
||||
selectedOption = uiState.editGeschlecht,
|
||||
onOptionSelected = onGeschlechtChange,
|
||||
optionLabel = { it.label },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editFarbe,
|
||||
onValueChange = onFarbeChange,
|
||||
label = "Farbe",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
MsTextField(
|
||||
value = uiState.editGeburtsjahr,
|
||||
onValueChange = onGeburtsjahrChange,
|
||||
label = "Geburtsjahr",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MsEnumDropdown(
|
||||
label = "Status",
|
||||
options = PferdeStatus.entries.toTypedArray(),
|
||||
selectedOption = uiState.editStatus,
|
||||
onOptionSelected = onStatusChange,
|
||||
optionLabel = { it.label },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
if (uiState.editStatus == PferdeStatus.INAKTIV) {
|
||||
MsValidationWrapper(
|
||||
messages = listOf(
|
||||
ValidationMessage(
|
||||
"Pferd ist als inaktiv markiert und kann nicht für Nennungen verwendet werden.",
|
||||
ValidationSeverity.WARNING
|
||||
)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Zusätzliche Pferde-Informationen",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-Place Preview für den PferdeScreen.
|
||||
*/
|
||||
@Composable
|
||||
fun PferdeScreenPreviewContent() {
|
||||
val viewModel = PferdeViewModel()
|
||||
at.mocode.frontend.core.designsystem.theme.AppTheme {
|
||||
Surface {
|
||||
PferdeScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package at.mocode.frontend.features.pferde.presentation
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import at.mocode.frontend.features.pferde.domain.Geschlecht
|
||||
import at.mocode.frontend.features.pferde.domain.Pferd
|
||||
import at.mocode.frontend.features.pferde.domain.PferdeStatus
|
||||
|
||||
/**
|
||||
* UI-State für die Pferde-Verwaltung.
|
||||
*/
|
||||
data class PferdeUiState(
|
||||
val searchResults: List<Pferd> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val selectedPferd: Pferd? = null,
|
||||
val isEditing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val editName: String = "",
|
||||
val editLebensnummer: String = "",
|
||||
val editGeschlecht: Geschlecht = Geschlecht.WALLACH,
|
||||
val editFarbe: String = "",
|
||||
val editGeburtsjahr: String = "",
|
||||
val editStatus: PferdeStatus = PferdeStatus.AKTIV
|
||||
)
|
||||
|
||||
/**
|
||||
* ViewModel für die Pferde-Verwaltung.
|
||||
*/
|
||||
open class PferdeViewModel(initialLoad: Boolean = true) {
|
||||
var uiState by mutableStateOf(PferdeUiState())
|
||||
protected set
|
||||
|
||||
init {
|
||||
if (initialLoad) {
|
||||
loadPferde()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPferde() {
|
||||
val mockData = listOf(
|
||||
Pferd("1", "Bella", "040001234567801", Geschlecht.STUTE, "Braun", 2015, PferdeStatus.AKTIV),
|
||||
Pferd("2", "Casanova", "040001234567802", Geschlecht.WALLACH, "Schimmel", 2012, PferdeStatus.AKTIV),
|
||||
Pferd("3", "Spirit", "040001234567803", Geschlecht.HENGST, "Rappe", 2018, PferdeStatus.AKTIV),
|
||||
Pferd("4", "Lucky", "040001234567804", Geschlecht.WALLACH, "Fuchs", 2010, PferdeStatus.VERKAUFT)
|
||||
)
|
||||
uiState = uiState.copy(searchResults = mockData)
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
uiState = uiState.copy(searchQuery = query)
|
||||
}
|
||||
|
||||
fun selectPferd(pferd: Pferd) {
|
||||
uiState = uiState.copy(
|
||||
selectedPferd = pferd,
|
||||
isEditing = true,
|
||||
editName = pferd.name,
|
||||
editLebensnummer = pferd.lebensnummer,
|
||||
editGeschlecht = pferd.geschlecht,
|
||||
editFarbe = pferd.farbe,
|
||||
editGeburtsjahr = pferd.geburtsjahr?.toString() ?: "",
|
||||
editStatus = pferd.status
|
||||
)
|
||||
}
|
||||
|
||||
fun onEditNameChange(value: String) {
|
||||
uiState = uiState.copy(editName = value)
|
||||
}
|
||||
|
||||
fun onEditLebensnummerChange(value: String) {
|
||||
uiState = uiState.copy(editLebensnummer = value)
|
||||
}
|
||||
|
||||
fun onEditGeschlechtChange(value: Geschlecht) {
|
||||
uiState = uiState.copy(editGeschlecht = value)
|
||||
}
|
||||
|
||||
fun onEditFarbeChange(value: String) {
|
||||
uiState = uiState.copy(editFarbe = value)
|
||||
}
|
||||
|
||||
fun onEditGeburtsjahrChange(value: String) {
|
||||
uiState = uiState.copy(editGeburtsjahr = value)
|
||||
}
|
||||
|
||||
fun onEditStatusChange(value: PferdeStatus) {
|
||||
uiState = uiState.copy(editStatus = value)
|
||||
}
|
||||
|
||||
fun onSave() {
|
||||
uiState = uiState.copy(isEditing = false)
|
||||
}
|
||||
|
||||
fun onCancel() {
|
||||
uiState = uiState.copy(isEditing = false)
|
||||
}
|
||||
}
|
||||
+14
-13
@@ -15,8 +15,9 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.core.designsystem.components.DashboardCard
|
||||
import at.mocode.frontend.core.designsystem.components.DenseButton
|
||||
import at.mocode.frontend.core.designsystem.components.ButtonSize
|
||||
import at.mocode.frontend.core.designsystem.components.MsButton
|
||||
import at.mocode.frontend.core.designsystem.components.MsCard
|
||||
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||
|
||||
@Composable
|
||||
@@ -58,7 +59,7 @@ fun PingScreen(
|
||||
|
||||
// Right Panel: Terminal Log (40%)
|
||||
// Hier nutzen wir bewusst einen dunklen "Terminal"-Look, unabhängig vom Theme
|
||||
DashboardCard(
|
||||
MsCard(
|
||||
modifier = Modifier
|
||||
.weight(0.4f)
|
||||
.fillMaxHeight()
|
||||
@@ -139,14 +140,14 @@ private fun ActionToolbar(viewModel: PingViewModel) {
|
||||
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingXS),
|
||||
verticalArrangement = Arrangement.spacedBy(Dimens.SpacingXS)
|
||||
) {
|
||||
DenseButton(text = "Simple", onClick = { viewModel.performSimplePing() })
|
||||
DenseButton(text = "Enhanced", onClick = { viewModel.performEnhancedPing() })
|
||||
DenseButton(text = "Secure", onClick = { viewModel.performSecurePing() })
|
||||
DenseButton(text = "Health", onClick = { viewModel.performHealthCheck() })
|
||||
DenseButton(
|
||||
MsButton(text = "Simple", size = ButtonSize.SMALL, onClick = { viewModel.performSimplePing() })
|
||||
MsButton(text = "Enhanced", size = ButtonSize.SMALL, onClick = { viewModel.performEnhancedPing() })
|
||||
MsButton(text = "Secure", size = ButtonSize.SMALL, onClick = { viewModel.performSecurePing() })
|
||||
MsButton(text = "Health", size = ButtonSize.SMALL, onClick = { viewModel.performHealthCheck() })
|
||||
MsButton(
|
||||
text = "Sync",
|
||||
onClick = { viewModel.triggerSync() },
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
size = ButtonSize.SMALL,
|
||||
onClick = { viewModel.triggerSync() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -156,7 +157,7 @@ private fun StatusGrid(uiState: PingUiState) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
|
||||
// Row 1
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingS)) {
|
||||
DashboardCard(modifier = Modifier.weight(1f)) {
|
||||
MsCard(modifier = Modifier.weight(1f)) {
|
||||
StatusHeader("SIMPLE / SECURE PING")
|
||||
if (uiState.simplePingResponse != null) {
|
||||
KeyValueRow("Status", uiState.simplePingResponse.status)
|
||||
@@ -167,7 +168,7 @@ private fun StatusGrid(uiState: PingUiState) {
|
||||
}
|
||||
}
|
||||
|
||||
DashboardCard(modifier = Modifier.weight(1f)) {
|
||||
MsCard(modifier = Modifier.weight(1f)) {
|
||||
StatusHeader("HEALTH CHECK")
|
||||
if (uiState.healthResponse != null) {
|
||||
KeyValueRow("Status", uiState.healthResponse.status)
|
||||
@@ -180,7 +181,7 @@ private fun StatusGrid(uiState: PingUiState) {
|
||||
}
|
||||
|
||||
// Row 2
|
||||
DashboardCard(modifier = Modifier.fillMaxWidth()) {
|
||||
MsCard(modifier = Modifier.fillMaxWidth()) {
|
||||
StatusHeader("ENHANCED PING (RESILIENCE)")
|
||||
if (uiState.enhancedPingResponse != null) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
|
||||
+8
-8
@@ -13,9 +13,9 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.LoadingIndicator
|
||||
import at.mocode.frontend.core.designsystem.components.MeldestelleButton
|
||||
import at.mocode.frontend.core.designsystem.components.MeldestelleTextField
|
||||
import at.mocode.frontend.core.designsystem.components.MsButton
|
||||
import at.mocode.frontend.core.designsystem.components.MsLoadingIndicator
|
||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
||||
|
||||
@Composable
|
||||
fun ProfileScreen(
|
||||
@@ -37,7 +37,7 @@ fun ProfileScreen(
|
||||
)
|
||||
|
||||
if (uiState.isLoading) {
|
||||
LoadingIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
MsLoadingIndicator(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
} else {
|
||||
// Fehleranzeige
|
||||
uiState.errorMessage?.let { error ->
|
||||
@@ -100,7 +100,7 @@ fun ZnsLinkSection(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
MeldestelleTextField(
|
||||
MsTextField(
|
||||
value = satznummer,
|
||||
onValueChange = { satznummer = it },
|
||||
label = "ZNS Satznummer",
|
||||
@@ -112,7 +112,7 @@ fun ZnsLinkSection(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
MeldestelleButton(
|
||||
MsButton(
|
||||
onClick = { onLink(satznummer) },
|
||||
text = "Jetzt verknüpfen",
|
||||
isLoading = isLinking,
|
||||
@@ -154,7 +154,7 @@ fun ProfileDetailsSection(
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (isEditing) {
|
||||
MeldestelleTextField(
|
||||
MsTextField(
|
||||
value = contactEmail,
|
||||
onValueChange = { contactEmail = it },
|
||||
label = "Kontakt E-Mail",
|
||||
@@ -162,7 +162,7 @@ fun ProfileDetailsSection(
|
||||
leadingIcon = Icons.Default.Email
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
MeldestelleTextField(
|
||||
MsTextField(
|
||||
value = bio,
|
||||
onValueChange = { bio = it },
|
||||
label = "Info / Bio",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Feature-Modul: Reiter-Verwaltung (Desktop-only)
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
group = "at.mocode.clients"
|
||||
version = "1.0.0"
|
||||
kotlin {
|
||||
jvm()
|
||||
sourceSets {
|
||||
jvmMain.dependencies {
|
||||
implementation(projects.frontend.core.designSystem)
|
||||
implementation(projects.frontend.core.domain)
|
||||
implementation(projects.frontend.core.navigation)
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.bundles.kmp.common)
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package at.mocode.frontend.features.reiter.domain
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* UI-Modell für einen Reiter.
|
||||
*/
|
||||
data class Reiter(
|
||||
val id: String,
|
||||
val vorname: String,
|
||||
val nachname: String,
|
||||
val satznummer: String?,
|
||||
val lizenz: LizenzKlasse = LizenzKlasse.KEINE,
|
||||
val sparte: Sparte = Sparte.KEINE,
|
||||
val status: ReiterStatus = ReiterStatus.AKTIV
|
||||
) {
|
||||
val name: String get() = "$vorname $nachname"
|
||||
}
|
||||
|
||||
enum class LizenzKlasse(val label: String) {
|
||||
KEINE("-"),
|
||||
R1("R1"),
|
||||
R1D1("R1D1"),
|
||||
R1S1("R1S1"),
|
||||
R2("R2"),
|
||||
R2D2("R2D2"),
|
||||
R2S2("R2S2"),
|
||||
R3("R3"),
|
||||
R4("R4")
|
||||
}
|
||||
|
||||
enum class Sparte(val label: String) {
|
||||
KEINE("-"),
|
||||
DRESSUR("Dressur"),
|
||||
SPRINGEN("Springen"),
|
||||
VIELSEITIGKEIT("Vielseitigkeit"),
|
||||
VOLTIGIEREN("Voltigieren"),
|
||||
FAHREN("Fahren"),
|
||||
REINING("Reining")
|
||||
}
|
||||
|
||||
enum class ReiterStatus(val label: String, val color: Color) {
|
||||
AKTIV("Aktiv", Color(0xFF2E7D32)),
|
||||
GESPERRT("Gesperrt", Color(0xFFC62828)),
|
||||
INAKTIV("Inaktiv", Color(0xFF757575))
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
package at.mocode.frontend.features.reiter.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.*
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
import at.mocode.frontend.features.reiter.domain.LizenzKlasse
|
||||
import at.mocode.frontend.features.reiter.domain.Reiter
|
||||
import at.mocode.frontend.features.reiter.domain.Sparte
|
||||
|
||||
@Composable
|
||||
fun ReiterScreen(
|
||||
viewModel: ReiterViewModel
|
||||
) {
|
||||
val uiState = viewModel.uiState
|
||||
|
||||
MsMasterDetailLayout(
|
||||
master = {
|
||||
ReiterListContent(
|
||||
uiState = uiState,
|
||||
onSearchChange = viewModel::onSearchQueryChange,
|
||||
onReiterSelected = viewModel::selectReiter
|
||||
)
|
||||
},
|
||||
detail = {
|
||||
if (uiState.isEditing) {
|
||||
ReiterEditorContent(
|
||||
uiState = uiState,
|
||||
onVornameChange = viewModel::onEditVornameChange,
|
||||
onNachnameChange = viewModel::onEditNameChange,
|
||||
onLizenzChange = viewModel::onEditLizenzChange,
|
||||
onSparteChange = viewModel::onEditSparteChange,
|
||||
onSave = viewModel::onSave,
|
||||
onCancel = viewModel::onCancel
|
||||
)
|
||||
} else {
|
||||
PlaceholderContent(
|
||||
title = "Kein Reiter ausgewählt",
|
||||
subtitle = "Wählen Sie einen Reiter aus der Liste aus oder legen Sie einen neuen an."
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReiterListContent(
|
||||
uiState: ReiterUiState,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onReiterSelected: (Reiter) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsFilterBar(
|
||||
searchQuery = uiState.searchQuery,
|
||||
onSearchQueryChange = onSearchChange,
|
||||
resultCount = uiState.searchResults.size
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
MsDataTable(
|
||||
items = uiState.searchResults,
|
||||
columns = listOf(
|
||||
MsColumnDefinition(
|
||||
title = "Vorname",
|
||||
weight = 1f,
|
||||
cellRenderer = { Text(it.vorname, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Nachname",
|
||||
weight = 1f,
|
||||
cellRenderer = { Text(it.nachname, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Lizenz",
|
||||
width = 80.dp,
|
||||
cellRenderer = { Text(it.lizenz.label, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Status",
|
||||
width = 100.dp,
|
||||
cellRenderer = {
|
||||
MsStatusBadge(
|
||||
text = it.status.label,
|
||||
containerColor = it.status.color.copy(alpha = 0.1f),
|
||||
contentColor = it.status.color
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
onRowClick = onReiterSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReiterEditorContent(
|
||||
uiState: ReiterUiState,
|
||||
onVornameChange: (String) -> Unit,
|
||||
onNachnameChange: (String) -> Unit,
|
||||
onLizenzChange: (LizenzKlasse) -> Unit,
|
||||
onSparteChange: (Sparte) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsActionToolbar(
|
||||
title = "Reiter Details",
|
||||
onSave = onSave,
|
||||
onCancel = onCancel
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
MsTextField(
|
||||
value = uiState.editVorname,
|
||||
onValueChange = onVornameChange,
|
||||
label = "Vorname",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editName,
|
||||
onValueChange = onNachnameChange,
|
||||
label = "Nachname",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
MsEnumDropdown(
|
||||
label = "Lizenzklasse",
|
||||
options = LizenzKlasse.entries.toTypedArray(),
|
||||
selectedOption = uiState.editLizenz,
|
||||
onOptionSelected = onLizenzChange,
|
||||
optionLabel = { it.label },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MsEnumDropdown(
|
||||
label = "Hauptsparte",
|
||||
options = Sparte.entries.toTypedArray(),
|
||||
selectedOption = uiState.editSparte,
|
||||
onOptionSelected = onSparteChange,
|
||||
optionLabel = { it.label },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
// Beispiel für ValidationWrapper
|
||||
MsValidationWrapper(
|
||||
messages = listOf(
|
||||
ValidationMessage("Warnung: Lizenz läuft in 14 Tagen ab.", ValidationSeverity.WARNING)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
"Zusätzliche Reiter-Informationen",
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReiterScreenPreviewContent() {
|
||||
val viewModel = ReiterViewModel().apply {
|
||||
// Optional: Hier könnten Mock-Daten direkt gesetzt werden,
|
||||
// falls das ViewModel dies unterstützt.
|
||||
}
|
||||
MaterialTheme {
|
||||
ReiterScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package at.mocode.frontend.features.reiter.presentation
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import at.mocode.frontend.features.reiter.domain.LizenzKlasse
|
||||
import at.mocode.frontend.features.reiter.domain.Reiter
|
||||
import at.mocode.frontend.features.reiter.domain.ReiterStatus
|
||||
import at.mocode.frontend.features.reiter.domain.Sparte
|
||||
|
||||
/**
|
||||
* UI-State für die Reiter-Verwaltung.
|
||||
*/
|
||||
data class ReiterUiState(
|
||||
val searchResults: List<Reiter> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val selectedReiter: Reiter? = null,
|
||||
val isEditing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val editName: String = "",
|
||||
val editVorname: String = "",
|
||||
val editLizenz: LizenzKlasse = LizenzKlasse.KEINE,
|
||||
val editSparte: Sparte = Sparte.KEINE,
|
||||
val editStatus: ReiterStatus = ReiterStatus.AKTIV
|
||||
)
|
||||
|
||||
/**
|
||||
* ViewModel für die Reiter-Verwaltung.
|
||||
* In einem echten Szenario würden wir hier ein Repository injizieren.
|
||||
*/
|
||||
open class ReiterViewModel(initialLoad: Boolean = true) {
|
||||
var uiState by mutableStateOf(ReiterUiState())
|
||||
protected set
|
||||
|
||||
init {
|
||||
if (initialLoad) {
|
||||
// Initialer Load (Mock-Daten)
|
||||
loadReiter()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadReiter() {
|
||||
val mockData = listOf(
|
||||
Reiter("1", "Stefan", "Möbius", "123456", LizenzKlasse.R2D2, Sparte.DRESSUR, ReiterStatus.AKTIV),
|
||||
Reiter("2", "Julia", "Reiterin", "654321", LizenzKlasse.R1, Sparte.SPRINGEN, ReiterStatus.AKTIV),
|
||||
Reiter("3", "Max", "Mustermann", "112233", LizenzKlasse.KEINE, Sparte.KEINE, ReiterStatus.GESPERRT),
|
||||
Reiter("4", "Lisa", "Springen", "445566", LizenzKlasse.R3, Sparte.SPRINGEN, ReiterStatus.AKTIV)
|
||||
)
|
||||
uiState = uiState.copy(searchResults = mockData)
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
uiState = uiState.copy(searchQuery = query)
|
||||
// Hier würde die Filter-Logik greifen
|
||||
}
|
||||
|
||||
fun selectReiter(reiter: Reiter) {
|
||||
uiState = uiState.copy(
|
||||
selectedReiter = reiter,
|
||||
isEditing = true,
|
||||
editVorname = reiter.vorname,
|
||||
editName = reiter.nachname,
|
||||
editLizenz = reiter.lizenz,
|
||||
editSparte = reiter.sparte,
|
||||
editStatus = reiter.status
|
||||
)
|
||||
}
|
||||
|
||||
fun onEditVornameChange(value: String) {
|
||||
uiState = uiState.copy(editVorname = value)
|
||||
}
|
||||
|
||||
fun onEditNameChange(value: String) {
|
||||
uiState = uiState.copy(editName = value)
|
||||
}
|
||||
|
||||
fun onEditLizenzChange(value: LizenzKlasse) {
|
||||
uiState = uiState.copy(editLizenz = value)
|
||||
}
|
||||
|
||||
fun onEditSparteChange(value: Sparte) {
|
||||
uiState = uiState.copy(editSparte = value)
|
||||
}
|
||||
|
||||
fun onSave() {
|
||||
// Mock-Speichern
|
||||
uiState = uiState.copy(isEditing = false)
|
||||
}
|
||||
|
||||
fun onCancel() {
|
||||
uiState = uiState.copy(isEditing = false)
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package at.mocode.frontend.features.reiter.presentation
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import at.mocode.frontend.features.reiter.domain.LizenzKlasse
|
||||
import at.mocode.frontend.features.reiter.domain.Reiter
|
||||
import at.mocode.frontend.features.reiter.domain.ReiterStatus
|
||||
import at.mocode.frontend.features.reiter.domain.Sparte
|
||||
import at.mocode.wui.preview.ComponentPreview
|
||||
|
||||
/**
|
||||
* Hilf's-ViewModel für die Vorschau, um den Status direkt setzen zu können.
|
||||
*/
|
||||
private class PreviewReiterViewModel(initialState: ReiterUiState) : ReiterViewModel(initialLoad = false) {
|
||||
init {
|
||||
uiState = initialState
|
||||
}
|
||||
}
|
||||
|
||||
@ComponentPreview
|
||||
@Composable
|
||||
fun PreviewReiterScreen_List() {
|
||||
val viewModel = ReiterViewModel() // Nutzt die Mock-Daten aus dem init-Block
|
||||
MaterialTheme {
|
||||
ReiterScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@ComponentPreview
|
||||
@Composable
|
||||
fun PreviewReiterScreen_Editing() {
|
||||
val mockReiter = Reiter(
|
||||
id = "1",
|
||||
vorname = "Stefan",
|
||||
nachname = "Möbius",
|
||||
satznummer = "123456",
|
||||
lizenz = LizenzKlasse.R2D2,
|
||||
sparte = Sparte.DRESSUR,
|
||||
status = ReiterStatus.AKTIV
|
||||
)
|
||||
val viewModel = PreviewReiterViewModel(
|
||||
ReiterUiState(
|
||||
searchResults = listOf(mockReiter),
|
||||
selectedReiter = mockReiter,
|
||||
isEditing = true,
|
||||
editVorname = mockReiter.vorname,
|
||||
editName = mockReiter.nachname,
|
||||
editLizenz = mockReiter.lizenz,
|
||||
editSparte = mockReiter.sparte,
|
||||
editStatus = mockReiter.status
|
||||
)
|
||||
)
|
||||
|
||||
MaterialTheme {
|
||||
ReiterScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@ComponentPreview
|
||||
@Composable
|
||||
fun PreviewReiterScreen_Empty() {
|
||||
val viewModel = PreviewReiterViewModel(ReiterUiState())
|
||||
MaterialTheme {
|
||||
ReiterScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -1,6 +1,8 @@
|
||||
package at.mocode.turnier.feature.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -34,6 +36,11 @@ fun TurnierDetailScreen(
|
||||
) {
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Temporäre Lösung bis zur echten Repository-Anbindung:
|
||||
// Da TurnierDetailScreen in einem anderen Modul liegt, übergeben wir
|
||||
// die Veranstaltungsinformationen eigentlich via ViewModel.
|
||||
// Hier nutzen wir vorerst koin oder Parameter.
|
||||
|
||||
val tabs = listOf(
|
||||
"STAMMDATEN",
|
||||
"ORGANISATION",
|
||||
|
||||
+262
-153
@@ -1,11 +1,11 @@
|
||||
package at.mocode.turnier.feature.presentation
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Usb
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -14,6 +14,7 @@ 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 java.time.LocalDate
|
||||
|
||||
private val PrimaryBlue = Color(0xFF1E3A8A)
|
||||
private val AccentBlue = Color(0xFF3B82F6)
|
||||
@@ -26,214 +27,328 @@ private val AccentBlue = Color(0xFF3B82F6)
|
||||
* - Turnier-Beschreibung: Titel, Sub-Titel
|
||||
* - Sponsoren
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StammdatenTabContent(turnierId: Long) {
|
||||
// In einer echten App würden wir diese Daten aus einem ViewModel laden.
|
||||
// Hier simulieren wir den State basierend auf den Anforderungen.
|
||||
|
||||
var turnierNr by remember { mutableStateOf("") }
|
||||
var typOto by remember { mutableStateOf(true) }
|
||||
var spracheDe by remember { mutableStateOf(true) }
|
||||
var sparteDressur by remember { mutableStateOf(false) }
|
||||
var sparteSpringen by remember { mutableStateOf(false) }
|
||||
var klasseC by remember { mutableStateOf(false) }
|
||||
var klasseB by remember { mutableStateOf(false) }
|
||||
var klasseA by remember { mutableStateOf(false) }
|
||||
var datumVon by remember { mutableStateOf("") }
|
||||
var datumBis 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("") }
|
||||
var bis by remember { mutableStateOf("") }
|
||||
var ort by remember { mutableStateOf("") }
|
||||
|
||||
var titel by remember { mutableStateOf("") }
|
||||
var subTitel by remember { mutableStateOf("") }
|
||||
val sponsoren = remember { mutableStateListOf<String>() }
|
||||
|
||||
var showZnsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// Hilfs-States für DatePicker
|
||||
var showDatePickerVon by remember { mutableStateOf(false) }
|
||||
var showDatePickerBis by remember { mutableStateOf(false) }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.verticalScroll(scrollState)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// ── Turnier-Konfiguration ────────────────────────────────────────────
|
||||
SectionCard(title = "Turnier-Konfiguration") {
|
||||
// ── Turnier-Konfiguration (Schritt 1 Logik) ───────────────────────────
|
||||
SectionCard(title = "Turnier-Konfiguration & ZNS") {
|
||||
FormRow("Turnier-Nr.:") {
|
||||
OutlinedTextField(
|
||||
value = turnierNr,
|
||||
onValueChange = { turnierNr = it },
|
||||
placeholder = { Text("z.B. 26128", fontSize = 13.sp) },
|
||||
modifier = Modifier.width(200.dp).height(48.dp),
|
||||
singleLine = true,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = turnierNr,
|
||||
onValueChange = { if (it.length <= 5 && it.all { c -> c.isDigit() }) turnierNr = it },
|
||||
placeholder = { Text("5-stellig", fontSize = 13.sp) },
|
||||
modifier = Modifier.width(120.dp),
|
||||
singleLine = true,
|
||||
enabled = !nrConfirmed
|
||||
)
|
||||
if (!nrConfirmed) {
|
||||
Button(
|
||||
onClick = { nrConfirmed = true },
|
||||
enabled = turnierNr.length == 5,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
|
||||
) {
|
||||
Text("Bestätigen")
|
||||
}
|
||||
} else {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = { nrConfirmed = false },
|
||||
label = { Text("Bestätigt") },
|
||||
trailingIcon = { Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
if (turnierNr.length == 5 && !nrConfirmed) {
|
||||
Text(
|
||||
"Bitte Turnier-Nummer bestätigen um fortzufahren.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FormRow("Typ:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = typOto, onClick = { typOto = true })
|
||||
Text("OTO (National)", fontSize = 13.sp)
|
||||
RadioButton(selected = !typOto, onClick = { typOto = false })
|
||||
Text("FEI (International)", fontSize = 13.sp)
|
||||
FilterChip(
|
||||
selected = typ == "ÖTO (National)",
|
||||
onClick = { typ = "ÖTO (National)" },
|
||||
label = { Text("ÖTO (National)") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = typ == "FEI (International)",
|
||||
onClick = { typ = "FEI (International)" },
|
||||
label = { Text("FEI (International)") }
|
||||
)
|
||||
}
|
||||
}
|
||||
FormRow("ZNS-Daten:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
FormRow("ZNS-Stammdaten:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(
|
||||
onClick = {},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue),
|
||||
onClick = { showZnsDialog = true },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue)
|
||||
) {
|
||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Import via Internet", fontSize = 13.sp)
|
||||
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import via Internet")
|
||||
}
|
||||
OutlinedButton(onClick = {}) {
|
||||
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("Import via USB", fontSize = 13.sp)
|
||||
OutlinedButton(onClick = { showZnsDialog = true }) {
|
||||
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Import via USB")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"Reiter-, Pferde-, Funktionärs- und Vereinsdaten vom OEPS Backend",
|
||||
fontSize = 11.sp,
|
||||
color = Color(0xFF6B7280),
|
||||
)
|
||||
}
|
||||
FormRow("Sprache:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = spracheDe, onClick = { spracheDe = true })
|
||||
Text("Deutsch", fontSize = 13.sp)
|
||||
RadioButton(selected = !spracheDe, onClick = { spracheDe = false })
|
||||
Text("English", fontSize = 13.sp)
|
||||
|
||||
val znsStatusColor = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(
|
||||
if (znsDataLoaded) Icons.Default.CheckCircle else Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = znsStatusColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen",
|
||||
color = znsStatusColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
FormRow("Sparten:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = sparteDressur, onCheckedChange = { sparteDressur = it })
|
||||
Text("Dressur", fontSize = 13.sp)
|
||||
Checkbox(checked = sparteSpringen, onCheckedChange = { sparteSpringen = it })
|
||||
Text("Springen", fontSize = 13.sp)
|
||||
}
|
||||
|
||||
// ── Sparten & Kategorien (Schritt 2 Logik) ───────────────────────────
|
||||
SectionCard(title = "Reglement & Sparten") {
|
||||
FormRow("Sparte:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = sparten.contains("Dressur"),
|
||||
onClick = { if (sparten.contains("Dressur")) sparten.remove("Dressur") else sparten.add("Dressur") },
|
||||
label = { Text("Dressur") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = sparten.contains("Springen"),
|
||||
onClick = { if (sparten.contains("Springen")) sparten.remove("Springen") else sparten.add("Springen") },
|
||||
label = { Text("Springen") }
|
||||
)
|
||||
}
|
||||
}
|
||||
FormRow("Klassen:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = klasseC, onCheckedChange = { klasseC = it })
|
||||
Text("C", fontSize = 13.sp)
|
||||
Checkbox(checked = klasseB, onCheckedChange = { klasseB = it })
|
||||
Text("B", fontSize = 13.sp)
|
||||
Checkbox(checked = klasseA, onCheckedChange = { klasseA = it })
|
||||
Text("A", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
FormRow("Kategorien:") {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(60.dp),
|
||||
color = Color(0xFFF3F4F6),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Bitte Sparte(n) auswählen",
|
||||
fontSize = 13.sp,
|
||||
color = Color(0xFF9CA3AF),
|
||||
|
||||
FormRow("Klasse:") {
|
||||
val klassenListe = listOf("C-NEU", "C", "B", "A", "L", "LM", "M", "S")
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
klassenListe.forEach { k ->
|
||||
FilterChip(
|
||||
selected = klassen.contains(k),
|
||||
onClick = { if (klassen.contains(k)) klassen.remove(k) else klassen.add(k) },
|
||||
label = { Text(k) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
FormRow("Datum:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
FormRow("Kategorien:") {
|
||||
// Logik zur Generierung der Kategorien
|
||||
val suggested = mutableListOf<String>()
|
||||
sparten.forEach { s ->
|
||||
val prefix = if (s == "Dressur") "CDN" else "CSN"
|
||||
klassen.forEach { k ->
|
||||
suggested.add("$prefix-$k")
|
||||
suggested.add("${prefix}P-$k") // Pony Variante
|
||||
}
|
||||
}
|
||||
|
||||
if (suggested.isEmpty()) {
|
||||
Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
|
||||
} else {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
suggested.forEach { c ->
|
||||
InputChip(
|
||||
selected = kat.contains(c),
|
||||
onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
|
||||
label = { Text(c) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FormRow("Zeitraum:") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = datumVon,
|
||||
onValueChange = { datumVon = it },
|
||||
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
|
||||
modifier = Modifier.width(160.dp).height(48.dp),
|
||||
singleLine = true,
|
||||
value = von,
|
||||
onValueChange = {},
|
||||
label = { Text("Von") },
|
||||
modifier = Modifier.width(160.dp).clickable { showDatePickerVon = true },
|
||||
readOnly = true,
|
||||
trailingIcon = { Icon(Icons.Default.DateRange, null) }
|
||||
)
|
||||
Text("bis", fontSize = 13.sp)
|
||||
Text("bis")
|
||||
OutlinedTextField(
|
||||
value = datumBis,
|
||||
onValueChange = { datumBis = it },
|
||||
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
|
||||
modifier = Modifier.width(160.dp).height(48.dp),
|
||||
singleLine = true,
|
||||
value = bis,
|
||||
onValueChange = {},
|
||||
label = { Text("Bis") },
|
||||
modifier = Modifier.width(160.dp).clickable { showDatePickerBis = true },
|
||||
readOnly = true,
|
||||
trailingIcon = { Icon(Icons.Default.DateRange, null) }
|
||||
)
|
||||
}
|
||||
Text("Hinweis: Muss innerhalb des Veranstaltungs-Zeitraums liegen.", fontSize = 11.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
FormRow("Ort:") {
|
||||
OutlinedTextField(
|
||||
value = ort,
|
||||
onValueChange = { ort = it },
|
||||
label = { Text("Austragungsort") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
supportingText = { Text("Muss mit Veranstaltungsort übereinstimmen.") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Turnier-Beschreibung ─────────────────────────────────────────────
|
||||
SectionCard(title = "Turnier-Beschreibung") {
|
||||
// ── Branding (Schritt 3 Logik) ───────────────────────────────────────
|
||||
SectionCard(title = "Turnier-Branding") {
|
||||
OutlinedTextField(
|
||||
value = titel,
|
||||
onValueChange = { titel = it },
|
||||
placeholder = { Text("z.B. Frühjahrs-Turnier 2026", fontSize = 13.sp) },
|
||||
label = { Text("Titel") },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = subTitel,
|
||||
onValueChange = { subTitel = it },
|
||||
placeholder = { Text("z.B. KIDS CUP • PONY EINSTEIGER CUP OÖ", fontSize = 13.sp) },
|
||||
label = { Text("Sub-Titel") },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sponsoren ────────────────────────────────────────────────────────
|
||||
SectionCard(
|
||||
title = "Sponsoren",
|
||||
action = {
|
||||
TextButton(onClick = {}) {
|
||||
Text("+ Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(80.dp),
|
||||
color = Color(0xFFF9FAFB),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Noch keine Sponsoren hinzugefügt", fontSize = 13.sp, color = Color(0xFF6B7280))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TextButton(onClick = {}) {
|
||||
Text("+ Ersten Sponsor hinzufügen", color = AccentBlue, fontSize = 13.sp)
|
||||
}
|
||||
FormRow("Sponsoren:") {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
sponsoren.forEach { s ->
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = { sponsoren.remove(s) },
|
||||
label = { Text(s) },
|
||||
trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(14.dp)) }
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { sponsoren.add("Neuer Sponsor") }) {
|
||||
Text("+ Hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aktions-Buttons ──────────────────────────────────────────────────
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = {}) { Text("Zurücksetzen") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// ── Footer ──────────────────────────────────────────────────────────
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(
|
||||
onClick = {},
|
||||
onClick = { /* Speichern */ },
|
||||
enabled = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() && titel.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
|
||||
) { Text("Speichern") }
|
||||
modifier = Modifier.padding(bottom = 24.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Save, null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Änderungen speichern")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialog-Simulationen
|
||||
if (showZnsDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showZnsDialog = false },
|
||||
title = { Text("ZNS Import") },
|
||||
text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { znsDataLoaded = true; showZnsDialog = false }) { Text("Importieren") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDatePickerVon) {
|
||||
val state = rememberDatePickerState()
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePickerVon = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
state.selectedDateMillis?.let {
|
||||
von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
|
||||
}
|
||||
showDatePickerVon = false
|
||||
}) { Text("OK") }
|
||||
}
|
||||
) { DatePicker(state) }
|
||||
}
|
||||
|
||||
if (showDatePickerBis) {
|
||||
val state = rememberDatePickerState()
|
||||
DatePickerDialog(
|
||||
onDismissRequest = { showDatePickerBis = false },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
state.selectedDateMillis?.let {
|
||||
bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
|
||||
}
|
||||
showDatePickerBis = false
|
||||
}) { Text("OK") }
|
||||
}
|
||||
) { DatePicker(state) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
title: String,
|
||||
action: @Composable (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
|
||||
action?.invoke()
|
||||
}
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = PrimaryBlue, fontWeight = FontWeight.Bold)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -241,20 +356,14 @@ private fun SectionCard(
|
||||
|
||||
@Composable
|
||||
private fun FormRow(label: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 13.sp,
|
||||
label,
|
||||
modifier = Modifier.width(140.dp).padding(top = 12.dp),
|
||||
color = Color(0xFF374151),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -1,6 +1,7 @@
|
||||
package at.mocode.veranstaltung.feature.presentation
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -9,8 +10,8 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -39,6 +40,7 @@ fun AdminUebersichtScreen(
|
||||
onVeranstalterAuswahl: () -> Unit,
|
||||
onVeranstaltungOeffnen: (Long) -> Unit,
|
||||
onPingService: () -> Unit = {},
|
||||
onVereineOeffnen: () -> Unit = {},
|
||||
) {
|
||||
// Placeholder-Daten für die UI-Struktur (sichtbar als Cards)
|
||||
val sample = listOf(
|
||||
@@ -66,6 +68,7 @@ fun AdminUebersichtScreen(
|
||||
inVorbereitung = 0,
|
||||
gesamt = 0,
|
||||
archiv = 0,
|
||||
onVereineClick = onVereineOeffnen
|
||||
)
|
||||
|
||||
// Toolbar
|
||||
@@ -155,6 +158,7 @@ private fun KpiKachelRow(
|
||||
inVorbereitung: Int,
|
||||
gesamt: Int,
|
||||
archiv: Int,
|
||||
onVereineClick: () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -175,10 +179,10 @@ private fun KpiKachelRow(
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
KpiKachel(
|
||||
label = "GESAMT",
|
||||
wert = gesamt.toString(),
|
||||
label = "VEREINE",
|
||||
wert = "4", // Mock
|
||||
akzentFarbe = Color(0xFF6B7280),
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1f).clickable { onVereineClick() },
|
||||
)
|
||||
KpiKachel(
|
||||
label = "ARCHIV",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Feature-Modul: Vereins-Verwaltung (Desktop-only)
|
||||
*/
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
group = "at.mocode.clients"
|
||||
version = "1.0.0"
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
sourceSets {
|
||||
jvmMain.dependencies {
|
||||
implementation(projects.frontend.core.designSystem)
|
||||
implementation(projects.frontend.core.domain)
|
||||
implementation(projects.frontend.core.navigation)
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.foundation)
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.ui)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(libs.bundles.kmp.common)
|
||||
implementation(libs.koin.core)
|
||||
implementation(libs.koin.compose)
|
||||
implementation(libs.koin.compose.viewmodel)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package at.mocode.frontend.features.verein.domain
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* UI-Modell für einen Verein.
|
||||
*/
|
||||
data class Verein(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val langname: String? = null,
|
||||
val oepsNr: String? = null,
|
||||
val ort: String? = null,
|
||||
val plz: String? = null,
|
||||
val land: String = "AUT",
|
||||
val status: VereinStatus = VereinStatus.AKTIV
|
||||
)
|
||||
|
||||
enum class VereinStatus(val label: String, val color: Color) {
|
||||
AKTIV("Aktiv", Color(0xFF2E7D32)),
|
||||
RUHEND("Ruhend", Color(0xFFE65100)),
|
||||
AUFGELOEST("Aufgelöst", Color(0xFFC62828))
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package at.mocode.frontend.features.verein.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import at.mocode.frontend.core.designsystem.components.*
|
||||
import at.mocode.frontend.core.designsystem.models.PlaceholderContent
|
||||
import at.mocode.frontend.features.verein.domain.Verein
|
||||
import at.mocode.frontend.features.verein.domain.VereinStatus
|
||||
|
||||
@Composable
|
||||
fun VereinScreen(
|
||||
viewModel: VereinViewModel
|
||||
) {
|
||||
val uiState = viewModel.uiState
|
||||
|
||||
MsMasterDetailLayout(
|
||||
master = {
|
||||
VereinListContent(
|
||||
uiState = uiState,
|
||||
onSearchChange = viewModel::onSearchQueryChange,
|
||||
onVereinSelected = viewModel::selectVerein,
|
||||
onAddNew = viewModel::onAddNew
|
||||
)
|
||||
},
|
||||
detail = {
|
||||
if (uiState.isEditing) {
|
||||
VereinEditorContent(
|
||||
uiState = uiState,
|
||||
onNameChange = viewModel::onEditNameChange,
|
||||
onLangnameChange = viewModel::onEditLangnameChange,
|
||||
onOepsNrChange = viewModel::onEditOepsNrChange,
|
||||
onOrtChange = viewModel::onEditOrtChange,
|
||||
onPlzChange = viewModel::onEditPlzChange,
|
||||
onStatusChange = viewModel::onEditStatusChange,
|
||||
onSave = viewModel::onSave,
|
||||
onCancel = viewModel::onCancel
|
||||
)
|
||||
} else {
|
||||
PlaceholderContent(
|
||||
title = "Kein Verein ausgewählt",
|
||||
subtitle = "Wählen Sie einen Verein aus der Liste aus oder legen Sie einen neuen an."
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VereinListContent(
|
||||
uiState: VereinUiState,
|
||||
onSearchChange: (String) -> Unit,
|
||||
onVereinSelected: (Verein) -> Unit,
|
||||
onAddNew: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsFilterBar(
|
||||
searchQuery = uiState.searchQuery,
|
||||
onSearchQueryChange = onSearchChange,
|
||||
resultCount = uiState.searchResults.size,
|
||||
actions = {
|
||||
MsButton(
|
||||
text = "Neu",
|
||||
onClick = onAddNew,
|
||||
variant = ButtonVariant.PRIMARY,
|
||||
size = ButtonSize.SMALL
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
MsDataTable(
|
||||
items = uiState.searchResults,
|
||||
columns = listOf(
|
||||
MsColumnDefinition(
|
||||
title = "Name",
|
||||
weight = 1.5f,
|
||||
cellRenderer = { Text(it.name, style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Ort",
|
||||
weight = 1f,
|
||||
cellRenderer = { Text(it.ort ?: "-", style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "OePS-Nr",
|
||||
width = 100.dp,
|
||||
cellRenderer = { Text(it.oepsNr ?: "-", style = MaterialTheme.typography.bodySmall) }
|
||||
),
|
||||
MsColumnDefinition(
|
||||
title = "Status",
|
||||
width = 100.dp,
|
||||
cellRenderer = {
|
||||
MsStatusBadge(
|
||||
text = it.status.label,
|
||||
containerColor = it.status.color.copy(alpha = 0.1f),
|
||||
contentColor = it.status.color
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
onRowClick = onVereinSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VereinEditorContent(
|
||||
uiState: VereinUiState,
|
||||
onNameChange: (String) -> Unit,
|
||||
onLangnameChange: (String) -> Unit,
|
||||
onOepsNrChange: (String) -> Unit,
|
||||
onOrtChange: (String) -> Unit,
|
||||
onPlzChange: (String) -> Unit,
|
||||
onStatusChange: (VereinStatus) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onCancel: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
MsActionToolbar(
|
||||
title = if (uiState.selectedVerein == null) "Neuer Verein" else "Verein Details",
|
||||
onSave = onSave,
|
||||
onCancel = onCancel
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
MsTextField(
|
||||
value = uiState.editName,
|
||||
onValueChange = onNameChange,
|
||||
label = "Name (Kurz)",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
MsTextField(
|
||||
value = uiState.editLangname,
|
||||
onValueChange = onLangnameChange,
|
||||
label = "Vollständiger Name",
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
MsTextField(
|
||||
value = uiState.editOepsNr,
|
||||
onValueChange = onOepsNrChange,
|
||||
label = "OePS-Nr",
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MsEnumDropdown(
|
||||
label = "Status",
|
||||
options = VereinStatus.entries.toTypedArray(),
|
||||
selectedOption = uiState.editStatus,
|
||||
onOptionSelected = onStatusChange,
|
||||
optionLabel = { it.label },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
MsTextField(
|
||||
value = uiState.editPlz,
|
||||
onValueChange = onPlzChange,
|
||||
label = "PLZ",
|
||||
modifier = Modifier.weight(0.3f)
|
||||
)
|
||||
MsTextField(
|
||||
value = uiState.editOrt,
|
||||
onValueChange = onOrtChange,
|
||||
label = "Ort",
|
||||
modifier = Modifier.weight(0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package at.mocode.frontend.features.verein.presentation
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import at.mocode.frontend.features.verein.domain.Verein
|
||||
import at.mocode.frontend.features.verein.domain.VereinStatus
|
||||
|
||||
/**
|
||||
* UI-State für die Vereins-Verwaltung.
|
||||
*/
|
||||
data class VereinUiState(
|
||||
val allVereine: List<Verein> = emptyList(),
|
||||
val searchResults: List<Verein> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val selectedVerein: Verein? = null,
|
||||
val isEditing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val editName: String = "",
|
||||
val editLangname: String = "",
|
||||
val editOepsNr: String = "",
|
||||
val editOrt: String = "",
|
||||
val editPlz: String = "",
|
||||
val editStatus: VereinStatus = VereinStatus.AKTIV
|
||||
)
|
||||
|
||||
/**
|
||||
* ViewModel für die Vereins-Verwaltung.
|
||||
*/
|
||||
open class VereinViewModel(initialLoad: Boolean = true) : ViewModel() {
|
||||
var uiState by mutableStateOf(VereinUiState())
|
||||
protected set
|
||||
|
||||
init {
|
||||
if (initialLoad) {
|
||||
loadVereine()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadVereine() {
|
||||
val mockData = listOf(
|
||||
Verein("1", "URV Neumarkt", "Union Reit- und Fahrverein Neumarkt", "4-201", "Neumarkt", "4212"),
|
||||
Verein("2", "RV Linz", "Reitverein Linz-Ebelsberg", "4-001", "Linz", "4030"),
|
||||
Verein("3", "RC Stadl-Paura", "Reitclub Pferdewelt Stadl-Paura", "4-100", "Stadl-Paura", "4650"),
|
||||
Verein("4", "Union Reitverein X", null, "1-123", "Wien", "1010", status = VereinStatus.RUHEND)
|
||||
)
|
||||
uiState = uiState.copy(
|
||||
allVereine = mockData,
|
||||
searchResults = mockData
|
||||
)
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
uiState = uiState.copy(searchQuery = query)
|
||||
filterResults()
|
||||
}
|
||||
|
||||
private fun filterResults() {
|
||||
val query = uiState.searchQuery.lowercase()
|
||||
val filtered = if (query.isEmpty()) {
|
||||
uiState.allVereine
|
||||
} else {
|
||||
uiState.allVereine.filter {
|
||||
it.name.lowercase().contains(query) ||
|
||||
it.oepsNr?.lowercase()?.contains(query) == true ||
|
||||
it.ort?.lowercase()?.contains(query) == true
|
||||
}
|
||||
}
|
||||
uiState = uiState.copy(searchResults = filtered)
|
||||
}
|
||||
|
||||
fun selectVerein(verein: Verein) {
|
||||
uiState = uiState.copy(
|
||||
selectedVerein = verein,
|
||||
isEditing = true,
|
||||
editName = verein.name,
|
||||
editLangname = verein.langname ?: "",
|
||||
editOepsNr = verein.oepsNr ?: "",
|
||||
editOrt = verein.ort ?: "",
|
||||
editPlz = verein.plz ?: "",
|
||||
editStatus = verein.status
|
||||
)
|
||||
}
|
||||
|
||||
fun onEditNameChange(value: String) {
|
||||
uiState = uiState.copy(editName = value)
|
||||
}
|
||||
|
||||
fun onEditLangnameChange(value: String) {
|
||||
uiState = uiState.copy(editLangname = value)
|
||||
}
|
||||
|
||||
fun onEditOepsNrChange(value: String) {
|
||||
uiState = uiState.copy(editOepsNr = value)
|
||||
}
|
||||
|
||||
fun onEditOrtChange(value: String) {
|
||||
uiState = uiState.copy(editOrt = value)
|
||||
}
|
||||
|
||||
fun onEditPlzChange(value: String) {
|
||||
uiState = uiState.copy(editPlz = value)
|
||||
}
|
||||
|
||||
fun onEditStatusChange(value: VereinStatus) {
|
||||
uiState = uiState.copy(editStatus = value)
|
||||
}
|
||||
|
||||
fun onSave() {
|
||||
// Mock-Speichern
|
||||
uiState = uiState.copy(isEditing = false)
|
||||
}
|
||||
|
||||
fun onCancel() {
|
||||
uiState = uiState.copy(isEditing = false)
|
||||
}
|
||||
|
||||
fun onAddNew() {
|
||||
uiState = uiState.copy(
|
||||
selectedVerein = null,
|
||||
isEditing = true,
|
||||
editName = "",
|
||||
editLangname = "",
|
||||
editOepsNr = "",
|
||||
editOrt = "",
|
||||
editPlz = "",
|
||||
editStatus = VereinStatus.AKTIV
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package at.mocode.frontend.features.verein.di
|
||||
|
||||
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||
import org.koin.core.module.dsl.viewModelOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
val vereinFeatureModule = module {
|
||||
viewModelOf(::VereinViewModel)
|
||||
}
|
||||
@@ -35,7 +35,10 @@ kotlin {
|
||||
implementation(projects.frontend.features.veranstaltungFeature)
|
||||
implementation(projects.frontend.features.turnierFeature)
|
||||
implementation(project(":frontend:features:profile-feature"))
|
||||
implementation(project(":frontend:features:reiter-feature"))
|
||||
implementation(project(":frontend:features:pferde-feature"))
|
||||
implementation(project(":frontend:features:billing-feature"))
|
||||
implementation(project(":frontend:features:verein-feature"))
|
||||
|
||||
// Compose Desktop
|
||||
implementation(compose.desktop.currentOs)
|
||||
|
||||
+9
-3
@@ -37,10 +37,16 @@ fun DesktopApp() {
|
||||
val authState by authTokenManager.authState.collectAsState()
|
||||
|
||||
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt
|
||||
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Onboarding) {
|
||||
// Vision_03 Update: Wir starten direkt in der Veranstaltungs-Übersicht (Offline-First)
|
||||
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login && currentScreen !is AppScreen.Veranstaltungen
|
||||
&& currentScreen !is AppScreen.VeranstalterAuswahl && currentScreen !is AppScreen.VeranstalterNeu
|
||||
&& currentScreen !is AppScreen.VeranstalterDetail && currentScreen !is AppScreen.VeranstaltungKonfig
|
||||
&& currentScreen !is AppScreen.VeranstaltungUebersicht && currentScreen !is AppScreen.TurnierDetail
|
||||
&& currentScreen !is AppScreen.TurnierNeu && currentScreen !is AppScreen.Vereine
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
// Wenn noch keine Authentifizierung vorhanden ist, zuerst Onboarding anzeigen
|
||||
nav.navigateToScreen(AppScreen.Onboarding)
|
||||
// Standard: Direkt zur Veranstaltungs-Übersicht (Offline-First-Modus)
|
||||
nav.navigateToScreen(AppScreen.Veranstaltungen)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-11
@@ -5,12 +5,6 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.window.singleWindowApplication
|
||||
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
|
||||
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlScreen
|
||||
import at.mocode.veranstalter.feature.presentation.VeranstalterDetailScreen
|
||||
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
|
||||
|
||||
/**
|
||||
* Hot-Reload Preview Entry Point
|
||||
@@ -31,6 +25,13 @@ fun main() = singleWindowApplication(title = "🔥 Hot-Reload Preview") {
|
||||
private fun PreviewContent() {
|
||||
MaterialTheme {
|
||||
Surface {
|
||||
|
||||
// --- REITER ---
|
||||
// ReiterScreen(viewModel = ReiterViewModel())
|
||||
|
||||
// --- PFERDE ---
|
||||
// PferdeScreen(viewModel = PferdeViewModel())
|
||||
|
||||
// ── Hier den gewünschten Screen eintragen ──────────────────────
|
||||
// VeranstalterAuswahlScreen(onVeranstalterSelected = {}, onNeuerVeranstalter = {})
|
||||
// VeranstalterNeuScreen(onBack = {}, onSave = {})
|
||||
@@ -40,11 +41,11 @@ private fun PreviewContent() {
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Standard: AdminUebersichtScreen (Startseite nach Login)
|
||||
AdminUebersichtScreen(
|
||||
onVeranstalterAuswahl = {},
|
||||
onVeranstaltungOeffnen = {},
|
||||
onPingService = {}
|
||||
)
|
||||
// AdminUebersichtScreen(
|
||||
// onVeranstalterAuswahl = {},
|
||||
// onVeranstaltungOeffnen = {},
|
||||
// onPingService = {}
|
||||
// )
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import at.mocode.frontend.core.network.networkModule
|
||||
import at.mocode.frontend.core.sync.di.syncModule
|
||||
import at.mocode.frontend.features.billing.di.billingModule
|
||||
import at.mocode.frontend.features.profile.di.profileModule
|
||||
import at.mocode.frontend.features.verein.di.vereinFeatureModule
|
||||
import at.mocode.nennung.feature.di.nennungFeatureModule
|
||||
import at.mocode.ping.feature.di.pingFeatureModule
|
||||
import at.mocode.zns.feature.di.znsImportModule
|
||||
@@ -35,10 +36,13 @@ fun main() = application {
|
||||
znsImportModule,
|
||||
profileModule,
|
||||
billingModule,
|
||||
vereinFeatureModule,
|
||||
desktopModule,
|
||||
)
|
||||
}
|
||||
println("[DesktopApp] KOIN initialisiert")
|
||||
// Testdaten für Prototyp laden
|
||||
at.mocode.desktop.v2.StoreV2.seed()
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopApp] Koin-Warnung: ${e.message}")
|
||||
}
|
||||
|
||||
+46
-39
@@ -5,10 +5,8 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -20,18 +18,18 @@ import androidx.compose.ui.unit.sp
|
||||
import at.mocode.frontend.core.navigation.AppScreen
|
||||
import at.mocode.frontend.features.profile.presentation.ProfileScreen
|
||||
import at.mocode.frontend.features.profile.presentation.ProfileViewModel
|
||||
import at.mocode.frontend.features.verein.presentation.VereinScreen
|
||||
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||
import at.mocode.ping.feature.presentation.PingScreen
|
||||
import at.mocode.ping.feature.presentation.PingViewModel
|
||||
import at.mocode.turnier.feature.presentation.TurnierDetailScreen
|
||||
import at.mocode.turnier.feature.presentation.TurnierWizardV2
|
||||
import at.mocode.veranstalter.feature.presentation.FakeVeranstalterStore
|
||||
import at.mocode.veranstalter.feature.presentation.FakeVeranstaltungStore
|
||||
import at.mocode.veranstalter.feature.presentation.VeranstalterAuswahlV2
|
||||
import at.mocode.veranstalter.feature.presentation.VeranstalterNeuScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.AdminUebersichtScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungDetailScreen
|
||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungNeuScreen
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
|
||||
// Primärfarbe der TopBar (kann später ins Theme ausgelagert werden)
|
||||
private val TopBarColor = Color(0xFF1E3A8A)
|
||||
@@ -108,7 +106,7 @@ private fun DesktopTopBar(
|
||||
|
||||
// Root-Link
|
||||
Text(
|
||||
text = "🏠 Admin - Verwaltung",
|
||||
text = "Veranstaltungen",
|
||||
color = TopBarTextColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
@@ -243,18 +241,21 @@ private fun DesktopTopBar(
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
}
|
||||
|
||||
is AppScreen.Vereine -> {
|
||||
BreadcrumbSeparator()
|
||||
Text(
|
||||
text = "Vereine",
|
||||
color = TopBarTextColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Logout rechts
|
||||
IconButton(onClick = onLogout) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "Abmelden",
|
||||
tint = TopBarTextColor,
|
||||
)
|
||||
}
|
||||
// Logout wurde auf Kundenwunsch entfernt
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,19 +302,17 @@ private fun DesktopContentArea(
|
||||
|
||||
// Root-Screen: Leitet in V2-Fluss ab
|
||||
is AppScreen.Veranstaltungen -> {
|
||||
// Direkt zur Veranstalter-Auswahl V2
|
||||
VeranstalterAuswahlV2(
|
||||
onZurueck = { /* bleibt root */ },
|
||||
onWeiter = { vId -> onNavigate(AppScreen.VeranstalterDetail(vId)) },
|
||||
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) },
|
||||
at.mocode.desktop.v2.VeranstaltungenUebersichtV2(
|
||||
onEventOpen = { vId, eId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, eId)) },
|
||||
onNewEvent = { onNavigate(AppScreen.VeranstaltungKonfig()) }
|
||||
)
|
||||
}
|
||||
|
||||
// Neuer Flow: Veranstalter auswählen → Detail → Veranstaltung-Übersicht
|
||||
is AppScreen.VeranstalterAuswahl -> VeranstalterAuswahlV2(
|
||||
onZurueck = { onNavigate(AppScreen.Veranstaltungen) },
|
||||
is AppScreen.VeranstalterAuswahl -> at.mocode.desktop.v2.VeranstalterAuswahlV2(
|
||||
onBack = { onNavigate(AppScreen.Veranstaltungen) },
|
||||
onWeiter = { veranstalterId -> onNavigate(AppScreen.VeranstalterDetail(veranstalterId)) },
|
||||
onNeuerVeranstalter = { onNavigate(AppScreen.VeranstalterNeu) },
|
||||
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
|
||||
)
|
||||
|
||||
is AppScreen.VeranstalterNeu -> VeranstalterNeuScreen(
|
||||
@@ -338,19 +337,15 @@ private fun DesktopContentArea(
|
||||
}
|
||||
is AppScreen.VeranstaltungKonfig -> {
|
||||
val vId = currentScreen.veranstalterId
|
||||
// V2: Validierung über StoreV2
|
||||
if (at.mocode.desktop.v2.StoreV2.vereine.none { it.id == vId }) {
|
||||
InvalidContextNotice(
|
||||
message = "Veranstalter (ID=$vId) nicht gefunden.",
|
||||
onBack = { onNavigate(AppScreen.VeranstalterAuswahl) }
|
||||
)
|
||||
} else {
|
||||
at.mocode.desktop.v2.VeranstaltungKonfigV2(
|
||||
veranstalterId = vId,
|
||||
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
|
||||
onSaved = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) }
|
||||
)
|
||||
}
|
||||
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard
|
||||
at.mocode.desktop.v2.VeranstaltungKonfigV2(
|
||||
veranstalterId = vId,
|
||||
onBack = {
|
||||
if (vId == 0L) onNavigate(AppScreen.Veranstaltungen)
|
||||
else onNavigate(AppScreen.VeranstalterDetail(vId))
|
||||
},
|
||||
onSaved = { evtId, finalVId -> onNavigate(AppScreen.VeranstaltungUebersicht(finalVId, evtId)) }
|
||||
)
|
||||
}
|
||||
is AppScreen.VeranstaltungUebersicht -> {
|
||||
val vId = currentScreen.veranstalterId
|
||||
@@ -391,7 +386,10 @@ private fun DesktopContentArea(
|
||||
// Turnier-Screens
|
||||
is AppScreen.TurnierDetail -> {
|
||||
val evtId = currentScreen.veranstaltungId
|
||||
if (!FakeVeranstaltungStore.exists(evtId)) {
|
||||
val parent = at.mocode.desktop.v2.StoreV2.vereine.firstOrNull { v ->
|
||||
at.mocode.desktop.v2.StoreV2.eventsFor(v.id).any { it.id == evtId }
|
||||
}
|
||||
if (parent == null) {
|
||||
InvalidContextNotice(
|
||||
message = "Veranstaltung (ID=$evtId) nicht gefunden.",
|
||||
onBack = { onNavigate(AppScreen.Veranstaltungen) }
|
||||
@@ -400,7 +398,7 @@ private fun DesktopContentArea(
|
||||
TurnierDetailScreen(
|
||||
veranstaltungId = evtId,
|
||||
turnierId = currentScreen.turnierId,
|
||||
onBack = { onNavigate(AppScreen.VeranstaltungDetail(evtId)) },
|
||||
onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -416,10 +414,11 @@ private fun DesktopContentArea(
|
||||
onBack = { onNavigate(AppScreen.Veranstaltungen) }
|
||||
)
|
||||
} else {
|
||||
TurnierWizardV2(
|
||||
at.mocode.desktop.v2.TurnierWizardV2(
|
||||
veranstalterId = parent.id,
|
||||
veranstaltungId = evtId,
|
||||
onBack = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
|
||||
onSave = { onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
|
||||
onSaved = { _ -> onNavigate(AppScreen.VeranstaltungUebersicht(parent.id, evtId)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -441,6 +440,14 @@ private fun DesktopContentArea(
|
||||
)
|
||||
}
|
||||
|
||||
// Vereins-Verwaltung
|
||||
is AppScreen.Vereine -> {
|
||||
val vereinViewModel: VereinViewModel = koinViewModel()
|
||||
VereinScreen(
|
||||
viewModel = vereinViewModel
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback → Root
|
||||
else -> AdminUebersichtScreen(
|
||||
onVeranstalterAuswahl = { onNavigate(AppScreen.VeranstalterAuswahl) },
|
||||
|
||||
+88
-1
@@ -12,21 +12,106 @@ data class Verein(
|
||||
|
||||
data class VeranstaltungV2(
|
||||
val id: Long,
|
||||
val veranstalterId: Long,
|
||||
var veranstalterId: Long,
|
||||
var titel: String,
|
||||
var datumVon: String,
|
||||
var datumBis: String?,
|
||||
var status: String = "In Vorbereitung",
|
||||
var beschreibung: String = "",
|
||||
var untertitel: String = "",
|
||||
var ort: String = "",
|
||||
var logoUrl: String? = null,
|
||||
var sponsoren: SnapshotStateList<String> = mutableStateListOf(),
|
||||
)
|
||||
|
||||
object StoreV2 {
|
||||
val oepsStammdaten: List<Verein> = listOf(
|
||||
Verein(1001, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
|
||||
Verein(1002, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
|
||||
Verein(1003, "Reitclub Ebelsberg", "V-OOE-0003", "Linz-Ebelsberg"),
|
||||
Verein(1004, "Union Reitverein Gschwandt", "V-OOE-0004", "Gschwandt"),
|
||||
Verein(1005, "Reitsportclub Gleisdorf", "V-ST-0005", "Gleisdorf"),
|
||||
Verein(1006, "Pferdesportzentrum Stadl-Paura", "V-OOE-0006", "Stadl-Paura"),
|
||||
)
|
||||
|
||||
val vereine: SnapshotStateList<Verein> = mutableStateListOf(
|
||||
Verein(1, "Union Reit- und Fahrverein Neumarkt/M.", "V-OOE-0001", "Neumarkt/M."),
|
||||
Verein(2, "Pferdesportverein Linz", "V-OOE-0002", "Linz"),
|
||||
)
|
||||
|
||||
fun addVerein(name: String, oeps: String, ort: String): Long {
|
||||
val id = (vereine.maxOfOrNull { it.id } ?: 0) + 1
|
||||
vereine.add(Verein(id, name, oeps, ort))
|
||||
return id
|
||||
}
|
||||
|
||||
private val veranstaltungen: MutableMap<Long, SnapshotStateList<VeranstaltungV2>> = mutableMapOf()
|
||||
|
||||
fun seed() {
|
||||
// Falls bereits Daten da sind (außer den statischen Vereinen), nichts tun
|
||||
if (veranstaltungen.isNotEmpty()) return
|
||||
|
||||
// 1. Neumarkt 2026 (ID 100)
|
||||
val neumarktId = 100L
|
||||
addEventFirst(
|
||||
1, VeranstaltungV2(
|
||||
id = neumarktId,
|
||||
veranstalterId = 1,
|
||||
titel = "Frühjahrsturnier Neumarkt/M. 2026",
|
||||
datumVon = "2026-04-10",
|
||||
datumBis = "2026-04-12",
|
||||
status = "Nennungsphase",
|
||||
beschreibung = "Traditionelles Frühjahrsturnier mit Spring- und Dressurprüfungen bis Klasse LM."
|
||||
)
|
||||
)
|
||||
|
||||
TurnierStoreV2.add(
|
||||
neumarktId,
|
||||
TurnierV2(101, neumarktId, 26128, datumVon = "2026-04-10", datumBis = "2026-04-12", znsDataLoaded = true).apply {
|
||||
kategorie.add("CSN-C-NEU")
|
||||
kategorie.add("CSNP-C-NEU")
|
||||
}
|
||||
)
|
||||
TurnierStoreV2.add(
|
||||
neumarktId,
|
||||
TurnierV2(102, neumarktId, 26129, datumVon = "2026-04-10", datumBis = "2026-04-12", znsDataLoaded = true).apply {
|
||||
kategorie.add("CDN-C-NEU")
|
||||
kategorie.add("CDNP-C-NEU")
|
||||
}
|
||||
)
|
||||
|
||||
// 2. Linz 2026 (ID 200)
|
||||
val linzId = 200L
|
||||
addEventFirst(
|
||||
2, VeranstaltungV2(
|
||||
id = linzId,
|
||||
veranstalterId = 2,
|
||||
titel = "Linzer Pferdefestival",
|
||||
datumVon = "2026-05-20",
|
||||
datumBis = "2026-05-24",
|
||||
status = "In Vorbereitung",
|
||||
beschreibung = "Großes Reit-Event am Ebelsberger Schlosspark."
|
||||
)
|
||||
)
|
||||
TurnierStoreV2.add(
|
||||
linzId,
|
||||
TurnierV2(201, linzId, 26500, datumVon = "2026-05-20", datumBis = "2026-05-24", znsDataLoaded = true).apply {
|
||||
kategorie.add("CSN-B*")
|
||||
})
|
||||
|
||||
// 3. Ein historisches Event (ID 300)
|
||||
addEventFirst(
|
||||
1, VeranstaltungV2(
|
||||
id = 300L,
|
||||
veranstalterId = 1,
|
||||
titel = "Herbst-Turnier 2025",
|
||||
datumVon = "2025-09-15",
|
||||
datumBis = "2025-09-17",
|
||||
status = "Abgeschlossen"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun eventsFor(vereinId: Long): SnapshotStateList<VeranstaltungV2> =
|
||||
veranstaltungen.getOrPut(vereinId) { mutableStateListOf() }
|
||||
|
||||
@@ -39,4 +124,6 @@ object StoreV2 {
|
||||
val idx = list.indexOfFirst { it.id == veranstaltungId }
|
||||
if (idx >= 0) list.removeAt(idx)
|
||||
}
|
||||
|
||||
fun allEvents(): List<VeranstaltungV2> = veranstaltungen.values.flatten()
|
||||
}
|
||||
|
||||
+1390
-116
File diff suppressed because it is too large
Load Diff
@@ -99,7 +99,7 @@ foojayResolver = "1.0.0"
|
||||
benManesVersions = "0.51.0"
|
||||
detekt = "1.23.6"
|
||||
ktlint = "12.1.1"
|
||||
dokka = "2.1.0"
|
||||
dokka = "2.2.0"
|
||||
firebaseDatabaseKtx = "22.0.1"
|
||||
|
||||
[libraries]
|
||||
|
||||
@@ -126,6 +126,9 @@ include(":frontend:features:zns-import-feature")
|
||||
include(":frontend:features:veranstalter-feature")
|
||||
include(":frontend:features:veranstaltung-feature")
|
||||
include(":frontend:features:profile-feature")
|
||||
include(":frontend:features:reiter-feature")
|
||||
include(":frontend:features:pferde-feature")
|
||||
include(":frontend:features:verein-feature")
|
||||
include(":frontend:features:turnier-feature")
|
||||
include(":frontend:features:billing-feature")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user