39 Commits

Author SHA1 Message Date
42398dad21 Merge pull request 'dev' (#46) 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 2m47s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #46
2026-07-17 14:00:12 +02:00
Torsten Schulz (local)
281c25b05d feat: aktualisiere die Version auf 1.8.4 und verbessere die Erkennung von Jugendmannschaften in den Filterfunktionen
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 8m54s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m41s
2026-07-17 13:46:25 +02:00
Torsten Schulz (local)
1beb5c2eee feat: verbessere die Auswahl von Spielplan-Team-IDs durch Checkboxen und aktualisiere die Logik zur Verarbeitung von Team-Kriterien
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m43s
2026-07-17 12:05:22 +02:00
Torsten Schulz (local)
461f2de8dc feat: unterstütze mehrere Spielplan-Team-IDs und verbessere die Zuordnung importierter Mannschaften
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m17s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m46s
2026-07-17 11:49:55 +02:00
Torsten Schulz (local)
75a7c409cd feat: füge Auswahl für Spielplan-Team-ID hinzu und lade Teams von der API
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m40s
2026-07-17 11:20:15 +02:00
Torsten Schulz (local)
751c302036 feat: füge Importfunktion für Spielpläne von myTischtennis hinzu und verbessere den Ladevorgang
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m43s
2026-07-17 11:03:46 +02:00
Torsten Schulz (local)
f0e1ad39b0 Verbessere die Altersklassenerkennung in der Funktion sideMatchesJugendTeam durch Verwendung eines regulären Ausdrucks zur genauen Übereinstimmung
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m4s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m55s
2026-07-17 10:33:46 +02:00
Torsten Schulz (local)
1dc84023d0 feat: füge Unterstützung für importierte Spielplan-Teams hinzu und verbessere Teamfilter-Logik
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m37s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 09:29:34 +02:00
Torsten Schulz (local)
30465c7833 Verbessere Codequalität und Sicherheit durch Refactoring, Hinzufügen von ESLint-Regeln und Aktualisierung der OSV-Scanner-Konfiguration
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m57s
2026-07-17 09:08:42 +02:00
Torsten Schulz (local)
d260f00756 Verbessere Fehlerbehandlung in der Verschlüsselung und PDF-Generierung durch Hinzufügen von Ursacheninformationen
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m34s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 08:52:16 +02:00
Torsten Schulz (local)
f0ac4b7e56 Verbessere Fehlerbehandlung beim Laden von PDF-Vorlagen und füge Ursacheninformationen hinzu
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
2026-07-17 08:50:47 +02:00
Torsten Schulz (local)
bd45beadd5 feat(cms): add endpoint for manual spielplan import with league tables handling
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 3m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-17 08:41:11 +02:00
Torsten Schulz (local)
f060c9771f Füge Unterstützung für Spielplan-Team-ID in mehreren Komponenten hinzu und aktualisiere CSV-Export
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m2s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
2026-07-17 08:29:34 +02:00
d563d81584 Merge pull request 'Füge Funktion zur Umwandlung von Saison-Slugs in Labels hinzu und aktualisiere PDF-Daten mit saisonalen Informationen' (#45) 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 1m59s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #45
2026-07-15 11:38:20 +02:00
Torsten Schulz (local)
0ab71166aa Refactor Spielplan-Filterlogik in separate Utility-Funktionen und verbessere die Filterung im PDF-Generierungsprozess 2026-07-15 11:30:06 +02:00
Torsten Schulz (local)
d9c5d242ec Füge Funktion zur Umwandlung von Saison-Slugs in Labels hinzu und aktualisiere PDF-Daten mit saisonalen Informationen
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m51s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m56s
2026-07-15 11:27:34 +02:00
380d0a8332 Merge pull request 'dev' (#44) 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 2m41s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #44
2026-07-15 11:12:16 +02:00
Torsten Schulz (local)
cb1e7a2fed Aktualisiere Actions-Checkout- und Setup-Versionen in CI-Workflows
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 10m18s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m6s
2026-07-15 10:42:20 +02:00
Torsten Schulz (local)
3586704cce Update file permissions for multiple server and test files to executable mode
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 10m29s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-07-15 08:45:12 +02:00
301bd7acbd Merge pull request 'dev' (#43) 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 2m44s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #43
2026-06-23 23:50:21 +02:00
Torsten Schulz (local)
7f3b58c5bc updated packages
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m16s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 3m7s
2026-06-23 16:33:29 +02:00
Torsten Schulz (local)
f29e8a4dac Bugfixing
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m10s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-23 16:21:53 +02:00
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
Torsten Schulz (local)
b69130c2b2 Fixed semgrep error
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m56s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m36s
2026-06-14 21:29:53 +02:00
Torsten Schulz (local)
77aabef4a9 Benachrichtigungen erweitert
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m53s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Emails korrigiert
2026-06-14 01:05:19 +02:00
Torsten Schulz (local)
4b699de853 NPM fix
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m47s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m6s
2026-06-13 00:42:00 +02:00
Torsten Schulz (local)
e3cb7282bc Android implementation of sportbetrieb, 401-fix
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m29s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-13 00:30:06 +02:00
Torsten Schulz (local)
e537839e28 Updated stuff
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m4s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m10s
2026-06-12 16:49:00 +02:00
Torsten Schulz (local)
44d441811c Fixed some problems
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 6m38s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m37s
2026-06-12 11:42:22 +02:00
Torsten Schulz (local)
da1efa5a74 Added notifications for actual news
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m59s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-11 09:03:16 +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
c7a306e8fa Merge branch 'main' into dev
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m55s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
2026-06-10 16:41:53 +02:00
45de2a576c Merge pull request 'dev' (#40) 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 2m5s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #40
2026-05-31 15:14:03 +02:00
e1ad5f7205 Merge pull request 'feat: add robots.txt and sitemap.xml routes for SEO optimization' (#39) 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 2m11s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #39
2026-05-31 14:03:04 +02:00
14341b7a63 Merge pull request 'dev' (#38) 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 2m43s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #38
2026-05-30 23:54:07 +02:00
803481ca8e Merge pull request 'dev' (#37) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m15s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m24s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #37
2026-05-30 00:30:30 +02:00
46a8d5a77a Merge pull request 'dev' (#36) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m25s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #36
2026-05-28 08:56:09 +02:00
96c1d0260b Merge pull request 'chore(version): update version to 1.6.1' (#35) from dev into main
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m7s
Code Analysis and Production Deploy / deploy-production (push) Successful in 2m18s
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #35
2026-05-27 20:45:54 +02:00
6e00a1b829 Merge pull request 'dev' (#34) from dev into main
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 33s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
Reviewed-on: #34
2026-05-27 20:43:46 +02:00
583 changed files with 4959 additions and 8403 deletions

0
.codex Normal file → Executable file
View File

6
.gitea/workflows/code-analysis.yml Normal file → Executable 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@v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
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@v4 uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
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 --lockfile ./package-lock.json ./osv-scanner scan -L ./package-lock.json --config ./.osv-scanner.toml
deploy-production: deploy-production:
runs-on: ubuntu-latest runs-on: ubuntu-latest

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

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

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

0
.gitignore vendored Normal file → Executable file
View File

7
.gitleaks.toml Executable file
View File

@@ -0,0 +1,7 @@
[[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 Normal file → Executable file
View File

0
.nvmrc Normal file → Executable file
View File

4
.osv-scanner.toml Normal file
View File

@@ -0,0 +1,4 @@
[[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 Normal file → Executable file
View File

0
ANDROID_ARCHITECTURE.md Normal file → Executable file
View File

0
ANDROID_KOTLIN_PLAN.md Normal file → Executable file
View File

0
ANDROID_PORT_TODO.md Normal file → Executable file
View File

0
ANDROID_REPO_ENDPOINTS.md Normal file → Executable file
View File

0
AUTH_README.md Normal file → Executable file
View File

0
DATENSCHUTZ.md Normal file → Executable file
View File

0
DATENSCHUTZ_UEBERSICHT.md Normal file → Executable file
View File

0
DEPLOYMENT.md Normal file → Executable file
View File

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

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

@@ -88,6 +88,13 @@ 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 Normal file → Executable file
View File

Binary file not shown.

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

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

View File

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

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

View File

View File

View File

@@ -21,6 +21,7 @@ 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,
@@ -231,7 +232,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 = true, val showBirthday: Boolean = false,
) )
data class ProfileUserDto( data class ProfileUserDto(
val id: String? = null, val id: String? = null,
@@ -328,6 +329,7 @@ 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(
@@ -590,6 +592,21 @@ 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>
@@ -635,6 +652,9 @@ 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>
@@ -713,6 +733,7 @@ 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(
@ApplicationContext private val context: Context, @param: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,8 +51,12 @@ object HarheimerNotifications {
.setContentIntent(createContentIntent(context, notificationId, data)) .setContentIntent(createContentIntent(context, notificationId, data))
.setAutoCancel(true) .setAutoCancel(true)
.build() .build()
NotificationManagerCompat.from(context).notify(notificationId, notification) return try {
return true NotificationManagerCompat.from(context).notify(notificationId, notification)
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 {
@@ -72,7 +76,12 @@ 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" -> Destinations.MemberNews.route "news", "news_expiring" -> 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,6 +10,8 @@ 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(
@@ -83,6 +85,93 @@ 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 = {
@@ -288,6 +377,61 @@ 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,6 +422,7 @@ 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,
@@ -484,7 +485,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.MemberNews.route)) add(MenuTarget("News", Destinations.CmsNews.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,6 +48,7 @@ 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,7 +2,9 @@ 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
@@ -10,6 +12,7 @@ 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
@@ -327,43 +330,141 @@ fun NavGraph(
) )
} }
composable(Destinations.CmsStartseite.route) { composable(Destinations.CmsStartseite.route) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsStartseiteScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsInhalte.route) { composable(Destinations.CmsInhalte.route) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsInhalteScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsVereinsmeisterschaften.route) { composable(Destinations.CmsVereinsmeisterschaften.route) {
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation) RequireCmsAccess(
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) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsMitgliederverwaltung.route) { composable(Destinations.CmsMitgliederverwaltung.route) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsMitgliederverwaltungScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsNewsletter.route) { composable(Destinations.CmsNewsletter.route) {
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessNewsletter,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsNewsletterScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsContactRequests.route) { composable(Destinations.CmsContactRequests.route) {
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessContactRequests,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsContactRequestsScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsEinstellungen.route) { composable(Destinations.CmsEinstellungen.route) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsEinstellungenScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsBenutzer.route) { composable(Destinations.CmsBenutzer.route) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsBenutzerScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.CmsPasswordResetDiagnostics.route) { composable(Destinations.CmsPasswordResetDiagnostics.route) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation) RequireCmsAccess(
allowed = navigationState.canAccessFullCms,
navController = navController,
) {
de.harheimertc.ui.screens.cms.CmsPasswordResetDiagnosticsScreen(navController, !persistentNavigation)
}
} }
composable(Destinations.Cms.route) { composable(Destinations.Cms.route) {
de.harheimertc.ui.screens.cms.CmsDashboardScreen(navController, !persistentNavigation) RequireCmsAccess(
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,15 +132,22 @@ 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(news = news, selected = selected, onSelect = { id, sel -> NewsListItem(
id?.let { news = news,
selection = if (sel) selection + it else selection - it canWrite = canWrite,
} selected = selected,
}, onEdit = { openEdit(news) }, onDelete = { news.id?.let { id -> deletingIds = listOf(id) } }) 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 // bulk action bar
if (selection.isNotEmpty()) { if (canWrite && 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") }
@@ -255,6 +262,7 @@ 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,
@@ -264,7 +272,11 @@ 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(checked = selected, onCheckedChange = { onSelect(news.id, it) }) Checkbox(
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)) {
@@ -297,9 +309,11 @@ private fun NewsListItem(
Text("Aktualisiert: ${news.updated}") Text("Aktualisiert: ${news.updated}")
} }
} }
Row { if (canWrite) {
TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") } Row {
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { onEdit(news) }) { Text("Bearbeiten") }
TextButton(onClick = { news.id?.let { onDelete(it) } }) { Text("Löschen") }
}
} }
} }
} }

View File

@@ -1,8 +1,10 @@
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
@@ -36,6 +38,8 @@ 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
@@ -48,10 +52,12 @@ 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
@@ -61,6 +67,7 @@ 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
@@ -433,116 +440,426 @@ 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 config = state.config val context = LocalContext.current
var ortName by remember { mutableStateOf("") } var activeTab by remember { mutableStateOf("termine") }
var ortStrasse by remember { mutableStateOf("") } val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
var ortPlz by remember { mutableStateOf("") } var spielplanCsv by remember { mutableStateOf("") }
var ortOrt by remember { mutableStateOf("") } var spielplanEditorOpen by remember { mutableStateOf(false) }
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() } var terminDialogOpen by remember { mutableStateOf(false) }
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() } var editingTermin by remember { mutableStateOf<TerminDto?>(null) }
var terminDatum by remember { mutableStateOf("") }
var terminUhrzeit by remember { mutableStateOf("") }
var terminTitel by remember { mutableStateOf("") }
var terminBeschreibung by remember { mutableStateOf("") }
var terminKategorie by remember { mutableStateOf("Sonstiges") }
var terminKategorieOpen by remember { mutableStateOf(false) }
val tabs = listOf(
"termine" to "Termine",
"mannschaften" to "Mannschaften",
"spielplaene" to "Spielpläne",
)
val spielplanTeamOptions = remember(state.sportSpielplanHeaders, state.sportSpielplanRows) {
importedSpielplanTeams(state.sportSpielplanHeaders, state.sportSpielplanRows)
}
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
LaunchedEffect(config) { LaunchedEffect(Unit) {
config?.let { viewModel.loadSportbetrieb()
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)
}
} }
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") { LaunchedEffect(state.sportMannschaften) {
when { mannschaften.clear()
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") } mannschaften.addAll(state.sportMannschaften)
else -> { }
item {
Button( LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
onClick = { spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
viewModel.saveConfig( }
config.copy(
training = config.training.copy( fun openTerminDialog(termin: TerminDto?) {
ort = config.training.ort.copy( editingTermin = termin
name = ortName, terminDatum = termin?.datum.orEmpty()
strasse = ortStrasse, terminUhrzeit = termin?.uhrzeit.orEmpty()
plz = ortPlz, terminTitel = termin?.titel.orEmpty()
ort = ortOrt, terminBeschreibung = termin?.beschreibung.orEmpty()
), terminKategorie = termin?.kategorie ?: "Sonstiges"
zeiten = trainingTimes.toList(), terminDialogOpen = true
), }
trainer = trainers.toList(),
), fun openDatePicker() {
) val calendar = Calendar.getInstance()
}, runCatching {
enabled = !state.saving, val parts = terminDatum.split("-")
modifier = Modifier.fillMaxWidth(), if (parts.size == 3) {
) { calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
Text(if (state.saving) "Speichert..." else "Speichern")
}
}
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 { FormMessages(state.error, state.message) }
} }
} }
DatePickerDialog(
context,
{ _, year, month, day ->
terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH),
).show()
} }
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") {
if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") }
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
tabs.forEach { (id, label) ->
if (activeTab == id) {
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
} else {
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
}
}
}
}
if (!state.sportLoading) {
when (activeTab) {
"termine" -> {
item {
Button(
onClick = { openTerminDialog(null) },
enabled = !state.sportSaving,
modifier = Modifier.fillMaxWidth(),
) {
Text("Termin hinzufügen")
}
}
if (state.sportTermine.isEmpty()) {
item { EmptyCard("Keine Termine gefunden.") }
}
items(state.sportTermine.size) { index ->
val termin = state.sportTermine[index]
DataCard(termin.titel.ifBlank { "Termin" }) {
InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" "))
InfoRow("Kategorie", termin.kategorie ?: "Sonstiges")
if (!termin.beschreibung.isNullOrBlank()) {
Text(termin.beschreibung, color = Accent700)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) {
Text("Bearbeiten")
}
TextButton(
onClick = { viewModel.deleteSportTermin(termin) },
enabled = !state.sportSaving,
modifier = Modifier.weight(1f),
) {
Text("Löschen")
}
}
}
}
}
"mannschaften" -> {
item {
if (state.sportMannschaftenSeasons.isNotEmpty()) {
DataCard("Saison") {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
state.sportMannschaftenSeasons.forEach { season ->
if (season == state.sportMannschaftenSeason) {
Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) }
} else {
OutlinedButton(
onClick = { viewModel.loadSportMannschaftenSeason(season) },
modifier = Modifier.weight(1f),
) {
Text(season)
}
}
}
}
}
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Button(
onClick = {
mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
},
modifier = Modifier.weight(1f),
) {
Text("Hinzufügen")
}
Button(
onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
enabled = !state.sportSaving,
modifier = Modifier.weight(1f),
) {
Text(if (state.sportSaving) "Speichert..." else "Speichern")
}
}
}
if (mannschaften.isEmpty()) {
item { EmptyCard("Keine Mannschaften gefunden.") }
}
items(mannschaften.size) { index ->
MannschaftEditorCard(
row = mannschaften[index],
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 = {
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())
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
@@ -619,11 +936,13 @@ 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(item, NewsletterCard(
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
@@ -637,7 +956,9 @@ 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(group, NewsletterGroupCard(
group,
canWrite = canWrite,
onEdit = { g -> onEdit = { g ->
editingGroup = g editingGroup = g
grpName = g.name grpName = g.name
@@ -753,6 +1074,12 @@ 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 {
@@ -764,6 +1091,14 @@ 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)
} }
} }
@@ -790,6 +1125,16 @@ 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(),
), ),
) )
}, },
@@ -822,6 +1167,63 @@ 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())
@@ -950,6 +1352,7 @@ 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),
@@ -1229,29 +1632,44 @@ private fun ContactRequestCard(request: ContactRequestDto, viewModel: CmsViewMod
} }
@Composable @Composable
private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) { private fun NewsletterCard(
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 ?: "-")
Row { if (canWrite) {
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") } Row {
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
if (newsletter.status != "sent") { TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") } if (newsletter.status != "sent") {
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
}
} }
} }
} }
} }
@Composable @Composable
private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) { private fun NewsletterGroupCard(
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())
Row { if (canWrite) {
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") } Row {
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
}
} }
} }
} }

View File

@@ -13,6 +13,10 @@ 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
@@ -39,6 +43,16 @@ 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
@@ -179,6 +193,183 @@ 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)
@@ -462,3 +653,18 @@ class CmsViewModel @Inject constructor(
} }
} }
} }
private fun SpielDto.valueForHeader(header: String): String = when (header) {
"Termin" -> termin
"HeimMannschaft" -> heimMannschaft
"GastMannschaft" -> gastMannschaft
"HeimMannschaftAltersklasse" -> heimAltersklasse
"GastMannschaftAltersklasse" -> gastAltersklasse
"Altersklasse" -> altersklasse
"Liga" -> liga
"Staffel" -> staffel
"Runde" -> runde.orEmpty()
"SpieleHeim" -> spieleHeim
"SpieleGast" -> spieleGast
else -> ""
}

View File

@@ -117,20 +117,22 @@ fun HomeScreen(
) )
} }
} }
item { if (navigationState.canAccessFullCms) {
HomeCustomizationSection( item {
sections = state.homepageSections, HomeCustomizationSection(
spielplanSeasons = state.spielplanSeasons, sections = state.homepageSections,
spielplanTeamsBySeason = state.spielplanTeamsBySeason, spielplanSeasons = state.spielplanSeasons,
editEnabled = editHomeSections, spielplanTeamsBySeason = state.spielplanTeamsBySeason,
onToggleEdit = { editHomeSections = !editHomeSections }, editEnabled = editHomeSections,
onMoveUp = viewModel::moveSectionUp, onToggleEdit = { editHomeSections = !editHomeSections },
onMoveDown = viewModel::moveSectionDown, onMoveUp = viewModel::moveSectionUp,
onEnabledChange = viewModel::setSectionEnabled, onMoveDown = viewModel::moveSectionDown,
onAddSpielplanWidget = viewModel::addSpielplanTeamWidget, onEnabledChange = viewModel::setSectionEnabled,
onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget, onAddSpielplanWidget = viewModel::addSpielplanTeamWidget,
onReset = viewModel::resetSections, onUpdateSpielplanWidget = viewModel::updateSpielplanTeamWidget,
) 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()?.isBefore(LocalDateTime.now()) != true } .filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.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