feat(android): sort CMS users, remove invite button, close CMS submenu on navigate

This commit is contained in:
Torsten Schulz (local)
2026-05-29 00:11:42 +02:00
parent c8b7f5ec2e
commit b4c31374c0
3 changed files with 380 additions and 29 deletions

View File

@@ -61,13 +61,17 @@ fun AppNavigationHeader(
if (webTabletNavigation) { if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, navigationState) WebTabletNavigation(selectedRoute, onNavigate, navigationState)
} else { } else {
CompactNavigation(selectedRoute, onNavigate) CompactNavigation(selectedRoute, onNavigate, navigationState)
} }
} }
} }
@Composable @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) }) BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f)) 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("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Galerie", Destinations.Gallery.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)) 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, navigationState: NavigationUiState,
) { ) {
val section = menuSection(selectedRoute) 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Brand() Brand()
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -93,12 +103,12 @@ private fun WebTabletNavigation(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { onNavigate(Destinations.Home.route) }) MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) }) MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) }) MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) }) MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { onNavigate(Destinations.Membership.route) }) MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) }) MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
if (navigationState.showGallery) { if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) }) MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
} }
@@ -106,11 +116,18 @@ private fun WebTabletNavigation(
if (navigationState.loggedIn) { if (navigationState.loggedIn) {
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) }) MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
} }
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { onNavigate(Destinations.Contact.route) }) MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
TextButton(onClick = { onNavigate(Destinations.Login.route) }) { Text("Login", color = Color.White) } TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
} }
} }
val subItems = submenu(section, navigationState) 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<MenuTarget>()
if (cmsChildren.any { it.route == selectedRoute }) {
cmsExpanded.value = true
}
// First row: render all subitems but do NOT render CMS children here
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -118,8 +135,33 @@ private fun WebTabletNavigation(
.padding(top = 3.dp), .padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
subItems.forEach { item -> subItems.forEachIndexed { idx, item ->
SubLink(item.label, item.route == selectedRoute) { onNavigate(item.route) } 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<MenuT
add(MenuTarget("Mein Profil", Destinations.Profile.route)) add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route)) add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route)) if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
// CMS child items (will be rendered when CMS parent is expanded)
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route)) if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route)) if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route)) if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))

View File

@@ -2,6 +2,8 @@ package de.harheimertc.ui.screens.cms
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.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@@ -23,6 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember 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
@@ -30,6 +33,8 @@ import androidx.compose.ui.graphics.Color
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
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
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
@@ -230,7 +235,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
} }
@Composable @Composable
private fun CmsPage( fun CmsPage(
navController: NavController, navController: NavController,
showBackNavigation: Boolean, showBackNavigation: Boolean,
title: String, title: String,
@@ -281,28 +286,126 @@ private fun CmsConfigPage(
@Composable @Composable
private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List<CmsUserDto>) { private fun CmsUserListPage(navController: NavController, showBackNavigation: Boolean, title: String, users: List<CmsUserDto>) {
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") { CmsPage(navController, showBackNavigation, title, "Registrierte Zugänge und Rollen") {
if (users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") } if (state.users.isEmpty()) item { EmptyCard("Keine Benutzer gefunden oder keine Berechtigung.") }
items(users.size) { index -> UserCard(users[index]) }
// 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 @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() }) { DataCard(user.name.ifBlank { user.email.orEmpty() }) {
InfoRow("E-Mail", user.email ?: "-") InfoRow("E-Mail", user.email ?: "-")
InfoRow("Rollen", user.roles.ifEmpty { listOfNotNull(user.role) }.joinToString(", ").ifBlank { "mitglied" }) InfoRow("Rollen", user.roles.ifEmpty { listOfNotNull(user.role) }.joinToString(", ").ifBlank { "mitglied" })
InfoRow("Status", if (user.active) "Aktiv" else "Inaktiv") InfoRow("Status", if (user.active) "Aktiv" else "Inaktiv")
InfoRow("Letzter Login", user.lastLogin ?: "-") 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<String>().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 @Composable
private fun ContactRequestCard(request: ContactRequestDto) { 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 }) { DataCard(request.name.ifBlank { request.email }) {
InfoRow("E-Mail", request.email) InfoRow("E-Mail", request.email)
InfoRow("Status", request.status.ifBlank { "offen" }) InfoRow("Status", request.status.ifBlank { "offen" })
InfoRow("Nachricht", request.message) 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") }
}
)
} }
} }

View File

@@ -9,12 +9,15 @@ import de.harheimertc.data.ContactRequestDto
import de.harheimertc.data.NewsletterDto import de.harheimertc.data.NewsletterDto
import de.harheimertc.data.NewsletterGroupDto import de.harheimertc.data.NewsletterGroupDto
import de.harheimertc.data.PasswordResetAttemptDto import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.NewsSaveRequest
import de.harheimertc.repositories.CmsRepository import de.harheimertc.repositories.CmsRepository
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import de.harheimertc.ui.util.ErrorMapper
data class CmsUiState( data class CmsUiState(
val loading: Boolean = true, val loading: Boolean = true,
@@ -27,6 +30,7 @@ data class CmsUiState(
val newsletters: List<NewsletterDto> = emptyList(), val newsletters: List<NewsletterDto> = emptyList(),
val newsletterGroups: List<NewsletterGroupDto> = emptyList(), val newsletterGroups: List<NewsletterGroupDto> = emptyList(),
val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(), val passwordResetAttempts: List<PasswordResetAttemptDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
) )
@HiltViewModel @HiltViewModel
@@ -43,21 +47,51 @@ class CmsViewModel @Inject constructor(
fun load() { fun load() {
viewModelScope.launch { viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null) _state.value = _state.value.copy(loading = true, error = null)
val config = async { repository.config().getOrNull() }
val users = async { repository.users().getOrNull()?.users.orEmpty() } val configRes = async { repository.config() }
val requests = async { repository.contactRequests().getOrNull().orEmpty() } val usersRes = async { repository.users() }
val newsletters = async { repository.newsletters().getOrNull()?.newsletters.orEmpty() } val requestsRes = async { repository.contactRequests() }
val groups = async { repository.newsletterGroups().getOrNull()?.groups.orEmpty() } val newslettersRes = async { repository.newsletters() }
val diagnostics = async { repository.passwordResetDiagnostics().getOrNull()?.attempts.orEmpty() } 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( _state.value = CmsUiState(
loading = false, loading = false,
config = config.await(), error = if (errors.isNotEmpty()) errors.joinToString("; ") else null,
users = users.await(), config = configResult.getOrNull(),
contactRequests = requests.await(), users = orderedUsers,
newsletters = newsletters.await(), contactRequests = requestsResult.getOrNull().orEmpty(),
newsletterGroups = groups.await(), newsletters = newslettersResult.getOrNull()?.newsletters.orEmpty(),
passwordResetAttempts = diagnostics.await(), newsletterGroups = groupsResult.getOrNull()?.groups.orEmpty(),
news = newsResult.getOrNull()?.news.orEmpty(),
passwordResetAttempts = diagnosticsResult.getOrNull()?.attempts.orEmpty(),
) )
} }
} }
@@ -76,9 +110,180 @@ class CmsViewModel @Inject constructor(
.onFailure { .onFailure {
_state.value = _state.value.copy( _state.value = _state.value.copy(
saving = false, 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<Int>, 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<Int>, 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<Int>) {
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<String>) {
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.")
}
}
}
} }