1 Commits

Author SHA1 Message Date
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
583 changed files with 8434 additions and 4990 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 runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 uses: actions/checkout@v4
with: with:
clean: true clean: true
fetch-depth: 0 fetch-depth: 0
@@ -29,7 +29,7 @@ jobs:
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
@@ -116,7 +116,7 @@ jobs:
chmod +x osv-scanner chmod +x osv-scanner
./osv-scanner --version ./osv-scanner --version
test -f ./package-lock.json test -f ./package-lock.json
./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml ./osv-scanner --lockfile ./package-lock.json
deploy-production: deploy-production:
runs-on: ubuntu-latest 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: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 uses: actions/cache@v4
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches

0
.gitignore vendored Executable file → Normal file
View File

View File

@@ -1,7 +0,0 @@
[[allowlists]]
description = "generated/imported non-secret data"
paths = [
'''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''',
'''android-app/app/build/.*''',
'''android-app/\.idea/planningMode\.xml$''',
]

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

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

@@ -88,13 +88,6 @@ android {
versionName = androidVersionName versionName = androidVersionName
} }
lint {
disable += setOf(
"AutoboxingStateCreation",
"MutableCollectionMutableState",
)
}
signingConfigs { signingConfigs {
create("release") { create("release") {
if (hasReleaseSigning) { if (hasReleaseSigning) {

View File

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

Binary file not shown.

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

@@ -21,7 +21,6 @@ import okhttp3.RequestBody
data class ContactRequest(val name: String, val email: String, val message: String) 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 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 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( data class TerminDto(
val datum: String = "", val datum: String = "",
val uhrzeit: String? = null, val uhrzeit: String? = null,
@@ -232,7 +231,7 @@ data class ProfileVisibilityDto(
val showEmail: Boolean = true, val showEmail: Boolean = true,
val showPhone: Boolean = true, val showPhone: Boolean = true,
val showAddress: Boolean = false, val showAddress: Boolean = false,
val showBirthday: Boolean = false, val showBirthday: Boolean = true,
) )
data class ProfileUserDto( data class ProfileUserDto(
val id: String? = null, val id: String? = null,
@@ -329,7 +328,6 @@ data class MemberDto(
val editable: Boolean = false, val editable: Boolean = false,
val isMannschaftsspieler: Boolean = false, val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false, val hasHallKey: Boolean = false,
val showBirthday: Boolean = false,
val loginRoles: List<String> = emptyList(), val loginRoles: List<String> = emptyList(),
) )
data class MembersResponse( data class MembersResponse(
@@ -592,21 +590,6 @@ interface ApiService {
@GET("/api/termine") @GET("/api/termine")
suspend fun termine(): Response<TermineResponse> 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") @GET("/api/spielplan")
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse> suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
@@ -652,9 +635,6 @@ interface ApiService {
@POST("/api/cms/save-csv") @POST("/api/cms/save-csv")
suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse> suspend fun saveCsv(@Body request: SaveCsvRequest): Response<SaveCsvResponse>
@POST("/api/cms/import-spielplan")
suspend fun importSpielplan(): Response<AuthMessageResponse>
@POST("/api/membership/generate-pdf") @POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse> suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
@@ -733,7 +713,6 @@ interface ApiService {
val notes: String? = null, val notes: String? = null,
val isMannschaftsspieler: Boolean = false, val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false, val hasHallKey: Boolean = false,
val showBirthday: Boolean = false,
) )
data class BulkImportRequest(val members: List<Map<String, String>>) data class BulkImportRequest(val members: List<Map<String, String>>)

View File

View File

@@ -19,7 +19,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class ConnectivityMonitor @Inject constructor( class ConnectivityMonitor @Inject constructor(
@param:ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _online = MutableStateFlow(hasInternetAccess()) private val _online = MutableStateFlow(hasInternetAccess())
@@ -46,4 +46,4 @@ class ConnectivityMonitor @Inject constructor(
val capabilities = manager.getNetworkCapabilities(network) ?: return false val capabilities = manager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} }
} }

View File

View File

View File

View File

View File

@@ -51,12 +51,8 @@ object HarheimerNotifications {
.setContentIntent(createContentIntent(context, notificationId, data)) .setContentIntent(createContentIntent(context, notificationId, data))
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
return try { NotificationManagerCompat.from(context).notify(notificationId, notification)
NotificationManagerCompat.from(context).notify(notificationId, notification) return true
true
} catch (_: SecurityException) {
false
}
} }
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent { private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
@@ -76,12 +72,7 @@ object HarheimerNotifications {
} }
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) { private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
"news", "news_expiring" -> Destinations.MemberNews.route "news" -> Destinations.MemberNews.route
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
"team_matches" -> Destinations.Spielplan.route
"birthdays" -> Destinations.MemberArea.route
"contact_request" -> Destinations.CmsContactRequests.route
"user_registration" -> Destinations.CmsBenutzer.route
else -> Destinations.Home.route else -> Destinations.Home.route
} }
} }

View File

@@ -10,8 +10,6 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
import de.harheimertc.data.SaveCsvRequest import de.harheimertc.data.SaveCsvRequest
import de.harheimertc.data.SaveCsvResponse import de.harheimertc.data.SaveCsvResponse
import de.harheimertc.data.SecureOfflineCache import de.harheimertc.data.SecureOfflineCache
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import javax.inject.Inject import javax.inject.Inject
class CmsRepository @Inject constructor( class CmsRepository @Inject constructor(
@@ -85,93 +83,6 @@ class CmsRepository @Inject constructor(
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort") 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],
spielplanMannschaftId = values.getOrElse(10) { "" },
)
}
}
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 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.")
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> = suspend fun users(): Result<CmsUsersResponse> =
fetchEncryptedFallback( fetchEncryptedFallback(
load = { load = {
@@ -377,61 +288,6 @@ 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 = "",
val spielplanMannschaftId: String = "",
)
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
val header = listOf(
"Mannschaft",
"Liga",
"Staffelleiter",
"Telefon",
"Heimspieltag",
"Spielsystem",
"Mannschaftsführer",
"Spieler",
"Weitere Informationen Link",
"Letzte Aktualisierung",
"Spielplan Mannschaft ID",
).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,
row.spielplanMannschaftId,
).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>> = private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()

View File

@@ -422,7 +422,6 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.CmsStartseite.route, Destinations.CmsStartseite.route,
Destinations.CmsInhalte.route, Destinations.CmsInhalte.route,
Destinations.CmsVereinsmeisterschaften.route, Destinations.CmsVereinsmeisterschaften.route,
Destinations.CmsNews.route,
Destinations.CmsSportbetrieb.route, Destinations.CmsSportbetrieb.route,
Destinations.CmsMitgliederverwaltung.route, Destinations.CmsMitgliederverwaltung.route,
Destinations.CmsNewsletter.route, Destinations.CmsNewsletter.route,
@@ -485,7 +484,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
add(MenuTarget("Startseite", Destinations.CmsStartseite.route)) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route)) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route)) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
add(MenuTarget("News", Destinations.CmsNews.route)) add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route)) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route)) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route)) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))

View File

View File

@@ -48,7 +48,6 @@ sealed class Destinations(val route: String) {
object CmsStartseite : Destinations("cms/startseite") object CmsStartseite : Destinations("cms/startseite")
object CmsInhalte : Destinations("cms/inhalte") object CmsInhalte : Destinations("cms/inhalte")
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften") object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
object CmsNews : Destinations("cms/news")
object CmsSportbetrieb : Destinations("cms/sportbetrieb") object CmsSportbetrieb : Destinations("cms/sportbetrieb")
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung") object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
object CmsNewsletter : Destinations("cms/newsletter") object CmsNewsletter : Destinations("cms/newsletter")

View File

@@ -2,9 +2,7 @@ package de.harheimertc.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Button
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.foundation.layout.BoxWithConstraints 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -330,141 +327,43 @@ fun NavGraph(
) )
} }
composable(Destinations.CmsStartseite.route) { composable(Destinations.CmsStartseite.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsInhalte.route) { composable(Destinations.CmsInhalte.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsVereinsmeisterschaften.route) { composable(Destinations.CmsVereinsmeisterschaften.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
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)
}
} }
composable(Destinations.CmsSportbetrieb.route) { composable(Destinations.CmsSportbetrieb.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsMitgliederverwaltung.route) { composable(Destinations.CmsMitgliederverwaltung.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsNewsletter.route) { composable(Destinations.CmsNewsletter.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessNewsletter,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsContactRequests.route) { composable(Destinations.CmsContactRequests.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessContactRequests,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsEinstellungen.route) { composable(Destinations.CmsEinstellungen.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsBenutzer.route) { composable(Destinations.CmsBenutzer.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsPasswordResetDiagnostics.route) { composable(Destinations.CmsPasswordResetDiagnostics.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.Cms.route) { composable(Destinations.Cms.route) {
RequireCmsAccess( de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation)
allowed = navigationState.canAccessCms,
navController = navController,
) {
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) { private fun NavHostController.navigateTopLevel(route: String) {
val isTeamDetail = route.startsWith("mannschaften/") && val isTeamDetail = route.startsWith("mannschaften/") &&
route != Destinations.Spielsysteme.route route != Destinations.Spielsysteme.route

View File

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

View File

@@ -1,10 +1,8 @@
package de.harheimertc.ui.screens.cms package de.harheimertc.ui.screens.cms
import android.content.Intent import android.content.Intent
import android.app.DatePickerDialog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -38,8 +36,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -52,12 +48,10 @@ import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetMatchingUserDto import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetStepDto import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.FormMessages import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.repositories.CmsMannschaftRow
import de.harheimertc.repositories.MeisterschaftResult import de.harheimertc.repositories.MeisterschaftResult
import de.harheimertc.ui.theme.Accent100 import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500 import de.harheimertc.ui.theme.Accent500
@@ -67,7 +61,6 @@ import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600 import de.harheimertc.ui.theme.Primary600
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Calendar
import java.util.Locale import java.util.Locale
@Composable @Composable
@@ -440,428 +433,118 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
@Composable @Composable
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val config = state.config
var activeTab by remember { mutableStateOf("termine") } var ortName by remember { mutableStateOf("") }
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() } var ortStrasse by remember { mutableStateOf("") }
var spielplanCsv by remember { mutableStateOf("") } var ortPlz by remember { mutableStateOf("") }
var spielplanEditorOpen by remember { mutableStateOf(false) } var ortOrt by remember { mutableStateOf("") }
var terminDialogOpen by remember { mutableStateOf(false) } val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
var editingTermin by remember { mutableStateOf<TerminDto?>(null) } val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
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 spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
}
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
LaunchedEffect(Unit) { LaunchedEffect(config) {
viewModel.loadSportbetrieb() config?.let {
} ortName = it.training.ort.name
ortStrasse = it.training.ort.strasse
LaunchedEffect(state.sportMannschaften) { ortPlz = it.training.ort.plz
mannschaften.clear() ortOrt = it.training.ort.ort
mannschaften.addAll(state.sportMannschaften) trainingTimes.clear()
} trainingTimes.addAll(it.training.zeiten)
trainers.clear()
LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) { trainers.addAll(it.trainer)
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") { CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") } when {
item { state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
Row( else -> {
modifier = Modifier.fillMaxWidth(), item {
horizontalArrangement = Arrangement.spacedBy(8.dp), Button(
) { onClick = {
tabs.forEach { (id, label) -> viewModel.saveConfig(
if (activeTab == id) { config.copy(
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) } training = config.training.copy(
} else { ort = config.training.ort.copy(
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) } name = ortName,
strasse = ortStrasse,
plz = ortPlz,
ort = ortOrt,
),
zeiten = trainingTimes.toList(),
),
trainer = trainers.toList(),
),
)
},
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (state.saving) "Speichert..." else "Speichern")
} }
} }
} item {
} DataCard("Trainingsort") {
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
if (!state.sportLoading) { OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
when (activeTab) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
"termine" -> { OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
item { 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( Button(
onClick = { openTerminDialog(null) }, onClick = {
enabled = !state.sportSaving, trainingTimes.add(
de.harheimertc.data.TrainingTimeDto(
id = "training-${System.currentTimeMillis()}",
tag = "Montag",
),
)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Termin hinzufügen") Text("Trainingszeit 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 {
item { DataCard("Trainer") {
if (state.sportMannschaftenSeasons.isNotEmpty()) { trainers.forEachIndexed { index, trainer ->
DataCard("Saison") { TrainerEditorCard(
Row( trainer = trainer,
modifier = Modifier.fillMaxWidth(), onChange = { updated -> trainers[index] = updated },
horizontalArrangement = Arrangement.spacedBy(8.dp), onRemove = { trainers.removeAt(index) },
) { )
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)
}
}
}
}
}
} }
} Button(
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],
spielplanTeams = spielplanTeamOptions,
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()) {
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")
}
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,
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 = { onClick = {
onChange(row.copy(spielplanMannschaftId = team.id)) trainers.add(
teamPickerOpen = false de.harheimertc.data.TrainerDto(
id = "trainer-${System.currentTimeMillis()}",
),
)
}, },
) modifier = Modifier.fillMaxWidth(),
) {
Text("Trainer hinzufügen")
}
} }
} }
item { FormMessages(state.error, state.message) }
} }
} }
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 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(";") } }
}
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 @Composable
fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
@@ -936,13 +619,11 @@ fun CmsNewsletterScreen(
if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") } if (!state.loading && state.newsletters.isEmpty()) item { EmptyCard("Keine Newsletter gefunden.") }
items(state.newsletters.size) { index -> items(state.newsletters.size) { index ->
val item = state.newsletters[index] val item = state.newsletters[index]
NewsletterCard( NewsletterCard(item,
item,
canWrite = canWrite,
onEdit = { nl -> onEdit = { nl ->
editingNewsletter = nl editingNewsletter = nl
nlTitle = nl.title nlTitle = nl.title
nlContent = nl.title nlContent = nl.title ?: ""
nlType = "subscription" nlType = "subscription"
nlTargetGroup = "" nlTargetGroup = ""
nlSendToExternal = true nlSendToExternal = true
@@ -956,9 +637,7 @@ fun CmsNewsletterScreen(
if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") } if (!state.loading && state.newsletterGroups.isEmpty()) item { EmptyCard("Keine Gruppen gefunden.") }
items(state.newsletterGroups.size) { index -> items(state.newsletterGroups.size) { index ->
val group = state.newsletterGroups[index] val group = state.newsletterGroups[index]
NewsletterGroupCard( NewsletterGroupCard(group,
group,
canWrite = canWrite,
onEdit = { g -> onEdit = { g ->
editingGroup = g editingGroup = g
grpName = g.name grpName = g.name
@@ -1074,12 +753,6 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
var websiteVorname by remember { mutableStateOf("") } var websiteVorname by remember { mutableStateOf("") }
var websiteNachname by remember { mutableStateOf("") } var websiteNachname by remember { mutableStateOf("") }
var websiteEmail 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) { LaunchedEffect(config) {
config?.let { config?.let {
@@ -1091,14 +764,6 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
websiteVorname = it.website.verantwortlicher.vorname websiteVorname = it.website.verantwortlicher.vorname
websiteNachname = it.website.verantwortlicher.nachname websiteNachname = it.website.verantwortlicher.nachname
websiteEmail = it.website.verantwortlicher.email 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)
} }
} }
@@ -1125,16 +790,6 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
email = websiteEmail, 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(),
), ),
) )
}, },
@@ -1167,63 +822,6 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth()) 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 { item {
DataCard("Systemstatus") { DataCard("Systemstatus") {
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString()) InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
@@ -1352,7 +950,6 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
val cards = listOf( val cards = listOf(
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route), Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route), Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route),
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route), Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route), Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route), Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
@@ -1632,44 +1229,29 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
} }
@Composable @Composable
private fun NewsletterCard( private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
newsletter: NewsletterDto,
canWrite: Boolean,
onEdit: (NewsletterDto) -> Unit = {},
onDelete: (String) -> Unit = {},
onSend: (String) -> Unit = {},
) {
DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) { DataCard(newsletter.subject.ifBlank { newsletter.title.ifBlank { newsletter.id } }) {
InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf") InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf")
InfoRow("Erstellt", newsletter.createdAt ?: "-") InfoRow("Erstellt", newsletter.createdAt ?: "-")
InfoRow("Versendet", newsletter.sentAt ?: "-") InfoRow("Versendet", newsletter.sentAt ?: "-")
if (canWrite) { Row {
Row { TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") } TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } if (newsletter.status != "sent") {
if (newsletter.status != "sent") { TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
}
} }
} }
} }
} }
@Composable @Composable
private fun NewsletterGroupCard( private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) {
group: NewsletterGroupDto,
canWrite: Boolean,
onEdit: (NewsletterGroupDto) -> Unit = {},
onDelete: (String) -> Unit = {},
) {
DataCard(group.name.ifBlank { group.id }) { DataCard(group.name.ifBlank { group.id }) {
InfoRow("Beschreibung", group.description.ifBlank { "-" }) InfoRow("Beschreibung", group.description.ifBlank { "-" })
InfoRow("Abonnenten", group.subscribers.size.toString()) InfoRow("Abonnenten", group.subscribers.size.toString())
if (canWrite) { Row {
Row { TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") } TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
}
} }
} }
} }

View File

@@ -13,10 +13,6 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest 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.CmsRepository
import de.harheimertc.repositories.MeisterschaftResult import de.harheimertc.repositories.MeisterschaftResult
import kotlinx.coroutines.async import kotlinx.coroutines.async
@@ -43,16 +39,6 @@ data class CmsUiState(
val passwordResetFailedOnly: Boolean = true, val passwordResetFailedOnly: Boolean = true,
val news: List<NewsDto> = emptyList(), val news: List<NewsDto> = emptyList(),
val meisterschaften: List<MeisterschaftResult> = 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 @HiltViewModel
@@ -193,183 +179,6 @@ 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 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)
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) { fun saveConfig(config: ConfigResponse) {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null) _state.value = _state.value.copy(saving = true, error = null, message = null)
@@ -653,18 +462,3 @@ 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

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

View File

@@ -90,7 +90,7 @@ class HomeViewModel @Inject constructor(
loading = false, loading = false,
heroImageUrl = data.heroImageUrl, heroImageUrl = data.heroImageUrl,
termine = data.termine termine = data.termine
.filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true } .filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
.sortedBy { it.asDateTime() } .sortedBy { it.asDateTime() }
.take(3), .take(3),
spiele = data.spiele spiele = data.spiele

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