refactor: Migrate from monolithic to modular architecture

- Restructure project into domain-specific modules (core, masterdata, members, horses, events, infrastructure)
- Create shared client components in common-ui module
- Implement CI/CD workflows with GitHub Actions
- Consolidate documentation in docs directory
- Remove deprecated modules and documentation files
- Add cleanup and migration scripts for transition
- Update README with new project structure and setup instructions
This commit is contained in:
stefan
2025-07-22 18:44:18 +02:00
parent 8229e8e571
commit a256622f37
314 changed files with 5930 additions and 19817 deletions
@@ -0,0 +1,25 @@
package at.mocode.client.common
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import at.mocode.client.common.theme.MeldestelleTheme
/**
* Base application theme wrapper for consistent UI across all applications.
* This is a simplified version that just applies the theme.
* Specific applications should implement their own App composable with navigation.
*/
@Composable
fun BaseApp(content: @Composable () -> Unit) {
MeldestelleTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
content()
}
}
}
@@ -0,0 +1,232 @@
package at.mocode.client.common.api
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import java.util.concurrent.ConcurrentHashMap
/**
* Shared API client for making HTTP requests to the backend API.
* Provides methods for common HTTP operations and handles response deserialization.
* Includes a simple caching mechanism for GET requests.
*/
object ApiClient {
// Public properties to avoid inline function issues
val BASE_URL = "http://localhost:8080"
val json = Json { ignoreUnknownKeys = true; isLenient = true }
val httpClient = HttpClient(CIO) {
install(ContentNegotiation) {
json(json)
}
// Add error handling, timeouts, etc.
engine {
requestTimeout = 30_000 // 30 seconds
}
}
// Cache implementation
val cache = ConcurrentHashMap<String, Pair<Any, Long>>()
val CACHE_TTL = 30_000L // 30 seconds
/**
* Generic GET method with ApiResponse handling and caching
*
* @param endpoint The API endpoint to call (without base URL)
* @param cacheable Whether to cache the response
* @return The deserialized data of type T
* @throws ApiException if the request fails or returns an error
*/
suspend inline fun <reified T> get(endpoint: String, cacheable: Boolean = true): T? {
try {
// Check cache if cacheable
if (cacheable) {
val cacheKey = endpoint
val cachedValue = cache[cacheKey]
if (cachedValue != null && System.currentTimeMillis() - cachedValue.second < CACHE_TTL) {
@Suppress("UNCHECKED_CAST")
return cachedValue.first as T
}
}
// Make HTTP request
val response = httpClient.get("$BASE_URL$endpoint")
val responseText = response.bodyAsText()
val apiResponse = json.decodeFromString<ApiResponse<T>>(responseText)
// Handle success/error
if (apiResponse.success) {
val data = apiResponse.data
// Update cache if cacheable
if (cacheable && data != null) {
val cacheKey = endpoint
cache[cacheKey] = Pair(data, System.currentTimeMillis())
}
return data
} else {
throw ApiException(
message = apiResponse.error?.message ?: "Unknown API error",
code = apiResponse.error?.code ?: "ERROR",
details = apiResponse.error?.details
)
}
} catch (e: Exception) {
if (e is ApiException) throw e
throw ApiException(
message = "Error executing GET request: ${e.message}",
code = "ERROR",
details = null
)
}
}
/**
* Generic POST method with ApiResponse handling
*
* @param endpoint The API endpoint to call (without base URL)
* @param body The request body to send
* @return The deserialized data of type T
* @throws ApiException if the request fails or returns an error
*/
suspend inline fun <reified T> post(endpoint: String, body: Any): T {
try {
// Make HTTP request
val response = httpClient.post("$BASE_URL$endpoint") {
contentType(ContentType.Application.Json)
setBody(body)
}
val responseText = response.bodyAsText()
val apiResponse = json.decodeFromString<ApiResponse<T>>(responseText)
// Handle success/error
if (apiResponse.success) {
return apiResponse.data
?: throw IllegalStateException("API response success but data is null")
} else {
throw ApiException(
message = apiResponse.error?.message ?: "Unknown API error",
code = apiResponse.error?.code ?: "ERROR",
details = apiResponse.error?.details
)
}
} catch (e: Exception) {
if (e is ApiException) throw e
throw ApiException(
message = "Error executing POST request: ${e.message}",
code = "ERROR",
details = null
)
}
}
/**
* Generic PUT method with ApiResponse handling
*
* @param endpoint The API endpoint to call (without base URL)
* @param body The request body to send
* @return The deserialized data of type T
* @throws ApiException if the request fails or returns an error
*/
suspend inline fun <reified T> put(endpoint: String, body: Any): T {
try {
// Make HTTP request
val response = httpClient.put("$BASE_URL$endpoint") {
contentType(ContentType.Application.Json)
setBody(body)
}
val responseText = response.bodyAsText()
val apiResponse = json.decodeFromString<ApiResponse<T>>(responseText)
// Handle success/error
if (apiResponse.success) {
return apiResponse.data
?: throw IllegalStateException("API response success but data is null")
} else {
throw ApiException(
message = apiResponse.error?.message ?: "Unknown API error",
code = apiResponse.error?.code ?: "ERROR",
details = apiResponse.error?.details
)
}
} catch (e: Exception) {
if (e is ApiException) throw e
throw ApiException(
message = "Error executing PUT request: ${e.message}",
code = "ERROR",
details = null
)
}
}
/**
* Generic DELETE method with ApiResponse handling
*
* @param endpoint The API endpoint to call (without base URL)
* @return The deserialized data of type T
* @throws ApiException if the request fails or returns an error
*/
suspend inline fun <reified T> delete(endpoint: String): T {
try {
// Make HTTP request
val response = httpClient.delete("$BASE_URL$endpoint")
val responseText = response.bodyAsText()
val apiResponse = json.decodeFromString<ApiResponse<T>>(responseText)
// Handle success/error
if (apiResponse.success) {
return apiResponse.data
?: throw IllegalStateException("API response success but data is null")
} else {
throw ApiException(
message = apiResponse.error?.message ?: "Unknown API error",
code = apiResponse.error?.code ?: "ERROR",
details = apiResponse.error?.details
)
}
} catch (e: Exception) {
if (e is ApiException) throw e
throw ApiException(
message = "Error executing DELETE request: ${e.message}",
code = "ERROR",
details = null
)
}
}
/**
* Clears the cache
*/
fun clearCache() {
cache.clear()
}
/**
* Removes a specific item from the cache
*/
fun invalidateCache(endpoint: String) {
cache.remove(endpoint)
}
}
/**
* Exception thrown when an API request fails
*/
class ApiException(
message: String,
val code: String,
val details: Map<String, String>?
) : Exception(message)
@@ -0,0 +1,142 @@
package at.mocode.client.common.components.events
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.events.domain.model.Veranstaltung
/**
* Utility functions for event display in Compose UI
* This is a Compose-based replacement for the JS-specific EventUIUtils
*/
object EventComposeUtils {
/**
* Formats an event as a summary string
*/
fun formatEventSummary(event: Veranstaltung): String {
return buildString {
append("${event.name}")
append(" | ${event.ort}")
append(" | ${event.startDatum}")
if (event.isMultiDay()) {
append(" - ${event.endDatum}")
}
}
}
/**
* Returns a formatted date range string for an event
*/
fun formatEventDateRange(event: Veranstaltung): String {
return if (event.isMultiDay()) {
"${event.startDatum} - ${event.endDatum} (${event.getDurationInDays()} Tage)"
} else {
"${event.startDatum} (Eintägige Veranstaltung)"
}
}
/**
* Returns a list of status indicators for an event
*/
fun getEventStatusList(event: Veranstaltung): List<String> {
val statusList = mutableListOf<String>()
if (event.istAktiv) statusList.add("Aktiv")
if (event.istOeffentlich) statusList.add("Öffentlich")
if (event.isRegistrationOpen()) statusList.add("Anmeldung offen")
return statusList
}
}
/**
* A compact event card for displaying basic event information
*/
@Composable
fun CompactEventCard(
event: Veranstaltung,
onClick: () -> Unit = {}
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Text(
text = event.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📍",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = event.ort,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📅",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = EventComposeUtils.formatEventDateRange(event),
style = MaterialTheme.typography.bodyMedium
)
}
// Status indicators
val statusList = EventComposeUtils.getEventStatusList(event)
if (statusList.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Status: ${statusList.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* A badge that displays the event status
*/
@Composable
fun EventStatusBadge(event: Veranstaltung) {
val statusList = EventComposeUtils.getEventStatusList(event)
if (statusList.isNotEmpty()) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = statusList.first(),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
@@ -0,0 +1,44 @@
package at.mocode.client.common.components.events
import at.mocode.events.domain.model.Veranstaltung
/**
* Simple JS-specific utility functions for event management UI
*/
object EventUIUtils {
/**
* Formats an event for display in the browser
*/
fun formatEventForDisplay(event: Veranstaltung): String {
return buildString {
append("Event: ${event.name}")
append(" | Location: ${event.ort}")
append(" | From: ${event.startDatum} to: ${event.endDatum}")
if (event.beschreibung != null) {
append(" | Description: ${event.beschreibung}")
}
if (event.sparten.isNotEmpty()) {
append(" | Sports: ${event.sparten.joinToString(", ") { it.name }}")
}
}
}
/**
* Creates a simple HTML representation of an event
*/
fun createEventHtml(event: Veranstaltung): String {
return """
<div class="event-card">
<h3>${event.name}</h3>
<p><strong>Location:</strong> ${event.ort}</p>
<p><strong>Date:</strong> ${event.startDatum} - ${event.endDatum}</p>
${if (event.beschreibung != null) "<p><strong>Description:</strong> ${event.beschreibung}</p>" else ""}
${if (event.sparten.isNotEmpty())
"<p><strong>Sports:</strong> ${event.sparten.joinToString(", ") { it.name }}</p>"
else ""}
</div>
""".trimIndent()
}
}
@@ -0,0 +1,233 @@
package at.mocode.client.common.components.events
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.events.domain.model.Veranstaltung
/**
* Compose component that displays a list of events (Veranstaltungen).
* This is a Compose-based replacement for the React-based VeranstaltungsListe component.
*/
@Composable
fun VeranstaltungsListe(
events: List<Veranstaltung> = emptyList(),
isLoading: Boolean = false,
errorMessage: String? = null
) {
// UI rendering with Compose
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Veranstaltungen",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = errorMessage,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
events.isEmpty() -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "Keine Veranstaltungen verfügbar",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(events) { event ->
EventCard(event = event)
}
}
}
}
}
}
@Composable
private fun EventCard(event: Veranstaltung) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = event.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📍",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = event.ort,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📅",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (event.isMultiDay()) {
"${event.startDatum} - ${event.endDatum} (${event.getDurationInDays()} Tage)"
} else {
"${event.startDatum} (Eintägige Veranstaltung)"
},
style = MaterialTheme.typography.bodyMedium
)
}
// Status indicators
val statusList = mutableListOf<String>()
if (event.istAktiv) statusList.add("Aktiv")
if (event.istOeffentlich) statusList.add("Öffentlich")
if (event.isRegistrationOpen()) statusList.add("Anmeldung offen")
if (statusList.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Status: ${statusList.joinToString(", ")}",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Description
if (!event.beschreibung.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📝",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = event.beschreibung!!,
style = MaterialTheme.typography.bodyMedium
)
}
}
// Sports/Sparten
if (event.sparten.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🏆",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Sparten: ${event.sparten.joinToString(", ") { it.name }}",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Additional info
event.maxTeilnehmer?.let { max ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "👥",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Max. Teilnehmer: $max",
style = MaterialTheme.typography.bodyMedium
)
}
}
event.anmeldeschluss?.let { deadline ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Anmeldeschluss: $deadline",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
@@ -0,0 +1,176 @@
package at.mocode.client.common.components.events
import at.mocode.events.domain.model.Veranstaltung
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import react.*
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.h1
import react.dom.html.ReactHTML.h3
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.span
import emotion.react.css
/**
* Props for the VeranstaltungsListe component
*/
external interface VeranstaltungsListeProps : Props
// Create Ktor client for API calls
private val apiClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
}
/**
* React component that displays a list of events (Veranstaltungen).
*
* This component loads event data from the API and renders it as HTML.
* Uses useState for state management and useEffectOnce for data loading.
*/
val VeranstaltungsListe = FC<VeranstaltungsListeProps> { _ ->
// State management with useState
var events by useState<List<Veranstaltung>>(emptyList())
var loading by useState(true)
var error by useState<String?>(null)
// Data loading with useEffectOnce hook
useEffectOnce {
val scope = MainScope()
scope.launch {
try {
loading = true
error = null
// Load data with Ktor client
val response = apiClient.get("http://localhost:8080/api/events")
val loadedEvents: List<Veranstaltung> = response.body()
events = loadedEvents
} catch (e: Exception) {
error = "Fehler beim Laden der Veranstaltungen: ${e.message}"
console.error("Error loading events:", e)
} finally {
loading = false
}
}
}
// Render HTML with React DOM elements
div {
css {
// Basic styling for the main container
}
h1 {
+"Veranstaltungen"
}
when {
loading -> {
div {
+"Lade Veranstaltungen..."
}
}
error != null -> {
div {
+error!!
}
}
events.isEmpty() -> {
div {
+"Keine Veranstaltungen verfügbar"
}
}
else -> {
div {
events.forEach { event ->
div {
h3 {
+event.name
}
p {
span {
+"📍"
}
+" ${event.ort}"
}
p {
span {
+"📅"
}
if (event.isMultiDay()) {
+" ${event.startDatum} - ${event.endDatum} (${event.getDurationInDays()} Tage)"
} else {
+" ${event.startDatum} (Eintägige Veranstaltung)"
}
}
// Status indicators
val statusList = mutableListOf<String>()
if (event.istAktiv) statusList.add("Aktiv")
if (event.istOeffentlich) statusList.add("Öffentlich")
if (event.isRegistrationOpen()) statusList.add("Anmeldung offen")
if (statusList.isNotEmpty()) {
p {
span {
+"️"
}
+" Status: ${statusList.joinToString(", ")}"
}
}
// Description
if (!event.beschreibung.isNullOrBlank()) {
p {
span {
+"📝"
}
+" ${event.beschreibung}"
}
}
// Sports/Sparten
if (event.sparten.isNotEmpty()) {
p {
span {
+"🏆"
}
+" Sparten: ${event.sparten.joinToString(", ") { it.name }}"
}
}
// Additional info
event.maxTeilnehmer?.let { max ->
p {
span {
+"👥"
}
+" Max. Teilnehmer: $max"
}
}
event.anmeldeschluss?.let { deadline ->
p {
span {
+"⏰"
}
+" Anmeldeschluss: $deadline"
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,237 @@
package at.mocode.client.common.components.horses
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.horses.domain.model.DomPferd
/**
* Compose component that displays a list of horses (Pferde).
* This is a Compose-based replacement for the React-based PferdeListe component.
*/
@Composable
fun PferdeListe(
horses: List<DomPferd> = emptyList(),
isLoading: Boolean = false,
errorMessage: String? = null,
onHorseClick: (DomPferd) -> Unit = {}
) {
// UI rendering with Compose
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Pferde-Register",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = errorMessage,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
horses.isEmpty() -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "Keine Pferde verfügbar",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(horses) { horse ->
HorseCard(horse = horse, onClick = { onHorseClick(horse) })
}
}
}
}
}
}
@Composable
private fun HorseCard(
horse: DomPferd,
onClick: () -> Unit = {}
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = horse.getDisplayName(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// Basic information
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🐎",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Geschlecht: ${horse.geschlecht.name}",
style = MaterialTheme.typography.bodyMedium
)
}
horse.geburtsdatum?.let { birthDate ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📅",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = buildString {
append("Geburtsdatum: $birthDate")
horse.getAge()?.let { age ->
append(" (${age} Jahre alt)")
}
},
style = MaterialTheme.typography.bodyMedium
)
}
}
// Breed and color
val breedAndColor = mutableListOf<String>()
horse.rasse?.let { breedAndColor.add("Rasse: $it") }
horse.farbe?.let { breedAndColor.add("Farbe: $it") }
if (breedAndColor.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🏇",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = breedAndColor.joinToString(" | "),
style = MaterialTheme.typography.bodyMedium
)
}
}
// Identification numbers (show only the most important ones in the card)
val identificationNumbers = mutableListOf<String>()
horse.lebensnummer?.let { identificationNumbers.add("Lebensnummer: $it") }
horse.oepsNummer?.let { identificationNumbers.add("OEPS: $it") }
if (identificationNumbers.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🆔",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = identificationNumbers.joinToString(" | "),
style = MaterialTheme.typography.bodyMedium
)
}
}
// Status indicators
val statusList = mutableListOf<String>()
if (horse.istAktiv) statusList.add("Aktiv") else statusList.add("Inaktiv")
if (horse.isOepsRegistered()) statusList.add("OEPS registriert")
if (horse.isFeiRegistered()) statusList.add("FEI registriert")
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Status: ${statusList.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Data source
Text(
text = "Datenquelle: ${horse.datenQuelle.name}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
/**
* A badge that displays the horse's registration status
*/
@Composable
fun HorseStatusBadge(horse: DomPferd) {
val status = when {
horse.isFeiRegistered() -> "FEI"
horse.isOepsRegistered() -> "OEPS"
else -> null
}
status?.let {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = it,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
@@ -0,0 +1,308 @@
package at.mocode.client.common.components.horses
import at.mocode.horses.domain.model.DomPferd
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import react.*
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.h1
import react.dom.html.ReactHTML.h3
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.span
import emotion.react.css
/**
* Props for the PferdeListe component
*/
external interface PferdeListeProps : Props
// Create Ktor client for API calls
private val apiClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
}
/**
* React component that displays a list of horses (Pferde).
*
* This component loads horse data from the API and renders it as HTML.
* Uses useState for state management and useEffectOnce for data loading.
*/
val PferdeListe = FC<PferdeListeProps> { _ ->
// State management with useState
var horses by useState<List<DomPferd>>(emptyList())
var loading by useState(true)
var error by useState<String?>(null)
// Data loading with useEffectOnce hook
useEffectOnce {
val scope = MainScope()
scope.launch {
try {
loading = true
error = null
// Load data with Ktor client
val response = apiClient.get("http://localhost:8080/api/horses")
val loadedHorses: List<DomPferd> = response.body()
horses = loadedHorses
} catch (e: Exception) {
error = "Fehler beim Laden der Pferde: ${e.message}"
console.error("Error loading horses:", e)
} finally {
loading = false
}
}
}
// Render HTML with React DOM elements
div {
css {
// Basic styling for the main container
"padding" to "20px"
"fontFamily" to "Arial, sans-serif"
"maxWidth" to "1200px"
"margin" to "0 auto"
}
h1 {
css {
"color" to "#2c3e50"
"borderBottom" to "2px solid #3498db"
"paddingBottom" to "10px"
"marginBottom" to "20px"
}
+"Pferde-Register"
}
when {
loading -> {
div {
css {
"padding" to "20px"
"textAlign" to "center"
"color" to "#666"
"fontSize" to "18px"
}
+"Lade Pferde..."
}
}
error != null -> {
div {
css {
"padding" to "20px"
"textAlign" to "center"
"color" to "#e74c3c"
"backgroundColor" to "#fdeaea"
"border" to "1px solid #e74c3c"
"borderRadius" to "8px"
"margin" to "20px 0"
}
+error!!
}
}
horses.isEmpty() -> {
div {
css {
"padding" to "20px"
"textAlign" to "center"
"color" to "#666"
"backgroundColor" to "#f8f9fa"
"border" to "1px solid #e0e0e0"
"borderRadius" to "8px"
"margin" to "20px 0"
}
+"Keine Pferde verfügbar"
}
}
else -> {
div {
css {
"display" to "grid"
"gridTemplateColumns" to "repeat(auto-fill, minmax(300px, 1fr))"
"gap" to "20px"
}
horses.forEach { horse ->
div {
css {
"border" to "1px solid #e0e0e0"
"borderRadius" to "8px"
"padding" to "15px"
"backgroundColor" to "#f9f9f9"
"boxShadow" to "0 2px 4px rgba(0,0,0,0.1)"
"transition" to "transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out"
"hover" to {
"transform" to "translateY(-5px)"
"boxShadow" to "0 5px 15px rgba(0,0,0,0.1)"
}
}
h3 {
css {
"color" to "#3498db"
"marginTop" to "0"
"marginBottom" to "10px"
"borderBottom" to "1px solid #e0e0e0"
"paddingBottom" to "5px"
}
+horse.getDisplayName()
}
// Basic information
p {
span {
+"🐎"
}
+" Geschlecht: ${horse.geschlecht.name}"
}
horse.geburtsdatum?.let { birthDate ->
p {
span {
+"📅"
}
+" Geburtsdatum: $birthDate"
horse.getAge()?.let { age ->
+" (${age} Jahre alt)"
}
}
}
horse.rasse?.let { breed ->
p {
span {
+"🏇"
}
+" Rasse: $breed"
}
}
horse.farbe?.let { color ->
p {
span {
+"🎨"
}
+" Farbe: $color"
}
}
horse.stockmass?.let { height ->
p {
span {
+"📏"
}
+" Stockmaß: ${height} cm"
}
}
// Identification numbers
val identificationNumbers = mutableListOf<String>()
horse.lebensnummer?.let { identificationNumbers.add("Lebensnummer: $it") }
horse.chipNummer?.let { identificationNumbers.add("Chip: $it") }
horse.passNummer?.let { identificationNumbers.add("Pass: $it") }
horse.oepsNummer?.let { identificationNumbers.add("OEPS: $it") }
horse.feiNummer?.let { identificationNumbers.add("FEI: $it") }
if (identificationNumbers.isNotEmpty()) {
p {
span {
+"🆔"
}
+" Identifikation: ${identificationNumbers.joinToString(", ")}"
}
}
// Pedigree information
val pedigreeInfo = mutableListOf<String>()
horse.vaterName?.let { pedigreeInfo.add("Vater: $it") }
horse.mutterName?.let { pedigreeInfo.add("Mutter: $it") }
horse.mutterVaterName?.let { pedigreeInfo.add("Muttervater: $it") }
if (pedigreeInfo.isNotEmpty()) {
p {
span {
+"🧬"
}
+" Abstammung: ${pedigreeInfo.joinToString(", ")}"
}
}
// Breeding information
horse.zuechterName?.let { breeder ->
p {
span {
+"👨‍🌾"
}
+" Züchter: $breeder"
}
}
horse.zuchtbuchNummer?.let { studbook ->
p {
span {
+"📖"
}
+" Zuchtbuchnummer: $studbook"
}
}
// Status indicators
val statusList = mutableListOf<String>()
if (horse.istAktiv) statusList.add("Aktiv") else statusList.add("Inaktiv")
if (horse.hasCompleteIdentification()) statusList.add("Vollständig identifiziert")
if (horse.isOepsRegistered()) statusList.add("OEPS registriert")
if (horse.isFeiRegistered()) statusList.add("FEI registriert")
p {
span {
+"️"
}
+" Status: ${statusList.joinToString(", ")}"
}
// Data source
p {
span {
+"📊"
}
+" Datenquelle: ${horse.datenQuelle.name}"
}
// Notes
horse.bemerkungen?.let { notes ->
p {
span {
+"📝"
}
+" Bemerkungen: $notes"
}
}
// Creation and update dates
p {
span {
+"📅"
}
+" Erstellt am: ${horse.createdAt}"
}
p {
span {
+"🔄"
}
+" Zuletzt geändert: ${horse.updatedAt}"
}
}
}
}
}
}
}
}
@@ -0,0 +1,266 @@
package at.mocode.client.common.components.masterdata
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
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.masterdata.domain.model.LandDefinition
/**
* Compose component that displays master data (Stammdaten).
* This is a Compose-based replacement for the React-based StammdatenListe component.
* Currently focuses on countries (LandDefinition) but can be extended for other master data types.
*/
@Composable
fun StammdatenListe(
countries: List<LandDefinition> = emptyList(),
isLoading: Boolean = false,
errorMessage: String? = null,
onCountryClick: (LandDefinition) -> Unit = {}
) {
// UI rendering with Compose
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Stammdaten",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "Länder",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 16.dp)
)
when {
isLoading -> {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = errorMessage,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
countries.isEmpty() -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "Keine Länder verfügbar",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 300.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(countries) { country ->
CountryCard(country = country, onClick = { onCountryClick(country) })
}
}
}
}
}
}
@Composable
private fun CountryCard(
country: LandDefinition,
onClick: () -> Unit = {}
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
onClick = onClick
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = country.nameDeutsch,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// ISO codes
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🌍",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "ISO-Codes: ${country.isoAlpha2Code} / ${country.isoAlpha3Code}",
style = MaterialTheme.typography.bodyMedium
)
country.isoNumerischerCode?.let { numCode ->
Text(
text = " / $numCode",
style = MaterialTheme.typography.bodyMedium
)
}
}
// English name if available
country.nameEnglisch?.let { englishName ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🇬🇧",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Englischer Name: $englishName",
style = MaterialTheme.typography.bodyMedium
)
}
}
// EU/EWR membership
val membershipInfo = mutableListOf<String>()
country.istEuMitglied?.let { isEuMember ->
if (isEuMember) membershipInfo.add("EU-Mitglied")
}
country.istEwrMitglied?.let { isEwrMember ->
if (isEwrMember) membershipInfo.add("EWR-Mitglied")
}
if (membershipInfo.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🇪🇺",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Mitgliedschaft: ${membershipInfo.joinToString(", ")}",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Status
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Status: ${if (country.istAktiv) "Aktiv" else "Inaktiv"}",
style = MaterialTheme.typography.bodyMedium
)
}
// Sort order if available
country.sortierReihenfolge?.let { sortOrder ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🔢",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Sortierreihenfolge: $sortOrder",
style = MaterialTheme.typography.bodyMedium
)
}
}
// Coat of arms/flag URL if available
country.wappenUrl?.let { flagUrl ->
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🏴",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Wappen/Flagge: $flagUrl",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
/**
* A badge that displays the country's EU/EWR membership status
*/
@Composable
fun CountryMembershipBadge(country: LandDefinition) {
val membership = when {
country.istEuMitglied == true -> "EU"
country.istEwrMitglied == true -> "EWR"
else -> null
}
membership?.let {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = it,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
@@ -0,0 +1,257 @@
package at.mocode.client.common.components.masterdata
import at.mocode.masterdata.domain.model.LandDefinition
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import react.*
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.h1
import react.dom.html.ReactHTML.h2
import react.dom.html.ReactHTML.h3
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.span
import emotion.react.css
/**
* Props for the StammdatenListe component
*/
external interface StammdatenListeProps : Props
// Create Ktor client for API calls
private val apiClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
})
}
}
/**
* React component that displays master data (Stammdaten).
*
* This component loads master data from the API and renders it as HTML.
* Currently focuses on countries (LandDefinition) but can be extended for other master data types.
* Uses useState for state management and useEffectOnce for data loading.
*/
val StammdatenListe = FC<StammdatenListeProps> { _ ->
// State management with useState
var countries by useState<List<LandDefinition>>(emptyList())
var loading by useState(true)
var error by useState<String?>(null)
// Data loading with useEffectOnce hook
useEffectOnce {
val scope = MainScope()
scope.launch {
try {
loading = true
error = null
// Load data with Ktor client
val response = apiClient.get("http://localhost:8080/api/masterdata/countries")
val loadedCountries: List<LandDefinition> = response.body()
countries = loadedCountries
} catch (e: Exception) {
error = "Fehler beim Laden der Stammdaten: ${e.message}"
console.error("Error loading master data:", e)
} finally {
loading = false
}
}
}
// Render HTML with React DOM elements
div {
css {
// Basic styling for the main container
"padding" to "20px"
"fontFamily" to "Arial, sans-serif"
"maxWidth" to "1200px"
"margin" to "0 auto"
}
h1 {
css {
"color" to "#2c3e50"
"borderBottom" to "2px solid #3498db"
"paddingBottom" to "10px"
"marginBottom" to "20px"
}
+"Stammdaten"
}
h2 {
css {
"color" to "#34495e"
"marginTop" to "20px"
"marginBottom" to "15px"
"fontSize" to "1.5em"
}
+"Länder"
}
when {
loading -> {
div {
css {
"padding" to "20px"
"textAlign" to "center"
"color" to "#666"
"fontSize" to "18px"
}
+"Lade Stammdaten..."
}
}
error != null -> {
div {
css {
"padding" to "20px"
"textAlign" to "center"
"color" to "#e74c3c"
"backgroundColor" to "#fdeaea"
"border" to "1px solid #e74c3c"
"borderRadius" to "8px"
"margin" to "20px 0"
}
+error!!
}
}
countries.isEmpty() -> {
div {
css {
"padding" to "20px"
"textAlign" to "center"
"color" to "#666"
"backgroundColor" to "#f8f9fa"
"border" to "1px solid #e0e0e0"
"borderRadius" to "8px"
"margin" to "20px 0"
}
+"Keine Länder verfügbar"
}
}
else -> {
div {
css {
"display" to "grid"
"gridTemplateColumns" to "repeat(auto-fill, minmax(300px, 1fr))"
"gap" to "20px"
}
countries.forEach { country ->
div {
css {
"border" to "1px solid #e0e0e0"
"borderRadius" to "8px"
"padding" to "15px"
"backgroundColor" to "#f9f9f9"
"boxShadow" to "0 2px 4px rgba(0,0,0,0.1)"
"transition" to "transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out"
"hover" to {
"transform" to "translateY(-5px)"
"boxShadow" to "0 5px 15px rgba(0,0,0,0.1)"
}
}
h3 {
css {
"color" to "#3498db"
"marginTop" to "0"
"marginBottom" to "10px"
"borderBottom" to "1px solid #e0e0e0"
"paddingBottom" to "5px"
}
+country.nameDeutsch
}
// ISO codes
p {
span {
+"🌍"
}
+" ISO-Codes: ${country.isoAlpha2Code} / ${country.isoAlpha3Code}"
country.isoNumerischerCode?.let { numCode ->
+" / $numCode"
}
}
// English name if available
country.nameEnglisch?.let { englishName ->
p {
span {
+"🇬🇧"
}
+" Englischer Name: $englishName"
}
}
// EU/EWR membership
val membershipInfo = mutableListOf<String>()
country.istEuMitglied?.let { isEuMember ->
if (isEuMember) membershipInfo.add("EU-Mitglied")
}
country.istEwrMitglied?.let { isEwrMember ->
if (isEwrMember) membershipInfo.add("EWR-Mitglied")
}
if (membershipInfo.isNotEmpty()) {
p {
span {
+"🇪🇺"
}
+" Mitgliedschaft: ${membershipInfo.joinToString(", ")}"
}
}
// Status
p {
span {
+"️"
}
+" Status: ${if (country.istAktiv) "Aktiv" else "Inaktiv"}"
}
// Sort order if available
country.sortierReihenfolge?.let { sortOrder ->
p {
span {
+"🔢"
}
+" Sortierreihenfolge: $sortOrder"
}
}
// Coat of arms/flag URL if available
country.wappenUrl?.let { flagUrl ->
p {
span {
+"🏴"
}
+" Wappen/Flagge: $flagUrl"
}
}
// Creation and update dates
p {
span {
+"📅"
}
+" Erstellt am: ${country.createdAt}"
}
p {
span {
+"🔄"
}
+" Zuletzt geändert: ${country.updatedAt}"
}
}
}
}
}
}
}
}
@@ -0,0 +1,148 @@
package at.mocode.client.common.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.client.web.viewmodel.CreatePersonViewModel
import at.mocode.client.web.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(personId = 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 findByStammVereinId(vereinId: com.benasher44.uuid.Uuid): List<at.mocode.members.domain.model.DomPerson> {
return emptyList() // Mock implementation
}
override suspend fun findByName(searchTerm: String, limit: Int): List<at.mocode.members.domain.model.DomPerson> {
return emptyList() // Mock implementation
}
override suspend fun findAllActive(limit: Int, offset: Int): List<at.mocode.members.domain.model.DomPerson> {
return emptyList() // Mock implementation
}
override suspend fun countActive(): Long {
return 0L // Mock implementation
}
override suspend fun existsByOepsSatzNr(oepsSatzNr: String): Boolean {
return false // Mock implementation - no duplicates for demo
}
override suspend fun delete(id: com.benasher44.uuid.Uuid): Boolean {
return true // 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 findByOepsVereinsNr(oepsVereinsNr: String): at.mocode.members.domain.model.DomVerein? {
return null // Mock implementation
}
override suspend fun findByName(searchTerm: String, limit: Int): List<at.mocode.members.domain.model.DomVerein> {
return emptyList() // Mock implementation
}
override suspend fun findByBundeslandId(bundeslandId: com.benasher44.uuid.Uuid): List<at.mocode.members.domain.model.DomVerein> {
return emptyList() // Mock implementation
}
override suspend fun findByLandId(landId: com.benasher44.uuid.Uuid): List<at.mocode.members.domain.model.DomVerein> {
return emptyList() // Mock implementation
}
override suspend fun findAllActive(limit: Int, offset: Int): List<at.mocode.members.domain.model.DomVerein> {
return emptyList() // Mock implementation
}
override suspend fun findByLocation(searchTerm: String, limit: Int): List<at.mocode.members.domain.model.DomVerein> {
return emptyList() // Mock implementation
}
override suspend fun save(verein: at.mocode.members.domain.model.DomVerein): at.mocode.members.domain.model.DomVerein {
return verein.copy(vereinId = com.benasher44.uuid.uuid4()) // Mock implementation
}
override suspend fun delete(id: com.benasher44.uuid.Uuid): Boolean {
return true // Mock implementation
}
override suspend fun existsByOepsVereinsNr(oepsVereinsNr: String): Boolean {
return false // Mock implementation
}
override suspend fun countActive(): Long {
return 0L // Mock implementation
}
override suspend fun countActiveByBundeslandId(bundeslandId: com.benasher44.uuid.Uuid): Long {
return 0L // 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
}
override suspend fun stateExists(stateId: com.benasher44.uuid.Uuid): Boolean {
return true // Mock implementation - assume all states exist
}
override suspend fun getCountryById(countryId: com.benasher44.uuid.Uuid): MasterDataService.CountryInfo? {
return null // Mock implementation
}
override suspend fun getStateById(stateId: com.benasher44.uuid.Uuid): MasterDataService.StateInfo? {
return null // Mock implementation
}
override suspend fun getAllCountries(): List<MasterDataService.CountryInfo> {
return emptyList() // Mock implementation
}
override suspend fun getStatesByCountry(countryId: com.benasher44.uuid.Uuid): List<MasterDataService.StateInfo> {
return emptyList() // Mock implementation
}
}
// 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)
}
}
@@ -0,0 +1,109 @@
package at.mocode.client.common.repository
import at.mocode.client.common.api.ApiClient
import at.mocode.client.common.api.ApiException
import kotlinx.datetime.LocalDate
/**
* Client-side implementation of the EventRepository interface.
* Uses the ApiClient to make HTTP requests to the backend API.
*/
class ClientEventRepository : EventRepository {
private val baseEndpoint = "/api/events"
override suspend fun findById(id: String): Event? {
return try {
ApiClient.get<Event>("$baseEndpoint/$id")
} catch (e: Exception) {
println("[ERROR] Failed to fetch event with ID $id: ${e.message}")
null
}
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint?limit=$limit&offset=$offset") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to fetch active events: ${e.message}")
emptyList()
}
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint?search=$searchTerm&limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to search events by name: ${e.message}")
emptyList()
}
}
override suspend fun findByLocation(location: String, limit: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint?location=$location&limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to search events by location: ${e.message}")
emptyList()
}
}
override suspend fun findByDateRange(startDate: LocalDate, endDate: LocalDate, limit: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint?startDate=$startDate&endDate=$endDate&limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to search events by date range: ${e.message}")
emptyList()
}
}
override suspend fun findUpcoming(limit: Int): List<Event> {
return try {
ApiClient.get<List<Event>>("$baseEndpoint/upcoming?limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to fetch upcoming events: ${e.message}")
emptyList()
}
}
override suspend fun save(event: Event): Event {
return try {
if (event.id.isBlank()) {
// Create new event
ApiClient.post<Event>(baseEndpoint, event)
} else {
// Update existing event
ApiClient.put<Event>("$baseEndpoint/${event.id}", event)
}
} catch (e: ApiException) {
println("[ERROR] Failed to save event: ${e.message}")
throw e
} catch (e: Exception) {
println("[ERROR] Unexpected error while saving event: ${e.message}")
throw ApiException(
message = "Failed to save event: ${e.message}",
code = "SAVE_ERROR",
details = null
)
}
}
override suspend fun delete(id: String): Boolean {
return try {
ApiClient.delete<Boolean>("$baseEndpoint/$id")
true
} catch (e: Exception) {
println("[ERROR] Failed to delete event with ID $id: ${e.message}")
false
}
}
override suspend fun countActive(): Long {
return try {
ApiClient.get<Long>("$baseEndpoint/count") ?: 0L
} catch (e: Exception) {
println("[ERROR] Failed to count active events: ${e.message}")
0L
}
}
}
@@ -0,0 +1,81 @@
package at.mocode.client.common.repository
import at.mocode.client.common.api.ApiClient
import at.mocode.client.common.api.ApiException
/**
* Client-side implementation of the PersonRepository interface.
* Uses the ApiClient to make HTTP requests to the backend API.
*/
class ClientPersonRepository : PersonRepository {
private val baseEndpoint = "/api/persons"
override suspend fun findById(id: String): Person? {
return try {
ApiClient.get<Person>("$baseEndpoint/$id")
} catch (e: Exception) {
println("[ERROR] Failed to fetch person with ID $id: ${e.message}")
null
}
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Person> {
return try {
ApiClient.get<List<Person>>("$baseEndpoint?limit=$limit&offset=$offset") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to fetch active persons: ${e.message}")
emptyList()
}
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Person> {
return try {
ApiClient.get<List<Person>>("$baseEndpoint?search=$searchTerm&limit=$limit") ?: emptyList()
} catch (e: Exception) {
println("[ERROR] Failed to search persons by name: ${e.message}")
emptyList()
}
}
override suspend fun save(person: Person): Person {
return try {
if (person.id.isBlank()) {
// Create new person
ApiClient.post<Person>(baseEndpoint, person)
} else {
// Update existing person
ApiClient.put<Person>("$baseEndpoint/${person.id}", person)
}
} catch (e: ApiException) {
println("[ERROR] Failed to save person: ${e.message}")
throw e
} catch (e: Exception) {
println("[ERROR] Unexpected error while saving person: ${e.message}")
throw ApiException(
message = "Failed to save person: ${e.message}",
code = "SAVE_ERROR",
details = null
)
}
}
override suspend fun delete(id: String): Boolean {
return try {
ApiClient.delete<Boolean>("$baseEndpoint/$id")
true
} catch (e: Exception) {
println("[ERROR] Failed to delete person with ID $id: ${e.message}")
false
}
}
override suspend fun countActive(): Long {
return try {
ApiClient.get<Long>("$baseEndpoint/count") ?: 0L
} catch (e: Exception) {
println("[ERROR] Failed to count active persons: ${e.message}")
0L
}
}
}
@@ -0,0 +1,48 @@
package at.mocode.client.common.repository
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
/**
* Simplified Event data class for client-side use.
* This is a client-side representation of the Veranstaltung entity from the domain model.
*/
@Serializable
data class Event(
val id: String = "",
val name: String,
val beschreibung: String? = null,
val startDatum: LocalDate,
val endDatum: LocalDate,
val ort: String,
val veranstalterVereinId: String? = null,
val sparten: List<String> = emptyList(),
val istAktiv: Boolean = true,
val istOeffentlich: Boolean = true,
val maxTeilnehmer: Int? = null,
val anmeldeschluss: LocalDate? = null,
val createdAt: String? = null,
val updatedAt: String? = null
) {
/**
* Checks if the event is currently accepting registrations.
*/
fun isRegistrationOpen(): Boolean {
// Simplified implementation - can be enhanced with proper date comparison
return istAktiv && anmeldeschluss != null
}
/**
* Returns the duration of the event in days.
*/
fun getDurationInDays(): Int {
return (endDatum.toEpochDays() - startDatum.toEpochDays()).toInt() + 1
}
/**
* Checks if the event spans multiple days.
*/
fun isMultiDay(): Boolean {
return startDatum != endDatum
}
}
@@ -0,0 +1,85 @@
package at.mocode.client.common.repository
import kotlinx.datetime.LocalDate
/**
* Client-side repository interface for Event entities.
* This is a simplified version of the domain repository interface.
*/
interface EventRepository {
/**
* Finds an event by its ID.
*
* @param id The unique identifier of the event
* @return The event if found, null otherwise
*/
suspend fun findById(id: String): Event?
/**
* Finds all active events with pagination.
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of active events
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Event>
/**
* Finds events by name (partial match).
*
* @param searchTerm The search term to match against event names
* @param limit Maximum number of results to return
* @return List of matching events
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Event>
/**
* Finds events by location (partial match).
*
* @param location The location to match against event locations
* @param limit Maximum number of results to return
* @return List of matching events
*/
suspend fun findByLocation(location: String, limit: Int = 50): List<Event>
/**
* Finds events by date range.
*
* @param startDate The start date of the range
* @param endDate The end date of the range
* @param limit Maximum number of results to return
* @return List of events within the date range
*/
suspend fun findByDateRange(startDate: LocalDate, endDate: LocalDate, limit: Int = 100): List<Event>
/**
* Finds upcoming events.
*
* @param limit Maximum number of results to return
* @return List of upcoming events
*/
suspend fun findUpcoming(limit: Int = 50): List<Event>
/**
* Saves an event (create or update).
*
* @param event The event to save
* @return The saved event with updated information
*/
suspend fun save(event: Event): Event
/**
* Deletes an event by ID.
*
* @param id The unique identifier of the event to delete
* @return true if the event was deleted, false if not found
*/
suspend fun delete(id: String): Boolean
/**
* Counts the total number of active events.
*
* @return The total count of active events
*/
suspend fun countActive(): Long
}
@@ -0,0 +1,56 @@
package at.mocode.client.common.repository
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
/**
* Simplified Person data class for client-side use.
* This is a client-side representation of the DomPerson entity from the domain model.
*/
@Serializable
data class Person(
val id: String = "",
val nachname: String,
val vorname: String,
val titel: String? = null,
val oepsSatzNr: String? = null,
val geburtsdatum: LocalDate? = null,
val geschlecht: String? = null,
val telefon: String? = null,
val email: String? = null,
val strasse: String? = null,
val plz: String? = null,
val ort: String? = null,
val adresszusatz: String? = null,
val feiId: String? = null,
val mitgliedsNummer: String? = null,
val istGesperrt: Boolean = false,
val sperrGrund: String? = null,
val notizen: String? = null,
val datenQuelle: String = "MANUELL",
val createdAt: String? = null,
val updatedAt: String? = null
) {
/**
* Returns the full name of the person, including title if available.
*/
fun getFullName(): String {
return buildString {
titel?.let { append("$it ") }
append("$vorname $nachname")
}
}
/**
* Returns a display-friendly representation of the address.
*/
fun getFormattedAddress(): String? {
if (strasse == null || plz == null || ort == null) return null
return buildString {
append(strasse)
adresszusatz?.let { append(", $it") }
append(", $plz $ort")
}
}
}
@@ -0,0 +1,56 @@
package at.mocode.client.common.repository
/**
* Client-side repository interface for Person entities.
* This is a simplified version of the domain repository interface.
*/
interface PersonRepository {
/**
* Finds a person by their ID.
*
* @param id The unique identifier of the person
* @return The person if found, null otherwise
*/
suspend fun findById(id: String): Person?
/**
* Finds all active persons with pagination.
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of active persons
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Person>
/**
* Finds persons by name (partial match).
*
* @param searchTerm The search term to match against person names
* @param limit Maximum number of results to return
* @return List of matching persons
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Person>
/**
* Saves a person (create or update).
*
* @param person The person to save
* @return The saved person with updated information
*/
suspend fun save(person: Person): Person
/**
* Deletes a person by ID.
*
* @param id The unique identifier of the person to delete
* @return true if the person was deleted, false if not found
*/
suspend fun delete(id: String): Boolean
/**
* Counts the total number of active persons.
*
* @return The total count of active persons
*/
suspend fun countActive(): Long
}
@@ -0,0 +1,49 @@
package at.mocode.client.common.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
)
}