16 Commits

Author SHA1 Message Date
stefan f44b2c8126 feat(event-feature): enhance Veranstaltungs- & Turnier-Workflow
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Waiting to run
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Waiting to run
- Extended `Veranstaltung` domain model with new fields: `untertitel`, `logoUrl`, and `sponsoren`.
- Refined navigation in `DesktopMainLayout.kt` to check turnier context and improve routing.
- Overhauled `TurnierStammdatenTab` with enhanced interactivity: dynamic chip-based selectors for Spartens, Klassen, and Sponsors, as well as date pickers and ZNS import handling.
- Implemented validations for date ranges and required fields.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 17:33:14 +02:00
stefan 496e801943 feat(verein-feature): add Vereinsverwaltung module with screens, ViewModel, and integration
- Introduced `verein-feature` module for managing Vereine, including list, detail, and editor views using `MsMasterDetailLayout`.
- Added new domain models (`Verein`, `VereinStatus`) and integrated mock data for development.
- Registered the new feature in `settings.gradle.kts` and `DesktopMainLayout.kt`, including breadcrumb navigation and entry point.
- Updated `VeranstaltungenUebersichtV2` to add Vereine as a quick-access KPI tile.
- Removed unnecessary logout functionality and adjusted the root navigation for consistency.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 15:00:24 +02:00
stefan 1699c24875 feat(workflow): introduce Event-First workflow with improved UX and test data seeding
- Replaced `VeranstalterAuswahlV2` with `VeranstaltungenUebersichtV2` for a direct entry point to event management.
- Integrated onboarding directly into the event creation flow (`VeranstaltungKonfigV2`).
- Added realistic test data (`StoreV2.seed()`) for instant workflow testing.
- Updated initial navigation flow (`DesktopApp.kt`) to prioritize the event-first approach.
- Enhanced screen and component interactions to streamline the user journey in offline-first mode.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 13:34:42 +02:00
stefan 683ef956fc docs(adr): add ADR-0020 for LAN communication and data isolation architecture
- Documented decision for Peer-to-Peer (P2P) model with mDNS discovery, WebSocket transport, and shared security keys.
- Addressed data isolation with namespacing by turnierId.
- Updated roadmap to reflect progress in Phase 6: Vernetzung & Inter-App Kommunikation.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 13:02:08 +02:00
stefan 6bbf6dc966 feat(pferde-feature): introduce Pferde management module with screens, ViewModel, and domain models
- Added `pferde-feature` module for managing horses, including list, detail, and editing views.
- Implemented `MsMasterDetailLayout` for PferdeScreen, integrating `MsDataTable`, `MsFilterBar`, and `MsActionToolbar`.
- Defined domain models (`Pferd`, `Geschlecht`, `PferdeStatus`) with mock data support.
- Updated roadmap to mark `Pferde-Verwaltung (MVP)` as complete.
- Registered the new module in `settings.gradle.kts` and `meldestelle-desktop` build configuration.
- Added previews for Pferde and Reiter components to support IDE render.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 12:20:03 +02:00
stefan 94306329c9 feat(reiter-feature): introduce Reiter management module with screens, ViewModel, and domain models
- Added `reiter-feature` module for managing riders, including list and detail views.
- Implemented `MsMasterDetailLayout` for ReiterScreen, integrating `MsDataTable`, `MsFilterBar`, and `MsActionToolbar`.
- Defined domain models (`Reiter`, `LizenzKlasse`, `Sparte`, `ReiterStatus`) with mock data support.
- Updated roadmap to reflect progress in Phase 5: Routing & Screen-Komposition.
- Registered the new module in `settings.gradle.kts`.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 11:09:41 +02:00
stefan 659e699c33 feat(design-system): add MsDialogShell and MsConfirmDialog components, and update roadmap
- Introduced `MsDialogShell` for a standardized modal dialog framework.
- Added `MsConfirmDialog` as a utility for common confirmation dialogs.
- Updated `Frontend_Komponenten_Roadmap.md` to mark `MsDialogShell` as complete.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:57:09 +02:00
stefan 96c9abb264 feat(design-system): add MsActionToolbar component and update roadmap
- Introduced `MsActionToolbar` for consistent placement of primary actions like Save, Cancel, Add, and Delete.
- Updated `Frontend_Komponenten_Roadmap.md` to mark this component as complete in Phase 4.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:52:58 +02:00
stefan ad529f7395 feat(design-system): add MsMasterDetailLayout component and update roadmap
- Introduced `MsMasterDetailLayout` as a standardized layout for master-detail screens, including support for customizable headers and split proportions.
- Updated `Frontend_Komponenten_Roadmap.md` to mark this component as complete in Phase 4.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:52:30 +02:00
stefan b2a0883388 feat(design-system): add MsSearchableSelect component and update roadmap
- Introduced `MsSearchableSelect` for autocomplete search and selection of objects like riders, horses, or clubs.
- Updated `Frontend_Komponenten_Roadmap.md` to mark Phase 3 as complete.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:46:26 +02:00
stefan 442caa59ff feat(design-system): add MsValidationWrapper component and update roadmap
- Introduced `MsValidationWrapper` for consistent display of validation messages (errors, warnings, info) in input components.
- Updated `Frontend_Komponenten_Roadmap.md` to mark this component as complete in Phase 3.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:42:40 +02:00
stefan 340f341594 feat(design-system): add MsEnumDropdown component and update roadmap
- Introduced a reusable `MsEnumDropdown` component for selecting enum values with customizable options, labels, and error handling.
- Updated `Frontend_Komponenten_Roadmap.md` to reflect progress in Phase 3, marking `MsEnumDropdown` as completed.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:40:50 +02:00
stefan 5980fbe14f feat(design-system): add MsFilterBar, MsDataTable, and MsStatusBadge components
- Implemented MsFilterBar for search, filters, and result count display in list views.
- Introduced MsDataTable for high-density, flexible data visualization with column definitions and alternate row styling.
- Added MsStatusBadge for compact, reusable status indicators with predefined styles (Success, Warning, Error, Info).
- Updated roadmap documentation to mark these components as complete in Phase 2.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:30:43 +02:00
stefan 2d532eb41c docs(roadmap): mark Phase 1 as complete and update progress in Frontend_Komponenten_Roadmap.md
- Marked Phase 1 (`Cleanup & Konsolidierung`) as complete and updated task checklists accordingly.
- Recorded fixes for `ping-feature` and `profile-feature` references in the roadmap.
- Improved clarity for completed refactorings and theme adjustments.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:10:31 +02:00
stefan c20899752a refactor(design-system): consolidate components and standardize naming with Ms prefix
- Removed redundant files: `AppFooter`, `AppHeader`, `AppScaffold`, `LoadingIndicator`, `MeldestelleButton`, `MeldestelleTextField`, `DashboardCard`.
- Introduced `MsFooter`, `MsHeader`, `MsScaffold`, `MsLoadingIndicator`, `MsButton`, `MsTextField`, `MsCard` with standardized implementation and naming.
- Updated references in `profile-feature` and `ping-feature` to use the new components.
- Aligned with roadmap goals for a consistent, reusable, and high-density design system.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 10:07:17 +02:00
stefan 5065febca2 chore(dependencies): bump Dokka version to 2.2.0 in libs.versions.toml
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-03-31 08:37:27 +02:00
59 changed files with 4475 additions and 417 deletions
@@ -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)
@@ -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
@@ -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")
@@ -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,
@@ -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
@@ -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)
}
@@ -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.*
+14 -2
View File
@@ -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)
+1
View File
@@ -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

@@ -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
)
}
}
@@ -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
)
}
}
}
@@ -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,
@@ -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)
}
}
}
}
@@ -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)
}
}
}
}
@@ -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
)
}
}
@@ -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)
)
}
}
}
@@ -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
)
}
@@ -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()
@@ -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,
@@ -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
)
@@ -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()
}
}
}
}
@@ -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(
@@ -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")
}
}
}
}
}
}
}
}
@@ -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
)
}
@@ -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,
@@ -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
)
}
}
@@ -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)
}
}
}
@@ -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))
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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()) {
@@ -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)
}
}
}
@@ -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))
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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",
@@ -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.:") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = turnierNr,
onValueChange = { turnierNr = it },
placeholder = { Text("z.B. 26128", fontSize = 13.sp) },
modifier = Modifier.width(200.dp).height(48.dp),
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)
}
}
FormRow("ZNS-Daten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {},
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)
}
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)
}
}
Text(
"Reiter-, Pferde-, Funktionärs- und Vereinsdaten vom OEPS Backend",
fontSize = 11.sp,
color = Color(0xFF6B7280),
FilterChip(
selected = typ == "ÖTO (National)",
onClick = { typ = "ÖTO (National)" },
label = { Text("ÖTO (National)") }
)
}
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)
}
}
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)
}
}
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),
FilterChip(
selected = typ == "FEI (International)",
onClick = { typ = "FEI (International)" },
label = { Text("FEI (International)") }
)
}
}
}
FormRow("Datum:") {
FormRow("ZNS-Stammdaten:") {
Row(horizontalArrangement = Arrangement.spacedBy(8.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,
Button(
onClick = { showZnsDialog = true },
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue)
) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import via Internet")
}
OutlinedButton(onClick = { showZnsDialog = true }) {
Icon(Icons.Default.Usb, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Import via USB")
}
}
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("bis", fontSize = 13.sp)
OutlinedTextField(
value = datumBis,
onValueChange = { datumBis = it },
placeholder = { Text("DD.MM.YYYY", fontSize = 12.sp) },
modifier = Modifier.width(160.dp).height(48.dp),
singleLine = true,
Text(
if (znsDataLoaded) "ZNS-Daten geladen" else "Keine ZNS-Daten geladen",
color = znsStatusColor,
fontWeight = FontWeight.Bold,
fontSize = 13.sp
)
}
}
}
// ── Turnier-Beschreibung ─────────────────────────────────────────────
SectionCard(title = "Turnier-Beschreibung") {
// ── 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("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("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 = von,
onValueChange = {},
label = { Text("Von") },
modifier = Modifier.width(160.dp).clickable { showDatePickerVon = true },
readOnly = true,
trailingIcon = { Icon(Icons.Default.DateRange, null) }
)
Text("bis")
OutlinedTextField(
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.") }
)
}
}
// ── 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()
)
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")
}
}
}
}
// ── Footer ──────────────────────────────────────────────────────────
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = { /* Speichern */ },
enabled = nrConfirmed && znsDataLoaded && kat.isNotEmpty() && von.isNotBlank() && titel.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
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") }
}
)
}
// ── 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)
}
}
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) }
}
// ── Aktions-Buttons ──────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = {}) { Text("Zurücksetzen") }
Spacer(Modifier.width(8.dp))
Button(
onClick = {},
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
) { Text("Speichern") }
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()
}
}
@@ -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)
}
}
}
@@ -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))
}
@@ -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)
)
}
}
}
@@ -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
)
}
}
@@ -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)
@@ -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)
}
}
@@ -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}")
}
@@ -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,20 +337,16 @@ 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 {
// Falls vId == 0, kommen wir aus der Gesamtübersicht und wählen erst im Wizard
at.mocode.desktop.v2.VeranstaltungKonfigV2(
veranstalterId = vId,
onBack = { onNavigate(AppScreen.VeranstalterDetail(vId)) },
onSaved = { evtId -> onNavigate(AppScreen.VeranstaltungUebersicht(vId, evtId)) }
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
val evtId = currentScreen.veranstaltungId
@@ -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) },
@@ -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()
}
+1 -1
View File
@@ -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]
+3
View File
@@ -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")