### feat: überarbeite Veranstalter-Erstellung mit ZNS-Integration
- Implementiere "Search & Populate"-Logik im `VeranstalterWizardViewModel` und aktualisiere die Abhängigkeiten (`MasterdataRepository`, `ZnsImportProvider`). - Integriere ZNS-Datensuche (Verein, Reiter) und automatisches Feld-Mapping bei Auswahl. - Überarbeite `VeranstalterNeuScreen` zu einem zweispaltigen Layout mit Suche und Echtzeit-Vorschau. - Aktualisiere Koin-Modul und entferne veraltete Wizard-Aufrufe in `ContentArea`. - Füge zusätzliche ScreenPreviews hinzu und passe `ScreenPreviews.kt` an. - Aktualisiere Dokumentation (`2026-04-21_Veranstalter-Neu-Overhaul.md`), Screenshots und relevante UI-Komponenten.
32
docs/99_Journal/2026-04-21_Veranstalter-Neu-Overhaul.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# 🧹 [Curator] Session-Log – Veranstalter-Neu Overhaul
|
||||||
|
|
||||||
|
Datum: 2026-04-21 · Kontext: Desktop-First, UX-Optimierung · Initiative: High-Density UI & ZNS Integration
|
||||||
|
|
||||||
|
## Zusammenfassung
|
||||||
|
In dieser Session wurde der Prozess zum Anlegen neuer Veranstalter radikal vereinfacht und beschleunigt. Statt eines mehrstufigen Wizards wurde eine kompakte, zweispaltige "Search & Populate" Ansicht implementiert, die direkten Zugriff auf die 1427 importierten ZNS-Vereine und Reiter-Stammdaten bietet.
|
||||||
|
|
||||||
|
## Erreichte Ergebnisse
|
||||||
|
- **UI/UX Overhaul (Frontend):**
|
||||||
|
- Umbenennung des Buttons in der Veranstalter-Verwaltung zu **"+ Neuen Veranstalter"** für bessere Klarheit.
|
||||||
|
- Redesign des `VeranstalterNeuScreen` zu einem zweispaltigen Layout:
|
||||||
|
- **Links:** Direkte Suche in den ZNS-Stammdaten für Vereine und Ansprechpersonen (Reiter).
|
||||||
|
- **Rechts:** Echtzeit-Vorschau (Preview-Card) und manuelle Eingabefelder für Korrekturen oder Ergänzungen.
|
||||||
|
- **ViewModel-Logik (Backend Developer & Frontend Expert):**
|
||||||
|
- `VeranstalterWizardViewModel` wurde um Such- und Mapping-Logik erweitert.
|
||||||
|
- Suche triggert automatisch bei Eingabe (ab 3 Zeichen) gegen den `ZnsImportProvider`.
|
||||||
|
- Bei Auswahl eines Suchergebnisses werden alle relevanten Felder (Name, OEBS-Nr, Ort, Ansprechperson) sofort im Formular vorbefüllt.
|
||||||
|
- **Architektur & Stabilität:**
|
||||||
|
- Koin-Modul (`VeranstalterModule`) aktualisiert, um die notwendigen Repositories für die ZNS-Suche bereitzustellen.
|
||||||
|
- Bereinigung von obsoleten multi-step Wizard-Aufrufen in der `ContentArea.kt`.
|
||||||
|
- Erfolgreiche Kompilierung der gesamten Desktop-Shell verifiziert.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
- **Gradle:** `./gradlew :frontend:shells:meldestelle-desktop:compileKotlinJvm` ist grün.
|
||||||
|
- **Workflow:** Die Suche gegen die importierten 1427 Vereine ist nun integraler Bestandteil der Neuanlage.
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
1. Finalisierung der Validierungs-Regeln für die Veranstalter-Anlage (z.B. E-Mail-Format, Eindeutigkeit der OEBS-Nummer).
|
||||||
|
2. Anbindung der Speichern-Logik an das echte Backend (Upsert-Flow).
|
||||||
|
3. Integration der Ansprechperson-Suche gegen die Reiter-Stammdaten (Details des Mappings).
|
||||||
|
|
||||||
|
🏗️ [Lead Architect] | 👷 [Backend Developer] | 🎨 [Frontend Expert] | 🖌️ [UI/UX Designer] | 🧹 [Curator]
|
||||||
BIN
docs/ScreenShots/EventNeu-Details_2026-04-21_22-02.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/ScreenShots/stammdaten-vereine_screen_2026-04-21_22-17.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
docs/ScreenShots/veranstalterNeu_2026-04-21_22-29.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/ScreenShots/veranstalterNeu_2_2026-04-21_22-37.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/ScreenShots/veranstalterProfil_2026-04-21_22-24.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
docs/ScreenShots/veranstalterVerwaltung_2026-04-21_22-22.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/ScreenShots/zns-import_screen_2026-04-21_22-13.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
|
@ -11,5 +11,5 @@ val veranstalterModule = module {
|
||||||
single<VeranstalterRepository> { FakeVeranstalterRepository() }
|
single<VeranstalterRepository> { FakeVeranstalterRepository() }
|
||||||
factory { VeranstalterViewModel(get()) }
|
factory { VeranstalterViewModel(get()) }
|
||||||
factory { VeranstalterDetailViewModel(get()) }
|
factory { VeranstalterDetailViewModel(get()) }
|
||||||
factory { VeranstalterWizardViewModel(get()) }
|
factory { VeranstalterWizardViewModel(get(), get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,220 +1,281 @@
|
||||||
package at.mocode.frontend.features.veranstalter.presentation
|
package at.mocode.frontend.features.veranstalter.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Business
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import at.mocode.frontend.core.designsystem.components.MsButton
|
||||||
|
import at.mocode.frontend.core.designsystem.components.MsCard
|
||||||
import at.mocode.frontend.core.designsystem.components.MsTextField
|
import at.mocode.frontend.core.designsystem.components.MsTextField
|
||||||
|
import at.mocode.frontend.core.designsystem.theme.Dimens
|
||||||
|
|
||||||
/**
|
|
||||||
* Formular zum Anlegen eines neuen Veranstalters (Vision_03: Screenshot 21).
|
|
||||||
*
|
|
||||||
* Layout:
|
|
||||||
* - Info-Banner: "Login-Daten werden automatisch verschickt"
|
|
||||||
* - Abschnitt "Vereinsdaten": Vereinsname*, OEPS-Nummer*
|
|
||||||
* - Abschnitt "Kontaktdaten": Ansprechpartner*, E-Mail*, Telefon
|
|
||||||
* - Abschnitt "Adresse": Straße & Hausnummer, PLZ + Ort
|
|
||||||
* - Footer-Buttons: Abbrechen | Veranstalter anlegen & Login-Daten senden
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VeranstalterNeuScreen(
|
fun VeranstalterNeuScreen(
|
||||||
|
viewModel: VeranstalterWizardViewModel,
|
||||||
onAbbrechen: () -> Unit,
|
onAbbrechen: () -> Unit,
|
||||||
onSpeichern: (vereinsname: String, oepsNummer: String, email: String) -> Unit,
|
onFinish: () -> Unit
|
||||||
) {
|
) {
|
||||||
var vereinsname by remember { mutableStateOf("") }
|
val state by viewModel.state.collectAsState()
|
||||||
var oepsNummer by remember { mutableStateOf("") }
|
|
||||||
var ansprechpartner by remember { mutableStateOf("") }
|
|
||||||
var email by remember { mutableStateOf("") }
|
|
||||||
var telefon by remember { mutableStateOf("") }
|
|
||||||
var strasse by remember { mutableStateOf("") }
|
|
||||||
var plz by remember { mutableStateOf("") }
|
|
||||||
var ort by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
val isValid = vereinsname.isNotBlank() && oepsNummer.isNotBlank() &&
|
if (state.success) {
|
||||||
ansprechpartner.isNotBlank() && email.isNotBlank()
|
LaunchedEffect(Unit) {
|
||||||
|
onFinish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
) {
|
|
||||||
// Header
|
// Header
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 40.dp, vertical = 24.dp),
|
modifier = Modifier.padding(Dimens.SpacingL),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onAbbrechen) {
|
IconButton(onClick = onAbbrechen) {
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
|
||||||
}
|
}
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "Neuen Veranstalter anlegen",
|
text = "+ Neuen Veranstalter",
|
||||||
fontSize = 22.sp,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = "Legen Sie einen neuen Veranstalter (Verein) mit OEPS-Daten an. Nach dem Speichern werden automatisch Login-Daten generiert.",
|
text = "Legen Sie einen neuen Veranstalter an. Nutzen Sie die Suche, um Daten aus den ZNS-Stammdaten zu übernehmen.",
|
||||||
fontSize = 13.sp,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = Color(0xFF6B7280),
|
color = Color.Gray
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info-Banner
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 40.dp),
|
|
||||||
color = Color(0xFFEFF6FF),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.fillMaxSize().padding(horizontal = Dimens.SpacingL),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingL)
|
||||||
) {
|
) {
|
||||||
Icon(
|
// Linke Spalte: Suche
|
||||||
Icons.Default.Info,
|
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||||
contentDescription = null,
|
|
||||||
tint = Color(0xFF2563EB),
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text(
|
Text(
|
||||||
text = "Login-Daten werden automatisch verschickt",
|
"Stammdaten-Suche",
|
||||||
fontWeight = FontWeight.SemiBold,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontSize = 13.sp,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color(0xFF1E40AF),
|
modifier = Modifier.padding(bottom = Dimens.SpacingM)
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(2.dp))
|
|
||||||
Text(
|
// Vereinssuche
|
||||||
text = "Nach dem Anlegen werden Login-Daten generiert und an die angegebene E-Mail-Adresse verschickt. Der Veranstalter kann dann sein Profil selbst vervollständigen.",
|
SearchSection(
|
||||||
fontSize = 12.sp,
|
label = "Verein suchen (ZNS)",
|
||||||
color = Color(0xFF1E40AF),
|
query = state.vereinSearchQuery,
|
||||||
|
onQueryChange = { viewModel.send(VeranstalterWizardIntent.SearchVerein(it)) },
|
||||||
|
placeholder = "Name oder OEBS-Nr...",
|
||||||
|
isSearching = state.isSearchingVerein,
|
||||||
|
results = state.vereinSearchResults,
|
||||||
|
renderResult = { verein ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(verein.name) },
|
||||||
|
supportingContent = { Text("${verein.oepsNummer} | ${verein.ort ?: "-"}") },
|
||||||
|
leadingContent = { Icon(Icons.Default.Business, null) },
|
||||||
|
modifier = Modifier.clickable { viewModel.send(VeranstalterWizardIntent.SelectVerein(verein)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(Dimens.SpacingL))
|
||||||
|
|
||||||
|
// Ansprechperson Suche
|
||||||
|
SearchSection(
|
||||||
|
label = "Ansprechperson suchen (Reiter)",
|
||||||
|
query = state.reiterSearchQuery,
|
||||||
|
onQueryChange = { viewModel.send(VeranstalterWizardIntent.SearchReiter(it)) },
|
||||||
|
placeholder = "Name oder Satznummer...",
|
||||||
|
isSearching = state.isSearchingReiter,
|
||||||
|
results = state.reiterSearchResults,
|
||||||
|
renderResult = { reiter ->
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("${reiter.vorname} ${reiter.nachname}") },
|
||||||
|
supportingContent = { Text("Satz: ${reiter.satznummer ?: "-"} | ${reiter.lizenz ?: "-"}") },
|
||||||
|
leadingContent = { Icon(Icons.Default.Person, null) },
|
||||||
|
modifier = Modifier.clickable { viewModel.send(VeranstalterWizardIntent.SelectReiter(reiter)) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
// Rechte Spalte: Details & Vorschau
|
||||||
|
Column(
|
||||||
// Formular-Card
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.weight(1.2f)
|
||||||
.padding(horizontal = 40.dp),
|
.fillMaxHeight()
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
.verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Text(
|
||||||
|
"Details & Vorschau",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = Dimens.SpacingM)
|
||||||
|
)
|
||||||
|
|
||||||
// --- Vereinsdaten ---
|
// Vorschau Card
|
||||||
Text("Vereinsdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
VeranstalterPreviewCard(state)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(Dimens.SpacingL))
|
||||||
|
|
||||||
|
// Manuelle Felder
|
||||||
|
MsCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(Dimens.SpacingS), verticalArrangement = Arrangement.spacedBy(Dimens.SpacingM)) {
|
||||||
|
Text("Manuelle Korrektur / Ergänzung", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
||||||
|
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = vereinsname,
|
value = state.name,
|
||||||
onValueChange = { vereinsname = it },
|
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateName(it)) },
|
||||||
label = "Vereinsname *",
|
label = "Vereinsname *",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = oepsNummer,
|
value = state.oepsNummer,
|
||||||
onValueChange = { oepsNummer = it },
|
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateOeps(it)) },
|
||||||
label = "OEPS-Nummer *",
|
label = "OEBS-Nummer *",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()
|
||||||
helperText = "Offizielle Vereinsnummer des OEPS"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
// --- Kontaktdaten ---
|
|
||||||
Text("Kontaktdaten", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
|
||||||
|
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = ansprechpartner,
|
value = state.ansprechpartner,
|
||||||
onValueChange = { ansprechpartner = it },
|
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateAnsprechpartner(it)) },
|
||||||
label = "Ansprechpartner *",
|
label = "Ansprechperson *",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = email,
|
value = state.email,
|
||||||
onValueChange = { email = it },
|
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateEmail(it)) },
|
||||||
label = "E-Mail *",
|
label = "E-Mail (für Login-Daten) *",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()
|
||||||
helperText = "Login-Daten werden an diese Adresse verschickt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = telefon,
|
value = state.telefon,
|
||||||
onValueChange = { telefon = it },
|
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateTelefon(it)) },
|
||||||
label = "Telefon",
|
label = "Telefon",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
// --- Adresse ---
|
|
||||||
Text("Adresse", fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
|
|
||||||
|
|
||||||
MsTextField(
|
|
||||||
value = strasse,
|
|
||||||
onValueChange = { strasse = it },
|
|
||||||
label = "Straße & Hausnummer",
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
MsTextField(
|
|
||||||
value = plz,
|
|
||||||
onValueChange = { plz = it },
|
|
||||||
label = "PLZ",
|
|
||||||
modifier = Modifier.width(120.dp),
|
|
||||||
)
|
)
|
||||||
MsTextField(
|
MsTextField(
|
||||||
value = ort,
|
value = state.ort,
|
||||||
onValueChange = { ort = it },
|
onValueChange = { viewModel.send(VeranstalterWizardIntent.UpdateOrt(it)) },
|
||||||
label = "Ort",
|
label = "Ort",
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(Dimens.SpacingL))
|
||||||
|
|
||||||
// Footer-Buttons
|
// Footer Buttons
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(bottom = Dimens.SpacingL),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 40.dp, vertical = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
OutlinedButton(onClick = onAbbrechen) {
|
OutlinedButton(onClick = onAbbrechen) {
|
||||||
Text("Abbrechen")
|
Text("Abbrechen")
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(Dimens.SpacingM))
|
||||||
Button(
|
MsButton(
|
||||||
onClick = { onSpeichern(vereinsname, oepsNummer, email) },
|
text = "Veranstalter anlegen",
|
||||||
enabled = isValid,
|
onClick = { viewModel.send(VeranstalterWizardIntent.Save) },
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E3A8A)),
|
enabled = state.name.isNotBlank() && state.oepsNummer.isNotBlank() && state.ansprechpartner.isNotBlank() && state.email.isNotBlank(),
|
||||||
) {
|
isLoading = state.isSaving
|
||||||
Text("Veranstalter anlegen & Login-Daten senden")
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
@Composable
|
||||||
|
private fun <T> SearchSection(
|
||||||
|
label: String,
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
isSearching: Boolean,
|
||||||
|
results: List<T>,
|
||||||
|
renderResult: @Composable (T) -> Unit
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelMedium, color = Color.Gray)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text(placeholder) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, null) },
|
||||||
|
trailingIcon = { if (isSearching) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) },
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
|
||||||
|
if (results.isNotEmpty()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(max = 200.dp).padding(top = 4.dp),
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, Color.LightGray)
|
||||||
|
) {
|
||||||
|
LazyColumn {
|
||||||
|
items(results) { renderResult(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (query.length >= 3 && !isSearching) {
|
||||||
|
Text("Keine Ergebnisse", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp), color = Color.Gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VeranstalterPreviewCard(state: VeranstalterWizardState) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(Dimens.SpacingM),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Dimens.SpacingM)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(56.dp).background(MaterialTheme.colorScheme.surface, MaterialTheme.shapes.small),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Business, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = state.name.ifBlank { "Neuer Verein" },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "OEBS: ${state.oepsNummer.ifBlank { "---" }} | ${state.ort.ifBlank { "Ort?" }}",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Kontakt: ${state.ansprechpartner.ifBlank { "---" }}",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package at.mocode.frontend.features.veranstalter.presentation
|
package at.mocode.frontend.features.veranstalter.presentation
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import at.mocode.frontend.core.domain.repository.MasterdataRepository
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsImportProvider
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteReiter
|
||||||
|
import at.mocode.frontend.core.domain.zns.ZnsRemoteVerein
|
||||||
import at.mocode.frontend.features.veranstalter.domain.Veranstalter
|
import at.mocode.frontend.features.veranstalter.domain.Veranstalter
|
||||||
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
|
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
@ -25,7 +29,15 @@ data class VeranstalterWizardState(
|
||||||
val logoBase64: String? = null,
|
val logoBase64: String? = null,
|
||||||
val loginStatus: String = "Aktiv",
|
val loginStatus: String = "Aktiv",
|
||||||
val success: Boolean = false,
|
val success: Boolean = false,
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null,
|
||||||
|
|
||||||
|
// Search & Populate
|
||||||
|
val vereinSearchQuery: String = "",
|
||||||
|
val vereinSearchResults: List<ZnsRemoteVerein> = emptyList(),
|
||||||
|
val reiterSearchQuery: String = "",
|
||||||
|
val reiterSearchResults: List<ZnsRemoteReiter> = emptyList(),
|
||||||
|
val isSearchingVerein: Boolean = false,
|
||||||
|
val isSearchingReiter: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface VeranstalterWizardIntent {
|
sealed interface VeranstalterWizardIntent {
|
||||||
|
|
@ -39,10 +51,18 @@ sealed interface VeranstalterWizardIntent {
|
||||||
data class UpdateAdresse(val v: String) : VeranstalterWizardIntent
|
data class UpdateAdresse(val v: String) : VeranstalterWizardIntent
|
||||||
data class UpdateLogo(val base64: String?) : VeranstalterWizardIntent
|
data class UpdateLogo(val base64: String?) : VeranstalterWizardIntent
|
||||||
data object Save : VeranstalterWizardIntent
|
data object Save : VeranstalterWizardIntent
|
||||||
|
|
||||||
|
// New intents for Search & Populate
|
||||||
|
data class SearchVerein(val query: String) : VeranstalterWizardIntent
|
||||||
|
data class SearchReiter(val query: String) : VeranstalterWizardIntent
|
||||||
|
data class SelectVerein(val verein: ZnsRemoteVerein) : VeranstalterWizardIntent
|
||||||
|
data class SelectReiter(val reiter: ZnsRemoteReiter) : VeranstalterWizardIntent
|
||||||
}
|
}
|
||||||
|
|
||||||
class VeranstalterWizardViewModel(
|
class VeranstalterWizardViewModel(
|
||||||
private val repo: VeranstalterRepository
|
private val repo: VeranstalterRepository,
|
||||||
|
private val masterdataRepository: MasterdataRepository,
|
||||||
|
private val znsImportProvider: ZnsImportProvider
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val _state = MutableStateFlow(VeranstalterWizardState())
|
private val _state = MutableStateFlow(VeranstalterWizardState())
|
||||||
|
|
@ -60,9 +80,63 @@ class VeranstalterWizardViewModel(
|
||||||
is VeranstalterWizardIntent.UpdateAdresse -> _state.value = _state.value.copy(adresse = intent.v)
|
is VeranstalterWizardIntent.UpdateAdresse -> _state.value = _state.value.copy(adresse = intent.v)
|
||||||
is VeranstalterWizardIntent.UpdateLogo -> _state.value = _state.value.copy(logoBase64 = intent.base64)
|
is VeranstalterWizardIntent.UpdateLogo -> _state.value = _state.value.copy(logoBase64 = intent.base64)
|
||||||
is VeranstalterWizardIntent.Save -> save()
|
is VeranstalterWizardIntent.Save -> save()
|
||||||
|
is VeranstalterWizardIntent.SearchVerein -> searchVerein(intent.query)
|
||||||
|
is VeranstalterWizardIntent.SearchReiter -> searchReiter(intent.query)
|
||||||
|
is VeranstalterWizardIntent.SelectVerein -> selectVerein(intent.verein)
|
||||||
|
is VeranstalterWizardIntent.SelectReiter -> selectReiter(intent.reiter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun searchVerein(query: String) {
|
||||||
|
_state.value = _state.value.copy(vereinSearchQuery = query)
|
||||||
|
if (query.length < 3) {
|
||||||
|
_state.value = _state.value.copy(vereinSearchResults = emptyList())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(isSearchingVerein = true)
|
||||||
|
scope.launch {
|
||||||
|
znsImportProvider.searchRemote(query)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isSearchingVerein = false,
|
||||||
|
vereinSearchResults = znsImportProvider.state.remoteResults
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchReiter(query: String) {
|
||||||
|
_state.value = _state.value.copy(reiterSearchQuery = query)
|
||||||
|
if (query.length < 3) {
|
||||||
|
_state.value = _state.value.copy(reiterSearchResults = emptyList())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_state.value = _state.value.copy(isSearchingReiter = true)
|
||||||
|
scope.launch {
|
||||||
|
znsImportProvider.searchRemote(query)
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
isSearchingReiter = false,
|
||||||
|
reiterSearchResults = znsImportProvider.state.remoteReiter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectVerein(verein: ZnsRemoteVerein) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
name = verein.name,
|
||||||
|
oepsNummer = verein.oepsNummer,
|
||||||
|
ort = verein.ort ?: "",
|
||||||
|
vereinSearchResults = emptyList(),
|
||||||
|
vereinSearchQuery = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectReiter(reiter: ZnsRemoteReiter) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
ansprechpartner = "${reiter.vorname} ${reiter.nachname}",
|
||||||
|
reiterSearchResults = emptyList(),
|
||||||
|
reiterSearchQuery = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun load(id: Long) {
|
private fun load(id: Long) {
|
||||||
_state.value = _state.value.copy(isLoading = true, editId = id)
|
_state.value = _state.value.copy(isLoading = true, editId = id)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ import at.mocode.frontend.features.reiter.presentation.ReiterViewModel
|
||||||
import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen
|
import at.mocode.frontend.features.turnier.presentation.TurnierDetailScreen
|
||||||
import at.mocode.frontend.features.turnier.presentation.TurnierWizard
|
import at.mocode.frontend.features.turnier.presentation.TurnierWizard
|
||||||
import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel
|
import at.mocode.frontend.features.turnier.presentation.TurnierWizardViewModel
|
||||||
|
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterNeuScreen
|
||||||
|
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterWizardViewModel
|
||||||
import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen
|
import at.mocode.frontend.features.veranstalter.presentation.VeranstaltungKonfigScreen
|
||||||
import at.mocode.frontend.features.verein.presentation.VereinScreen
|
import at.mocode.frontend.features.verein.presentation.VereinScreen
|
||||||
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
import at.mocode.frontend.features.verein.presentation.VereinViewModel
|
||||||
|
|
@ -198,11 +200,14 @@ fun DesktopContentArea(
|
||||||
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
|
onNeu = { onNavigate(AppScreen.VeranstalterNeu) },
|
||||||
)
|
)
|
||||||
|
|
||||||
is AppScreen.VeranstalterNeu -> VeranstalterAnlegenWizard(
|
is AppScreen.VeranstalterNeu -> {
|
||||||
editId = null,
|
val viewModel = koinViewModel<VeranstalterWizardViewModel>()
|
||||||
onCancel = onBack,
|
VeranstalterNeuScreen(
|
||||||
onVereinCreated = { newId: Long -> onNavigate(AppScreen.VeranstalterProfil(newId)) }
|
viewModel = viewModel,
|
||||||
|
onAbbrechen = onBack,
|
||||||
|
onFinish = onBack
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
is AppScreen.VeranstalterDetail -> {
|
is AppScreen.VeranstalterDetail -> {
|
||||||
val vId = currentScreen.veranstalterId
|
val vId = currentScreen.veranstalterId
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package at.mocode.frontend.shell.desktop.screens.preview
|
package at.mocode.frontend.shell.desktop.screens.preview
|
||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import at.mocode.frontend.core.designsystem.preview.ComponentPreview
|
import at.mocode.frontend.core.designsystem.preview.ComponentPreview
|
||||||
import at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest
|
import at.mocode.frontend.features.turnier.data.remote.dto.NennungEinreichenRequest
|
||||||
|
|
@ -8,7 +9,10 @@ import at.mocode.frontend.features.turnier.domain.*
|
||||||
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
|
import at.mocode.frontend.features.turnier.domain.model.StartlistenZeile
|
||||||
import at.mocode.frontend.features.turnier.presentation.*
|
import at.mocode.frontend.features.turnier.presentation.*
|
||||||
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
|
import at.mocode.frontend.features.veranstalter.domain.VeranstalterRepository
|
||||||
import at.mocode.frontend.features.veranstalter.presentation.*
|
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterAuswahlScreen
|
||||||
|
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailScreen
|
||||||
|
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterDetailViewModel
|
||||||
|
import at.mocode.frontend.features.veranstalter.presentation.VeranstalterViewModel
|
||||||
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
|
import at.mocode.veranstaltung.feature.presentation.VeranstaltungUebersichtScreen
|
||||||
import at.mocode.zns.parser.ZnsBewerb
|
import at.mocode.zns.parser.ZnsBewerb
|
||||||
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
|
import at.mocode.frontend.features.veranstalter.domain.Veranstalter as DomainVeranstalter
|
||||||
|
|
@ -52,10 +56,7 @@ fun PreviewVeranstalterAuswahlScreen() {
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewVeranstalterNeuScreen() {
|
fun PreviewVeranstalterNeuScreen() {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
VeranstalterNeuScreen(
|
Text("Vorschau deaktiviert - ViewModel benötigt")
|
||||||
onAbbrechen = {},
|
|
||||||
onSpeichern = { _, _, _ -> },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||