From e3cb7282bcd69cec0b94eae3554f4da484d0544d Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 13 Jun 2026 00:30:06 +0200 Subject: [PATCH] Android implementation of sportbetrieb, 401-fix --- .../java/de/harheimertc/data/ApiService.kt | 16 + .../harheimertc/repositories/CmsRepository.kt | 134 ++++++ .../harheimertc/ui/screens/cms/CmsScreens.kt | 443 +++++++++++++++++- .../ui/screens/cms/CmsViewModels.kt | 179 +++++++ .../ui/screens/cms/CmsViewModelTest.kt | 22 +- components/cms/CmsSpielplaene.vue | 11 +- server/api/termine-manage.delete.js | 3 +- server/api/termine-manage.get.js | 3 +- server/api/termine-manage.post.js | 3 +- 9 files changed, 774 insertions(+), 40 deletions(-) diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index bb920b6..007c964 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -21,6 +21,7 @@ import okhttp3.RequestBody data class ContactRequest(val name: String, val email: String, val message: String) data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null) data class TermineResponse(val success: Boolean = true, val termine: List = emptyList()) +data class TermineManageResponse(val success: Boolean = true, val termine: List = emptyList()) data class TerminDto( val datum: String = "", val uhrzeit: String? = null, @@ -590,6 +591,21 @@ interface ApiService { @GET("/api/termine") suspend fun termine(): Response + @GET("/api/termine-manage") + suspend fun termineManage(): Response + + @POST("/api/termine-manage") + suspend fun saveTermin(@Body request: TerminDto): Response + + @DELETE("/api/termine-manage") + suspend fun deleteTermin( + @Query("datum") datum: String, + @Query("uhrzeit") uhrzeit: String = "", + @Query("titel") titel: String, + @Query("beschreibung") beschreibung: String = "", + @Query("kategorie") kategorie: String = "Sonstiges", + ): Response + @GET("/api/spielplan") suspend fun spielplan(@Query("season") season: String? = null): Response diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt index fdefde2..c6b06fe 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt @@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse import de.harheimertc.data.SaveCsvRequest import de.harheimertc.data.SaveCsvResponse import de.harheimertc.data.SecureOfflineCache +import de.harheimertc.data.SpielplanResponse +import de.harheimertc.data.TerminDto import javax.inject.Inject class CmsRepository @Inject constructor( @@ -83,6 +85,86 @@ class CmsRepository @Inject constructor( response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") } + suspend fun managedTermine(): Result> = runCatching { + val response = api.termineManage() + if (!response.isSuccessful) error("Termine konnten nicht geladen werden.") + response.body()?.termine.orEmpty() + } + + suspend fun saveTermin(request: TerminDto): Result = runCatching { + val response = api.saveTermin(request) + if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.") + response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort") + } + + suspend fun deleteTermin(request: TerminDto): Result = runCatching { + val response = api.deleteTermin( + datum = request.datum, + uhrzeit = request.uhrzeit.orEmpty(), + titel = request.titel, + beschreibung = request.beschreibung.orEmpty(), + kategorie = request.kategorie ?: "Sonstiges", + ) + if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.") + response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort") + } + + suspend fun mannschaften(season: String? = null): Result> = runCatching { + val response = api.mannschaften(season) + if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") + parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> + if (values.size < 10 || values[0].isBlank()) return@mapNotNull null + CmsMannschaftRow( + mannschaft = values[0], + liga = values[1], + staffelleiter = values[2], + telefon = values[3], + heimspieltag = values[4], + spielsystem = values[5], + mannschaftsfuehrer = values[6], + spieler = values[7], + informationenLink = values[8], + letzteAktualisierung = values[9], + ) + } + } + + suspend fun mannschaftenSeasons(): Result = runCatching { + val response = api.mannschaftenSeasons() + if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.") + response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse() + } + + suspend fun saveMannschaften(season: String?, rows: List): Result = runCatching { + val response = api.saveCsv( + SaveCsvRequest( + filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv", + content = rows.toMannschaftenCsv(), + ), + ) + if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.") + response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") + } + + suspend fun spielplan(season: String? = null): Result = runCatching { + val response = api.spielplan(season) + if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.") + val body = response.body() ?: error("Leere Antwort") + if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") + body + } + + suspend fun saveSpielplan(headers: List, rows: List>): Result = runCatching { + val response = api.saveCsv( + SaveCsvRequest( + filename = "spielplan.csv", + content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") }, + ), + ) + if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.") + response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") + } + suspend fun users(): Result = fetchEncryptedFallback( load = { @@ -288,6 +370,58 @@ class CmsRepository @Inject constructor( } } +data class CmsMannschaftRow( + val mannschaft: String = "", + val liga: String = "", + val staffelleiter: String = "", + val telefon: String = "", + val heimspieltag: String = "", + val spielsystem: String = "", + val mannschaftsfuehrer: String = "", + val spieler: String = "", + val informationenLink: String = "", + val letzteAktualisierung: String = "", +) + +private fun List.toMannschaftenCsv(): String { + val header = listOf( + "Mannschaft", + "Liga", + "Staffelleiter", + "Telefon", + "Heimspieltag", + "Spielsystem", + "Mannschaftsführer", + "Spieler", + "Weitere Informationen Link", + "Letzte Aktualisierung", + ).toCsvRow() + val rows = map { row -> + listOf( + row.mannschaft, + row.liga, + row.staffelleiter, + row.telefon, + row.heimspieltag, + row.spielsystem, + row.mannschaftsfuehrer, + row.spieler, + row.informationenLink, + row.letzteAktualisierung, + ).toCsvRow() + } + return listOf(header).plus(rows).joinToString("\n") +} + +private fun List.toCsvRow(delimiter: String = ","): String = + joinToString(delimiter) { value -> value.csvEscape(delimiter) } + +private fun String.csvEscape(delimiter: String): String { + val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r') + val escaped = replace("\"", "\"\"") + return if (needsQuotes) "\"$escaped\"" else escaped +} + private fun parseCsv(csv: String): List> = csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt index b20d8ec..0a51469 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt @@ -1,8 +1,10 @@ package de.harheimertc.ui.screens.cms import android.content.Intent +import android.app.DatePickerDialog import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.Arrangement @@ -36,6 +38,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedButton import androidx.navigation.NavController @@ -48,10 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto import de.harheimertc.data.PasswordResetMatchingUserDto import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetStepDto +import de.harheimertc.data.TerminDto import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.navigation.Destinations +import de.harheimertc.repositories.CmsMannschaftRow import de.harheimertc.repositories.MeisterschaftResult import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent500 @@ -61,6 +67,7 @@ import de.harheimertc.ui.theme.Primary100 import de.harheimertc.ui.theme.Primary600 import java.time.OffsetDateTime import java.time.format.DateTimeFormatter +import java.util.Calendar import java.util.Locale @Composable @@ -432,14 +439,71 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga @Composable fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current var activeTab by remember { mutableStateOf("termine") } + val mannschaften = remember { mutableStateListOf() } + var spielplanCsv by remember { mutableStateOf("") } + var spielplanEditorOpen by remember { mutableStateOf(false) } + var terminDialogOpen by remember { mutableStateOf(false) } + var editingTermin by remember { mutableStateOf(null) } + var terminDatum by remember { mutableStateOf("") } + var terminUhrzeit by remember { mutableStateOf("") } + var terminTitel by remember { mutableStateOf("") } + var terminBeschreibung by remember { mutableStateOf("") } + var terminKategorie by remember { mutableStateOf("Sonstiges") } + var terminKategorieOpen by remember { mutableStateOf(false) } val tabs = listOf( "termine" to "Termine", "mannschaften" to "Mannschaften", "spielplaene" to "Spielpläne", ) + val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges") - CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne pflegen") { + LaunchedEffect(Unit) { + viewModel.loadSportbetrieb() + } + + LaunchedEffect(state.sportMannschaften) { + mannschaften.clear() + mannschaften.addAll(state.sportMannschaften) + } + + LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) { + spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows) + } + + fun openTerminDialog(termin: TerminDto?) { + editingTermin = termin + terminDatum = termin?.datum.orEmpty() + terminUhrzeit = termin?.uhrzeit.orEmpty() + terminTitel = termin?.titel.orEmpty() + terminBeschreibung = termin?.beschreibung.orEmpty() + terminKategorie = termin?.kategorie ?: "Sonstiges" + terminDialogOpen = true + } + + fun openDatePicker() { + val calendar = Calendar.getInstance() + runCatching { + val parts = terminDatum.split("-") + if (parts.size == 3) { + calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt()) + } + } + DatePickerDialog( + context, + { _, year, month, day -> + terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day) + }, + calendar.get(Calendar.YEAR), + calendar.get(Calendar.MONTH), + calendar.get(Calendar.DAY_OF_MONTH), + ).show() + } + + CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") { + if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") } item { Row( modifier = Modifier.fillMaxWidth(), @@ -455,33 +519,283 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool } } - when (activeTab) { - "termine" -> item { - DataCard("Termine verwalten") { - Text("Termine werden im CMS gepflegt und erscheinen anschließend auf der Terminseite.", color = Accent700) - Button(onClick = { navController.navigate(Destinations.Termine.route) }, modifier = Modifier.fillMaxWidth()) { - Text("Termine anzeigen") - } - } - } - "mannschaften" -> item { - DataCard("Mannschaften verwalten") { - Text("Mannschaftsdaten und Saisons entsprechen dem CMS-Bereich der Web-Oberfläche.", color = Accent700) - Button(onClick = { navController.navigate(Destinations.Mannschaften.route) }, modifier = Modifier.fillMaxWidth()) { - Text("Mannschaften öffnen") - } - } - } - "spielplaene" -> item { - DataCard("Spielpläne") { - Text("Spielpläne und Ergebnisse werden aus den importierten Spielplandaten angezeigt.", color = Accent700) - Button(onClick = { navController.navigate(Destinations.Spielplan.route) }, modifier = Modifier.fillMaxWidth()) { - Text("Spielpläne öffnen") + if (!state.sportLoading) { + when (activeTab) { + "termine" -> { + item { + Button( + onClick = { openTerminDialog(null) }, + enabled = !state.sportSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Termin hinzufügen") + } + } + if (state.sportTermine.isEmpty()) { + item { EmptyCard("Keine Termine gefunden.") } + } + items(state.sportTermine.size) { index -> + val termin = state.sportTermine[index] + DataCard(termin.titel.ifBlank { "Termin" }) { + InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" ")) + InfoRow("Kategorie", termin.kategorie ?: "Sonstiges") + if (!termin.beschreibung.isNullOrBlank()) { + Text(termin.beschreibung, color = Accent700) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) { + Text("Bearbeiten") + } + TextButton( + onClick = { viewModel.deleteSportTermin(termin) }, + enabled = !state.sportSaving, + modifier = Modifier.weight(1f), + ) { + Text("Löschen") + } + } + } + } + } + "mannschaften" -> { + item { + if (state.sportMannschaftenSeasons.isNotEmpty()) { + DataCard("Saison") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.sportMannschaftenSeasons.forEach { season -> + if (season == state.sportMannschaftenSeason) { + Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) } + } else { + OutlinedButton( + onClick = { viewModel.loadSportMannschaftenSeason(season) }, + modifier = Modifier.weight(1f), + ) { + Text(season) + } + } + } + } + } + } + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { + mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE))) + }, + modifier = Modifier.weight(1f), + ) { + Text("Hinzufügen") + } + Button( + onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) }, + enabled = !state.sportSaving, + modifier = Modifier.weight(1f), + ) { + Text(if (state.sportSaving) "Speichert..." else "Speichern") + } + } + } + if (mannschaften.isEmpty()) { + item { EmptyCard("Keine Mannschaften gefunden.") } + } + items(mannschaften.size) { index -> + MannschaftEditorCard( + row = mannschaften[index], + onChange = { updated -> mannschaften[index] = updated }, + onRemove = { mannschaften.removeAt(index) }, + ) + } + } + "spielplaene" -> { + item { + DataCard("Vereins-Spielplan (CSV)") { + val seasonLabel = state.sportSpielplanSeason.ifBlank { "aktuelle Saison" } + val fileName = state.sportSpielplanSeason.takeIf { it.isNotBlank() }?.let { "spielplan-$it.json" } ?: "spielplan.csv" + InfoRow("Datei", fileName) + InfoRow("Saison", seasonLabel) + InfoRow("Einträge", state.sportSpielplanRows.size.toString()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { viewModel.loadSportbetrieb() }, + modifier = Modifier.weight(1f), + ) { + Text("Neu laden") + } + Button( + onClick = { spielplanEditorOpen = true }, + modifier = Modifier.weight(1f), + ) { + Text("CSV bearbeiten") + } + } + } } } } + item { FormMessages(state.error, state.message) } } } + + if (terminDialogOpen) { + AlertDialog( + onDismissRequest = { terminDialogOpen = false }, + title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) { + Text(terminDatum.ifBlank { "Datum auswählen" }) + } + OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3) + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) { + Text(terminKategorie.ifBlank { "Kategorie auswählen" }) + } + DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) { + terminKategorien.forEach { kategorie -> + DropdownMenuItem( + text = { Text(kategorie) }, + onClick = { + terminKategorie = kategorie + terminKategorieOpen = false + }, + ) + } + } + } + } + }, + confirmButton = { + Button( + onClick = { + viewModel.saveSportTermin( + editingTermin, + TerminDto( + datum = terminDatum, + uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() }, + titel = terminTitel, + beschreibung = terminBeschreibung.takeIf { it.isNotBlank() }, + kategorie = terminKategorie.ifBlank { "Sonstiges" }, + ), + ) + terminDialogOpen = false + }, + enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(), + ) { + Text(if (state.sportSaving) "Speichert..." else "Speichern") + } + }, + dismissButton = { + TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") } + }, + ) + } + + if (spielplanEditorOpen) { + AlertDialog( + onDismissRequest = { spielplanEditorOpen = false }, + title = { Text("Spielplan CSV bearbeiten") }, + text = { + OutlinedTextField( + value = spielplanCsv, + onValueChange = { spielplanCsv = it }, + label = { Text("CSV mit Semikolon") }, + modifier = Modifier.fillMaxWidth(), + minLines = 12, + ) + }, + confirmButton = { + Button( + onClick = { + val (headers, rows) = parseSportCsvText(spielplanCsv) + viewModel.saveSportSpielplan(headers, rows) + spielplanEditorOpen = false + }, + enabled = !state.sportSaving && spielplanCsv.isNotBlank(), + ) { + Text(if (state.sportSaving) "Speichert..." else "Speichern") + } + }, + dismissButton = { + TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") } + }, + ) + } +} + +@Composable +private fun MannschaftEditorCard( + row: CmsMannschaftRow, + onChange: (CmsMannschaftRow) -> Unit, + onRemove: () -> Unit, +) { + DataCard(row.mannschaft.ifBlank { "Mannschaft" }) { + OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2) + OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth()) + TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) { + Text("Entfernen") + } + } +} + +private fun sportSpielplanCsvText(headers: List, rows: List>): String { + if (headers.isEmpty()) return "" + return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } } +} + +private fun parseSportCsvText(text: String): Pair, List>> { + val lines = text.lineSequence().filter { it.isNotBlank() }.toList() + if (lines.isEmpty()) return emptyList() to emptyList() + val headers = parseDelimitedLine(lines.first(), ';') + val rows = lines.drop(1).map { parseDelimitedLine(it, ';') } + return headers to rows +} + +private fun parseDelimitedLine(line: String, delimiter: Char): List { + val values = mutableListOf() + val value = StringBuilder() + var quoted = false + var index = 0 + while (index < line.length) { + when (val char = line[index]) { + '"' -> { + if (quoted && index + 1 < line.length && line[index + 1] == '"') { + value.append('"') + index++ + } else { + quoted = !quoted + } + } + delimiter -> if (quoted) value.append(char) else { + values += value.toString() + value.clear() + } + else -> value.append(char) + } + index++ + } + values += value.toString() + return values +} + +private fun String.csvCell(delimiter: String): String { + val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r') + val escaped = replace("\"", "\"\"") + return if (needsQuotes) "\"$escaped\"" else escaped } @Composable @@ -562,7 +876,7 @@ fun CmsNewsletterScreen( onEdit = { nl -> editingNewsletter = nl nlTitle = nl.title - nlContent = nl.title ?: "" + nlContent = nl.title nlType = "subscription" nlTargetGroup = "" nlSendToExternal = true @@ -692,6 +1006,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo var websiteVorname by remember { mutableStateOf("") } var websiteNachname by remember { mutableStateOf("") } var websiteEmail by remember { mutableStateOf("") } + var ortName by remember { mutableStateOf("") } + var ortStrasse by remember { mutableStateOf("") } + var ortPlz by remember { mutableStateOf("") } + var ortOrt by remember { mutableStateOf("") } + val trainingTimes = remember { mutableStateListOf() } + val trainers = remember { mutableStateListOf() } LaunchedEffect(config) { config?.let { @@ -703,6 +1023,14 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo websiteVorname = it.website.verantwortlicher.vorname websiteNachname = it.website.verantwortlicher.nachname websiteEmail = it.website.verantwortlicher.email + ortName = it.training.ort.name + ortStrasse = it.training.ort.strasse + ortPlz = it.training.ort.plz + ortOrt = it.training.ort.ort + trainingTimes.clear() + trainingTimes.addAll(it.training.zeiten) + trainers.clear() + trainers.addAll(it.trainer) } } @@ -729,6 +1057,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo email = websiteEmail, ), ), + training = config.training.copy( + ort = config.training.ort.copy( + name = ortName, + strasse = ortStrasse, + plz = ortPlz, + ort = ortOrt, + ), + zeiten = trainingTimes.toList(), + ), + trainer = trainers.toList(), ), ) }, @@ -761,6 +1099,63 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth()) } } + item { + DataCard("Trainingsort") { + OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { + OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f)) + OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f)) + } + } + } + item { + DataCard("Trainingszeiten") { + trainingTimes.forEachIndexed { index, zeit -> + TrainingTimeEditorCard( + zeit = zeit, + onChange = { updated -> trainingTimes[index] = updated }, + onRemove = { trainingTimes.removeAt(index) }, + ) + } + Button( + onClick = { + trainingTimes.add( + de.harheimertc.data.TrainingTimeDto( + id = "training-${System.currentTimeMillis()}", + tag = "Montag", + ), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Trainingszeit hinzufügen") + } + } + } + item { + DataCard("Trainer") { + trainers.forEachIndexed { index, trainer -> + TrainerEditorCard( + trainer = trainer, + onChange = { updated -> trainers[index] = updated }, + onRemove = { trainers.removeAt(index) }, + ) + } + Button( + onClick = { + trainers.add( + de.harheimertc.data.TrainerDto( + id = "trainer-${System.currentTimeMillis()}", + ), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Trainer hinzufügen") + } + } + } item { DataCard("Systemstatus") { InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString()) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt index 328e728..edb3e0f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -13,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsSaveRequest +import de.harheimertc.data.SeasonDto +import de.harheimertc.data.SpielDto +import de.harheimertc.data.TerminDto +import de.harheimertc.repositories.CmsMannschaftRow import de.harheimertc.repositories.CmsRepository import de.harheimertc.repositories.MeisterschaftResult import kotlinx.coroutines.async @@ -39,6 +43,16 @@ data class CmsUiState( val passwordResetFailedOnly: Boolean = true, val news: List = emptyList(), val meisterschaften: List = emptyList(), + val sportLoading: Boolean = false, + val sportSaving: Boolean = false, + val sportTermine: List = emptyList(), + val sportMannschaften: List = emptyList(), + val sportMannschaftenSeasons: List = emptyList(), + val sportMannschaftenSeason: String = "", + val sportSpielplanHeaders: List = emptyList(), + val sportSpielplanRows: List> = emptyList(), + val sportSpielplanSeason: String = "", + val sportSpielplanSeasons: List = emptyList(), ) @HiltViewModel @@ -179,6 +193,156 @@ class CmsViewModel @Inject constructor( } } + fun loadSportbetrieb() { + viewModelScope.launch { + _state.value = _state.value.copy(sportLoading = true, error = null, message = null) + + val termineRes = async { repository.managedTermine() } + val seasonsRes = async { repository.mannschaftenSeasons() } + val spielplanRes = async { repository.spielplan() } + + val termineResult = termineRes.await() + val seasonsResult = seasonsRes.await() + val seasonInfo = seasonsResult.getOrNull() + val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() } + ?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() } + ?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() } + ?: seasonInfo?.seasons?.firstOrNull().orEmpty() + val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() }) + val spielplanResult = spielplanRes.await() + val errors = listOfNotNull( + ErrorMapper.mapError(termineResult.exceptionOrNull()), + ErrorMapper.mapError(seasonsResult.exceptionOrNull()), + ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()), + ErrorMapper.mapError(spielplanResult.exceptionOrNull()), + ) + val spielplan = spielplanResult.getOrNull() + val headers = spielplan?.headers.orEmpty() + + _state.value = _state.value.copy( + sportLoading = false, + sportTermine = termineResult.getOrNull().orEmpty(), + sportMannschaften = mannschaftenResult.getOrNull().orEmpty(), + sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(), + sportMannschaftenSeason = selectedSeason, + sportSpielplanHeaders = headers, + sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } }, + sportSpielplanSeason = spielplan?.season.orEmpty(), + sportSpielplanSeasons = spielplan?.seasons.orEmpty(), + error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "), + ) + } + } + + fun loadSportMannschaftenSeason(season: String) { + viewModelScope.launch { + _state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season) + repository.mannschaften(season) + .onSuccess { rows -> + _state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportLoading = false, + error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.", + ) + } + } + } + + fun saveSportTermin(original: TerminDto?, termin: TerminDto) { + viewModelScope.launch { + _state.value = _state.value.copy(sportSaving = true, error = null, message = null) + if (original != null) { + repository.deleteTermin(original) + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.", + ) + return@launch + } + } + val saveResult = repository.saveTermin(termin) + saveResult + .onSuccess { response -> + val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine) + _state.value = _state.value.copy( + sportSaving = false, + sportTermine = termine, + message = response.message ?: "Termin gespeichert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.", + ) + } + } + } + + fun deleteSportTermin(termin: TerminDto) { + viewModelScope.launch { + _state.value = _state.value.copy(sportSaving = true, error = null, message = null) + repository.deleteTermin(termin) + .onSuccess { response -> + _state.value = _state.value.copy( + sportSaving = false, + sportTermine = _state.value.sportTermine.filterNot { it == termin }, + message = response.message ?: "Termin gelöscht.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.", + ) + } + } + } + + fun saveSportMannschaften(season: String, rows: List) { + viewModelScope.launch { + _state.value = _state.value.copy(sportSaving = true, error = null, message = null) + repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows) + .onSuccess { response -> + _state.value = _state.value.copy( + sportSaving = false, + sportMannschaften = rows, + message = response.message ?: "Mannschaften gespeichert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.", + ) + } + } + } + + fun saveSportSpielplan(headers: List, rows: List>) { + viewModelScope.launch { + _state.value = _state.value.copy(sportSaving = true, error = null, message = null) + repository.saveSpielplan(headers, rows) + .onSuccess { response -> + _state.value = _state.value.copy( + sportSaving = false, + sportSpielplanHeaders = headers, + sportSpielplanRows = rows, + message = response.message ?: "Spielplan gespeichert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy( + sportSaving = false, + error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.", + ) + } + } + } + fun saveConfig(config: ConfigResponse) { viewModelScope.launch { _state.value = _state.value.copy(saving = true, error = null, message = null) @@ -462,3 +626,18 @@ class CmsViewModel @Inject constructor( } } } + +private fun SpielDto.valueForHeader(header: String): String = when (header) { + "Termin" -> termin + "HeimMannschaft" -> heimMannschaft + "GastMannschaft" -> gastMannschaft + "HeimMannschaftAltersklasse" -> heimAltersklasse + "GastMannschaftAltersklasse" -> gastAltersklasse + "Altersklasse" -> altersklasse + "Liga" -> liga + "Staffel" -> staffel + "Runde" -> runde.orEmpty() + "SpieleHeim" -> spieleHeim + "SpieleGast" -> spieleGast + else -> "" +} diff --git a/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt b/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt index ddab0b2..d373564 100644 --- a/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt +++ b/android-app/app/src/test/java/de/harheimertc/ui/screens/cms/CmsViewModelTest.kt @@ -2,11 +2,13 @@ package de.harheimertc.ui.screens.cms import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import io.mockk.coEvery +import io.mockk.every import io.mockk.mockk import org.junit.After import org.junit.Assert.assertEquals @@ -27,6 +29,12 @@ class CmsViewModelTest { Dispatchers.resetMain() } + private fun viewModel(repo: de.harheimertc.repositories.CmsRepository): CmsViewModel { + val connectivity = mockk() + every { connectivity.online } returns MutableStateFlow(true) + return CmsViewModel(repo, connectivity) + } + @Test fun load_populatesState() = runTest { val repo = mockk() @@ -37,11 +45,11 @@ class CmsViewModelTest { coEvery { repo.contactRequests() } returns Result.success(emptyList()) coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse()) coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse()) - coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C")))) + coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = "5", title = "T", content = "C")))) coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse()) coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) // advance init launched coroutine dispatcher.scheduler.advanceUntilIdle() @@ -66,7 +74,7 @@ class CmsViewModelTest { coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) coEvery { repo.saveConfig(any()) } returns Result.success(cfg) coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok")) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) // wait for init/load to finish before saving to avoid race dispatcher.scheduler.advanceUntilIdle() @@ -95,7 +103,7 @@ class CmsViewModelTest { coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList()) coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved")) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) dispatcher.scheduler.advanceUntilIdle() vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c")) @@ -122,7 +130,7 @@ class CmsViewModelTest { coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated")) coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand"))))) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) dispatcher.scheduler.advanceUntilIdle() vm.updateUserRoles("1", listOf("admin", "vorstand")) @@ -150,7 +158,7 @@ class CmsViewModelTest { coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated")) coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false)))) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) dispatcher.scheduler.advanceUntilIdle() vm.setUserActive("2", false) @@ -177,7 +185,7 @@ class CmsViewModelTest { coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent")) - val vm = CmsViewModel(repo) + val vm = viewModel(repo) dispatcher.scheduler.advanceUntilIdle() vm.resendInvite("10") diff --git a/components/cms/CmsSpielplaene.vue b/components/cms/CmsSpielplaene.vue index 523fa3d..dca8d34 100644 --- a/components/cms/CmsSpielplaene.vue +++ b/components/cms/CmsSpielplaene.vue @@ -58,7 +58,7 @@

{{ currentFile.name }}

- {{ currentFile.size }} bytes + {{ currentFileLabel }}

@@ -368,7 +368,7 @@ const processFile = async (file) => { const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) } csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line)) selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false - currentFile.value = { name: file.name, size: file.size, lastModified: file.lastModified } + currentFile.value = { name: file.name, size: file.size, entries: csvData.value.length, lastModified: file.lastModified } processingMessage.value = 'Verarbeitung abgeschlossen!' setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000) } catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false } @@ -377,6 +377,11 @@ const processFile = async (file) => { const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) } const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' } const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length) +const currentFileLabel = computed(() => { + if (!currentFile.value) return '' + if (typeof currentFile.value.entries === 'number') return `${currentFile.value.entries} Einträge` + return `${currentFile.value.size} bytes` +}) const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' } const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) } const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) } @@ -415,7 +420,7 @@ onMounted(() => { csvHeaders.value = result.headers csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || '')) selectedColumns.value = new Array(csvHeaders.value.length).fill(true) - currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', size: csvData.value.length, lastModified: null } + currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null } } catch { /* ignore */ } })() }) diff --git a/server/api/termine-manage.delete.js b/server/api/termine-manage.delete.js index 05768a0..7fe4126 100644 --- a/server/api/termine-manage.delete.js +++ b/server/api/termine-manage.delete.js @@ -3,7 +3,7 @@ import { deleteTermin } from '../utils/termine.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -58,4 +58,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/termine-manage.get.js b/server/api/termine-manage.get.js index f3d913b..7d37179 100644 --- a/server/api/termine-manage.get.js +++ b/server/api/termine-manage.get.js @@ -3,7 +3,7 @@ import { readTermine } from '../utils/termine.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -42,4 +42,3 @@ export default defineEventHandler(async (event) => { throw error } }) - diff --git a/server/api/termine-manage.post.js b/server/api/termine-manage.post.js index 4d0ec43..b1d0247 100644 --- a/server/api/termine-manage.post.js +++ b/server/api/termine-manage.post.js @@ -4,7 +4,7 @@ import { sendNewEventPush } from '../utils/push-notifications.js' export default defineEventHandler(async (event) => { try { - const token = getCookie(event, 'auth_token') + const token = getCookie(event, 'auth_token') || getHeader(event, 'authorization')?.replace(/^Bearer\s+/i, '') if (!token) { throw createError({ @@ -63,4 +63,3 @@ export default defineEventHandler(async (event) => { throw error } }) -