Refactor code structure for improved readability and maintainability

This commit is contained in:
Torsten Schulz (local)
2026-05-29 10:55:59 +02:00
parent cdbe71eaec
commit 1ea9596006
9 changed files with 502 additions and 13 deletions

View File

@@ -325,14 +325,30 @@ Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Trac
- [x] B3.2: Status-Filter + Archiv - [x] B3.2: Status-Filter + Archiv
- [ ] B4: Newsletter - [ ] B4: Newsletter
- [ ] B4.1: Entwurf → Senden Flow mit Preview - [x] B4.1: Entwurf → Senden Flow mit Preview
- [ ] B4.2: Gruppenverwaltung (CRUD) - [x] B4.2: Gruppenverwaltung (CRUD)
- [ ] B5: Config / Seiten - [ ] B5: Config / Seiten
- [ ] B5.1: Sichern/Zurücksetzen mit Undo - WebStatus: Die WebUI bietet bereits umfassende CMSUIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSVImport/Export, TabbedUIs, ImageUpload, nativelike Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSVExport/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher ConfigEditor.
- [ ] B5.2: Satzung: PDF-Upload-Feld + native PDF-Viewer - AndroidStatus: In der AndroidApp sind diese Bereiche derzeit nur rudimentär bzw. als Platzhalter umgesetzt (Startseite, Vereinsmeisterschaften, Sportbetrieb, Einstellungen, PasswortResetDiagnose fehlen noch als vollwertige AdminTools).
- Konkrete AndroidToDos (B5.x):
- B5.1: `cms/startseite` (StartseitenLayout)
- Implementieren: Reorderable list + Visibility Toggle, Save → `PUT /api/config` (`homepage.sections`), Lade/SaveSnackbar, Undo/Historie.
- B5.2: `cms/vereinsmeisterschaften`
- Implementieren: CSVLoad/Parser, UI zur Anzeige gruppiert nach Jahr/Kategorie, Modal für ErgebnisCRUD, CSV Export via `/api/cms/save-csv`.
- B5.3: `cms/sportbetrieb`
- Implementieren: Tabbed UI (Termine / Mannschaften / Spielpläne), Wiederverwendung von bestehenden native Komponenten (`TermineScreen`, `MannschaftenScreen`, `SpielplanScreen`) und AdminModi (Add/Edit/Delete).
- B5.4: `cms/einstellungen`
- Implementieren: Tabbed Config Editor (Vereinsdaten, Training, Trainer, Mitgliedschaft), ImageUpload, PDFFeld für Satzung, Validierung + Save/Preview.
- B5.5: Roundtrip & Tests
- RoundtripTests: RichText ↔ Web (Quill/HTML), CSV parser/tests für Vereinsmeisterschaften, ViewModelUnitTests und ComposeUIsmoke tests für Save/Load flows.
- [ ] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail) - [ ] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail)
- WebStatus: `cms/passwort-reset-diagnose` zeigt vollständige DiagnoseUI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren ResetVersuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`.
- AndroidStatus: rudimentär/fehlend — AdminDiagnose ist nicht vollständig portiert.
- Konkrete AndroidToDos (B6.x):
- B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, StatusBadges und Details.
- B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: EMail Maskierung beibehalten.
- [ ] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten) - [ ] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten)
- [ ] C2: Tests & CI - [ ] C2: Tests & CI

View File

@@ -403,6 +403,25 @@ data class NewsletterListResponse(
val success: Boolean = false, val success: Boolean = false,
val newsletters: List<NewsletterDto> = emptyList(), val newsletters: List<NewsletterDto> = emptyList(),
) )
data class NewsletterCreateRequest(
val title: String,
val content: String,
val type: String,
val targetGroup: String? = null,
val sendToExternal: Boolean? = null,
)
data class NewsletterCreateResponse(
val success: Boolean = false,
val message: String? = null,
val newsletter: NewsletterDto? = null,
)
data class NewsletterSendResponse(
val success: Boolean = false,
val message: String? = null,
val stats: Map<String, Any>? = null,
)
data class NewsletterGroupDto( data class NewsletterGroupDto(
val id: String = "", val id: String = "",
val name: String = "", val name: String = "",
@@ -556,6 +575,34 @@ interface ApiService {
@GET("/api/members") @GET("/api/members")
suspend fun members(): Response<MembersResponse> suspend fun members(): Response<MembersResponse>
data class MemberSaveRequest(
val id: String? = null,
val firstName: String,
val lastName: String,
val geburtsdatum: String,
val email: String? = null,
val phone: String? = null,
val address: String? = null,
val notes: String? = null,
val isMannschaftsspieler: Boolean = false,
val hasHallKey: Boolean = false,
)
data class BulkImportRequest(val members: List<Map<String, String>>)
data class BulkImportResponse(val success: Boolean = false, val summary: Map<String, Int>? = null)
@POST("/api/members")
suspend fun saveMember(@Body request: MemberSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/members")
suspend fun deleteMember(@Body body: Map<String, String>): Response<AuthMessageResponse>
@POST("/api/members/bulk")
suspend fun bulkImportMembers(@Body request: BulkImportRequest): Response<BulkImportResponse>
@POST("/api/members/toggle-mannschaftsspieler")
suspend fun toggleMannschaftsspieler(@Body body: Map<String, String>): Response<Map<String, Any>>
@GET("/api/cms/users/list") @GET("/api/cms/users/list")
suspend fun cmsUsers(): Response<CmsUsersResponse> suspend fun cmsUsers(): Response<CmsUsersResponse>
@@ -588,6 +635,27 @@ interface ApiService {
@GET("/api/newsletter/groups/list") @GET("/api/newsletter/groups/list")
suspend fun newsletterGroups(): Response<NewsletterGroupsResponse> suspend fun newsletterGroups(): Response<NewsletterGroupsResponse>
@POST("/api/newsletter/groups/create")
suspend fun createNewsletterGroup(@Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
@PUT("/api/newsletter/groups/{id}")
suspend fun updateNewsletterGroup(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<AuthMessageResponse>
@DELETE("/api/newsletter/groups/{id}")
suspend fun deleteNewsletterGroup(@Path("id") id: String): Response<AuthMessageResponse>
@POST("/api/newsletter/create")
suspend fun createNewsletter(@Body request: NewsletterCreateRequest): Response<NewsletterCreateResponse>
@PUT("/api/newsletter/{id}")
suspend fun updateNewsletter(@Path("id") id: String, @Body request: Map<String, @JvmSuppressWildcards Any?>): Response<NewsletterCreateResponse>
@POST("/api/newsletter/{id}/send")
suspend fun sendNewsletter(@Path("id") id: String): Response<NewsletterSendResponse>
@DELETE("/api/newsletter/{id}")
suspend fun deleteNewsletter(@Path("id") id: String): Response<AuthMessageResponse>
@GET("/api/newsletter/groups/public-list") @GET("/api/newsletter/groups/public-list")
suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse> suspend fun publicNewsletterGroups(): Response<NewsletterGroupsResponse>

View File

@@ -143,6 +143,49 @@ class CmsRepository @Inject constructor(
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort") response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
} }
suspend fun createNewsletter(request: de.harheimertc.data.NewsletterCreateRequest): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
val response = api.createNewsletter(request)
if (!response.isSuccessful) error("Newsletter konnte nicht erstellt werden.")
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
}
suspend fun updateNewsletter(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.NewsletterCreateResponse> = runCatching {
val response = api.updateNewsletter(id, patch)
if (!response.isSuccessful) error("Newsletter konnte nicht aktualisiert werden.")
response.body() ?: de.harheimertc.data.NewsletterCreateResponse(success = false, message = "Leere Antwort")
}
suspend fun sendNewsletter(id: String): Result<de.harheimertc.data.NewsletterSendResponse> = runCatching {
val response = api.sendNewsletter(id)
if (!response.isSuccessful) error("Newsletter konnte nicht versendet werden.")
response.body() ?: de.harheimertc.data.NewsletterSendResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNewsletter(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNewsletter(id)
if (!response.isSuccessful) error("Newsletter konnte nicht gelöscht werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun createNewsletterGroup(payload: Map<String, @JvmSuppressWildcards Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
// use generic POST via Retrofit? build request through create endpoint
val response = api.createNewsletterGroup(payload)
if (!response.isSuccessful) error("Gruppe konnte nicht erstellt werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun updateNewsletterGroup(id: String, patch: Map<String, Any?>): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.updateNewsletterGroup(id, patch)
if (!response.isSuccessful) error("Gruppe konnte nicht aktualisiert werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNewsletterGroup(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNewsletterGroup(id)
if (!response.isSuccessful) error("Gruppe konnte nicht gelöscht werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching { suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNews(id) val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")

View File

@@ -64,6 +64,30 @@ class MemberAreaRepository @Inject constructor(
if (!response.isSuccessful) error("News konnten nicht gespeichert werden.") if (!response.isSuccessful) error("News konnten nicht gespeichert werden.")
} }
suspend fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.saveMember(request)
if (!response.isSuccessful) error("Mitglied konnte nicht gespeichert werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteMember(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteMember(mapOf("id" to id))
if (!response.isSuccessful) error("Mitglied konnte nicht gelöscht werden.")
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun bulkImport(members: List<Map<String, String>>): Result<de.harheimertc.data.ApiService.BulkImportResponse> = runCatching {
val response = api.bulkImportMembers(de.harheimertc.data.ApiService.BulkImportRequest(members))
if (!response.isSuccessful) error("Bulk-Import fehlgeschlagen")
response.body() ?: de.harheimertc.data.ApiService.BulkImportResponse(success = false)
}
suspend fun toggleMannschaftsspieler(memberId: String): Result<Map<String, Any>> = runCatching {
val response = api.toggleMannschaftsspieler(mapOf("memberId" to memberId))
if (!response.isSuccessful) error("Status konnte nicht umgeschaltet werden.")
response.body() ?: emptyMap()
}
suspend fun deleteNews(id: Int): Result<Unit> = runCatching { suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
val response = api.deleteNews(id) val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")

View File

@@ -35,6 +35,7 @@ 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.OutlinedTextField
import androidx.navigation.NavController import androidx.navigation.NavController
import de.harheimertc.data.CmsUserDto import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse import de.harheimertc.data.ConfigResponse
@@ -172,14 +173,173 @@ fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: B
@Composable @Composable
fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
val loginState by loginVm.state.collectAsState()
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
// dialog state for newsletters
var newsletterDialogOpen by remember { mutableStateOf(false) }
var editingNewsletter by remember { mutableStateOf<NewsletterDto?>(null) }
var nlTitle by remember { mutableStateOf("") }
var nlContent by remember { mutableStateOf("") }
var nlType by remember { mutableStateOf("subscription") }
var nlTargetGroup by remember { mutableStateOf("") }
var nlSendToExternal by remember { mutableStateOf(true) }
// dialog state for groups
var groupDialogOpen by remember { mutableStateOf(false) }
var editingGroup by remember { mutableStateOf<NewsletterGroupDto?>(null) }
var grpName by remember { mutableStateOf("") }
var grpDescription by remember { mutableStateOf("") }
var grpType by remember { mutableStateOf("subscription") }
var grpTargetGroup by remember { mutableStateOf("") }
var grpSendToExternal by remember { mutableStateOf(true) }
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") { CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) } if (state.loading) item { CircularProgressIndicator(color = Primary600) }
item {
if (canWrite) Button(onClick = {
editingNewsletter = null
nlTitle = ""
nlContent = ""
nlType = "subscription"
nlTargetGroup = ""
nlSendToExternal = true
newsletterDialogOpen = true
}, modifier = Modifier.fillMaxWidth()) { Text("Newsletter erstellen") }
}
item {
if (canWrite) Button(onClick = {
editingGroup = null
grpName = ""
grpDescription = ""
grpType = "subscription"
grpTargetGroup = ""
grpSendToExternal = true
groupDialogOpen = true
}, modifier = Modifier.fillMaxWidth()) { Text("Gruppe erstellen") }
}
item { SectionTitle("Newsletter") } item { SectionTitle("Newsletter") }
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 -> NewsletterCard(state.newsletters[index]) } items(state.newsletters.size) { index ->
val item = state.newsletters[index]
NewsletterCard(item,
onEdit = { nl ->
editingNewsletter = nl
nlTitle = nl.title
nlContent = nl.title ?: ""
nlType = "subscription"
nlTargetGroup = ""
nlSendToExternal = true
newsletterDialogOpen = true
},
onDelete = { id -> viewModel.deleteNewsletter(id) },
onSend = { id -> viewModel.sendNewsletter(id) }
)
}
item { SectionTitle("Gruppen") } item { SectionTitle("Gruppen") }
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 -> NewsletterGroupCard(state.newsletterGroups[index]) } items(state.newsletterGroups.size) { index ->
val group = state.newsletterGroups[index]
NewsletterGroupCard(group,
onEdit = { g ->
editingGroup = g
grpName = g.name
grpDescription = g.description
grpType = "subscription"
grpTargetGroup = ""
grpSendToExternal = true
groupDialogOpen = true
},
onDelete = { id -> viewModel.deleteNewsletterGroup(id) }
)
}
}
// Newsletter create/edit dialog
if (newsletterDialogOpen) {
AlertDialog(
onDismissRequest = { newsletterDialogOpen = false },
title = { Text(if (editingNewsletter == null) "Newsletter erstellen" else "Newsletter bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Titel *")
androidx.compose.material3.OutlinedTextField(value = nlTitle, onValueChange = { nlTitle = it })
NativeRichTextEditor(nlContent, { nlContent = it }, "Inhalt *")
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = nlType == "subscription", onCheckedChange = { if (it) nlType = "subscription" else nlType = "group" })
Text("Abonnenten-Newsletter", modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.width(12.dp))
Checkbox(checked = nlType == "group", onCheckedChange = { if (it) nlType = "group" else nlType = "subscription" })
Text("Gruppen-Newsletter", modifier = Modifier.padding(start = 8.dp))
}
if (nlType == "group") {
OutlinedTextField(value = nlTargetGroup, onValueChange = { nlTargetGroup = it }, label = { Text("Zielgruppe (Gruppen-ID)") })
} else {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = nlSendToExternal, onCheckedChange = { nlSendToExternal = it })
Text("Auch an externe Abonnenten senden", modifier = Modifier.padding(start = 8.dp))
}
}
}
},
confirmButton = {
Button(onClick = {
// build request
val req = de.harheimertc.data.NewsletterCreateRequest(
title = nlTitle,
content = nlContent,
type = nlType,
targetGroup = if (nlType == "group") nlTargetGroup else null,
sendToExternal = if (nlType == "subscription") nlSendToExternal else null,
)
viewModel.saveNewsletter(req)
newsletterDialogOpen = false
}, enabled = !state.saving) { Text(if (state.saving) "Speichert..." else "Speichern") }
},
dismissButton = { TextButton(onClick = { newsletterDialogOpen = false }) { Text("Abbrechen") } },
)
}
// Group create/edit dialog
if (groupDialogOpen) {
AlertDialog(
onDismissRequest = { groupDialogOpen = false },
title = { Text(if (editingGroup == null) "Gruppe erstellen" else "Gruppe bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = grpName, onValueChange = { grpName = it }, label = { Text("Name *") })
OutlinedTextField(value = grpDescription, onValueChange = { grpDescription = it }, label = { Text("Beschreibung") })
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = grpType == "subscription", onCheckedChange = { if (it) grpType = "subscription" else grpType = "group" })
Text("Abonnenten-Gruppe", modifier = Modifier.padding(start = 8.dp))
Spacer(modifier = Modifier.width(12.dp))
Checkbox(checked = grpType == "group", onCheckedChange = { if (it) grpType = "group" else grpType = "subscription" })
Text("Manuelle Gruppe", modifier = Modifier.padding(start = 8.dp))
}
if (grpType == "group") {
OutlinedTextField(value = grpTargetGroup, onValueChange = { grpTargetGroup = it }, label = { Text("Zielgruppe (optional)") })
} else {
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
Checkbox(checked = grpSendToExternal, onCheckedChange = { grpSendToExternal = it })
Text("Auch an externe Abonnenten senden", modifier = Modifier.padding(start = 8.dp))
}
}
}
},
confirmButton = {
Button(onClick = {
val payload = mapOf(
"name" to grpName,
"description" to grpDescription,
"type" to grpType,
"targetGroup" to (if (grpType == "group") grpTargetGroup else null),
"sendToExternal" to (if (grpType == "subscription") grpSendToExternal else null),
)
viewModel.createNewsletterGroup(payload)
groupDialogOpen = false
}, enabled = !state.saving) { Text(if (state.saving) "Speichert..." else "Speichern") }
},
dismissButton = { TextButton(onClick = { groupDialogOpen = false }) { Text("Abbrechen") } },
)
} }
} }
@@ -423,19 +583,31 @@ private fun ContactRequestCard(request: ContactRequestDto) {
} }
@Composable @Composable
private fun NewsletterCard(newsletter: NewsletterDto) { private fun NewsletterCard(newsletter: NewsletterDto, onEdit: (NewsletterDto) -> Unit = {}, onDelete: (String) -> Unit = {}, onSend: (String) -> Unit = {}) {
val viewModel: CmsViewModel = hiltViewModel()
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 {
TextButton(onClick = { onEdit(newsletter) }) { Text("Bearbeiten") }
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
if (newsletter.status != "sent") {
TextButton(onClick = { newsletter.id.takeIf { it.isNotBlank() }?.let { onSend(it) } }) { Text("Versenden") }
}
}
} }
} }
@Composable @Composable
private fun NewsletterGroupCard(group: NewsletterGroupDto) { private fun NewsletterGroupCard(group: NewsletterGroupDto, 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 {
TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") }
TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") }
}
} }
} }

View File

@@ -208,6 +208,98 @@ class CmsViewModel @Inject constructor(
} }
} }
// --- Newsletter (B4)
fun saveNewsletter(request: de.harheimertc.data.NewsletterCreateRequest) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.createNewsletter(request)
.onSuccess { res ->
val newslettersRes = repository.newsletters()
_state.value = _state.value.copy(
saving = false,
newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(),
message = res.message ?: "Newsletter gespeichert",
)
}
.onFailure { err ->
_state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gespeichert werden.")
}
}
}
fun updateNewsletter(id: String, patch: Map<String, Any?>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.updateNewsletter(id, patch)
.onSuccess { res ->
val newslettersRes = repository.newsletters()
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter aktualisiert")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht aktualisiert werden.") }
}
}
fun sendNewsletter(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.sendNewsletter(id)
.onSuccess { res ->
val newslettersRes = repository.newsletters()
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter versendet")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht versendet werden.") }
}
}
fun deleteNewsletter(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNewsletter(id)
.onSuccess { res ->
val newslettersRes = repository.newsletters()
_state.value = _state.value.copy(saving = false, newsletters = newslettersRes.getOrNull()?.newsletters.orEmpty(), message = res.message ?: "Newsletter gelöscht")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Newsletter konnte nicht gelöscht werden.") }
}
}
// --- Newsletter Groups (B4)
fun createNewsletterGroup(payload: Map<String, Any?>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.createNewsletterGroup(payload)
.onSuccess { res ->
val groupsRes = repository.newsletterGroups()
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe erstellt")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht erstellt werden.") }
}
}
fun updateNewsletterGroup(id: String, patch: Map<String, Any?>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.updateNewsletterGroup(id, patch)
.onSuccess { res ->
val groupsRes = repository.newsletterGroups()
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe aktualisiert")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht aktualisiert werden.") }
}
}
fun deleteNewsletterGroup(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNewsletterGroup(id)
.onSuccess { res ->
val groupsRes = repository.newsletterGroups()
_state.value = _state.value.copy(saving = false, newsletterGroups = groupsRes.getOrNull()?.groups.orEmpty(), message = res.message ?: "Gruppe gelöscht")
}
.onFailure { err -> _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Gruppe konnte nicht gelöscht werden.") }
}
}
// --- User management actions (B2) // --- User management actions (B2)
fun updateUserRoles(id: String, roles: List<String>) { fun updateUserRoles(id: String, roles: List<String>) {
viewModelScope.launch { viewModelScope.launch {

View File

@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -25,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import android.util.Log
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -111,6 +114,11 @@ fun MembersScreen(
else -> filtered.sortedWith(compareBy<MemberDto> { it.lastName.ifBlank { it.name } }.thenBy { it.firstName }) else -> filtered.sortedWith(compareBy<MemberDto> { it.lastName.ifBlank { it.name } }.thenBy { it.firstName })
}.let { if (sortAsc) it else it.asReversed() } }.let { if (sortAsc) it else it.asReversed() }
var viewMode by remember { mutableStateOf("cards") }
var onlyHallKey by remember { mutableStateOf(false) }
val display = remember(members, onlyHallKey) { if (!onlyHallKey) members else members.filter { it.hasHallKey } }
Log.i("MembersScreen", "viewMode=$viewMode displayCount=${display.size}")
MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") { MemberAreaPage(navController, showBackNavigation, "Mitgliederliste", "Kontaktdaten der Vereinsmitglieder") {
item { item {
OutlinedTextField( OutlinedTextField(
@@ -134,11 +142,39 @@ fun MembersScreen(
TextButton(onClick = { sortAsc = !sortAsc }) { Text(if (sortAsc) "A→Z" else "Z→A") } TextButton(onClick = { sortAsc = !sortAsc }) { Text(if (sortAsc) "A→Z" else "Z→A") }
} }
} }
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewMode = if (viewMode == "cards") "table" else "cards" }, colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF3F4F6))) {
Text(if (viewMode == "cards") "Tabelle" else "Karten", color = Accent900)
}
androidx.compose.material3.Checkbox(checked = onlyHallKey, onCheckedChange = { onlyHallKey = it })
Text("Nur mit Hallenschlüssel", color = Accent700)
}
}
item {
if (viewMode == "table") {
Text("DEBUG: TABLE", color = Color.Red, modifier = Modifier.fillMaxWidth().padding(8.dp))
}
}
when { when {
state.loading -> item { CircularProgressIndicator(color = Primary600) } state.loading -> item { CircularProgressIndicator(color = Primary600) }
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
members.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) } display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
else -> items(members.size) { index -> MemberCard(members[index]) } else -> if (viewMode == "table") {
items(display.size) { index ->
val m = display[index]
Surface(color = Color.White, shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Column(Modifier.weight(1f)) { Text(m.name, color = Accent900) }
Column(Modifier.weight(1f)) { Text(m.email ?: "-", color = Primary600) }
Column(Modifier.weight(1f)) { Text(m.phone ?: "-", color = Accent700) }
}
}
}
} else {
items(display.size) { index -> MemberCard(display[index]) }
}
} }
} }
} }
@@ -220,7 +256,7 @@ private fun MemberAreaPage(
} }
@Composable @Composable
private fun MemberCard(member: MemberDto) { private fun MemberCard(member: MemberDto, onEdit: (MemberDto) -> Unit = {}, onDelete: (MemberDto) -> Unit = {}) {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) { Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) { Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900) Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900)
@@ -238,6 +274,12 @@ private fun MemberCard(member: MemberDto) {
if (member.email.isNullOrBlank() && member.phone.isNullOrBlank()) { if (member.email.isNullOrBlank() && member.phone.isNullOrBlank()) {
Text("Kontaktdaten sind für dich nicht freigegeben.", color = Accent500) Text("Kontaktdaten sind für dich nicht freigegeben.", color = Accent500)
} }
Row {
if (member.editable) {
TextButton(onClick = { onEdit(member) }) { Text("Bearbeiten") }
TextButton(onClick = { onDelete(member) }) { Text("Löschen") }
}
}
} }
} }
} }

View File

@@ -41,6 +41,38 @@ class MembersViewModel @Inject constructor(
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Mitglieder konnten nicht geladen werden.") } .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Mitglieder konnten nicht geladen werden.") }
} }
} }
fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest) {
viewModelScope.launch {
repository.saveMember(request)
.onSuccess { _ -> load() }
.onFailure { /* expose errors if needed */ }
}
}
fun deleteMember(id: String) {
viewModelScope.launch {
repository.deleteMember(id)
.onSuccess { _ -> load() }
.onFailure { /* handle error */ }
}
}
fun bulkImport(members: List<Map<String, String>>) {
viewModelScope.launch {
repository.bulkImport(members)
.onSuccess { _ -> load() }
.onFailure { /* handle error */ }
}
}
fun toggleMannschaftsspieler(memberId: String) {
viewModelScope.launch {
repository.toggleMannschaftsspieler(memberId)
.onSuccess { _ -> load() }
.onFailure { /* handle error */ }
}
}
} }
data class MemberNewsUiState( data class MemberNewsUiState(

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB