feat(android): sort CMS users, remove invite button, close CMS submenu on navigate
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user