2 Commits

Author SHA1 Message Date
05e25aa3d1 Merge pull request 'dev' (#42) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m15s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #42
2026-06-16 14:09:23 +02:00
ecae88af78 Merge pull request 'dev' (#41) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Successful in 3m0s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #41
2026-06-10 16:49:07 +02:00
579 changed files with 6903 additions and 2784 deletions

0
.codex Executable file → Normal file
View File

6
.gitea/workflows/code-analysis.yml Executable file → Normal file
View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
uses: actions/checkout@v4
with:
clean: true
fetch-depth: 0
@@ -29,7 +29,7 @@ jobs:
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
@@ -116,7 +116,7 @@ jobs:
chmod +x osv-scanner
./osv-scanner --version
test -f ./package-lock.json
./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
./osv-scanner --lockfile ./package-lock.json
deploy-production:
runs-on: ubuntu-latest

0
.gitea/workflows/version-gate.yml Executable file → Normal file
View File

6
.github/workflows/android-ci.yml vendored Executable file → Normal file
View File

@@ -10,14 +10,14 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Cache Gradle
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830
uses: actions/cache@v4
with:
path: |
~/.gradle/caches

0
.gitignore vendored Executable file → Normal file
View File

0
.gitleaks.toml Executable file → Normal file
View File

0
.gitleaksignore Executable file → Normal file
View File

0
.nvmrc Executable file → Normal file
View File

View File

@@ -1,4 +0,0 @@
[[IgnoredVulns]]
id = "GHSA-v3m3-f69x-jf25"
ignoreUntil = 2026-12-31
reason = "Temporary exception: Quill 2.0.3 is required by the current RichTextEditor implementation, and OSV currently reports no fixed version. Track upstream fix and remove this ignore once a patched release is available."

0
.semgrepignore Executable file → Normal file
View File

0
ANDROID_ARCHITECTURE.md Executable file → Normal file
View File

0
ANDROID_KOTLIN_PLAN.md Executable file → Normal file
View File

0
ANDROID_PORT_TODO.md Executable file → Normal file
View File

0
ANDROID_REPO_ENDPOINTS.md Executable file → Normal file
View File

0
AUTH_README.md Executable file → Normal file
View File

0
DATENSCHUTZ.md Executable file → Normal file
View File

0
DATENSCHUTZ_UEBERSICHT.md Executable file → Normal file
View File

0
DEPLOYMENT.md Executable file → Normal file
View File

0
android-app/PLAYSTORE_ASSETS.md Executable file → Normal file
View File

0
android-app/app/build.gradle.kts Executable file → Normal file
View File

View File

0
android-app/app/local/release/app-local-release.aab Executable file → Normal file
View File

0
android-app/app/proguard-rules.pro vendored Executable file → Normal file
View File

0
android-app/app/src/androidTest/AndroidManifest.xml Executable file → Normal file
View File

View File

0
android-app/app/src/debug/AndroidManifest.xml Executable file → Normal file
View File

0
android-app/app/src/main/AndroidManifest.xml Executable file → Normal file
View File

View File

View File

View File

@@ -652,9 +652,6 @@ interface ApiService {
@POST("/api/cms/save-csv")
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
@POST("/api/cms/import-spielplan")
suspend fun importSpielplan(): Response<AuthMessageResponse>
@POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>

View File

View File

View File

View File

View File

View File

@@ -125,7 +125,6 @@ class CmsRepository @Inject constructor(
spieler = values[7],
informationenLink = values[8],
letzteAktualisierung = values[9],
spielplanMannschaftId = values.getOrElse(10) { "" },
)
}
}
@@ -147,12 +146,6 @@ class CmsRepository @Inject constructor(
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
}
suspend fun importSpielplan(): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.importSpielplan()
if (!response.isSuccessful) error("Spielplan konnte nicht importiert werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(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.")
@@ -388,7 +381,6 @@ data class CmsMannschaftRow(
val spieler: String = "",
val informationenLink: String = "",
val letzteAktualisierung: String = "",
val spielplanMannschaftId: String = "",
)
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
@@ -403,7 +395,6 @@ private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
"Spieler",
"Weitere Informationen Link",
"Letzte Aktualisierung",
"Spielplan Mannschaft ID",
).toCsvRow()
val rows = map { row ->
listOf(
@@ -417,7 +408,6 @@ private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
row.spieler,
row.informationenLink,
row.letzteAktualisierung,
row.spielplanMannschaftId,
).toCsvRow()
}
return listOf(header).plus(rows).joinToString("\n")

View File

View File

@@ -2,9 +2,7 @@ package de.harheimertc.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -12,7 +10,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@@ -330,141 +327,46 @@ fun NavGraph(
)
}
composable(Destinations.CmsStartseite.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsInhalte.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsVereinsmeisterschaften.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsNews.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsSportbetrieb.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsMitgliederverwaltung.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsNewsletter.route) {
RequireCmsAccess(
allowed = navigationState.canAccessNewsletter,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsContactRequests.route) {
RequireCmsAccess(
allowed = navigationState.canAccessContactRequests,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsEinstellungen.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsBenutzer.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
}
composable(Destinations.CmsPasswordResetDiagnostics.route) {
RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
}
composable(Destinations.Cms.route) {
RequireCmsAccess(
allowed = navigationState.canAccessCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
}
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
}
}
}
}
}
@Composable
private fun RequireCmsAccess(
allowed: Boolean,
navController: NavHostController,
content: @Composable () -> Unit,
) {
if (allowed) {
content()
return
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Kein Zugriff auf diesen Bereich.",
style = MaterialTheme.typography.headlineSmall,
color = Color(0xFF7F1D1D),
)
Text(
text = "Bitte mit einem berechtigten Konto anmelden.",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF7C2D12),
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
)
Button(onClick = { navController.navigateTopLevel(Destinations.Login.route) }) {
Text("Zum Login")
}
}
}
private fun NavHostController.navigateTopLevel(route: String) {
val isTeamDetail = route.startsWith("mannschaften/") &&
route != Destinations.Spielsysteme.route

View File

@@ -132,22 +132,15 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
items(state.news) { news ->
val selected = news.id?.let { selection.contains(it) } ?: false
NewsListItem(
news = news,
canWrite = canWrite,
selected = selected,
onSelect = { id, sel ->
id?.let {
selection = if (sel) selection + it else selection - it
}
},
onEdit = { openEdit(news) },
onDelete = { news.id?.let { id -> deletingIds = listOf(id) } },
)
NewsListItem(news = news, selected = selected, onSelect = { id, sel ->
id?.let {
selection = if (sel) selection + it else selection - it
}
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } })
}
// bulk action bar
if (canWrite && selection.isNotEmpty()) {
if (selection.isNotEmpty()) {
item {
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.bulkSetPublic(selection.toList(), true) }) { Text("Als öffentlich markieren") }
@@ -262,7 +255,6 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
@Composable
private fun NewsListItem(
news: NewsDto,
canWrite: Boolean,
selected: Boolean = false,
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
onEdit: (NewsDto) -> Unit,
@@ -272,11 +264,7 @@ private fun NewsListItem(
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(
checked = selected,
onCheckedChange = { onSelect(news.id, it) },
enabled = canWrite,
)
Checkbox(checked = selected, onCheckedChange = { onSelect(news.id, it) })
Text(news.title.ifBlank { "(Ohne Titel)" }, modifier = Modifier.padding(start = 8.dp))
if (news.isPublic) {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, modifier = Modifier.padding(start = 8.dp)) {
@@ -309,11 +297,9 @@ private fun NewsListItem(
Text("Aktualisiert: ${news.updated}")
}
}
if (canWrite) {
Row {
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
}
Row {
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
}
}
}

View File

@@ -458,9 +458,6 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
"mannschaften" to "Mannschaften",
"spielplaene" to "Spielpläne",
)
val spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
}
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
LaunchedEffect(Unit) {
@@ -609,7 +606,6 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
items(mannschaften.size) { index ->
MannschaftEditorCard(
row = mannschaften[index],
spielplanTeams = spielplanTeamOptions,
onChange = { updated -> mannschaften[index] = updated },
onRemove = { mannschaften.removeAt(index) },
)
@@ -623,19 +619,9 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
InfoRow("Datei", fileName)
InfoRow("Saison", seasonLabel)
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Button(
onClick = { viewModel.importSportSpielplan() },
enabled = !state.sportSaving,
modifier = Modifier.weight(1f),
) {
Text(if (state.sportSaving) "Importiert..." else "Von myTischtennis importieren")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(
onClick = { viewModel.loadSportbetrieb() },
enabled = !state.sportSaving,
modifier = Modifier.weight(1f),
) {
Text("Neu laden")
@@ -746,32 +732,11 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
@Composable
private fun MannschaftEditorCard(
row: CmsMannschaftRow,
spielplanTeams: List<ImportedSpielplanTeam>,
onChange: (CmsMannschaftRow) -> Unit,
onRemove: () -> Unit,
) {
var teamPickerOpen by remember(row.mannschaft, spielplanTeams) { mutableStateOf(false) }
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = row.spielplanMannschaftId, onValueChange = { onChange(row.copy(spielplanMannschaftId = it)) }, label = { Text("Spielplan-Team-ID") }, supportingText = { Text("Eindeutige myTischtennis-Team-ID") }, modifier = Modifier.fillMaxWidth())
if (spielplanTeams.isNotEmpty()) {
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { teamPickerOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text("Aus importierten Teams auswählen")
}
DropdownMenu(expanded = teamPickerOpen, onDismissRequest = { teamPickerOpen = false }) {
spielplanTeams.forEach { team ->
DropdownMenuItem(
text = { Text(team.label) },
onClick = {
onChange(row.copy(spielplanMannschaftId = team.id))
teamPickerOpen = false
},
)
}
}
}
}
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())
@@ -787,35 +752,6 @@ private fun MannschaftEditorCard(
}
}
private data class ImportedSpielplanTeam(val id: String, val label: String)
private fun importedSpielplanTeams(headers: List<String>, rows: List<List<String>>): List<ImportedSpielplanTeam> {
fun index(name: String) = headers.indexOf(name)
fun value(row: List<String>, column: String): String {
val columnIndex = index(column)
return row.getOrElse(columnIndex) { "" }.trim()
}
val teams = linkedMapOf<String, ImportedSpielplanTeam>()
listOf("Heim", "Gast").forEach { side ->
rows.forEach { row ->
val isHarheimer = value(row, "${side}VereinNr") == "43030" || value(row, "${side}VereinName") == "Harheimer TC"
val id = value(row, "${side}MannschaftId")
if (!isHarheimer || id.isBlank()) return@forEach
val teamName = value(row, "${side}Mannschaft")
val ageClass = value(row, "${side}MannschaftAltersklasse")
val teamNumber = value(row, "${side}MannschaftNr")
val label = listOf(teamName, ageClass, teamNumber.takeIf { it.isNotBlank() }?.let { "Nr. $it" })
.filterNotNull()
.filter { it.isNotBlank() }
.joinToString(" · ")
teams.putIfAbsent(id, ImportedSpielplanTeam(id, "$label [$id]"))
}
}
return teams.values.sortedBy { it.label }
}
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(";") } }
@@ -936,9 +872,7 @@ fun CmsNewsletterScreen(
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
items(state.newsletters.size) { index ->
val item = state.newsletters[index]
NewsletterCard(
item,
canWrite = canWrite,
NewsletterCard(item,
onEdit = { nl ->
editingNewsletter = nl
nlTitle = nl.title
@@ -956,9 +890,7 @@ fun CmsNewsletterScreen(
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
items(state.newsletterGroups.size) { index ->
val group = state.newsletterGroups[index]
NewsletterGroupCard(
group,
canWrite = canWrite,
NewsletterGroupCard(group,
onEdit = { g ->
editingGroup = g
grpName = g.name
@@ -1632,44 +1564,29 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
}
@Composable
private fun NewsletterCard(
newsletter: NewsletterDto,
canWrite: Boolean,
onEdit: (NewsletterDto) -> Unit = {},
onDelete: (String) -> Unit = {},
onSend: (String) -> Unit = {},
) {
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
InfoRow("Erstellt", newsletter.createdAt ?: "-")
InfoRow("Versendet", newsletter.sentAt ?: "-")
if (canWrite) {
Row {
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
if (newsletter.status != "sent") {
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
}
Row {
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
if (newsletter.status != "sent") {
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
}
}
}
}
@Composable
private fun NewsletterGroupCard(
group: NewsletterGroupDto,
canWrite: Boolean,
onEdit: (NewsletterGroupDto) -> Unit = {},
onDelete: (String) -> Unit = {},
) {
private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) {
DataCard(group.name.ifBlank { group.id }) {
InfoRow("Beschreibung", group.description.ifBlank { "-" })
InfoRow("Abonnenten", group.subscribers.size.toString())
if (canWrite) {
Row {
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
}
Row {
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
}
}
}

View File

@@ -322,33 +322,6 @@ class CmsViewModel @Inject constructor(
}
}
fun importSportSpielplan() {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
repository.importSpielplan()
.onSuccess { response ->
if (!response.success) {
_state.value = _state.value.copy(
sportSaving = false,
error = response.message ?: "Spielplan konnte nicht importiert werden.",
)
return@onSuccess
}
_state.value = _state.value.copy(
sportSaving = false,
message = response.message ?: "Spielplan aktualisiert.",
)
loadSportbetrieb()
}
.onFailure { err ->
_state.value = _state.value.copy(
sportSaving = false,
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht importiert werden.",
)
}
}
}
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
viewModelScope.launch {
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)

View File

@@ -117,22 +117,20 @@ fun HomeScreen(
)
}
}
if (navigationState.canAccessFullCms) {
item {
HomeCustomizationSection(
sections = state.homepageSections,
spielplanSeasons = state.spielplanSeasons,
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
editEnabled = editHomeSections,
onToggleEdit = { editHomeSections = !editHomeSections },
onMoveUp = viewModel::moveSectionUp,
onMoveDown = viewModel::moveSectionDown,
onEnabledChange = viewModel::setSectionEnabled,
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
onReset = viewModel::resetSections,
)
}
item {
HomeCustomizationSection(
sections = state.homepageSections,
spielplanSeasons = state.spielplanSeasons,
spielplanTeamsBySeason = state.spielplanTeamsBySeason,
editEnabled = editHomeSections,
onToggleEdit = { editHomeSections = !editHomeSections },
onMoveUp = viewModel::moveSectionUp,
onMoveDown = viewModel::moveSectionDown,
onEnabledChange = viewModel::setSectionEnabled,
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
onReset = viewModel::resetSections,
)
}
if (state.error) {
item {

Some files were not shown because too many files have changed in this diff Show More