diff --git a/ANDROID_KOTLIN_PLAN.md b/ANDROID_KOTLIN_PLAN.md index 0b07fe0..da1755d 100644 --- a/ANDROID_KOTLIN_PLAN.md +++ b/ANDROID_KOTLIN_PLAN.md @@ -325,14 +325,30 @@ Wenn du willst, trage ich die einzelnen Subtickets in unserem lokalen Issue-Trac - [x] B3.2: Status-Filter + Archiv - [ ] B4: Newsletter - - [ ] B4.1: Entwurf → Senden Flow mit Preview - - [ ] B4.2: Gruppenverwaltung (CRUD) + - [x] B4.1: Entwurf → Senden Flow mit Preview + - [x] B4.2: Gruppenverwaltung (CRUD) -- [ ] B5: Config / Seiten - - [ ] B5.1: Sichern/Zurücksetzen mit Undo - - [ ] B5.2: Satzung: PDF-Upload-Feld + native PDF-Viewer + - [ ] B5: Config / Seiten + - Web‑Status: Die Web‑UI bietet bereits umfassende CMS‑UIs für `cms/startseite`, `cms/vereinsmeisterschaften`, `cms/sportbetrieb` und `cms/einstellungen` (Drag&Drop, CSV‑Import/Export, Tabbed‑UIs, ImageUpload, native‑like Modals). `cms/startseite` speichert `homepage.sections` via `PUT /api/config`, `vereinsmeisterschaften` arbeitet mit CSV‑Export/Import, `sportbetrieb` kapselt Termine/Mannschaften/Spielpläne in Tabs, `einstellungen` ist ein umfangreicher Config‑Editor. + - Android‑Status: In der Android‑App sind diese Bereiche derzeit nur rudimentär bzw. als Platzhalter umgesetzt (Startseite, Vereinsmeisterschaften, Sportbetrieb, Einstellungen, Passwort‑Reset‑Diagnose fehlen noch als vollwertige Admin‑Tools). + - Konkrete Android‑ToDos (B5.x): + - B5.1: `cms/startseite` (Startseiten‑Layout) + - Implementieren: Reorderable list + Visibility Toggle, Save → `PUT /api/config` (`homepage.sections`), Lade/Save‑Snackbar, Undo/Historie. + - B5.2: `cms/vereinsmeisterschaften` + - Implementieren: CSV‑Load/Parser, UI zur Anzeige gruppiert nach Jahr/Kategorie, Modal für Ergebnis‑CRUD, 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 Admin‑Modi (Add/Edit/Delete). + - B5.4: `cms/einstellungen` + - Implementieren: Tabbed Config Editor (Vereinsdaten, Training, Trainer, Mitgliedschaft), ImageUpload, PDF‑Feld für Satzung, Validierung + Save/Preview. + - B5.5: Roundtrip & Tests + - Roundtrip‑Tests: RichText ↔ Web (Quill/HTML), CSV parser/tests für Vereinsmeisterschaften, ViewModel‑Unit‑Tests und Compose‑UI‑smoke tests für Save/Load flows. -- [ ] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail) + - [ ] B6: Diagnostics / Passwort-Reset-Diagnose (Export/Detail) + - Web‑Status: `cms/passwort-reset-diagnose` zeigt vollständige Diagnose‑UI mit Suche, Maskierung, Filter (nur Auffälligkeiten) und listbaren Reset‑Versuchen; Backend: `/api/cms/password-reset-diagnostics` liefert `matchingUsers`, `attempts`, `retentionHours`. + - Android‑Status: rudimentär/fehlend — Admin‑Diagnose ist nicht vollständig portiert. + - Konkrete Android‑ToDos (B6.x): + - B6.1: Implementieren Suche + Filter UI, Rendering der `attempts` mit Zeitstempeln, Status‑Badges und Details. + - B6.2: Logs exportieren / share (falls API Export unterstützt) und Datenschutz: E‑Mail Maskierung beibehalten. - [ ] C1: Offline-/Caching-Strategie (verschlüsselt für geschützte CMS-Daten) - [ ] C2: Tests & CI diff --git a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt index f310e12..01b714f 100644 --- a/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt +++ b/android-app/app/src/main/java/de/harheimertc/data/ApiService.kt @@ -403,6 +403,25 @@ data class NewsletterListResponse( val success: Boolean = false, val newsletters: List = 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? = null, +) data class NewsletterGroupDto( val id: String = "", val name: String = "", @@ -556,6 +575,34 @@ interface ApiService { @GET("/api/members") suspend fun members(): Response + 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>) + data class BulkImportResponse(val success: Boolean = false, val summary: Map? = null) + + @POST("/api/members") + suspend fun saveMember(@Body request: MemberSaveRequest): Response + + @DELETE("/api/members") + suspend fun deleteMember(@Body body: Map): Response + + @POST("/api/members/bulk") + suspend fun bulkImportMembers(@Body request: BulkImportRequest): Response + + @POST("/api/members/toggle-mannschaftsspieler") + suspend fun toggleMannschaftsspieler(@Body body: Map): Response> + @GET("/api/cms/users/list") suspend fun cmsUsers(): Response @@ -588,6 +635,27 @@ interface ApiService { @GET("/api/newsletter/groups/list") suspend fun newsletterGroups(): Response + @POST("/api/newsletter/groups/create") + suspend fun createNewsletterGroup(@Body request: Map): Response + + @PUT("/api/newsletter/groups/{id}") + suspend fun updateNewsletterGroup(@Path("id") id: String, @Body request: Map): Response + + @DELETE("/api/newsletter/groups/{id}") + suspend fun deleteNewsletterGroup(@Path("id") id: String): Response + + @POST("/api/newsletter/create") + suspend fun createNewsletter(@Body request: NewsletterCreateRequest): Response + + @PUT("/api/newsletter/{id}") + suspend fun updateNewsletter(@Path("id") id: String, @Body request: Map): Response + + @POST("/api/newsletter/{id}/send") + suspend fun sendNewsletter(@Path("id") id: String): Response + + @DELETE("/api/newsletter/{id}") + suspend fun deleteNewsletter(@Path("id") id: String): Response + @GET("/api/newsletter/groups/public-list") suspend fun publicNewsletterGroups(): Response diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt index eb01d3a..9dc3845 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/CmsRepository.kt @@ -143,6 +143,49 @@ class CmsRepository @Inject constructor( response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort") } + suspend fun createNewsletter(request: de.harheimertc.data.NewsletterCreateRequest): Result = 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): Result = 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 = 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 = 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): Result = 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): Result = 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 = 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 = runCatching { val response = api.deleteNews(id) if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt index 532cb6a..4c6bbf3 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MemberAreaRepository.kt @@ -64,6 +64,30 @@ class MemberAreaRepository @Inject constructor( if (!response.isSuccessful) error("News konnten nicht gespeichert werden.") } + suspend fun saveMember(request: de.harheimertc.data.ApiService.MemberSaveRequest): Result = 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 = 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>): Result = 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> = 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 = runCatching { val response = api.deleteNews(id) if (!response.isSuccessful) error("News konnten nicht gelöscht werden.") diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt index d6fe065..78fc30f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsScreens.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.OutlinedTextField import androidx.navigation.NavController import de.harheimertc.data.CmsUserDto import de.harheimertc.data.ConfigResponse @@ -172,14 +173,173 @@ fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: B @Composable fun CmsNewsletterScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) { 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(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(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") { 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") } 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") } 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 -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 } }) { InfoRow("Status", newsletter.status ?: if (newsletter.sentAt != null) "versendet" else "Entwurf") InfoRow("Erstellt", newsletter.createdAt ?: "-") 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 -private fun NewsletterGroupCard(group: NewsletterGroupDto) { +private fun NewsletterGroupCard(group: NewsletterGroupDto, onEdit: (NewsletterGroupDto) -> Unit = {}, onDelete: (String) -> Unit = {}) { DataCard(group.name.ifBlank { group.id }) { InfoRow("Beschreibung", group.description.ifBlank { "-" }) InfoRow("Abonnenten", group.subscribers.size.toString()) + Row { + TextButton(onClick = { onEdit(group) }) { Text("Bearbeiten") } + TextButton(onClick = { group.id.takeIf { it.isNotBlank() }?.let { onDelete(it) } }) { Text("Löschen") } + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt index bdcd443..bcd39ec 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -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) { + 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) { + 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) { + 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) fun updateUserRoles(id: String, roles: List) { viewModelScope.launch { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt index 1351dd4..ac7f769 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailScreens.kt @@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -25,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import android.util.Log import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -111,6 +114,11 @@ fun MembersScreen( else -> filtered.sortedWith(compareBy { it.lastName.ifBlank { it.name } }.thenBy { it.firstName }) }.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") { item { OutlinedTextField( @@ -134,11 +142,39 @@ fun MembersScreen( 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 { state.loading -> item { CircularProgressIndicator(color = Primary600) } state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) } - members.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) } - else -> items(members.size) { index -> MemberCard(members[index]) } + display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) } + 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 -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) { 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) @@ -238,6 +274,12 @@ private fun MemberCard(member: MemberDto) { if (member.email.isNullOrBlank() && member.phone.isNullOrBlank()) { 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") } + } + } } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt index 1a24309..7d3bd2d 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt @@ -41,6 +41,38 @@ class MembersViewModel @Inject constructor( .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>) { + 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( diff --git a/android-app/member_screen.png b/android-app/member_screen.png new file mode 100644 index 0000000..f41c5cd Binary files /dev/null and b/android-app/member_screen.png differ