From b4c31374c0d705a16252c3ee4ec9da9e72f5f171 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 29 May 2026 00:11:42 +0200 Subject: [PATCH] feat(android): sort CMS users, remove invite button, close CMS submenu on navigate --- .../ui/components/AppNavigationHeader.kt | 67 ++++- .../harheimertc/ui/screens/cms/CmsScreens.kt | 111 ++++++++- .../ui/screens/cms/CmsViewModels.kt | 231 +++++++++++++++++- 3 files changed, 380 insertions(+), 29 deletions(-) diff --git a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt index b60713b..434a61b 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/components/AppNavigationHeader.kt @@ -61,13 +61,17 @@ fun AppNavigationHeader( if (webTabletNavigation) { WebTabletNavigation(selectedRoute, onNavigate, navigationState) } else { - CompactNavigation(selectedRoute, onNavigate) + CompactNavigation(selectedRoute, onNavigate, navigationState) } } } @Composable -private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Unit) { +private fun CompactNavigation( + selectedRoute: String?, + onNavigate: (String) -> Unit, + navigationState: NavigationUiState = NavigationUiState(), +) { BrandRow(onLogin = { onNavigate(Destinations.Login.route) }) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f)) @@ -75,6 +79,9 @@ private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Un CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f)) CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f)) CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f)) + if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) { + CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f)) + } } } @@ -85,6 +92,9 @@ private fun WebTabletNavigation( navigationState: NavigationUiState, ) { val section = menuSection(selectedRoute) + var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) } + // Helper that closes the CMS submenu when navigating away + val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) } Row(verticalAlignment = Alignment.CenterVertically) { Brand() Spacer(Modifier.width(16.dp)) @@ -93,12 +103,12 @@ private fun WebTabletNavigation( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically, ) { - MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { onNavigate(Destinations.Home.route) }) - MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) }) - MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) }) - MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) }) - MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { onNavigate(Destinations.Membership.route) }) - MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) }) + MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) }) + MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) }) + MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) }) + MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) }) + MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) }) + MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) }) if (navigationState.showGallery) { MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) }) } @@ -106,11 +116,18 @@ private fun WebTabletNavigation( if (navigationState.loggedIn) { MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) }) } - MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { onNavigate(Destinations.Contact.route) }) - TextButton(onClick = { onNavigate(Destinations.Login.route) }) { Text("Login", color = Color.White) } + MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) }) + TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) } } } val subItems = submenu(section, navigationState) + // determine CMS parent index and children + val cmsIndex = subItems.indexOfFirst { it.label == "CMS" } + val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList() + if (cmsChildren.any { it.route == selectedRoute }) { + cmsExpanded.value = true + } + // First row: render all subitems but do NOT render CMS children here Row( modifier = Modifier .fillMaxWidth() @@ -118,8 +135,33 @@ private fun WebTabletNavigation( .padding(top = 3.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - subItems.forEach { item -> - SubLink(item.label, item.route == selectedRoute) { onNavigate(item.route) } + subItems.forEachIndexed { idx, item -> + if (idx == cmsIndex) { + // CMS parent toggle + SubLink(item.label, item.route == selectedRoute) { + cmsExpanded.value = !cmsExpanded.value + } + } else if (idx > cmsIndex && cmsIndex >= 0) { + // skip cms children here; they'll be rendered in the second row when expanded + } else { + // normal item before CMS: close cms submenu on navigate + SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) } + } + } + } + + // Second row: when CMS expanded, render its children beneath + if (cmsExpanded.value && cmsChildren.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(top = 6.dp, bottom = 3.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + cmsChildren.forEach { child -> + SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) } + } } } } @@ -301,6 +343,7 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List) { + val viewModel: CmsViewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + val pending = state.users.filter { it.active == false } + val active = state.users.filter { it.active == true } + .sortedWith(compareBy({ (it.name ?: "").lowercase() })) + CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") { - if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") } - items(users.size) { index -> UserCard(users[index]) } + if (state.users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") } + + // Pending (inactive) users first with highlighted background + if (pending.isNotEmpty()) { + items(pending.size) { index -> + val user = pending[index] + Surface(color = Accent100, shape = RoundedCornerShape(14.dp), shadowElevation = 2.dp, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(user.name.ifBlank { user.email ?: "Unbekannt" }, style = MaterialTheme.typography.titleMedium, color = Accent900) + Text(user.email ?: "-", color = Accent700) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp)) { + Button(onClick = { viewModel.setUserActive(user.id, true) }, enabled = !state.saving) { Text("Freischalten") } + Button(onClick = { viewModel.resendInvite(user.id) }, enabled = !state.saving) { Text("Invite erneut") } + } + } + } + } + } + + // Active users + items(active.size) { index -> UserCard(active[index], viewModel) } } } @Composable -private fun UserCard(user: CmsUserDto) { +private fun UserCard(user: CmsUserDto, viewModel: CmsViewModel) { + var rolesDialogOpen by remember { mutableStateOf(false) } + val roleOptions = listOf("admin", "vorstand", "trainer", "newsletter") + val state by viewModel.state.collectAsState() + DataCard(user.name.ifBlank { user.email.orEmpty() }) { InfoRow("E-Mail", user.email ?: "-") InfoRow("Rollen", user.roles.ifEmpty { listOfNotNull(user.role) }.joinToString(", ").ifBlank { "mitglied" }) InfoRow("Status", if (user.active) "Aktiv" else "Inaktiv") InfoRow("Letzter Login", user.lastLogin ?: "-") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + TextButton(onClick = { viewModel.setUserActive(user.id, !user.active) }, enabled = !state.saving) { Text(if (user.active) "Deaktivieren" else "Aktivieren") } + Spacer(modifier = Modifier.width(6.dp)) + TextButton(onClick = { rolesDialogOpen = true }) { Text("Rollen") } + } + } + + if (rolesDialogOpen) { + val selected = remember { mutableStateListOf().apply { addAll(user.roles) } } + AlertDialog( + onDismissRequest = { rolesDialogOpen = false }, + title = { Text("Rollen bearbeiten") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + roleOptions.forEach { role -> + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Checkbox(checked = selected.contains(role), onCheckedChange = { checked -> + if (checked) selected.add(role) else selected.remove(role) + }) + Text(role, modifier = Modifier.padding(start = 8.dp)) + } + } + } + }, + confirmButton = { + Button(onClick = { + viewModel.updateUserRoles(user.id, selected.toList()) + rolesDialogOpen = false + }) { Text("Speichern") } + }, + dismissButton = { + TextButton(onClick = { rolesDialogOpen = false }) { Text("Abbrechen") } + } + ) } } @Composable private fun ContactRequestCard(request: ContactRequestDto) { + val viewModel: CmsViewModel = hiltViewModel() + var replyOpen by remember { mutableStateOf(false) } + var replyText by remember { mutableStateOf("") } + val state by viewModel.state.collectAsState() + DataCard(request.name.ifBlank { request.email }) { InfoRow("E-Mail", request.email) InfoRow("Status", request.status.ifBlank { "offen" }) InfoRow("Nachricht", request.message) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + TextButton(onClick = { replyOpen = true }, enabled = !state.saving) { Text("Antworten") } + TextButton(onClick = { viewModel.toggleContactRequestStatus(request.id) }, enabled = !state.saving) { Text("Status umschalten") } + } + } + + if (replyOpen) { + AlertDialog( + onDismissRequest = { replyOpen = false }, + title = { Text("Antwort an ${request.name}") }, + text = { + Column { + Text(request.message) + Spacer(modifier = Modifier.width(6.dp)) + TextButton(onClick = {}) { /* placeholder to align */ } + androidx.compose.material3.OutlinedTextField(value = replyText, onValueChange = { replyText = it }, label = { Text("Antwort") }) + } + }, + confirmButton = { + Button(onClick = { + viewModel.replyToContactRequest(request.id, replyText) + replyOpen = false + replyText = "" + }) { Text("Senden") } + }, + dismissButton = { + TextButton(onClick = { replyOpen = false }) { Text("Abbrechen") } + } + ) } } 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 0cdff69..fb6b61e 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 @@ -9,12 +9,15 @@ import de.harheimertc.data.ContactRequestDto import de.harheimertc.data.NewsletterDto import de.harheimertc.data.NewsletterGroupDto import de.harheimertc.data.PasswordResetAttemptDto +import de.harheimertc.data.NewsDto +import de.harheimertc.data.NewsSaveRequest import de.harheimertc.repositories.CmsRepository import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import javax.inject.Inject +import de.harheimertc.ui.util.ErrorMapper data class CmsUiState( val loading: Boolean = true, @@ -27,6 +30,7 @@ data class CmsUiState( val newsletters: List = emptyList(), val newsletterGroups: List = emptyList(), val passwordResetAttempts: List = emptyList(), + val news: List = emptyList(), ) @HiltViewModel @@ -43,21 +47,51 @@ class CmsViewModel @Inject constructor( fun load() { viewModelScope.launch { _state.value = _state.value.copy(loading = true, error = null) - val config = async { repository.config().getOrNull() } - val users = async { repository.users().getOrNull()?.users.orEmpty() } - val requests = async { repository.contactRequests().getOrNull().orEmpty() } - val newsletters = async { repository.newsletters().getOrNull()?.newsletters.orEmpty() } - val groups = async { repository.newsletterGroups().getOrNull()?.groups.orEmpty() } - val diagnostics = async { repository.passwordResetDiagnostics().getOrNull()?.attempts.orEmpty() } + + val configRes = async { repository.config() } + val usersRes = async { repository.users() } + val requestsRes = async { repository.contactRequests() } + val newslettersRes = async { repository.newsletters() } + val groupsRes = async { repository.newsletterGroups() } + val newsRes = async { repository.news() } + val diagnosticsRes = async { repository.passwordResetDiagnostics() } + + val configResult = configRes.await() + val usersResult = usersRes.await() + val requestsResult = requestsRes.await() + val newslettersResult = newslettersRes.await() + val groupsResult = groupsRes.await() + val newsResult = newsRes.await() + val diagnosticsResult = diagnosticsRes.await() + + val errors = listOfNotNull( + ErrorMapper.mapError(configResult.exceptionOrNull()), + ErrorMapper.mapError(usersResult.exceptionOrNull()), + ErrorMapper.mapError(requestsResult.exceptionOrNull()), + ErrorMapper.mapError(newslettersResult.exceptionOrNull()), + ErrorMapper.mapError(groupsResult.exceptionOrNull()), + ErrorMapper.mapError(newsResult.exceptionOrNull()), + ErrorMapper.mapError(diagnosticsResult.exceptionOrNull()), + ) + + // Sort users so that pending (inactive) users come first, + // followed by active users sorted by display name (case-insensitive). + val fetchedUsers = usersResult.getOrNull()?.users.orEmpty() + val pendingUsers = fetchedUsers.filter { it.active == false } + val activeUsers = fetchedUsers.filter { it.active == true } + .sortedBy { (it.name ?: "").lowercase() } + val orderedUsers = pendingUsers + activeUsers _state.value = CmsUiState( loading = false, - config = config.await(), - users = users.await(), - contactRequests = requests.await(), - newsletters = newsletters.await(), - newsletterGroups = groups.await(), - passwordResetAttempts = diagnostics.await(), + error = if (errors.isNotEmpty()) errors.joinToString("; ") else null, + config = configResult.getOrNull(), + users = orderedUsers, + contactRequests = requestsResult.getOrNull().orEmpty(), + newsletters = newslettersResult.getOrNull()?.newsletters.orEmpty(), + newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(), + news = newsResult.getOrNull()?.news.orEmpty(), + passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(), ) } } @@ -76,9 +110,180 @@ class CmsViewModel @Inject constructor( .onFailure { _state.value = _state.value.copy( saving = false, - error = it.message ?: "Inhalt konnte nicht gespeichert werden.", + error = ErrorMapper.mapError(it) ?: "Inhalt konnte nicht gespeichert werden.", ) } } } + + fun saveNews(request: NewsSaveRequest) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.saveNews(request) + .onSuccess { msg -> + // refresh news list directly to preserve message + val newsRes = repository.news() + _state.value = _state.value.copy( + saving = false, + news = newsRes.getOrNull()?.news.orEmpty(), + message = msg.message ?: "Nachricht gespeichert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gespeichert werden.") + } + } + } + + fun bulkSetPublic(ids: List, makePublic: Boolean) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + ids.forEach { id -> + val existing = _state.value.news.find { it.id == id } + if (existing != null) { + val req = NewsSaveRequest( + id = existing.id, + title = existing.title, + content = existing.content, + isPublic = makePublic, + isHidden = existing.isHidden, + expiresAt = existing.expiresAt, + ) + repository.saveNews(req) + } + } + val newsRes = repository.news() + _state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.") + } + } + + fun bulkSetHidden(ids: List, makeHidden: Boolean) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + ids.forEach { id -> + val existing = _state.value.news.find { it.id == id } + if (existing != null) { + val req = NewsSaveRequest( + id = existing.id, + title = existing.title, + content = existing.content, + isPublic = existing.isPublic, + isHidden = makeHidden, + expiresAt = existing.expiresAt, + ) + repository.saveNews(req) + } + } + val newsRes = repository.news() + _state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Update abgeschlossen.") + } + } + + fun bulkDelete(ids: List) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + ids.forEach { id -> + repository.deleteNews(id) + } + val newsRes = repository.news() + _state.value = _state.value.copy(saving = false, news = newsRes.getOrNull()?.news.orEmpty(), message = "Bulk-Löschung abgeschlossen.") + } + } + + fun deleteNews(id: Int) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.deleteNews(id) + .onSuccess { msg -> + val newsRes = repository.news() + _state.value = _state.value.copy( + saving = false, + news = newsRes.getOrNull()?.news.orEmpty(), + message = msg.message ?: "Nachricht gelöscht.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "News konnten nicht gelöscht werden.") + } + } + } + + // --- User management actions (B2) + fun updateUserRoles(id: String, roles: List) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.updateUserRoles(id, roles) + .onSuccess { msg -> + val usersRes = repository.users() + _state.value = _state.value.copy( + saving = false, + users = usersRes.getOrNull()?.users.orEmpty(), + message = msg.message ?: "Rollen aktualisiert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Rollen konnten nicht aktualisiert werden.") + } + } + } + + fun setUserActive(id: String, active: Boolean) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.updateUserActive(id, active) + .onSuccess { msg -> + val usersRes = repository.users() + _state.value = _state.value.copy( + saving = false, + users = usersRes.getOrNull()?.users.orEmpty(), + message = msg.message ?: if (active) "Benutzer aktiviert." else "Benutzer deaktiviert.", + ) + } + .onFailure { err -> + _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Benutzerstatus konnte nicht geändert werden.") + } + } + } + + fun resendInvite(id: String) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.resendInvite(id) + .onSuccess { msg -> + _state.value = _state.value.copy(saving = false, message = msg.message ?: "Einladung erneut gesendet.") + } + .onFailure { err -> + _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Einladung konnte nicht gesendet werden.") + } + } + } + + // --- Contact requests (B3) + fun replyToContactRequest(id: String, message: String) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.replyToContactRequest(id, message) + .onSuccess { + val reqs = repository.contactRequests() + _state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Antwort gesendet.") + } + .onFailure { err -> + _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Antwort konnte nicht gesendet werden.") + } + } + } + + fun toggleContactRequestStatus(id: String) { + viewModelScope.launch { + _state.value = _state.value.copy(saving = true, error = null, message = null) + repository.toggleContactRequestStatus(id) + .onSuccess { + val reqs = repository.contactRequests() + _state.value = _state.value.copy(saving = false, contactRequests = reqs.getOrNull().orEmpty(), message = it.message ?: "Status aktualisiert.") + } + .onFailure { err -> + _state.value = _state.value.copy(saving = false, error = ErrorMapper.mapError(err) ?: "Status konnte nicht geändert werden.") + } + } + } }