diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts new file mode 100644 index 00000000..69a1cf2f --- /dev/null +++ b/composeApp/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.compose.compiler) +} + +kotlin { + js(IR) { + browser { + commonWebpackConfig { + outputFileName = "composeApp.js" + } + } + binaries.executable() + } + + jvm("desktop") + + sourceSets { + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + + // Project dependencies + implementation(project(":shared-kernel")) + implementation(project(":member-management")) + implementation(project(":master-data")) + implementation(project(":horse-registry")) + implementation(project(":event-management")) + + // Kotlinx dependencies + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("com.benasher44:uuid:0.8.4") + + // Navigation + implementation("org.jetbrains.androidx.navigation:navigation-compose:2.7.0-alpha07") + + // ViewModel + implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0") + } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") + } + + jsMain.dependencies { + implementation(compose.html.core) + } + + val desktopMain by getting { + dependencies { + implementation(compose.desktop.currentOs) + } + } + } +} + +compose.experimental { + web.application {} +} diff --git a/composeApp/src/commonMain/kotlin/App.kt b/composeApp/src/commonMain/kotlin/App.kt new file mode 100644 index 00000000..feb97b57 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/App.kt @@ -0,0 +1,54 @@ +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import at.mocode.ui.screens.PersonListScreen +import at.mocode.ui.screens.CreatePersonScreen +import at.mocode.ui.theme.MeldestelleTheme +import at.mocode.di.AppDependencies + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun App() { + MeldestelleTheme { + val navController = rememberNavController() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Meldestelle - Reitersport Management") } + ) + } + ) { paddingValues -> + NavHost( + navController = navController, + startDestination = "person_list", + modifier = Modifier.padding(paddingValues) + ) { + composable("person_list") { + val viewModel = remember { AppDependencies.personListViewModel() } + PersonListScreen( + viewModel = viewModel, + onNavigateToCreatePerson = { + navController.navigate("create_person") + } + ) + } + composable("create_person") { + val viewModel = remember { AppDependencies.createPersonViewModel() } + CreatePersonScreen( + viewModel = viewModel, + onNavigateBack = { + navController.popBackStack() + } + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/at/mocode/di/AppDependencies.kt b/composeApp/src/commonMain/kotlin/at/mocode/di/AppDependencies.kt new file mode 100644 index 00000000..147cd67b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/mocode/di/AppDependencies.kt @@ -0,0 +1,80 @@ +package at.mocode.di + +import at.mocode.members.application.usecase.CreatePersonUseCase +import at.mocode.members.domain.repository.PersonRepository +import at.mocode.members.domain.repository.VereinRepository +import at.mocode.members.domain.service.MasterDataService +import at.mocode.ui.viewmodel.CreatePersonViewModel +import at.mocode.ui.viewmodel.PersonListViewModel + +/** + * Simple dependency injection container for the application. + * In a real application, you might want to use a proper DI framework like Koin. + */ +object AppDependencies { + + // Mock implementations for demonstration + // In a real application, these would be proper implementations + private val mockPersonRepository = object : PersonRepository { + override suspend fun save(person: at.mocode.members.domain.model.DomPerson): at.mocode.members.domain.model.DomPerson { + // Mock implementation - just return the person with an ID + return person.copy(id = com.benasher44.uuid.uuid4()) + } + + override suspend fun findById(id: com.benasher44.uuid.Uuid): at.mocode.members.domain.model.DomPerson? { + return null // Mock implementation + } + + override suspend fun findByOepsSatzNr(oepsSatzNr: String): at.mocode.members.domain.model.DomPerson? { + return null // Mock implementation + } + + override suspend fun existsByOepsSatzNr(oepsSatzNr: String): Boolean { + return false // Mock implementation - no duplicates for demo + } + + override suspend fun findAll(): List { + return emptyList() // Mock implementation + } + + override suspend fun delete(id: com.benasher44.uuid.Uuid) { + // Mock implementation + } + } + + private val mockVereinRepository = object : VereinRepository { + override suspend fun findById(id: com.benasher44.uuid.Uuid): at.mocode.members.domain.model.DomVerein? { + return null // Mock implementation + } + + override suspend fun existsById(id: com.benasher44.uuid.Uuid): Boolean { + return true // Mock implementation - assume all clubs exist + } + + override suspend fun findAll(): List { + return emptyList() // Mock implementation + } + } + + private val mockMasterDataService = object : MasterDataService { + override suspend fun countryExists(countryId: com.benasher44.uuid.Uuid): Boolean { + return true // Mock implementation - assume all countries exist + } + } + + // Use case instances + private val createPersonUseCase = CreatePersonUseCase( + personRepository = mockPersonRepository, + vereinRepository = mockVereinRepository, + masterDataService = mockMasterDataService + ) + + // ViewModel factory methods + fun createPersonViewModel(): CreatePersonViewModel { + return CreatePersonViewModel(createPersonUseCase) + } + + fun personListViewModel(): PersonListViewModel { + return PersonListViewModel(mockPersonRepository) + } +} diff --git a/composeApp/src/commonMain/kotlin/at/mocode/ui/screens/CreatePersonScreen.kt b/composeApp/src/commonMain/kotlin/at/mocode/ui/screens/CreatePersonScreen.kt new file mode 100644 index 00000000..8ab2191a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/mocode/ui/screens/CreatePersonScreen.kt @@ -0,0 +1,319 @@ +package at.mocode.ui.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import at.mocode.enums.GeschlechtE +import at.mocode.ui.viewmodel.CreatePersonViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreatePersonScreen( + viewModel: CreatePersonViewModel, + onNavigateBack: () -> Unit +) { + var showGeschlechtDropdown by remember { mutableStateOf(false) } + + // Handle success navigation + LaunchedEffect(viewModel.isSuccess) { + if (viewModel.isSuccess) { + onNavigateBack() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Person erstellen") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Zurück") + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Error message + viewModel.errorMessage?.let { error -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = error, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + + // Basic Information Section + Text( + text = "Grunddaten", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + OutlinedTextField( + value = viewModel.nachname, + onValueChange = viewModel::updateNachname, + label = { Text("Nachname *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = viewModel.vorname, + onValueChange = viewModel::updateVorname, + label = { Text("Vorname *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = viewModel.titel, + onValueChange = viewModel::updateTitel, + label = { Text("Titel") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("z.B. Dr., Ing.") } + ) + + OutlinedTextField( + value = viewModel.oepsSatzNr, + onValueChange = viewModel::updateOepsSatzNr, + label = { Text("OEPS Satznummer") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("6-stellige Nummer") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + OutlinedTextField( + value = viewModel.geburtsdatum, + onValueChange = viewModel::updateGeburtsdatum, + label = { Text("Geburtsdatum") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("YYYY-MM-DD") } + ) + + // Gender Dropdown + ExposedDropdownMenuBox( + expanded = showGeschlechtDropdown, + onExpandedChange = { showGeschlechtDropdown = !showGeschlechtDropdown } + ) { + OutlinedTextField( + value = viewModel.geschlecht?.let { + when(it) { + GeschlechtE.M -> "Männlich" + GeschlechtE.W -> "Weiblich" + GeschlechtE.D -> "Divers" + GeschlechtE.UNBEKANNT -> "Unbekannt" + } + } ?: "", + onValueChange = { }, + readOnly = true, + label = { Text("Geschlecht") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showGeschlechtDropdown) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = showGeschlechtDropdown, + onDismissRequest = { showGeschlechtDropdown = false } + ) { + GeschlechtE.entries.forEach { option -> + DropdownMenuItem( + text = { + Text(when(option) { + GeschlechtE.M -> "Männlich" + GeschlechtE.W -> "Weiblich" + GeschlechtE.D -> "Divers" + GeschlechtE.UNBEKANNT -> "Unbekannt" + }) + }, + onClick = { + viewModel.updateGeschlecht(option) + showGeschlechtDropdown = false + } + ) + } + } + } + + // Contact Information Section + Text( + text = "Kontaktdaten", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + OutlinedTextField( + value = viewModel.telefon, + onValueChange = viewModel::updateTelefon, + label = { Text("Telefon") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone) + ) + + OutlinedTextField( + value = viewModel.email, + onValueChange = viewModel::updateEmail, + label = { Text("E-Mail") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email) + ) + + // Address Section + Text( + text = "Adresse", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + OutlinedTextField( + value = viewModel.strasse, + onValueChange = viewModel::updateStrasse, + label = { Text("Straße und Hausnummer") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = viewModel.plz, + onValueChange = viewModel::updatePlz, + label = { Text("PLZ") }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + + OutlinedTextField( + value = viewModel.ort, + onValueChange = viewModel::updateOrt, + label = { Text("Ort") }, + modifier = Modifier.weight(2f), + singleLine = true + ) + } + + OutlinedTextField( + value = viewModel.adresszusatz, + onValueChange = viewModel::updateAdresszusatz, + label = { Text("Adresszusatz") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + // Additional Information Section + Text( + text = "Weitere Informationen", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + + OutlinedTextField( + value = viewModel.feiId, + onValueChange = viewModel::updateFeiId, + label = { Text("FEI ID") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + OutlinedTextField( + value = viewModel.mitgliedsNummer, + onValueChange = viewModel::updateMitgliedsNummer, + label = { Text("Mitgliedsnummer beim Stammverein") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = viewModel.istGesperrt, + onCheckedChange = viewModel::updateIstGesperrt + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Person ist gesperrt") + } + + if (viewModel.istGesperrt) { + OutlinedTextField( + value = viewModel.sperrGrund, + onValueChange = viewModel::updateSperrGrund, + label = { Text("Sperrgrund") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 3 + ) + } + + OutlinedTextField( + value = viewModel.notizen, + onValueChange = viewModel::updateNotizen, + label = { Text("Interne Notizen") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 4 + ) + + // Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onNavigateBack, + modifier = Modifier.weight(1f), + enabled = !viewModel.isLoading + ) { + Text("Abbrechen") + } + + Button( + onClick = { + viewModel.createPerson() + }, + modifier = Modifier.weight(1f), + enabled = !viewModel.isLoading + ) { + if (viewModel.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Text("Erstellen") + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/at/mocode/ui/screens/PersonListScreen.kt b/composeApp/src/commonMain/kotlin/at/mocode/ui/screens/PersonListScreen.kt new file mode 100644 index 00000000..0bc2f032 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/mocode/ui/screens/PersonListScreen.kt @@ -0,0 +1,212 @@ +package at.mocode.ui.screens + +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.Add +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import at.mocode.members.domain.model.DomPerson +import at.mocode.enums.GeschlechtE +import at.mocode.enums.DatenQuelleE +import at.mocode.ui.viewmodel.PersonListViewModel +import kotlinx.datetime.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PersonListScreen( + viewModel: PersonListViewModel, + onNavigateToCreatePerson: () -> Unit +) { + + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = onNavigateToCreatePerson + ) { + Icon(Icons.Default.Add, contentDescription = "Person hinzufügen") + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + ) { + Text( + text = "Personen", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 16.dp) + ) + + // Error handling + viewModel.errorMessage?.let { error -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = { viewModel.clearError() } + ) { + Text("OK") + } + } + } + } + + // Loading indicator + if (viewModel.isLoading) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + if (!viewModel.isLoading && viewModel.persons.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Keine Personen vorhanden", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(viewModel.persons) { person -> + PersonCard(person = person) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PersonCard(person: DomPerson) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "${person.titel?.let { "$it " } ?: ""}${person.vorname} ${person.nachname}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + person.oepsSatzNr?.let { oepsNr -> + Text( + text = "OEPS: $oepsNr", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + + person.geburtsdatum?.let { birthDate -> + Text( + text = "Geboren: $birthDate", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Surface( + color = when (person.datenQuelle) { + DatenQuelleE.OEPS_ZNS -> MaterialTheme.colorScheme.primaryContainer + DatenQuelleE.MANUELL -> MaterialTheme.colorScheme.secondaryContainer + }, + shape = MaterialTheme.shapes.small + ) { + Text( + text = when (person.datenQuelle) { + DatenQuelleE.OEPS_ZNS -> "OEPS" + DatenQuelleE.MANUELL -> "Manuell" + }, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = when (person.datenQuelle) { + DatenQuelleE.OEPS_ZNS -> MaterialTheme.colorScheme.onPrimaryContainer + DatenQuelleE.MANUELL -> MaterialTheme.colorScheme.onSecondaryContainer + } + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + person.email?.let { email -> + Text( + text = "📧 $email", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + person.telefon?.let { phone -> + Text( + text = "📞 $phone", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (person.strasse != null && person.plz != null && person.ort != null) { + Text( + text = "📍 ${person.strasse}, ${person.plz} ${person.ort}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/at/mocode/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/at/mocode/ui/theme/Theme.kt new file mode 100644 index 00000000..f73fc9ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/mocode/ui/theme/Theme.kt @@ -0,0 +1,49 @@ +package at.mocode.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF6750A4), + secondary = Color(0xFF625B71), + tertiary = Color(0xFF7D5260), + background = Color(0xFF1C1B1F), + surface = Color(0xFF1C1B1F), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFFFEFBFF), + onSurface = Color(0xFFFEFBFF), +) + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF6750A4), + secondary = Color(0xFF625B71), + tertiary = Color(0xFF7D5260), + background = Color(0xFFFEFBFF), + surface = Color(0xFFFEFBFF), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), +) + +@Composable +fun MeldestelleTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = when { + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography(), + content = content + ) +} diff --git a/composeApp/src/commonMain/kotlin/at/mocode/ui/viewmodel/CreatePersonViewModel.kt b/composeApp/src/commonMain/kotlin/at/mocode/ui/viewmodel/CreatePersonViewModel.kt new file mode 100644 index 00000000..e1c061a8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/mocode/ui/viewmodel/CreatePersonViewModel.kt @@ -0,0 +1,176 @@ +package at.mocode.ui.viewmodel + +import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.mocode.members.application.usecase.CreatePersonUseCase +import at.mocode.members.domain.repository.PersonRepository +import at.mocode.members.domain.repository.VereinRepository +import at.mocode.members.domain.service.MasterDataService +import at.mocode.enums.GeschlechtE +import at.mocode.enums.DatenQuelleE +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate + +class CreatePersonViewModel( + private val createPersonUseCase: CreatePersonUseCase +) : ViewModel() { + + // Form state + var nachname by mutableStateOf("") + private set + var vorname by mutableStateOf("") + private set + var titel by mutableStateOf("") + private set + var oepsSatzNr by mutableStateOf("") + private set + var geburtsdatum by mutableStateOf("") + private set + var geschlecht by mutableStateOf(null) + private set + var telefon by mutableStateOf("") + private set + var email by mutableStateOf("") + private set + var strasse by mutableStateOf("") + private set + var plz by mutableStateOf("") + private set + var ort by mutableStateOf("") + private set + var adresszusatz by mutableStateOf("") + private set + var feiId by mutableStateOf("") + private set + var mitgliedsNummer by mutableStateOf("") + private set + var notizen by mutableStateOf("") + private set + var istGesperrt by mutableStateOf(false) + private set + var sperrGrund by mutableStateOf("") + private set + + // UI state + var isLoading by mutableStateOf(false) + private set + var errorMessage by mutableStateOf(null) + private set + var isSuccess by mutableStateOf(false) + private set + + // Update methods + fun updateNachname(value: String) { nachname = value } + fun updateVorname(value: String) { vorname = value } + fun updateTitel(value: String) { titel = value } + fun updateOepsSatzNr(value: String) { oepsSatzNr = value } + fun updateGeburtsdatum(value: String) { geburtsdatum = value } + fun updateGeschlecht(value: GeschlechtE?) { geschlecht = value } + fun updateTelefon(value: String) { telefon = value } + fun updateEmail(value: String) { email = value } + fun updateStrasse(value: String) { strasse = value } + fun updatePlz(value: String) { plz = value } + fun updateOrt(value: String) { ort = value } + fun updateAdresszusatz(value: String) { adresszusatz = value } + fun updateFeiId(value: String) { feiId = value } + fun updateMitgliedsNummer(value: String) { mitgliedsNummer = value } + fun updateNotizen(value: String) { notizen = value } + fun updateIstGesperrt(value: Boolean) { istGesperrt = value } + fun updateSperrGrund(value: String) { sperrGrund = value } + + fun clearError() { + errorMessage = null + } + + fun createPerson() { + // Basic validation + when { + nachname.isBlank() -> { + errorMessage = "Nachname ist erforderlich" + return + } + vorname.isBlank() -> { + errorMessage = "Vorname ist erforderlich" + return + } + } + + viewModelScope.launch { + isLoading = true + errorMessage = null + + try { + // Parse birth date if provided + val parsedGeburtsdatum = if (geburtsdatum.isNotBlank()) { + try { + val parts = geburtsdatum.split("-") + if (parts.size == 3) { + LocalDate(parts[0].toInt(), parts[1].toInt(), parts[2].toInt()) + } else null + } catch (e: Exception) { + errorMessage = "Ungültiges Datumsformat. Verwenden Sie YYYY-MM-DD" + isLoading = false + return@launch + } + } else null + + val request = CreatePersonUseCase.CreatePersonRequest( + oepsSatzNr = oepsSatzNr.takeIf { it.isNotBlank() }, + nachname = nachname, + vorname = vorname, + titel = titel.takeIf { it.isNotBlank() }, + geburtsdatum = parsedGeburtsdatum, + geschlechtE = geschlecht, + telefon = telefon.takeIf { it.isNotBlank() }, + email = email.takeIf { it.isNotBlank() }, + strasse = strasse.takeIf { it.isNotBlank() }, + plz = plz.takeIf { it.isNotBlank() }, + ort = ort.takeIf { it.isNotBlank() }, + adresszusatzZusatzinfo = adresszusatz.takeIf { it.isNotBlank() }, + feiId = feiId.takeIf { it.isNotBlank() }, + mitgliedsNummerBeiStammVerein = mitgliedsNummer.takeIf { it.isNotBlank() }, + istGesperrt = istGesperrt, + sperrGrund = sperrGrund.takeIf { it.isNotBlank() }, + datenQuelle = DatenQuelleE.MANUELL, + notizenIntern = notizen.takeIf { it.isNotBlank() } + ) + + val response = createPersonUseCase.execute(request) + + if (response.success) { + isSuccess = true + } else { + errorMessage = response.error?.message ?: "Unbekannter Fehler beim Erstellen der Person" + } + } catch (e: Exception) { + errorMessage = "Fehler beim Erstellen der Person: ${e.message}" + } finally { + isLoading = false + } + } + } + + fun resetForm() { + nachname = "" + vorname = "" + titel = "" + oepsSatzNr = "" + geburtsdatum = "" + geschlecht = null + telefon = "" + email = "" + strasse = "" + plz = "" + ort = "" + adresszusatz = "" + feiId = "" + mitgliedsNummer = "" + notizen = "" + istGesperrt = false + sperrGrund = "" + isLoading = false + errorMessage = null + isSuccess = false + } +} diff --git a/composeApp/src/commonMain/kotlin/at/mocode/ui/viewmodel/PersonListViewModel.kt b/composeApp/src/commonMain/kotlin/at/mocode/ui/viewmodel/PersonListViewModel.kt new file mode 100644 index 00000000..6c80b5b0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/at/mocode/ui/viewmodel/PersonListViewModel.kt @@ -0,0 +1,48 @@ +package at.mocode.ui.viewmodel + +import androidx.compose.runtime.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.mocode.members.domain.model.DomPerson +import at.mocode.members.domain.repository.PersonRepository +import kotlinx.coroutines.launch + +class PersonListViewModel( + private val personRepository: PersonRepository +) : ViewModel() { + + // UI state + var persons by mutableStateOf>(emptyList()) + private set + var isLoading by mutableStateOf(false) + private set + var errorMessage by mutableStateOf(null) + private set + + init { + loadPersons() + } + + fun loadPersons() { + viewModelScope.launch { + isLoading = true + errorMessage = null + + try { + persons = personRepository.findAll() + } catch (e: Exception) { + errorMessage = "Fehler beim Laden der Personen: ${e.message}" + } finally { + isLoading = false + } + } + } + + fun clearError() { + errorMessage = null + } + + fun refreshPersons() { + loadPersons() + } +} diff --git a/composeApp/src/commonTest/kotlin/at/mocode/ui/viewmodel/CreatePersonViewModelTest.kt b/composeApp/src/commonTest/kotlin/at/mocode/ui/viewmodel/CreatePersonViewModelTest.kt new file mode 100644 index 00000000..4d617260 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/at/mocode/ui/viewmodel/CreatePersonViewModelTest.kt @@ -0,0 +1,259 @@ +package at.mocode.ui.viewmodel + +import at.mocode.members.application.usecase.CreatePersonUseCase +import at.mocode.members.domain.model.DomPerson +import at.mocode.members.domain.repository.PersonRepository +import at.mocode.members.domain.repository.VereinRepository +import at.mocode.members.domain.service.MasterDataService +import at.mocode.enums.GeschlechtE +import at.mocode.enums.DatenQuelleE +import at.mocode.validation.ValidationResult +import com.benasher44.uuid.uuid4 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import kotlinx.datetime.LocalDate +import kotlin.test.* + +@OptIn(ExperimentalCoroutinesApi::class) +class CreatePersonViewModelTest { + + private lateinit var mockPersonRepository: PersonRepository + private lateinit var mockVereinRepository: VereinRepository + private lateinit var mockMasterDataService: MasterDataService + private lateinit var createPersonUseCase: CreatePersonUseCase + private lateinit var viewModel: CreatePersonViewModel + private val testDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + + mockPersonRepository = object : PersonRepository { + private val persons = mutableListOf() + + override suspend fun save(person: DomPerson): DomPerson { + val savedPerson = person.copy(id = uuid4()) + persons.add(savedPerson) + return savedPerson + } + + override suspend fun findById(id: com.benasher44.uuid.Uuid): DomPerson? { + return persons.find { it.id == id } + } + + override suspend fun findByOepsSatzNr(oepsSatzNr: String): DomPerson? { + return persons.find { it.oepsSatzNr == oepsSatzNr } + } + + override suspend fun existsByOepsSatzNr(oepsSatzNr: String): Boolean { + return persons.any { it.oepsSatzNr == oepsSatzNr } + } + + override suspend fun findAll(): List { + return persons.toList() + } + + override suspend fun delete(id: com.benasher44.uuid.Uuid) { + persons.removeAll { it.id == id } + } + } + + mockVereinRepository = object : VereinRepository { + override suspend fun findById(id: com.benasher44.uuid.Uuid): at.mocode.members.domain.model.DomVerein? { + return null + } + + override suspend fun existsById(id: com.benasher44.uuid.Uuid): Boolean { + return true + } + + override suspend fun findAll(): List { + return emptyList() + } + } + + mockMasterDataService = object : MasterDataService { + override suspend fun countryExists(countryId: com.benasher44.uuid.Uuid): Boolean { + return true + } + } + + createPersonUseCase = CreatePersonUseCase( + personRepository = mockPersonRepository, + vereinRepository = mockVereinRepository, + masterDataService = mockMasterDataService + ) + + viewModel = CreatePersonViewModel(createPersonUseCase) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state should be correct`() { + assertEquals("", viewModel.nachname) + assertEquals("", viewModel.vorname) + assertEquals("", viewModel.titel) + assertEquals("", viewModel.oepsSatzNr) + assertEquals("", viewModel.geburtsdatum) + assertNull(viewModel.geschlecht) + assertEquals("", viewModel.telefon) + assertEquals("", viewModel.email) + assertEquals("", viewModel.strasse) + assertEquals("", viewModel.plz) + assertEquals("", viewModel.ort) + assertEquals("", viewModel.adresszusatz) + assertEquals("", viewModel.feiId) + assertEquals("", viewModel.mitgliedsNummer) + assertEquals("", viewModel.notizen) + assertFalse(viewModel.istGesperrt) + assertEquals("", viewModel.sperrGrund) + assertFalse(viewModel.isLoading) + assertNull(viewModel.errorMessage) + assertFalse(viewModel.isSuccess) + } + + @Test + fun `update methods should change state correctly`() { + viewModel.updateNachname("Mustermann") + viewModel.updateVorname("Max") + viewModel.updateTitel("Dr.") + viewModel.updateGeschlecht(GeschlechtE.M) + viewModel.updateEmail("max@example.com") + viewModel.updateIstGesperrt(true) + + assertEquals("Mustermann", viewModel.nachname) + assertEquals("Max", viewModel.vorname) + assertEquals("Dr.", viewModel.titel) + assertEquals(GeschlechtE.M, viewModel.geschlecht) + assertEquals("max@example.com", viewModel.email) + assertTrue(viewModel.istGesperrt) + } + + @Test + fun `createPerson should fail with empty nachname`() = runTest { + // Given - empty nachname + viewModel.updateVorname("Max") + + // When + viewModel.createPerson() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertEquals("Nachname ist erforderlich", viewModel.errorMessage) + assertFalse(viewModel.isSuccess) + assertFalse(viewModel.isLoading) + } + + @Test + fun `createPerson should fail with empty vorname`() = runTest { + // Given - empty vorname + viewModel.updateNachname("Mustermann") + + // When + viewModel.createPerson() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertEquals("Vorname ist erforderlich", viewModel.errorMessage) + assertFalse(viewModel.isSuccess) + assertFalse(viewModel.isLoading) + } + + @Test + fun `createPerson should succeed with valid data`() = runTest { + // Given + viewModel.updateNachname("Mustermann") + viewModel.updateVorname("Max") + viewModel.updateGeschlecht(GeschlechtE.M) + viewModel.updateEmail("max@example.com") + + // When + viewModel.createPerson() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertTrue(viewModel.isSuccess) + assertNull(viewModel.errorMessage) + assertFalse(viewModel.isLoading) + } + + @Test + fun `createPerson should handle invalid date format`() = runTest { + // Given + viewModel.updateNachname("Mustermann") + viewModel.updateVorname("Max") + viewModel.updateGeburtsdatum("invalid-date") + + // When + viewModel.createPerson() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertEquals("Ungültiges Datumsformat. Verwenden Sie YYYY-MM-DD", viewModel.errorMessage) + assertFalse(viewModel.isSuccess) + assertFalse(viewModel.isLoading) + } + + @Test + fun `createPerson should handle valid date format`() = runTest { + // Given + viewModel.updateNachname("Mustermann") + viewModel.updateVorname("Max") + viewModel.updateGeburtsdatum("1990-05-15") + + // When + viewModel.createPerson() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertTrue(viewModel.isSuccess) + assertNull(viewModel.errorMessage) + assertFalse(viewModel.isLoading) + } + + @Test + fun `resetForm should clear all fields`() { + // Given - set some values + viewModel.updateNachname("Mustermann") + viewModel.updateVorname("Max") + viewModel.updateEmail("max@example.com") + viewModel.updateIstGesperrt(true) + + // When + viewModel.resetForm() + + // Then + assertEquals("", viewModel.nachname) + assertEquals("", viewModel.vorname) + assertEquals("", viewModel.email) + assertFalse(viewModel.istGesperrt) + assertFalse(viewModel.isLoading) + assertNull(viewModel.errorMessage) + assertFalse(viewModel.isSuccess) + } + + @Test + fun `clearError should reset error message`() { + // Given - simulate an error + viewModel.updateNachname("") // This will cause validation error + viewModel.updateVorname("Max") + + runTest { + viewModel.createPerson() + testDispatcher.scheduler.advanceUntilIdle() + } + + assertNotNull(viewModel.errorMessage) + + // When + viewModel.clearError() + + // Then + assertNull(viewModel.errorMessage) + } +} diff --git a/composeApp/src/commonTest/kotlin/at/mocode/ui/viewmodel/PersonListViewModelTest.kt b/composeApp/src/commonTest/kotlin/at/mocode/ui/viewmodel/PersonListViewModelTest.kt new file mode 100644 index 00000000..72b1816b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/at/mocode/ui/viewmodel/PersonListViewModelTest.kt @@ -0,0 +1,130 @@ +package at.mocode.ui.viewmodel + +import at.mocode.members.domain.model.DomPerson +import at.mocode.members.domain.repository.PersonRepository +import at.mocode.enums.GeschlechtE +import at.mocode.enums.DatenQuelleE +import com.benasher44.uuid.uuid4 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import kotlinx.datetime.LocalDate +import kotlin.test.* + +@OptIn(ExperimentalCoroutinesApi::class) +class PersonListViewModelTest { + + private lateinit var mockPersonRepository: PersonRepository + private lateinit var viewModel: PersonListViewModel + private val testDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + + mockPersonRepository = object : PersonRepository { + private val persons = mutableListOf() + + override suspend fun save(person: DomPerson): DomPerson { + val savedPerson = person.copy(id = uuid4()) + persons.add(savedPerson) + return savedPerson + } + + override suspend fun findById(id: com.benasher44.uuid.Uuid): DomPerson? { + return persons.find { it.id == id } + } + + override suspend fun findByOepsSatzNr(oepsSatzNr: String): DomPerson? { + return persons.find { it.oepsSatzNr == oepsSatzNr } + } + + override suspend fun existsByOepsSatzNr(oepsSatzNr: String): Boolean { + return persons.any { it.oepsSatzNr == oepsSatzNr } + } + + override suspend fun findAll(): List { + return persons.toList() + } + + override suspend fun delete(id: com.benasher44.uuid.Uuid) { + persons.removeAll { it.id == id } + } + } + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state should be correct`() { + viewModel = PersonListViewModel(mockPersonRepository) + + assertTrue(viewModel.persons.isEmpty()) + assertFalse(viewModel.isLoading) + assertNull(viewModel.errorMessage) + } + + @Test + fun `loadPersons should update persons list`() = runTest { + // Given + val testPerson = DomPerson( + nachname = "Test", + vorname = "User", + geschlechtE = GeschlechtE.M, + datenQuelle = DatenQuelleE.MANUELL + ) + mockPersonRepository.save(testPerson) + + // When + viewModel = PersonListViewModel(mockPersonRepository) + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertEquals(1, viewModel.persons.size) + assertEquals("Test", viewModel.persons.first().nachname) + assertEquals("User", viewModel.persons.first().vorname) + assertFalse(viewModel.isLoading) + assertNull(viewModel.errorMessage) + } + + @Test + fun `refreshPersons should reload data`() = runTest { + // Given + viewModel = PersonListViewModel(mockPersonRepository) + testDispatcher.scheduler.advanceUntilIdle() + + val initialCount = viewModel.persons.size + + // Add a new person to repository + val newPerson = DomPerson( + nachname = "New", + vorname = "Person", + geschlechtE = GeschlechtE.W, + datenQuelle = DatenQuelleE.MANUELL + ) + mockPersonRepository.save(newPerson) + + // When + viewModel.refreshPersons() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertEquals(initialCount + 1, viewModel.persons.size) + assertTrue(viewModel.persons.any { it.nachname == "New" }) + } + + @Test + fun `clearError should reset error message`() { + viewModel = PersonListViewModel(mockPersonRepository) + + // Simulate an error (this would normally happen in a real error scenario) + // For testing, we can't easily simulate repository errors with our mock + // but we can test the clearError functionality + + viewModel.clearError() + assertNull(viewModel.errorMessage) + } +} diff --git a/composeApp/src/desktopMain/kotlin/main.kt b/composeApp/src/desktopMain/kotlin/main.kt new file mode 100644 index 00000000..f57a1355 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/main.kt @@ -0,0 +1,11 @@ +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application + +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + title = "Meldestelle - Reitersport Management" + ) { + App() + } +} diff --git a/composeApp/src/jsMain/kotlin/main.kt b/composeApp/src/jsMain/kotlin/main.kt new file mode 100644 index 00000000..5900ce32 --- /dev/null +++ b/composeApp/src/jsMain/kotlin/main.kt @@ -0,0 +1,12 @@ +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.CanvasBasedWindow +import org.jetbrains.skiko.wasm.onWasmReady + +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + onWasmReady { + CanvasBasedWindow("Meldestelle - Reitersport Management") { + App() + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 0ee533c1..53bf22d9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,3 +35,6 @@ include(":member-management") include(":horse-registry") include(":event-management") include(":api-gateway") + +// Frontend module +include(":composeApp")