Android implementation of sportbetrieb, 401-fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m29s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

This commit is contained in:
Torsten Schulz (local)
2026-06-13 00:30:06 +02:00
parent e537839e28
commit e3cb7282bc
9 changed files with 774 additions and 40 deletions

View File

@@ -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<TerminDto> = emptyList())
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
data class TerminDto(
val datum: String = "",
val uhrzeit: String? = null,
@@ -590,6 +591,21 @@ interface ApiService {
@GET("/api/termine")
suspend fun termine(): Response<TermineResponse>
@GET("/api/termine-manage")
suspend fun termineManage(): Response<TermineManageResponse>
@POST("/api/termine-manage")
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
@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<AuthMessageResponse>
@GET("/api/spielplan")
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>

View File

@@ -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<List<TerminDto>> = runCatching {
val response = api.termineManage()
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
response.body()?.termine.orEmpty()
}
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = 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<de.harheimertc.data.AuthMessageResponse> = 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<List<CmsMannschaftRow>> = 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<de.harheimertc.data.MannschaftenSeasonsResponse> = 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<CmsMannschaftRow>): Result<SaveCsvResponse> = 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<SpielplanResponse> = 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<String>, rows: List<List<String>>): Result<SaveCsvResponse> = 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<CmsUsersResponse> =
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<CmsMannschaftRow>.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<String>.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<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()

View File

@@ -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<CmsMannschaftRow>() }
var spielplanCsv by remember { mutableStateOf("") }
var spielplanEditorOpen by remember { mutableStateOf(false) }
var terminDialogOpen by remember { mutableStateOf(false) }
var editingTermin by remember { mutableStateOf<TerminDto?>(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
}
}
if (!state.sportLoading) {
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")
"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.") }
}
"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")
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")
}
}
"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")
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<String>, rows: List<List<String>>): String {
if (headers.isEmpty()) return ""
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
}
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
if (lines.isEmpty()) return emptyList<String>() 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<String> {
val values = mutableListOf<String>()
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<de.harheimertc.data.TrainingTimeDto>() }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
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())

View File

@@ -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<NewsDto> = emptyList(),
val meisterschaften: List<MeisterschaftResult> = emptyList(),
val sportLoading: Boolean = false,
val sportSaving: Boolean = false,
val sportTermine: List<TerminDto> = emptyList(),
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
val sportMannschaftenSeasons: List<String> = emptyList(),
val sportMannschaftenSeason: String = "",
val sportSpielplanHeaders: List<String> = emptyList(),
val sportSpielplanRows: List<List<String>> = emptyList(),
val sportSpielplanSeason: String = "",
val sportSpielplanSeasons: List<SeasonDto> = 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<CmsMannschaftRow>) {
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<String>, rows: List<List<String>>) {
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 -> ""
}

View File

@@ -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<de.harheimertc.data.ConnectivityMonitor>()
every { connectivity.online } returns MutableStateFlow(true)
return CmsViewModel(repo, connectivity)
}
@Test
fun load_populatesState() = runTest {
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
@@ -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")

View File

@@ -58,7 +58,7 @@
<p class="text-sm font-medium text-green-800">
{{ currentFile.name }}
</p><p class="text-xs text-green-600">
{{ currentFile.size }} bytes
{{ currentFileLabel }}
</p>
</div>
</div>
@@ -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 */ }
})()
})

View File

@@ -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
}
})

View File

@@ -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
}
})

View File

@@ -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
}
})