diff --git a/mobile/src/main/java/de/deskhub/staff/MainActivity.kt b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt index 073e78fe..bca0493e 100644 --- a/mobile/src/main/java/de/deskhub/staff/MainActivity.kt +++ b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt @@ -5,6 +5,8 @@ import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -22,6 +24,14 @@ import de.deskhub.staff.ui.DeskHubViewModel import de.deskhub.staff.ui.DeskHubViewModelFactory class MainActivity : ComponentActivity() { + private val connectionPingHandler = Handler(Looper.getMainLooper()) + private val connectionPing = object : Runnable { + override fun run() { + AppConnectionPing.send(applicationContext) + connectionPingHandler.postDelayed(this, 4 * 60 * 1000L) + } + } + private val viewModel: DeskHubViewModel by viewModels { DeskHubViewModelFactory(TicketRepository(SecureSessionStore(applicationContext))) } @@ -35,9 +45,16 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() + connectionPingHandler.removeCallbacks(connectionPing) + connectionPing.run() DeskHubWidget.requestUpdate(applicationContext) } + override fun onPause() { + connectionPingHandler.removeCallbacks(connectionPing) + super.onPause() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) de.deskhub.staff.ui.I18n.init(applicationContext) @@ -48,9 +65,12 @@ class MainActivity : ComponentActivity() { // Nach der Anmeldung: um Erlaubnis für Benachrichtigungen bitten (einmal) und das Gerät für Push anmelden LaunchedEffect(signedIn) { if (signedIn) { + AppConnectionPing.scheduleBackground(applicationContext) requestNotificationPermissionOnce() PushRegistration.registerCurrent(applicationContext) CrashReporter.sendPending(applicationContext) + } else { + AppConnectionPing.cancelBackground(applicationContext) } } DeskHubApp(viewModel) @@ -86,4 +106,4 @@ class MainActivity : ComponentActivity() { prefs.edit().putBoolean("asked_notifications", true).apply() notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } -} \ No newline at end of file +} diff --git a/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt b/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt index d3c38270..2ce656eb 100644 --- a/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt +++ b/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt @@ -168,7 +168,7 @@ class TicketRepository( email = email.trim(), password = password, totpCode = totpCode?.takeIf { it.isNotBlank() }, - deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(), + deviceName = "Android (${Build.MANUFACTURER} ${Build.MODEL})".trim(), ), ) if (response.requiresTotp) return LoginResult.TotpRequired @@ -319,6 +319,11 @@ class TicketRepository( return requireApi().createTicket(request).ticket.toDomain() } + suspend fun appUpdate() = requireApi().appUpdate() + + suspend fun downloadAppUpdate(target: java.io.File, sha256: String, onProgress: (Int) -> Unit) = + requireApi().downloadAppUpdate(target, sha256, onProgress) + suspend fun activitySince(since: String): ActivityUpdate { val response = requireApi().activity(since) return ActivityUpdate(response.newReplies, response.newUnassigned, response.serverTime, response.health.map { HealthIssue(it.level, it.message) }) diff --git a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt index fd1a2b51..208a52ff 100644 --- a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt @@ -267,6 +267,7 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) { ) { outerPadding -> Column(Modifier.padding(outerPadding)) { StatusBanners(state, onRetry = viewModel::retryConnection) + UpdateBanner(state, viewModel) NavHost( navController = navController, startDestination = "dashboard", @@ -652,6 +653,69 @@ internal fun StatusBanners(state: DeskHubState, onRetry: () -> Unit) { } } +/** Hinweis „Update verfügbar“ mit Download und anschließender Installation (Android fragt dafür einmal nach der Erlaubnis). */ +@Composable +internal fun UpdateBanner(state: DeskHubState, viewModel: DeskHubViewModel) { + val context = LocalContext.current + val current = remember(context) { runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull().orEmpty() } + // Aus dem Play Store installierte Apps aktualisiert der Store selbst – Selbst-Update nur für direkt installierte APKs + val fromPlayStore = remember(context) { + runCatching { + if (android.os.Build.VERSION.SDK_INT >= 30) context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName + else @Suppress("DEPRECATION") context.packageManager.getInstallerPackageName(context.packageName) + }.getOrNull() == "com.android.vending" + } + LaunchedEffect(state.staff?.id) { + if (state.staff != null && current.isNotBlank() && !fromPlayStore) { + while (true) { + viewModel.checkAppUpdate(current) + kotlinx.coroutines.delay(6 * 3600 * 1000L) + } + } + } + LaunchedEffect(state.appUpdateFile) { + val file = state.appUpdateFile ?: return@LaunchedEffect + viewModel.appUpdateFileHandled() + if (android.os.Build.VERSION.SDK_INT >= 26 && !context.packageManager.canRequestPackageInstalls()) { + // Einmalige Erlaubnis „Aus dieser Quelle installieren“ – danach erneut auf „Jetzt aktualisieren“ tippen + context.startActivity( + android.content.Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, android.net.Uri.parse("package:${context.packageName}")) + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + return@LaunchedEffect + } + val uri = androidx.core.content.FileProvider.getUriForFile(context, "${context.packageName}.updates", file) + context.startActivity( + android.content.Intent(android.content.Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + val info = state.appUpdate ?: return + val firstBanner = !state.offline && state.healthIssues.isEmpty() // nur das oberste Banner braucht Abstand zur Statusleiste + Surface(color = MaterialTheme.colorScheme.primaryContainer, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.then(if (firstBanner) Modifier.statusBarsPadding() else Modifier).padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 4.dp)) { + Text( + state.appUpdateProgress?.let { "Update ${info.version} wird geladen … $it %" } + ?: ("Update verfügbar: DeskHub ${info.version} (installiert: $current)" + if (info.notes.isNotBlank()) "\n${info.notes}" else ""), + modifier = Modifier.padding(end = 8.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelLarge, + ) + state.appUpdateProgress?.let { + androidx.compose.material3.LinearProgressIndicator(progress = { it / 100f }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 6.dp, end = 8.dp)) + } + state.appUpdateError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp)) } + if (state.appUpdateProgress == null) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = viewModel::dismissAppUpdate) { Text("Später") } + TextButton(onClick = { viewModel.downloadAppUpdate(context.cacheDir) }) { Text("Jetzt aktualisieren") } + } + } + } + } +} + private fun androidx.navigation.NavController.navigateToTab(route: String) { navigate(route) { popUpTo(graph.findStartDestination().id) { saveState = true } diff --git a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt index 554fd9e2..a1836ee4 100644 --- a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt @@ -1,1809 +1,1856 @@ -package de.deskhub.staff.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import de.deskhub.staff.data.LoginResult -import de.deskhub.staff.data.TicketRepository -import de.deskhub.staff.data.remote.ApiException -import de.deskhub.staff.domain.model.ActivityUpdate -import de.deskhub.staff.domain.model.AuditLogEntry -import de.deskhub.staff.domain.model.BanEntry -import de.deskhub.staff.domain.model.BusinessHour -import de.deskhub.staff.domain.model.BusinessHoursData -import de.deskhub.staff.domain.model.CannedResponse -import de.deskhub.staff.domain.model.ChatContact -import de.deskhub.staff.domain.model.ChatConversation -import de.deskhub.staff.domain.model.Customer -import de.deskhub.staff.domain.model.CustomerProfile -import de.deskhub.staff.domain.model.DashboardStats -import de.deskhub.staff.domain.model.Department -import de.deskhub.staff.domain.model.DepartmentEmailOption -import de.deskhub.staff.domain.model.DepartmentInput -import de.deskhub.staff.domain.model.NamedOption -import de.deskhub.staff.domain.model.EmailAddressData -import de.deskhub.staff.domain.model.EmailAddressInput -import de.deskhub.staff.domain.model.EmailTemplate -import de.deskhub.staff.domain.model.EscalationData -import de.deskhub.staff.domain.model.HelpTopicData -import de.deskhub.staff.domain.model.HelpTopicInput -import de.deskhub.staff.domain.model.AvailabilityData -import de.deskhub.staff.domain.model.KbData -import de.deskhub.staff.domain.model.LiveChatDetail -import de.deskhub.staff.domain.model.LiveChatSummary -import de.deskhub.staff.domain.model.Metadata -import de.deskhub.staff.domain.model.TicketRuleAction -import de.deskhub.staff.domain.model.TicketRuleCondition -import de.deskhub.staff.domain.model.TicketRulesData -import de.deskhub.staff.domain.model.Organization -import de.deskhub.staff.domain.model.PriorityFull -import de.deskhub.staff.domain.model.RoleData -import de.deskhub.staff.domain.model.Staff -import de.deskhub.staff.domain.model.StaffAdminData -import de.deskhub.staff.domain.model.StaffAdminInput -import de.deskhub.staff.domain.model.StaffTask -import de.deskhub.staff.domain.model.TaskData -import de.deskhub.staff.domain.model.TeamData -import de.deskhub.staff.domain.model.TicketDetail -import de.deskhub.staff.domain.model.TicketFilters -import de.deskhub.staff.domain.model.TicketSummary -import de.deskhub.staff.domain.model.TimeEntriesReport -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -/** Zustand der Verwaltung des digitalen Assistenten (Einstellungen, Antworten, Fragen-Log, Ausprobieren). */ -data class AssistantUiState( - val overview: de.deskhub.staff.data.remote.dto.AssistantOverviewDto? = null, - val answers: List? = null, - val log: de.deskhub.staff.data.remote.dto.AssistantLogDto? = null, - val logResult: String = "", - val logPeriod: String = "30", - val logQuery: String = "", - val logPage: Int = 1, - val testResult: de.deskhub.staff.data.remote.dto.AssistantTestDto? = null, - val loading: Boolean = false, - val error: String? = null, - val message: String? = null, -) - -/** Zustand der Statusseite (Dienste und Störungsmeldungen für Kunden). */ -data class StatusUiState( - val data: de.deskhub.staff.data.remote.dto.StatusSummaryDto? = null, - val canManage: Boolean = false, - val loading: Boolean = false, - val error: String? = null, - val message: String? = null, -) - -data class DeskHubState( - val initializing: Boolean = true, - val staff: Staff? = null, - val authLoading: Boolean = false, - val authError: String? = null, - val totpRequired: Boolean = false, - /** Zuletzt verwendete Server-Adresse – bleibt auch nach dem Ausloggen erhalten, um erneutes Eintippen zu ersparen. */ - val savedServerUrl: String? = null, - val metadata: Metadata? = null, - val assistant: AssistantUiState = AssistantUiState(), - /** Ticket-Vorlagen für „Neues Ticket“, Auswertung der Zufriedenheitsabfrage und Statusseite. */ - val templates: List = emptyList(), - val csat: de.deskhub.staff.data.remote.dto.CsatResponse? = null, - val statusPage: StatusUiState = StatusUiState(), - /** Von außen angeforderter Bildschirm (zum Beispiel aus dem Widget): "newTicket" oder "tickets". */ - val requestedRoute: String? = null, - val cannedResponses: List = emptyList(), - val filters: TicketFilters = TicketFilters(), - val tickets: List = emptyList(), - val ticketsPage: Int = 1, - val ticketsTotal: Int = 0, - val ticketsHasMore: Boolean = false, - val ticketsLoading: Boolean = false, - val ticketsError: String? = null, - val selectedTicket: TicketDetail? = null, - val detailLoading: Boolean = false, - val detailError: String? = null, - val mutationLoading: Boolean = false, - val mutationError: String? = null, - val dashboard: DashboardStats? = null, - val dashboardLoading: Boolean = false, - val dashboardError: String? = null, - val customers: List = emptyList(), - val customersQuery: String = "", - val customersLoading: Boolean = false, - val customersError: String? = null, - val selectedCustomer: CustomerProfile? = null, - val customerLoading: Boolean = false, - val customerError: String? = null, - val newTicketPrefillName: String = "", - val newTicketPrefillEmail: String = "", - val newTicketLoading: Boolean = false, - val newTicketError: String? = null, - val activityBadgeCount: Int = 0, - val offline: Boolean = false, - val healthIssues: List = emptyList(), - val kb: KbData? = null, - val kbLoading: Boolean = false, - val kbError: String? = null, - val kbMutationLoading: Boolean = false, - val kbMutationError: String? = null, - val timeEntries: TimeEntriesReport? = null, - val timeEntriesLoading: Boolean = false, - val timeEntriesError: String? = null, - val timeEntriesStaffId: Int? = null, - val timeEntriesFrom: String = currentMonthStart(), - val timeEntriesTo: String = today(), - val escalation: EscalationData? = null, - val escalationLoading: Boolean = false, - val escalationError: String? = null, - val escalationMutationLoading: Boolean = false, - val escalationMutationError: String? = null, - val banlist: List? = null, - val banlistLoading: Boolean = false, - val banlistError: String? = null, - val banlistMutationLoading: Boolean = false, - val banlistMutationError: String? = null, - val banlistLastBulkResult: Pair? = null, - // Abteilungen - val departments2: List? = null, - val departmentEmailOptions: List = emptyList(), - val departmentStaffOptions: List = emptyList(), - val departments2Loading: Boolean = false, - val departments2Error: String? = null, - val departments2MutationLoading: Boolean = false, - val departments2MutationError: String? = null, - // Prioritäten - val report: de.deskhub.staff.data.remote.dto.ReportResponseDto? = null, - val reportConfig: de.deskhub.staff.data.remote.dto.ReportConfigDto = de.deskhub.staff.data.remote.dto.ReportConfigDto(), - val reportLoading: Boolean = false, - val reportError: String? = null, - val savedViews: List = emptyList(), - val savedViewError: String? = null, - val slaPlans: List? = null, - val slaPlansLoading: Boolean = false, - val slaPlansError: String? = null, - val slaPlansMutationLoading: Boolean = false, - val slaPlansMutationError: String? = null, - val departmentSlaPlans: List = emptyList(), - val prioritiesAdmin: List? = null, - val prioritiesAdminLoading: Boolean = false, - val prioritiesAdminError: String? = null, - val prioritiesAdminMutationLoading: Boolean = false, - val prioritiesAdminMutationError: String? = null, - // Hilfe-Themen - val helpTopics: HelpTopicData? = null, - val helpTopicsLoading: Boolean = false, - val helpTopicsError: String? = null, - val helpTopicsMutationLoading: Boolean = false, - val helpTopicsMutationError: String? = null, - // Organisationen - val organizations: List? = null, - val organizationsLoading: Boolean = false, - val organizationsError: String? = null, - val organizationsMutationLoading: Boolean = false, - val organizationsMutationError: String? = null, - // Teams - val teams: TeamData? = null, - val teamsLoading: Boolean = false, - val teamsError: String? = null, - val teamsMutationLoading: Boolean = false, - val teamsMutationError: String? = null, - // Rollen - val roles: RoleData? = null, - val rolesLoading: Boolean = false, - val rolesError: String? = null, - val rolesMutationLoading: Boolean = false, - val rolesMutationError: String? = null, - // Mitarbeiter - val staffAdmin: StaffAdminData? = null, - val staffAdminLoading: Boolean = false, - val staffAdminError: String? = null, - val staffAdminMutationLoading: Boolean = false, - val staffAdminMutationError: String? = null, - // Geschäftszeiten - val businessHours: BusinessHoursData? = null, - val businessHoursLoading: Boolean = false, - val businessHoursError: String? = null, - val businessHoursMutationLoading: Boolean = false, - val businessHoursMutationError: String? = null, - // E-Mail-Adressen - val emailAddresses: EmailAddressData? = null, - val emailAddressesLoading: Boolean = false, - val emailAddressesError: String? = null, - val emailAddressesMutationLoading: Boolean = false, - val emailAddressesMutationError: String? = null, - val emailAddressesNotice: String? = null, - // E-Mail-Vorlagen - val emailTemplates: List? = null, - val emailTemplatesLoading: Boolean = false, - val emailTemplatesError: String? = null, - val emailTemplatesMutationLoading: Boolean = false, - val emailTemplatesMutationError: String? = null, - // Admin-Protokoll - val auditLog: List = emptyList(), - val auditLogPage: Int = 1, - val auditLogHasMore: Boolean = false, - val auditLogLoading: Boolean = false, - val auditLogError: String? = null, - // Aufgaben - val tasks: TaskData? = null, - val tasksFilter: String = "my", - val tasksLoading: Boolean = false, - val tasksError: String? = null, - val tasksMutationLoading: Boolean = false, - val tasksMutationError: String? = null, - // Interner Chat - val chatContacts: List? = null, - val chatContactsLoading: Boolean = false, - val chatContactsError: String? = null, - val chatConversation: ChatConversation? = null, - val chatConversationLoading: Boolean = false, - val chatConversationError: String? = null, - val chatSendLoading: Boolean = false, - val chatSendError: String? = null, - // Live-Chat (Kunden-Widget, source='chat') - val liveChats: List? = null, - val liveChatsLoading: Boolean = false, - val liveChatsError: String? = null, - val liveChatDetail: LiveChatDetail? = null, - val liveChatDetailLoading: Boolean = false, - val liveChatDetailError: String? = null, - val liveChatSendLoading: Boolean = false, - val liveChatSendError: String? = null, - // Verfügbarkeit (Status) - val availability: AvailabilityData? = null, - val availabilityLoading: Boolean = false, - val availabilityError: String? = null, - val availabilitySaving: Boolean = false, - // Ticket-Regeln (Automatisierung) - val ticketRules: TicketRulesData? = null, - val ticketRulesLoading: Boolean = false, - val ticketRulesError: String? = null, - val ticketRulesMutationLoading: Boolean = false, - val ticketRulesMutationError: String? = null, - // Bild-Upload (Signaturen) - val imageUploadLoading: Boolean = false, - val imageUploadError: String? = null, -) - -private fun currentMonthStart(): String { - val now = java.util.Calendar.getInstance() - now.set(java.util.Calendar.DAY_OF_MONTH, 1) - return java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(now.time) -} - -private fun today(): String = - java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(java.util.Date()) - -class DeskHubViewModel( - private val repository: TicketRepository, -) : ViewModel() { - private val mutableState = MutableStateFlow(DeskHubState()) - val state: StateFlow = mutableState.asStateFlow() - private var activityPollingJob: Job? = null - private var ticketsPollingJob: Job? = null - private var chatPollingJob: Job? = null - private var liveChatListPollingJob: Job? = null - private var liveChatDetailPollingJob: Job? = null - private var lastLiveChatTypingPingAt = 0L - // Ticket, das über eine Benachrichtigung geöffnet werden soll, bevor die Anmeldung fertig ist - private var pendingTicket: String? = null - - init { - restoreSession() - // Verbindungsstatus: bei Ausfall alle 10 s erneut versuchen und nach Rückkehr die Daten auffrischen - viewModelScope.launch { - de.deskhub.staff.data.remote.ConnectionMonitor.offline.collect { off -> - mutableState.update { it.copy(offline = off) } - if (off) { - launch { - while (isActive && de.deskhub.staff.data.remote.ConnectionMonitor.offline.value) { - delay(10_000) - if (state.value.staff != null) runCatching { repository.activitySince("") } - } - if (state.value.staff != null) { - refreshTickets() - loadDashboard() - } - } - } - } - } - } - - /** Sofort erneut versuchen (Knopf im Verbindungs-Banner). */ - fun retryConnection() { - viewModelScope.launch { runCatching { repository.activitySince("") } } - } - - fun login(serverUrl: String, email: String, password: String, totpCode: String) { - viewModelScope.launch { - mutableState.update { it.copy(authLoading = true, authError = null) } - runCatching { - repository.login(serverUrl, email, password, totpCode.ifBlank { null }) - }.onSuccess { result -> - when (result) { - LoginResult.TotpRequired -> mutableState.update { - it.copy(authLoading = false, totpRequired = true) - } - is LoginResult.Success -> { - mutableState.update { - it.copy( - initializing = false, - staff = result.staff, - authLoading = false, - authError = null, - totpRequired = false, - ) - } - loadWorkspace() - loadChatContacts() - startActivityPolling() - startTicketsPolling() - applyPendingTicket() - } - } - }.onFailure { error -> - mutableState.update { - it.copy(authLoading = false, authError = error.userMessage()) - } - } - } - } - - fun cancelTotp() { - mutableState.update { it.copy(totpRequired = false, authError = null) } - } - - fun logout() { - viewModelScope.launch { - mutableState.update { it.copy(mutationLoading = true, mutationError = null) } - runCatching { repository.logout() } - .onSuccess { - activityPollingJob?.cancel() - ticketsPollingJob?.cancel() - chatPollingJob?.cancel() - mutableState.value = DeskHubState(initializing = false, savedServerUrl = repository.lastServerUrl()) - } - .onFailure { error -> - mutableState.update { - it.copy(mutationLoading = false, mutationError = error.userMessage()) - } - } - } - } - - fun clearActivityBadge() { - mutableState.update { it.copy(activityBadgeCount = 0) } - } - - private fun startActivityPolling() { - activityPollingJob?.cancel() - activityPollingJob = viewModelScope.launch { - var since = "" - while (isActive) { - runCatching { repository.activitySince(since) }.onSuccess { update: ActivityUpdate -> - since = update.serverTime - mutableState.update { it.copy(healthIssues = update.health) } - val newCount = update.newReplies + update.newUnassigned - if (newCount > 0) { - mutableState.update { it.copy(activityBadgeCount = it.activityBadgeCount + newCount) } - } - } - delay(60_000) - } - } - } - - // Läuft dauerhaft im Hintergrund (auch außerhalb des Tickets-Tabs, analog zu - // startActivityPolling()), damit neue/geänderte Tickets von selbst auftauchen, ohne - // dass manuell auf "Aktualisieren" getippt werden muss – Pendant zum Web-Live-Refresh - // in staff/tickets.php. Aktualisiert bewusst nur leise Seite 1 (kein ticketsLoading = - // kein Flackern) und nur, solange der Nutzer nicht gerade auf einer Folgeseite ist - // ("Weitere laden") – sonst würde die Liste unter den Fingern auf Seite 1 zurückspringen. - private fun startTicketsPolling() { - ticketsPollingJob?.cancel() - ticketsPollingJob = viewModelScope.launch { - while (isActive) { - delay(15_000) - if (state.value.staff != null && state.value.ticketsPage <= 1 && !state.value.ticketsLoading) { - runCatching { repository.tickets(state.value.filters, 1) } - .onSuccess { page -> - mutableState.update { - it.copy( - tickets = page.items, - ticketsPage = page.page, - ticketsTotal = page.total, - ticketsHasMore = page.hasMore, - ) - } - } - // Fehler werden bewusst verschluckt: ein einzelner Netzwerk-Hänger im - // Hintergrund soll weder die sichtbare Liste löschen noch eine - // Fehlermeldung aufploppen lassen – beim nächsten Tick wird es erneut versucht. - } - } - } - } - - fun updateFilters(filters: TicketFilters) { - mutableState.update { it.copy(filters = filters) } - } - - fun refreshTickets() { - loadTickets(reset = true) - } - - fun loadMoreTickets() { - if (!state.value.ticketsHasMore || state.value.ticketsLoading) return - loadTickets(reset = false) - } - - /** Tipp auf eine Push-Benachrichtigung: das Ticket öffnen (sobald die Anmeldung steht). */ - fun openTicketFromNotification(number: String) { - if (number.isBlank()) return - if (state.value.staff != null) openTicket(number) else pendingTicket = number - } - - private fun applyPendingTicket() { - pendingTicket?.let { pendingTicket = null; openTicket(it) } - } - - /** Administrator: Sperre eines Tickets aufheben und das Ticket danach neu laden (man übernimmt die Sperre). */ - fun unlockTicket(number: String) { - viewModelScope.launch { - runCatching { repository.unlockTicket(number) } - .onSuccess { openTicket(number) } - .onFailure { error -> mutableState.update { it.copy(mutationError = error.userMessage()) } } - } - } - - fun openTicket(number: String) { - viewModelScope.launch { - mutableState.update { - it.copy(selectedTicket = null, detailLoading = true, detailError = null) - } - runCatching { repository.ticket(number) } - .onSuccess { ticket -> - mutableState.update { - it.copy(selectedTicket = ticket, detailLoading = false) - } - } - .onFailure { error -> - mutableState.update { - it.copy(detailLoading = false, detailError = error.userMessage()) - } - } - } - } - - fun updateTicket(status: String, assignedStaffId: Int?) { - val ticket = state.value.selectedTicket ?: return - viewModelScope.launch { - mutableState.update { it.copy(mutationLoading = true, mutationError = null) } - val updateAssignment = assignedStaffId != ticket.summary.assignedStaff?.id - runCatching { - repository.updateTicket( - number = ticket.summary.number, - status = status.takeIf { it != ticket.summary.status }, - assignedStaffId = assignedStaffId, - updateAssignment = updateAssignment, - ) - }.onSuccess { - mutableState.update { it.copy(mutationLoading = false) } - openTicket(ticket.summary.number) - loadTickets(reset = true) - }.onFailure { error -> - mutableState.update { - it.copy(mutationLoading = false, mutationError = error.userMessage()) - } - } - } - } - - // ── Digitaler Assistent (Verwaltung) ───────────────────────────────────── - - private fun updateAssistant(block: (AssistantUiState) -> AssistantUiState) = mutableState.update { it.copy(assistant = block(it.assistant)) } - - private fun assistantRun(success: String? = null, onDone: () -> Unit = {}, block: suspend () -> Unit) { - viewModelScope.launch { - updateAssistant { it.copy(loading = true, error = null) } - runCatching { block() } - .onSuccess { updateAssistant { s -> s.copy(loading = false, message = success ?: s.message) }; onDone() } - .onFailure { e -> updateAssistant { it.copy(loading = false, error = e.userMessage()) } } - } - } - - fun assistantLoadOverview() = assistantRun { val o = repository.assistantOverview(); updateAssistant { it.copy(overview = o) } } - - fun assistantLoadAnswers() = assistantRun { val a = repository.assistantAnswers(); updateAssistant { it.copy(answers = a) } } - - fun assistantLoadLog(result: String? = null, period: String? = null, query: String? = null, page: Int? = null) { - updateAssistant { it.copy(logResult = result ?: it.logResult, logPeriod = period ?: it.logPeriod, logQuery = query ?: it.logQuery, logPage = page ?: 1) } - assistantRun { - val s = state.value.assistant - val l = repository.assistantLog(mapOf("result" to s.logResult, "period" to s.logPeriod, "q" to s.logQuery, "page" to s.logPage.toString())) - updateAssistant { it.copy(log = l) } - } - } - - fun assistantSaveSettings(s: de.deskhub.staff.data.remote.dto.AssistantSettingsDto) = assistantRun(success = "Gespeichert", onDone = { assistantLoadOverview() }) { - repository.assistantAction(mapOf("action" to "save_settings", "enabled" to s.enabled, "mode" to s.mode, "name" to s.name, "greeting" to s.greeting, - "noAnswer" to s.noAnswer, "departmentId" to s.departmentId, "priorityId" to s.priorityId, "logDays" to s.logDays)) - } - - fun assistantSaveAnswer(id: Int, title: String, keywords: String, answer: String, isActive: Boolean, onDone: () -> Unit) = - assistantRun(success = "Antwort gespeichert", onDone = { onDone(); assistantLoadAnswers() }) { - repository.assistantSaveAnswer(mapOf("action" to "save_answer", "id" to id, "title" to title, "keywords" to keywords, "answer" to answer, "isActive" to isActive)) - } - - fun assistantDeleteAnswer(id: Int, onDone: () -> Unit) = assistantRun(success = "Antwort gelöscht", onDone = { onDone(); assistantLoadAnswers() }) { - repository.assistantAction(mapOf("action" to "delete_answer", "id" to id)) - } - - fun assistantSaveSynonyms(text: String) = assistantRun(success = "Gleichbedeutende Wörter gespeichert", onDone = { assistantLoadOverview() }) { - repository.assistantAction(mapOf("action" to "save_synonyms", "text" to text)) - } - - fun assistantClearLog() = assistantRun(success = "Fragen-Log geleert", onDone = { assistantLoadLog(page = 1) }) { - repository.assistantAction(mapOf("action" to "clear_log")) - } - - fun assistantTest(question: String) = assistantRun { val r = repository.assistantTest(question); updateAssistant { it.copy(testResult = r) } } - - fun requestRoute(route: String) = mutableState.update { it.copy(requestedRoute = route) } - - fun routeHandled() = mutableState.update { it.copy(requestedRoute = null) } - - fun assistantClearMessage() = updateAssistant { it.copy(message = null) } - - /** Legt das geöffnete Ticket auf Wiedervorlage (ISO-Zeit) oder hebt sie auf (null). */ - fun snoozeTicket(untilIso: String?) { - val ticket = state.value.selectedTicket ?: return - viewModelScope.launch { - mutableState.update { it.copy(mutationLoading = true, mutationError = null) } - runCatching { repository.setSnooze(ticket.summary.number, untilIso) } - .onSuccess { - mutableState.update { it.copy(mutationLoading = false) } - openTicket(ticket.summary.number) - loadTickets(reset = true) - } - .onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } } - } - } - - /** Beobachten ein- oder ausschalten. */ - fun setFollowing(following: Boolean) { - val ticket = state.value.selectedTicket ?: return - viewModelScope.launch { - mutableState.update { it.copy(mutationLoading = true, mutationError = null) } - runCatching { repository.setFollowing(ticket.summary.number, following) } - .onSuccess { - mutableState.update { it.copy(mutationLoading = false) } - openTicket(ticket.summary.number) - } - .onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } } - } - } - - /** Cc-Empfänger zum geöffneten Ticket hinzufügen. */ - fun addCollaborator(email: String) = collaboratorChange { number -> repository.collaboratorAction(number, "add", email = email.trim()) } - - /** Cc-Empfänger vom geöffneten Ticket entfernen. */ - fun removeCollaborator(id: Int) = collaboratorChange { number -> repository.collaboratorAction(number, "remove", id = id) } - - private fun collaboratorChange(block: suspend (String) -> Unit) { - val ticket = state.value.selectedTicket ?: return - viewModelScope.launch { - mutableState.update { it.copy(mutationLoading = true, mutationError = null) } - runCatching { block(ticket.summary.number) } - .onSuccess { - mutableState.update { it.copy(mutationLoading = false) } - openTicket(ticket.summary.number) - } - .onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } } - } - } - - /** Ticket-Vorlagen laden (für den Bildschirm „Neues Ticket“). Fehler sind nicht schlimm: dann gibt es keine Auswahl. */ - fun loadTemplates() { - viewModelScope.launch { - runCatching { repository.ticketTemplates() }.onSuccess { list -> mutableState.update { it.copy(templates = list) } } - } - } - - /** Auswertung der Zufriedenheitsabfrage laden (für die Übersicht). */ - fun loadCsat(days: Int = 30) { - viewModelScope.launch { - runCatching { repository.csatStats(days) }.onSuccess { r -> mutableState.update { it.copy(csat = r) } } - } - } - - private fun updateStatusPage(block: (StatusUiState) -> StatusUiState) = mutableState.update { it.copy(statusPage = block(it.statusPage)) } - - fun statusLoad() { - viewModelScope.launch { - updateStatusPage { it.copy(loading = true, error = null) } - runCatching { repository.statusPage() } - .onSuccess { r -> updateStatusPage { it.copy(loading = false, data = r.status, canManage = r.canManage) } } - .onFailure { e -> updateStatusPage { it.copy(loading = false, error = e.userMessage()) } } - } - } - - /** Aktion auf der Statusseite (nur Administratoren), zum Beispiel Zustand eines Dienstes ändern oder eine Meldung veröffentlichen. */ - fun statusAct(request: Map, success: String) { - viewModelScope.launch { - updateStatusPage { it.copy(loading = true, error = null) } - runCatching { repository.statusAction(request) } - .onSuccess { r -> updateStatusPage { it.copy(loading = false, data = r.status, canManage = r.canManage, message = success) } } - .onFailure { e -> updateStatusPage { it.copy(loading = false, error = e.userMessage()) } } - } - } - - fun statusClearMessage() = updateStatusPage { it.copy(message = null, error = null) } - - /** Markiert das aktuell geöffnete Ticket als Spam, oder macht das (restore = true) rückgängig. */ - fun setTicketSpam(restore: Boolean) { - val ticket = state.value.selectedTicket ?: return - viewModelScope.launch { - mutableState.update { it.copy(mutationLoading = true, mutationError = null) } - runCatching { repository.setTicketSpam(ticket.summary.number, restore) } - .onSuccess { result -> - mutableState.update { it.copy(mutationLoading = false) } - if (result.failed.isNotEmpty()) { - val verb = if (restore) "zurückgeholt" else "als Spam markiert" - mutableState.update { - it.copy(mutationError = "Ticket wurde $verb, aber nicht alle Original-Mails konnten verschoben werden: " + result.failed.joinToString(" | ")) - } - } - openTicket(ticket.summary.number) - loadTickets(reset = true) - } - .onFailure { error -> - mutableState.update { - it.copy(mutationLoading = false, mutationError = error.userMessage()) - } - } - } - } - - fun addMessage(type: String, message: String, setStatus: String?, signature: String?, onSuccess: () -> Unit) { - val number = state.value.selectedTicket?.summary?.number ?: return - viewModelScope.launch { - mutableState.update { it.copy(mutationLoading = true, mutationError = null) } - runCatching { repository.addMessage(number, type, message, setStatus, signature) } - .onSuccess { - mutableState.update { it.copy(mutationLoading = false) } - onSuccess() - openTicket(number) - loadTickets(reset = true) - } - .onFailure { error -> - mutableState.update { - it.copy(mutationLoading = false, mutationError = error.userMessage()) - } - } - } - } - - fun clearMutationError() { - mutableState.update { it.copy(mutationError = null) } - } - - fun searchCustomers(query: String) { - mutableState.update { it.copy(customersQuery = query) } - viewModelScope.launch { - mutableState.update { it.copy(customersLoading = true, customersError = null) } - runCatching { repository.customers(query) } - .onSuccess { list -> mutableState.update { it.copy(customers = list, customersLoading = false) } } - .onFailure { error -> - mutableState.update { it.copy(customersLoading = false, customersError = error.userMessage()) } - } - } - } - - fun openCustomer(customer: Customer) { - viewModelScope.launch { - mutableState.update { it.copy(selectedCustomer = null, customerLoading = true, customerError = null) } - runCatching { - if (customer.id != null) repository.customerProfile(customer.id, null) - else repository.customerProfile(null, customer.email) - } - .onSuccess { profile -> mutableState.update { it.copy(selectedCustomer = profile, customerLoading = false) } } - .onFailure { error -> - mutableState.update { it.copy(customerLoading = false, customerError = error.userMessage()) } - } - } - } - - fun startNewTicket(name: String = "", email: String = "") { - mutableState.update { - it.copy(newTicketPrefillName = name, newTicketPrefillEmail = email, newTicketError = null) - } - } - - fun createTicket( - requesterName: String, - requesterEmail: String, - subject: String, - message: String, - departmentId: Int, - priorityId: Int, - assignedStaffId: Int?, - notifyCustomer: Boolean, - onSuccess: (String) -> Unit, - ) { - viewModelScope.launch { - mutableState.update { it.copy(newTicketLoading = true, newTicketError = null) } - runCatching { - repository.createTicket( - requesterName, requesterEmail, subject, message, - departmentId, priorityId, assignedStaffId, notifyCustomer, - ) - } - .onSuccess { ticket -> - mutableState.update { it.copy(newTicketLoading = false) } - onSuccess(ticket.number) - refreshTickets() - } - .onFailure { error -> - mutableState.update { it.copy(newTicketLoading = false, newTicketError = error.userMessage()) } - } - } - } - - fun loadDashboard() { - viewModelScope.launch { - mutableState.update { it.copy(dashboardLoading = true, dashboardError = null) } - runCatching { repository.dashboard() } - .onSuccess { stats -> - mutableState.update { it.copy(dashboard = stats, dashboardLoading = false) } - } - .onFailure { error -> - mutableState.update { - it.copy(dashboardLoading = false, dashboardError = error.userMessage()) - } - } - } - } - - // ─── Admin: Wissensdatenbank ────────────────────────────────────────────── - - fun loadKb() { - viewModelScope.launch { - mutableState.update { it.copy(kbLoading = true, kbError = null) } - runCatching { repository.kbData() } - .onSuccess { data -> mutableState.update { it.copy(kb = data, kbLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(kbLoading = false, kbError = error.userMessage()) } } - } - } - - fun saveKbArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } - runCatching { repository.kbSaveArticle(id, title, categoryId, body, isPublished) } - .onSuccess { - mutableState.update { it.copy(kbMutationLoading = false) } - onSuccess() - loadKb() - } - .onFailure { error -> - mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } - } - } - } - - fun deleteKbArticle(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } - runCatching { repository.kbDeleteArticle(id) } - .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } - .onFailure { error -> - mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } - } - } - } - - fun createKbCategory(name: String) { - viewModelScope.launch { - mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } - runCatching { repository.kbCreateCategory(name) } - .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } - .onFailure { error -> - mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } - } - } - } - - fun deleteKbCategory(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } - runCatching { repository.kbDeleteCategory(id) } - .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } - .onFailure { error -> - mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } - } - } - } - - fun clearKbMutationError() { - mutableState.update { it.copy(kbMutationError = null) } - } - - // ─── Admin: Zeiterfassung ───────────────────────────────────────────────── - - fun loadTimeEntries(staffId: Int? = state.value.timeEntriesStaffId, from: String = state.value.timeEntriesFrom, to: String = state.value.timeEntriesTo) { - viewModelScope.launch { - mutableState.update { - it.copy( - timeEntriesLoading = true, - timeEntriesError = null, - timeEntriesStaffId = staffId, - timeEntriesFrom = from, - timeEntriesTo = to, - ) - } - runCatching { repository.timeEntries(staffId, from, to) } - .onSuccess { report -> mutableState.update { it.copy(timeEntries = report, timeEntriesLoading = false) } } - .onFailure { error -> - mutableState.update { it.copy(timeEntriesLoading = false, timeEntriesError = error.userMessage()) } - } - } - } - - // ─── Admin: Eskalationsregeln ───────────────────────────────────────────── - - fun loadEscalationRules() { - viewModelScope.launch { - mutableState.update { it.copy(escalationLoading = true, escalationError = null) } - runCatching { repository.escalationData() } - .onSuccess { data -> mutableState.update { it.copy(escalation = data, escalationLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(escalationLoading = false, escalationError = error.userMessage()) } } - } - } - - fun saveEscalationRule( - id: Int?, - name: String, - isActive: Boolean, - triggerType: String, - triggerHours: Int, - conditionStatus: String?, - conditionPriorityId: Int?, - conditionDeptId: Int?, - actionSetPriorityId: Int?, - actionAssignStaffId: Int?, - actionSetStatus: String?, - actionNotifyAdmin: Boolean, - actionNotifyStaff: Boolean, - onSuccess: () -> Unit, - ) { - viewModelScope.launch { - mutableState.update { it.copy(escalationMutationLoading = true, escalationMutationError = null) } - runCatching { - repository.escalationSave( - id = id, name = name, isActive = isActive, triggerType = triggerType, - triggerHours = triggerHours, conditionStatus = conditionStatus, - conditionPriorityId = conditionPriorityId, conditionDeptId = conditionDeptId, - actionSetPriorityId = actionSetPriorityId, actionAssignStaffId = actionAssignStaffId, - actionSetStatus = actionSetStatus, actionNotifyAdmin = actionNotifyAdmin, - actionNotifyStaff = actionNotifyStaff, sortOrder = 0, - ) - }.onSuccess { - mutableState.update { it.copy(escalationMutationLoading = false) } - onSuccess() - loadEscalationRules() - }.onFailure { error -> - mutableState.update { it.copy(escalationMutationLoading = false, escalationMutationError = error.userMessage()) } - } - } - } - - fun deleteEscalationRule(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(escalationMutationLoading = true, escalationMutationError = null) } - runCatching { repository.escalationDelete(id) } - .onSuccess { mutableState.update { it.copy(escalationMutationLoading = false) }; loadEscalationRules() } - .onFailure { error -> - mutableState.update { it.copy(escalationMutationLoading = false, escalationMutationError = error.userMessage()) } - } - } - } - - fun toggleEscalationRule(id: Int) { - viewModelScope.launch { - runCatching { repository.escalationToggle(id) } - .onSuccess { loadEscalationRules() } - .onFailure { error -> mutableState.update { it.copy(escalationMutationError = error.userMessage()) } } - } - } - - fun clearEscalationMutationError() { - mutableState.update { it.copy(escalationMutationError = null) } - } - - // ─── Admin: Sperrliste ──────────────────────────────────────────────────── - - fun loadBanlist() { - viewModelScope.launch { - mutableState.update { it.copy(banlistLoading = true, banlistError = null) } - runCatching { repository.banlist() } - .onSuccess { items -> mutableState.update { it.copy(banlist = items, banlistLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(banlistLoading = false, banlistError = error.userMessage()) } } - } - } - - fun createBanEntry(matchType: String, value: String, reason: String?, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } - runCatching { repository.banCreate(matchType, value, reason) } - .onSuccess { - mutableState.update { it.copy(banlistMutationLoading = false) } - onSuccess() - loadBanlist() - } - .onFailure { error -> - mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } - } - } - } - - fun bulkAddBanEntries(values: String, reason: String?, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } - runCatching { repository.banBulkAdd(values, reason) } - .onSuccess { result -> - mutableState.update { it.copy(banlistMutationLoading = false, banlistLastBulkResult = result) } - onSuccess() - loadBanlist() - } - .onFailure { error -> - mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } - } - } - } - - fun deleteBanEntry(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } - runCatching { repository.banDelete(id) } - .onSuccess { mutableState.update { it.copy(banlistMutationLoading = false) }; loadBanlist() } - .onFailure { error -> - mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } - } - } - } - - fun clearBanlistMutationError() { - mutableState.update { it.copy(banlistMutationError = null) } - } - - fun clearBanlistBulkResult() { - mutableState.update { it.copy(banlistLastBulkResult = null) } - } - - // ─── Admin: Abteilungen ─────────────────────────────────────────────────── - - fun loadDepartments() { - viewModelScope.launch { - mutableState.update { it.copy(departments2Loading = true, departments2Error = null) } - runCatching { repository.departmentData() } - .onSuccess { d -> mutableState.update { it.copy(departments2 = d.departments, departmentEmailOptions = d.emailOptions, departmentStaffOptions = d.staffOptions, departmentSlaPlans = d.slaPlans, departments2Loading = false) } } - .onFailure { error -> mutableState.update { it.copy(departments2Loading = false, departments2Error = error.userMessage()) } } - } - } - - fun saveDepartment(id: Int?, input: DepartmentInput, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(departments2MutationLoading = true, departments2MutationError = null) } - runCatching { repository.departmentSave(id, input) } - .onSuccess { mutableState.update { it.copy(departments2MutationLoading = false) }; onSuccess(); loadDepartments() } - .onFailure { error -> mutableState.update { it.copy(departments2MutationLoading = false, departments2MutationError = error.userMessage()) } } - } - } - - fun deleteDepartment(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(departments2MutationLoading = true, departments2MutationError = null) } - runCatching { repository.departmentDelete(id) } - .onSuccess { mutableState.update { it.copy(departments2MutationLoading = false) }; loadDepartments() } - .onFailure { error -> mutableState.update { it.copy(departments2MutationLoading = false, departments2MutationError = error.userMessage()) } } - } - } - - fun clearDepartmentsError() = mutableState.update { it.copy(departments2MutationError = null) } - - // ─── Admin: Prioritäten ─────────────────────────────────────────────────── - - fun loadPrioritiesAdmin() { - viewModelScope.launch { - mutableState.update { it.copy(prioritiesAdminLoading = true, prioritiesAdminError = null) } - runCatching { repository.prioritiesAdmin() } - .onSuccess { items -> mutableState.update { it.copy(prioritiesAdmin = items, prioritiesAdminLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(prioritiesAdminLoading = false, prioritiesAdminError = error.userMessage()) } } - } - } - - fun savePriority(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(prioritiesAdminMutationLoading = true, prioritiesAdminMutationError = null) } - runCatching { repository.prioritySave(id, name, color, responseHours, resolutionHours, sortOrder) } - .onSuccess { mutableState.update { it.copy(prioritiesAdminMutationLoading = false) }; onSuccess(); loadPrioritiesAdmin() } - .onFailure { error -> mutableState.update { it.copy(prioritiesAdminMutationLoading = false, prioritiesAdminMutationError = error.userMessage()) } } - } - } - - fun deletePriority(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(prioritiesAdminMutationLoading = true, prioritiesAdminMutationError = null) } - runCatching { repository.priorityDelete(id) } - .onSuccess { mutableState.update { it.copy(prioritiesAdminMutationLoading = false) }; loadPrioritiesAdmin() } - .onFailure { error -> mutableState.update { it.copy(prioritiesAdminMutationLoading = false, prioritiesAdminMutationError = error.userMessage()) } } - } - } - - /** Bericht ausführen: mit der übergebenen Konfiguration oder als gespeicherter Bericht (savedId). */ - fun runReport(config: de.deskhub.staff.data.remote.dto.ReportConfigDto? = null, savedId: Int? = null) { - viewModelScope.launch { - mutableState.update { it.copy(reportLoading = true, reportError = null) } - runCatching { repository.report(config, savedId) } - .onSuccess { r -> mutableState.update { it.copy(report = r, reportConfig = r.config, reportLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(reportLoading = false, reportError = error.userMessage()) } } - } - } - - fun saveReport(name: String, shared: Boolean) { - viewModelScope.launch { - runCatching { repository.reportSave(name, shared, state.value.reportConfig) } - .onSuccess { runReport(state.value.reportConfig) } - .onFailure { error -> mutableState.update { it.copy(reportError = error.userMessage()) } } - } - } - - fun deleteReport(id: Int) { - viewModelScope.launch { - runCatching { repository.reportDelete(id) }.onSuccess { runReport(state.value.reportConfig) } - } - } - - fun loadSavedViews() { - viewModelScope.launch { - runCatching { repository.savedViews() } - .onSuccess { views -> mutableState.update { it.copy(savedViews = views) } } - } - } - - fun applySavedView(view: de.deskhub.staff.domain.model.SavedView) { - mutableState.update { it.copy(filters = view.filters) } - refreshTickets() - } - - fun saveCurrentView(name: String, shared: Boolean) { - viewModelScope.launch { - mutableState.update { it.copy(savedViewError = null) } - runCatching { repository.savedViewSave(name, shared, state.value.filters) } - .onSuccess { loadSavedViews() } - .onFailure { error -> mutableState.update { it.copy(savedViewError = error.userMessage()) } } - } - } - - fun deleteSavedView(id: Int) { - viewModelScope.launch { - runCatching { repository.savedViewDelete(id) }.onSuccess { loadSavedViews() } - } - } - - fun clearSavedViewError() = mutableState.update { it.copy(savedViewError = null) } - - fun loadSlaPlans() { - viewModelScope.launch { - mutableState.update { it.copy(slaPlansLoading = true, slaPlansError = null) } - runCatching { repository.slaPlans() } - .onSuccess { items -> mutableState.update { it.copy(slaPlans = items, slaPlansLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(slaPlansLoading = false, slaPlansError = error.userMessage()) } } - } - } - - fun saveSlaPlan(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(slaPlansMutationLoading = true, slaPlansMutationError = null) } - runCatching { repository.slaPlanSave(id, name, responseHours, resolutionHours, isActive, notes) } - .onSuccess { mutableState.update { it.copy(slaPlansMutationLoading = false) }; onSuccess(); loadSlaPlans() } - .onFailure { error -> mutableState.update { it.copy(slaPlansMutationLoading = false, slaPlansMutationError = error.userMessage()) } } - } - } - - fun deleteSlaPlan(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(slaPlansMutationLoading = true, slaPlansMutationError = null) } - runCatching { repository.slaPlanDelete(id) } - .onSuccess { mutableState.update { it.copy(slaPlansMutationLoading = false) }; loadSlaPlans() } - .onFailure { error -> mutableState.update { it.copy(slaPlansMutationLoading = false, slaPlansMutationError = error.userMessage()) } } - } - } - - fun clearSlaPlansError() = mutableState.update { it.copy(slaPlansMutationError = null) } - - fun clearPrioritiesError() = mutableState.update { it.copy(prioritiesAdminMutationError = null) } - - // ─── Admin: Hilfe-Themen ────────────────────────────────────────────────── - - fun loadHelpTopics() { - viewModelScope.launch { - mutableState.update { it.copy(helpTopicsLoading = true, helpTopicsError = null) } - runCatching { repository.helpTopics() } - .onSuccess { data -> mutableState.update { it.copy(helpTopics = data, helpTopicsLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(helpTopicsLoading = false, helpTopicsError = error.userMessage()) } } - } - } - - fun saveHelpTopic(id: Int?, input: HelpTopicInput, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(helpTopicsMutationLoading = true, helpTopicsMutationError = null) } - runCatching { repository.helpTopicSave(id, input) } - .onSuccess { mutableState.update { it.copy(helpTopicsMutationLoading = false) }; onSuccess(); loadHelpTopics() } - .onFailure { error -> mutableState.update { it.copy(helpTopicsMutationLoading = false, helpTopicsMutationError = error.userMessage()) } } - } - } - - fun deleteHelpTopic(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(helpTopicsMutationLoading = true, helpTopicsMutationError = null) } - runCatching { repository.helpTopicDelete(id) } - .onSuccess { mutableState.update { it.copy(helpTopicsMutationLoading = false) }; loadHelpTopics() } - .onFailure { error -> mutableState.update { it.copy(helpTopicsMutationLoading = false, helpTopicsMutationError = error.userMessage()) } } - } - } - - fun clearHelpTopicsError() = mutableState.update { it.copy(helpTopicsMutationError = null) } - - // ─── Admin: Organisationen ──────────────────────────────────────────────── - - fun loadOrganizations() { - viewModelScope.launch { - mutableState.update { it.copy(organizationsLoading = true, organizationsError = null) } - runCatching { repository.organizations() } - .onSuccess { items -> mutableState.update { it.copy(organizations = items, organizationsLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(organizationsLoading = false, organizationsError = error.userMessage()) } } - } - } - - fun saveOrganization(id: Int?, name: String, notes: String?, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(organizationsMutationLoading = true, organizationsMutationError = null) } - runCatching { repository.organizationSave(id, name, notes) } - .onSuccess { mutableState.update { it.copy(organizationsMutationLoading = false) }; onSuccess(); loadOrganizations() } - .onFailure { error -> mutableState.update { it.copy(organizationsMutationLoading = false, organizationsMutationError = error.userMessage()) } } - } - } - - fun deleteOrganization(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(organizationsMutationLoading = true, organizationsMutationError = null) } - runCatching { repository.organizationDelete(id) } - .onSuccess { mutableState.update { it.copy(organizationsMutationLoading = false) }; loadOrganizations() } - .onFailure { error -> mutableState.update { it.copy(organizationsMutationLoading = false, organizationsMutationError = error.userMessage()) } } - } - } - - fun clearOrganizationsError() = mutableState.update { it.copy(organizationsMutationError = null) } - - // ─── Admin: Teams ───────────────────────────────────────────────────────── - - fun loadTeams() { - viewModelScope.launch { - mutableState.update { it.copy(teamsLoading = true, teamsError = null) } - runCatching { repository.teams() } - .onSuccess { data -> mutableState.update { it.copy(teams = data, teamsLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(teamsLoading = false, teamsError = error.userMessage()) } } - } - } - - fun saveTeam(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(teamsMutationLoading = true, teamsMutationError = null) } - runCatching { repository.teamSave(id, name, leadId, notes, memberIds) } - .onSuccess { mutableState.update { it.copy(teamsMutationLoading = false) }; onSuccess(); loadTeams() } - .onFailure { error -> mutableState.update { it.copy(teamsMutationLoading = false, teamsMutationError = error.userMessage()) } } - } - } - - fun toggleTeam(id: Int) { - viewModelScope.launch { - runCatching { repository.teamToggle(id) } - .onSuccess { loadTeams() } - .onFailure { error -> mutableState.update { it.copy(teamsMutationError = error.userMessage()) } } - } - } - - fun deleteTeam(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(teamsMutationLoading = true, teamsMutationError = null) } - runCatching { repository.teamDelete(id) } - .onSuccess { mutableState.update { it.copy(teamsMutationLoading = false) }; loadTeams() } - .onFailure { error -> mutableState.update { it.copy(teamsMutationLoading = false, teamsMutationError = error.userMessage()) } } - } - } - - fun clearTeamsError() = mutableState.update { it.copy(teamsMutationError = null) } - - // ─── Admin: Rollen ──────────────────────────────────────────────────────── - - fun loadRoles() { - viewModelScope.launch { - mutableState.update { it.copy(rolesLoading = true, rolesError = null) } - runCatching { repository.roles() } - .onSuccess { data -> mutableState.update { it.copy(roles = data, rolesLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(rolesLoading = false, rolesError = error.userMessage()) } } - } - } - - fun saveRole(id: Int?, name: String, permissions: Map, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(rolesMutationLoading = true, rolesMutationError = null) } - runCatching { repository.roleSave(id, name, permissions) } - .onSuccess { mutableState.update { it.copy(rolesMutationLoading = false) }; onSuccess(); loadRoles() } - .onFailure { error -> mutableState.update { it.copy(rolesMutationLoading = false, rolesMutationError = error.userMessage()) } } - } - } - - fun deleteRole(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(rolesMutationLoading = true, rolesMutationError = null) } - runCatching { repository.roleDelete(id) } - .onSuccess { mutableState.update { it.copy(rolesMutationLoading = false) }; loadRoles() } - .onFailure { error -> mutableState.update { it.copy(rolesMutationLoading = false, rolesMutationError = error.userMessage()) } } - } - } - - fun clearRolesError() = mutableState.update { it.copy(rolesMutationError = null) } - - // ─── Admin: Mitarbeiter ─────────────────────────────────────────────────── - - fun loadStaffAdmin() { - viewModelScope.launch { - mutableState.update { it.copy(staffAdminLoading = true, staffAdminError = null) } - runCatching { repository.staffAdminList() } - .onSuccess { data -> mutableState.update { it.copy(staffAdmin = data, staffAdminLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(staffAdminLoading = false, staffAdminError = error.userMessage()) } } - } - } - - fun saveStaffAdmin(id: Int?, input: StaffAdminInput, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(staffAdminMutationLoading = true, staffAdminMutationError = null) } - runCatching { repository.staffAdminSave(id, input) } - .onSuccess { mutableState.update { it.copy(staffAdminMutationLoading = false) }; onSuccess(); loadStaffAdmin() } - .onFailure { error -> mutableState.update { it.copy(staffAdminMutationLoading = false, staffAdminMutationError = error.userMessage()) } } - } - } - - fun deleteStaffAdmin(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(staffAdminMutationLoading = true, staffAdminMutationError = null) } - runCatching { repository.staffAdminDelete(id) } - .onSuccess { mutableState.update { it.copy(staffAdminMutationLoading = false) }; loadStaffAdmin() } - .onFailure { error -> mutableState.update { it.copy(staffAdminMutationLoading = false, staffAdminMutationError = error.userMessage()) } } - } - } - - fun clearStaffAdminError() = mutableState.update { it.copy(staffAdminMutationError = null) } - - // ─── Admin: Geschäftszeiten ─────────────────────────────────────────────── - - fun loadBusinessHours() { - viewModelScope.launch { - mutableState.update { it.copy(businessHoursLoading = true, businessHoursError = null) } - runCatching { repository.businessHours() } - .onSuccess { data -> mutableState.update { it.copy(businessHours = data, businessHoursLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(businessHoursLoading = false, businessHoursError = error.userMessage()) } } - } - } - - fun saveBusinessHours(enabled: Boolean, hours: List, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(businessHoursMutationLoading = true, businessHoursMutationError = null) } - runCatching { repository.businessHoursSave(enabled, hours) } - .onSuccess { mutableState.update { it.copy(businessHoursMutationLoading = false) }; onSuccess(); loadBusinessHours() } - .onFailure { error -> mutableState.update { it.copy(businessHoursMutationLoading = false, businessHoursMutationError = error.userMessage()) } } - } - } - - fun addBusinessHoliday(date: String, name: String) { - viewModelScope.launch { - mutableState.update { it.copy(businessHoursMutationLoading = true, businessHoursMutationError = null) } - runCatching { repository.businessHolidayAdd(date, name) } - .onSuccess { mutableState.update { it.copy(businessHoursMutationLoading = false) }; loadBusinessHours() } - .onFailure { error -> mutableState.update { it.copy(businessHoursMutationLoading = false, businessHoursMutationError = error.userMessage()) } } - } - } - - fun deleteBusinessHoliday(id: Int) { - viewModelScope.launch { - runCatching { repository.businessHolidayDelete(id) } - .onSuccess { loadBusinessHours() } - .onFailure { error -> mutableState.update { it.copy(businessHoursMutationError = error.userMessage()) } } - } - } - - fun clearBusinessHoursError() = mutableState.update { it.copy(businessHoursMutationError = null) } - - // ─── Admin: E-Mail-Adressen ─────────────────────────────────────────────── - - fun loadEmailAddresses() { - viewModelScope.launch { - mutableState.update { it.copy(emailAddressesLoading = true, emailAddressesError = null) } - runCatching { repository.emailAddresses() } - .onSuccess { data -> mutableState.update { it.copy(emailAddresses = data, emailAddressesLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(emailAddressesLoading = false, emailAddressesError = error.userMessage()) } } - } - } - - fun saveEmailAddress(id: Int?, input: EmailAddressInput, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null, emailAddressesNotice = null) } - runCatching { repository.emailAddressSave(id, input) } - .onSuccess { mutableState.update { it.copy(emailAddressesMutationLoading = false) }; onSuccess(); loadEmailAddresses() } - .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } - } - } - - fun deleteEmailAddress(id: Int) { - viewModelScope.launch { - mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null) } - runCatching { repository.emailAddressDelete(id) } - .onSuccess { mutableState.update { it.copy(emailAddressesMutationLoading = false) }; loadEmailAddresses() } - .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } - } - } - - /** Test-Mail senden ("sendTest") bzw. IMAP-Verbindung prüfen ("testImap"); Ergebnis erscheint als Hinweis im Formular. */ - fun testEmailAddress(id: Int, action: String) { - viewModelScope.launch { - mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null, emailAddressesNotice = null) } - runCatching { repository.emailAddressAction(action, id) } - .onSuccess { msg -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesNotice = msg.ifBlank { "OK" }) } } - .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } - } - } - - fun clearEmailAddressesError() = mutableState.update { it.copy(emailAddressesMutationError = null, emailAddressesNotice = null) } - - // ─── Admin: E-Mail-Vorlagen ─────────────────────────────────────────────── - - fun loadEmailTemplates() { - viewModelScope.launch { - mutableState.update { it.copy(emailTemplatesLoading = true, emailTemplatesError = null) } - runCatching { repository.emailTemplates() } - .onSuccess { items -> mutableState.update { it.copy(emailTemplates = items, emailTemplatesLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(emailTemplatesLoading = false, emailTemplatesError = error.userMessage()) } } - } - } - - fun saveEmailTemplate(key: String, subject: String, bodyText: String, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(emailTemplatesMutationLoading = true, emailTemplatesMutationError = null) } - runCatching { repository.emailTemplateSave(key, subject, bodyText) } - .onSuccess { mutableState.update { it.copy(emailTemplatesMutationLoading = false) }; onSuccess(); loadEmailTemplates() } - .onFailure { error -> mutableState.update { it.copy(emailTemplatesMutationLoading = false, emailTemplatesMutationError = error.userMessage()) } } - } - } - - fun resetEmailTemplate(key: String) { - viewModelScope.launch { - mutableState.update { it.copy(emailTemplatesMutationLoading = true, emailTemplatesMutationError = null) } - runCatching { repository.emailTemplateReset(key) } - .onSuccess { mutableState.update { it.copy(emailTemplatesMutationLoading = false) }; loadEmailTemplates() } - .onFailure { error -> mutableState.update { it.copy(emailTemplatesMutationLoading = false, emailTemplatesMutationError = error.userMessage()) } } - } - } - - fun clearEmailTemplatesError() = mutableState.update { it.copy(emailTemplatesMutationError = null) } - - // ─── Admin: Admin-Protokoll ─────────────────────────────────────────────── - - fun loadAuditLog(reset: Boolean = true) { - viewModelScope.launch { - val page = if (reset) 1 else state.value.auditLogPage + 1 - mutableState.update { it.copy(auditLogLoading = true, auditLogError = null) } - runCatching { repository.auditLog(page) } - .onSuccess { result -> - mutableState.update { - it.copy( - auditLog = if (reset) result.items else it.auditLog + result.items, - auditLogPage = result.page, - auditLogHasMore = result.hasMore, - auditLogLoading = false, - ) - } - } - .onFailure { error -> mutableState.update { it.copy(auditLogLoading = false, auditLogError = error.userMessage()) } } - } - } - - // ─── Aufgaben ───────────────────────────────────────────────────────────── - - fun loadTasks(filter: String = state.value.tasksFilter) { - viewModelScope.launch { - mutableState.update { it.copy(tasksLoading = true, tasksError = null, tasksFilter = filter) } - runCatching { repository.tasks(filter) } - .onSuccess { data -> mutableState.update { it.copy(tasks = data, tasksLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(tasksLoading = false, tasksError = error.userMessage()) } } - } - } - - fun createTask(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?, onSuccess: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(tasksMutationLoading = true, tasksMutationError = null) } - runCatching { repository.taskCreate(title, description, assignedToId, dueAt, ticketNumber) } - .onSuccess { mutableState.update { it.copy(tasksMutationLoading = false) }; onSuccess(); loadTasks() } - .onFailure { error -> mutableState.update { it.copy(tasksMutationLoading = false, tasksMutationError = error.userMessage()) } } - } - } - - fun updateTaskStatus(id: Int, status: String) { - viewModelScope.launch { - runCatching { repository.taskUpdateStatus(id, status) } - .onSuccess { loadTasks() } - .onFailure { error -> mutableState.update { it.copy(tasksMutationError = error.userMessage()) } } - } - } - - fun deleteTask(id: Int) { - viewModelScope.launch { - runCatching { repository.taskDelete(id) } - .onSuccess { loadTasks() } - .onFailure { error -> mutableState.update { it.copy(tasksMutationError = error.userMessage()) } } - } - } - - fun clearTasksError() = mutableState.update { it.copy(tasksMutationError = null) } - - // ─── Interner Chat ──────────────────────────────────────────────────────── - - fun loadChatContacts() { - viewModelScope.launch { - mutableState.update { it.copy(chatContactsLoading = true, chatContactsError = null) } - runCatching { repository.chatContacts() } - .onSuccess { items -> mutableState.update { it.copy(chatContacts = items, chatContactsLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(chatContactsLoading = false, chatContactsError = error.userMessage()) } } - } - } - - fun openChatConversation(withId: Int) { - chatPollingJob?.cancel() - mutableState.update { it.copy(chatConversation = null, chatConversationLoading = true, chatConversationError = null) } - chatPollingJob = viewModelScope.launch { - while (isActive) { - runCatching { repository.chatConversation(withId) } - .onSuccess { conversation -> - mutableState.update { it.copy(chatConversation = conversation, chatConversationLoading = false) } - } - .onFailure { error -> - mutableState.update { it.copy(chatConversationLoading = false, chatConversationError = error.userMessage()) } - } - delay(8_000) - } - } - } - - fun closeChatConversation() { - chatPollingJob?.cancel() - mutableState.update { it.copy(chatConversation = null) } - loadChatContacts() - } - - fun sendChatMessage(toId: Int, message: String) { - viewModelScope.launch { - mutableState.update { it.copy(chatSendLoading = true, chatSendError = null) } - runCatching { repository.chatSend(toId, message) } - .onSuccess { - mutableState.update { it.copy(chatSendLoading = false) } - runCatching { repository.chatConversation(toId) } - .onSuccess { conversation -> mutableState.update { it.copy(chatConversation = conversation) } } - } - .onFailure { error -> mutableState.update { it.copy(chatSendLoading = false, chatSendError = error.userMessage()) } } - } - } - - fun clearChatSendError() = mutableState.update { it.copy(chatSendError = null) } - - // ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────── - // Eigener Tab, getrennt vom internen Chat oben – Chats mit Kunden über das - // Live-Chat-Widget im Kundenportal (siehe staff/live_chat.php im Web-Backend, - // gleiche Sperr-Logik über api/staff/v1/live_chat.php). - - /** Fügt neu eingetroffene Nachrichten hinzu, ohne bereits angezeigte zu duplizieren - * (der Server liefert bei jedem Poll immer die volle Historie, wie im Web-Widget). */ - private fun mergeLiveChatDetail(state: DeskHubState, detail: LiveChatDetail): LiveChatDetail { - val prevMessages = state.liveChatDetail?.messages.orEmpty() - val existingIds = prevMessages.map { it.id }.toSet() - val newOnes = detail.messages.filter { it.id !in existingIds } - return detail.copy(messages = prevMessages + newOnes) - } - - fun loadLiveChats() { - liveChatListPollingJob?.cancel() - mutableState.update { it.copy(liveChatsLoading = true, liveChatsError = null) } - liveChatListPollingJob = viewModelScope.launch { - while (isActive) { - runCatching { repository.liveChatList() } - .onSuccess { items -> mutableState.update { it.copy(liveChats = items, liveChatsLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(liveChatsLoading = false, liveChatsError = error.userMessage()) } } - delay(8_000) - } - } - } - - fun stopLiveChatListPolling() { - liveChatListPollingJob?.cancel() - } - - fun openLiveChat(number: String) { - liveChatDetailPollingJob?.cancel() - mutableState.update { it.copy(liveChatDetail = null, liveChatDetailLoading = true, liveChatDetailError = null) } - liveChatDetailPollingJob = viewModelScope.launch { - while (isActive) { - runCatching { repository.liveChatMessages(number) } - .onSuccess { detail -> - mutableState.update { it.copy(liveChatDetail = mergeLiveChatDetail(it, detail), liveChatDetailLoading = false) } - } - .onFailure { error -> mutableState.update { it.copy(liveChatDetailLoading = false, liveChatDetailError = error.userMessage()) } } - delay(8_000) - } - } - } - - fun closeLiveChat() { - liveChatDetailPollingJob?.cancel() - mutableState.update { it.copy(liveChatDetail = null) } - } - - fun sendLiveChatMessage(number: String, message: String) { - viewModelScope.launch { - mutableState.update { it.copy(liveChatSendLoading = true, liveChatSendError = null) } - runCatching { repository.liveChatSend(number, message) } - .onSuccess { - mutableState.update { it.copy(liveChatSendLoading = false) } - // Sofort einmal nachladen statt bis zu 8s auf den nächsten Poll zu warten. - runCatching { repository.liveChatMessages(number) } - .onSuccess { detail -> mutableState.update { it.copy(liveChatDetail = mergeLiveChatDetail(it, detail)) } } - } - .onFailure { error -> mutableState.update { it.copy(liveChatSendLoading = false, liveChatSendError = error.userMessage()) } } - } - } - - fun clearLiveChatSendError() = mutableState.update { it.copy(liveChatSendError = null) } - - /** Signalisiert dem Kunden-Widget "Mitarbeiter tippt gerade" – höchstens alle 3s, - * damit nicht bei jedem Tastenanschlag eine eigene Anfrage rausgeht. */ - fun pingLiveChatTyping(number: String) { - val now = System.currentTimeMillis() - if (now - lastLiveChatTypingPingAt < 3_000) return - lastLiveChatTypingPingAt = now - viewModelScope.launch { runCatching { repository.liveChatTyping(number) } } - } - - fun endLiveChat(number: String, onDone: () -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(liveChatSendLoading = true, liveChatSendError = null) } - runCatching { repository.liveChatEnd(number) } - .onSuccess { - mutableState.update { it.copy(liveChatSendLoading = false) } - onDone() - } - .onFailure { error -> mutableState.update { it.copy(liveChatSendLoading = false, liveChatSendError = error.userMessage()) } } - } - } - - // ─── Verfügbarkeit (Status) ──────────────────────────────────────────────── - - fun loadAvailability() { - viewModelScope.launch { - mutableState.update { it.copy(availabilityLoading = true, availabilityError = null) } - runCatching { repository.availability() } - .onSuccess { data -> mutableState.update { it.copy(availability = data, availabilityLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(availabilityLoading = false, availabilityError = error.userMessage()) } } - } - } - - /** Administrator: Status eines anderen Mitarbeiters ändern. */ - fun setMemberAvailability(staffId: Int, status: String, note: String) { - viewModelScope.launch { - mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) } - runCatching { repository.setAvailability(status, note, staffId) } - .onSuccess { - mutableState.update { it.copy(availabilitySaving = false) } - loadAvailability() - } - .onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } } - } - } - - fun setMyAvailability(status: String, note: String) { - viewModelScope.launch { - mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) } - runCatching { repository.setAvailability(status, note) } - .onSuccess { - mutableState.update { it.copy(availabilitySaving = false) } - loadAvailability() - } - .onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } } - } - } - - // ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────── - - fun loadTicketRules() { - viewModelScope.launch { - mutableState.update { it.copy(ticketRulesLoading = true, ticketRulesError = null) } - runCatching { repository.ticketRules() } - .onSuccess { data -> mutableState.update { it.copy(ticketRules = data, ticketRulesLoading = false) } } - .onFailure { error -> mutableState.update { it.copy(ticketRulesLoading = false, ticketRulesError = error.userMessage()) } } - } - } - - fun saveTicketRule( - id: Int?, - name: String, - matchType: String, - isActive: Boolean, - sortOrder: Int, - conditions: List, - actions: List, - onDone: () -> Unit, - ) { - viewModelScope.launch { - mutableState.update { it.copy(ticketRulesMutationLoading = true, ticketRulesMutationError = null) } - runCatching { repository.saveTicketRule(id, name, matchType, isActive, sortOrder, conditions, actions) } - .onSuccess { - mutableState.update { it.copy(ticketRulesMutationLoading = false) } - loadTicketRules() - onDone() - } - .onFailure { error -> mutableState.update { it.copy(ticketRulesMutationLoading = false, ticketRulesMutationError = error.userMessage()) } } - } - } - - fun toggleTicketRule(id: Int) { - viewModelScope.launch { - runCatching { repository.toggleTicketRule(id) }.onSuccess { loadTicketRules() } - } - } - - fun deleteTicketRule(id: Int) { - viewModelScope.launch { - runCatching { repository.deleteTicketRule(id) }.onSuccess { loadTicketRules() } - } - } - - fun clearTicketRulesMutationError() = mutableState.update { it.copy(ticketRulesMutationError = null) } - - fun saveAutoClose(enabled: Boolean, days: Int) { - viewModelScope.launch { - runCatching { repository.saveAutoClose(enabled, days) }.onSuccess { loadTicketRules() } - } - } - - // ─── Bild-Upload (Signaturen) ───────────────────────────────────────────── - - fun uploadSignatureImage(bytes: ByteArray, filename: String, mimeType: String, onSuccess: (String) -> Unit) { - viewModelScope.launch { - mutableState.update { it.copy(imageUploadLoading = true, imageUploadError = null) } - runCatching { repository.uploadImage(bytes, filename, mimeType) } - .onSuccess { url -> - mutableState.update { it.copy(imageUploadLoading = false) } - onSuccess(url) - } - .onFailure { error -> mutableState.update { it.copy(imageUploadLoading = false, imageUploadError = error.userMessage()) } } - } - } - - fun clearImageUploadError() = mutableState.update { it.copy(imageUploadError = null) } - - private fun restoreSession() { - viewModelScope.launch { - val restored = runCatching { repository.restoreSession() } - if (restored.isFailure || restored.getOrNull() == null) { - mutableState.update { - it.copy( - initializing = false, - authError = restored.exceptionOrNull()?.userMessage(), - savedServerUrl = repository.lastServerUrl(), - ) - } - return@launch - } - runCatching { repository.validateSession() } - .onSuccess { staff -> - mutableState.update { it.copy(initializing = false, staff = staff) } - loadWorkspace() - loadChatContacts() - startActivityPolling() - startTicketsPolling() - applyPendingTicket() - } - .onFailure { error -> - if (error is ApiException && error.status == 401) repository.clearSession() - mutableState.update { - it.copy(initializing = false, authError = error.userMessage(), savedServerUrl = repository.lastServerUrl()) - } - } - } - } - - private fun loadWorkspace() { - viewModelScope.launch { - mutableState.update { it.copy(ticketsLoading = true, ticketsError = null) } - val metadataRequest = async { repository.metadata() } - val ticketsRequest = async { repository.tickets(state.value.filters) } - val cannedRequest = async { runCatching { repository.cannedResponses() }.getOrDefault(emptyList()) } - runCatching { Triple(metadataRequest.await(), ticketsRequest.await(), cannedRequest.await()) } - .onSuccess { (metadata, page, canned) -> - mutableState.update { - it.copy( - metadata = metadata, - staff = metadata.staff, - cannedResponses = canned, - tickets = page.items, - ticketsPage = page.page, - ticketsTotal = page.total, - ticketsHasMore = page.hasMore, - ticketsLoading = false, - ) - } - } - .onFailure { error -> - mutableState.update { - it.copy(ticketsLoading = false, ticketsError = error.userMessage()) - } - } - } - } - - private fun loadTickets(reset: Boolean) { - viewModelScope.launch { - val pageNumber = if (reset) 1 else state.value.ticketsPage + 1 - mutableState.update { it.copy(ticketsLoading = true, ticketsError = null) } - runCatching { repository.tickets(state.value.filters, pageNumber) } - .onSuccess { page -> - mutableState.update { - it.copy( - tickets = if (reset) page.items else it.tickets + page.items, - ticketsPage = page.page, - ticketsTotal = page.total, - ticketsHasMore = page.hasMore, - ticketsLoading = false, - ) - } - } - .onFailure { error -> - mutableState.update { - it.copy(ticketsLoading = false, ticketsError = error.userMessage()) - } - } - } - } -} - -class DeskHubViewModelFactory( - private val repository: TicketRepository, -) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - require(modelClass.isAssignableFrom(DeskHubViewModel::class.java)) - return DeskHubViewModel(repository) as T - } -} - -private fun Throwable.userMessage(): String = when (this) { - is ApiException -> message - is IllegalArgumentException -> message ?: "Ungültige Eingabe." - else -> message ?: "Die Aktion ist fehlgeschlagen." -} +package de.deskhub.staff.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import de.deskhub.staff.data.LoginResult +import de.deskhub.staff.data.TicketRepository +import de.deskhub.staff.data.remote.ApiException +import de.deskhub.staff.domain.model.ActivityUpdate +import de.deskhub.staff.domain.model.AuditLogEntry +import de.deskhub.staff.domain.model.BanEntry +import de.deskhub.staff.domain.model.BusinessHour +import de.deskhub.staff.domain.model.BusinessHoursData +import de.deskhub.staff.domain.model.CannedResponse +import de.deskhub.staff.domain.model.ChatContact +import de.deskhub.staff.domain.model.ChatConversation +import de.deskhub.staff.domain.model.Customer +import de.deskhub.staff.domain.model.CustomerProfile +import de.deskhub.staff.domain.model.DashboardStats +import de.deskhub.staff.domain.model.Department +import de.deskhub.staff.domain.model.DepartmentEmailOption +import de.deskhub.staff.domain.model.DepartmentInput +import de.deskhub.staff.domain.model.NamedOption +import de.deskhub.staff.domain.model.EmailAddressData +import de.deskhub.staff.domain.model.EmailAddressInput +import de.deskhub.staff.domain.model.EmailTemplate +import de.deskhub.staff.domain.model.EscalationData +import de.deskhub.staff.domain.model.HelpTopicData +import de.deskhub.staff.domain.model.HelpTopicInput +import de.deskhub.staff.domain.model.AvailabilityData +import de.deskhub.staff.domain.model.KbData +import de.deskhub.staff.domain.model.LiveChatDetail +import de.deskhub.staff.domain.model.LiveChatSummary +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.TicketRuleAction +import de.deskhub.staff.domain.model.TicketRuleCondition +import de.deskhub.staff.domain.model.TicketRulesData +import de.deskhub.staff.domain.model.Organization +import de.deskhub.staff.domain.model.PriorityFull +import de.deskhub.staff.domain.model.RoleData +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StaffAdminData +import de.deskhub.staff.domain.model.StaffAdminInput +import de.deskhub.staff.domain.model.StaffTask +import de.deskhub.staff.domain.model.TaskData +import de.deskhub.staff.domain.model.TeamData +import de.deskhub.staff.domain.model.TicketDetail +import de.deskhub.staff.domain.model.TicketFilters +import de.deskhub.staff.domain.model.TicketSummary +import de.deskhub.staff.domain.model.TimeEntriesReport +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** Zustand der Verwaltung des digitalen Assistenten (Einstellungen, Antworten, Fragen-Log, Ausprobieren). */ +data class AssistantUiState( + val overview: de.deskhub.staff.data.remote.dto.AssistantOverviewDto? = null, + val answers: List? = null, + val log: de.deskhub.staff.data.remote.dto.AssistantLogDto? = null, + val logResult: String = "", + val logPeriod: String = "30", + val logQuery: String = "", + val logPage: Int = 1, + val testResult: de.deskhub.staff.data.remote.dto.AssistantTestDto? = null, + val loading: Boolean = false, + val error: String? = null, + val message: String? = null, +) + +/** Zustand der Statusseite (Dienste und Störungsmeldungen für Kunden). */ +data class StatusUiState( + val data: de.deskhub.staff.data.remote.dto.StatusSummaryDto? = null, + val canManage: Boolean = false, + val loading: Boolean = false, + val error: String? = null, + val message: String? = null, +) + +data class DeskHubState( + val initializing: Boolean = true, + val staff: Staff? = null, + val authLoading: Boolean = false, + val authError: String? = null, + val totpRequired: Boolean = false, + /** Zuletzt verwendete Server-Adresse – bleibt auch nach dem Ausloggen erhalten, um erneutes Eintippen zu ersparen. */ + val savedServerUrl: String? = null, + val metadata: Metadata? = null, + val assistant: AssistantUiState = AssistantUiState(), + /** Ticket-Vorlagen für „Neues Ticket“, Auswertung der Zufriedenheitsabfrage und Statusseite. */ + val templates: List = emptyList(), + val csat: de.deskhub.staff.data.remote.dto.CsatResponse? = null, + val statusPage: StatusUiState = StatusUiState(), + /** Von außen angeforderter Bildschirm (zum Beispiel aus dem Widget): "newTicket" oder "tickets". */ + val requestedRoute: String? = null, + val cannedResponses: List = emptyList(), + val filters: TicketFilters = TicketFilters(), + val tickets: List = emptyList(), + val ticketsPage: Int = 1, + val ticketsTotal: Int = 0, + val ticketsHasMore: Boolean = false, + val ticketsLoading: Boolean = false, + val ticketsError: String? = null, + val selectedTicket: TicketDetail? = null, + val detailLoading: Boolean = false, + val detailError: String? = null, + val mutationLoading: Boolean = false, + val mutationError: String? = null, + val dashboard: DashboardStats? = null, + val dashboardLoading: Boolean = false, + val dashboardError: String? = null, + val customers: List = emptyList(), + val customersQuery: String = "", + val customersLoading: Boolean = false, + val customersError: String? = null, + val selectedCustomer: CustomerProfile? = null, + val customerLoading: Boolean = false, + val customerError: String? = null, + val newTicketPrefillName: String = "", + val newTicketPrefillEmail: String = "", + val newTicketLoading: Boolean = false, + val newTicketError: String? = null, + val activityBadgeCount: Int = 0, + val offline: Boolean = false, + val healthIssues: List = emptyList(), + val appUpdate: de.deskhub.staff.data.remote.dto.AppUpdateDto? = null, + val appUpdateProgress: Int? = null, + val appUpdateFile: java.io.File? = null, + val appUpdateError: String? = null, + val kb: KbData? = null, + val kbLoading: Boolean = false, + val kbError: String? = null, + val kbMutationLoading: Boolean = false, + val kbMutationError: String? = null, + val timeEntries: TimeEntriesReport? = null, + val timeEntriesLoading: Boolean = false, + val timeEntriesError: String? = null, + val timeEntriesStaffId: Int? = null, + val timeEntriesFrom: String = currentMonthStart(), + val timeEntriesTo: String = today(), + val escalation: EscalationData? = null, + val escalationLoading: Boolean = false, + val escalationError: String? = null, + val escalationMutationLoading: Boolean = false, + val escalationMutationError: String? = null, + val banlist: List? = null, + val banlistLoading: Boolean = false, + val banlistError: String? = null, + val banlistMutationLoading: Boolean = false, + val banlistMutationError: String? = null, + val banlistLastBulkResult: Pair? = null, + // Abteilungen + val departments2: List? = null, + val departmentEmailOptions: List = emptyList(), + val departmentStaffOptions: List = emptyList(), + val departments2Loading: Boolean = false, + val departments2Error: String? = null, + val departments2MutationLoading: Boolean = false, + val departments2MutationError: String? = null, + // Prioritäten + val report: de.deskhub.staff.data.remote.dto.ReportResponseDto? = null, + val reportConfig: de.deskhub.staff.data.remote.dto.ReportConfigDto = de.deskhub.staff.data.remote.dto.ReportConfigDto(), + val reportLoading: Boolean = false, + val reportError: String? = null, + val savedViews: List = emptyList(), + val savedViewError: String? = null, + val slaPlans: List? = null, + val slaPlansLoading: Boolean = false, + val slaPlansError: String? = null, + val slaPlansMutationLoading: Boolean = false, + val slaPlansMutationError: String? = null, + val departmentSlaPlans: List = emptyList(), + val prioritiesAdmin: List? = null, + val prioritiesAdminLoading: Boolean = false, + val prioritiesAdminError: String? = null, + val prioritiesAdminMutationLoading: Boolean = false, + val prioritiesAdminMutationError: String? = null, + // Hilfe-Themen + val helpTopics: HelpTopicData? = null, + val helpTopicsLoading: Boolean = false, + val helpTopicsError: String? = null, + val helpTopicsMutationLoading: Boolean = false, + val helpTopicsMutationError: String? = null, + // Organisationen + val organizations: List? = null, + val organizationsLoading: Boolean = false, + val organizationsError: String? = null, + val organizationsMutationLoading: Boolean = false, + val organizationsMutationError: String? = null, + // Teams + val teams: TeamData? = null, + val teamsLoading: Boolean = false, + val teamsError: String? = null, + val teamsMutationLoading: Boolean = false, + val teamsMutationError: String? = null, + // Rollen + val roles: RoleData? = null, + val rolesLoading: Boolean = false, + val rolesError: String? = null, + val rolesMutationLoading: Boolean = false, + val rolesMutationError: String? = null, + // Mitarbeiter + val staffAdmin: StaffAdminData? = null, + val staffAdminLoading: Boolean = false, + val staffAdminError: String? = null, + val staffAdminMutationLoading: Boolean = false, + val staffAdminMutationError: String? = null, + // Geschäftszeiten + val businessHours: BusinessHoursData? = null, + val businessHoursLoading: Boolean = false, + val businessHoursError: String? = null, + val businessHoursMutationLoading: Boolean = false, + val businessHoursMutationError: String? = null, + // E-Mail-Adressen + val emailAddresses: EmailAddressData? = null, + val emailAddressesLoading: Boolean = false, + val emailAddressesError: String? = null, + val emailAddressesMutationLoading: Boolean = false, + val emailAddressesMutationError: String? = null, + val emailAddressesNotice: String? = null, + // E-Mail-Vorlagen + val emailTemplates: List? = null, + val emailTemplatesLoading: Boolean = false, + val emailTemplatesError: String? = null, + val emailTemplatesMutationLoading: Boolean = false, + val emailTemplatesMutationError: String? = null, + // Admin-Protokoll + val auditLog: List = emptyList(), + val auditLogPage: Int = 1, + val auditLogHasMore: Boolean = false, + val auditLogLoading: Boolean = false, + val auditLogError: String? = null, + // Aufgaben + val tasks: TaskData? = null, + val tasksFilter: String = "my", + val tasksLoading: Boolean = false, + val tasksError: String? = null, + val tasksMutationLoading: Boolean = false, + val tasksMutationError: String? = null, + // Interner Chat + val chatContacts: List? = null, + val chatContactsLoading: Boolean = false, + val chatContactsError: String? = null, + val chatConversation: ChatConversation? = null, + val chatConversationLoading: Boolean = false, + val chatConversationError: String? = null, + val chatSendLoading: Boolean = false, + val chatSendError: String? = null, + // Live-Chat (Kunden-Widget, source='chat') + val liveChats: List? = null, + val liveChatsLoading: Boolean = false, + val liveChatsError: String? = null, + val liveChatDetail: LiveChatDetail? = null, + val liveChatDetailLoading: Boolean = false, + val liveChatDetailError: String? = null, + val liveChatSendLoading: Boolean = false, + val liveChatSendError: String? = null, + // Verfügbarkeit (Status) + val availability: AvailabilityData? = null, + val availabilityLoading: Boolean = false, + val availabilityError: String? = null, + val availabilitySaving: Boolean = false, + // Ticket-Regeln (Automatisierung) + val ticketRules: TicketRulesData? = null, + val ticketRulesLoading: Boolean = false, + val ticketRulesError: String? = null, + val ticketRulesMutationLoading: Boolean = false, + val ticketRulesMutationError: String? = null, + // Bild-Upload (Signaturen) + val imageUploadLoading: Boolean = false, + val imageUploadError: String? = null, +) + +private fun currentMonthStart(): String { + val now = java.util.Calendar.getInstance() + now.set(java.util.Calendar.DAY_OF_MONTH, 1) + return java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(now.time) +} + +private fun today(): String = + java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(java.util.Date()) + +class DeskHubViewModel( + private val repository: TicketRepository, +) : ViewModel() { + private val mutableState = MutableStateFlow(DeskHubState()) + val state: StateFlow = mutableState.asStateFlow() + private var activityPollingJob: Job? = null + private var ticketsPollingJob: Job? = null + private var chatPollingJob: Job? = null + private var liveChatListPollingJob: Job? = null + private var liveChatDetailPollingJob: Job? = null + private var lastLiveChatTypingPingAt = 0L + // Ticket, das über eine Benachrichtigung geöffnet werden soll, bevor die Anmeldung fertig ist + private var pendingTicket: String? = null + + init { + restoreSession() + // Verbindungsstatus: bei Ausfall alle 10 s erneut versuchen und nach Rückkehr die Daten auffrischen + viewModelScope.launch { + de.deskhub.staff.data.remote.ConnectionMonitor.offline.collect { off -> + mutableState.update { it.copy(offline = off) } + if (off) { + launch { + while (isActive && de.deskhub.staff.data.remote.ConnectionMonitor.offline.value) { + delay(10_000) + if (state.value.staff != null) runCatching { repository.activitySince("") } + } + if (state.value.staff != null) { + refreshTickets() + loadDashboard() + } + } + } + } + } + } + + /** Sofort erneut versuchen (Knopf im Verbindungs-Banner). */ + fun retryConnection() { + viewModelScope.launch { runCatching { repository.activitySince("") } } + } + + fun login(serverUrl: String, email: String, password: String, totpCode: String) { + viewModelScope.launch { + mutableState.update { it.copy(authLoading = true, authError = null) } + runCatching { + repository.login(serverUrl, email, password, totpCode.ifBlank { null }) + }.onSuccess { result -> + when (result) { + LoginResult.TotpRequired -> mutableState.update { + it.copy(authLoading = false, totpRequired = true) + } + is LoginResult.Success -> { + mutableState.update { + it.copy( + initializing = false, + staff = result.staff, + authLoading = false, + authError = null, + totpRequired = false, + ) + } + loadWorkspace() + loadChatContacts() + startActivityPolling() + startTicketsPolling() + applyPendingTicket() + } + } + }.onFailure { error -> + mutableState.update { + it.copy(authLoading = false, authError = error.userMessage()) + } + } + } + } + + fun cancelTotp() { + mutableState.update { it.copy(totpRequired = false, authError = null) } + } + + fun logout() { + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.logout() } + .onSuccess { + activityPollingJob?.cancel() + ticketsPollingJob?.cancel() + chatPollingJob?.cancel() + mutableState.value = DeskHubState(initializing = false, savedServerUrl = repository.lastServerUrl()) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun clearActivityBadge() { + mutableState.update { it.copy(activityBadgeCount = 0) } + } + + // ─── App-Update: prüfen, laden, Installation anstoßen (Quelle: Plugin-Webseite über den DeskHub-Server) ─── + private var dismissedUpdate: String? = null + + private fun versionParts(v: String) = v.substringBefore('-').substringBefore('+').split('.').map { it.toIntOrNull() ?: 0 } + + private fun isNewerVersion(remote: String, local: String): Boolean { + val a = versionParts(remote); val b = versionParts(local) + for (i in 0 until maxOf(a.size, b.size)) { + val x = a.getOrElse(i) { 0 }; val y = b.getOrElse(i) { 0 } + if (x != y) return x > y + } + return false + } + + fun checkAppUpdate(currentVersion: String) { + if (state.value.appUpdateProgress != null) return + viewModelScope.launch { + runCatching { repository.appUpdate() }.onSuccess { info -> + val newer = info.available && info.version.isNotBlank() && isNewerVersion(info.version, currentVersion) + mutableState.update { it.copy(appUpdate = if (newer && info.version != dismissedUpdate) info else null) } + } + } + } + + fun dismissAppUpdate() { + dismissedUpdate = state.value.appUpdate?.version + mutableState.update { it.copy(appUpdate = null, appUpdateFile = null, appUpdateError = null) } + } + + fun downloadAppUpdate(cacheDir: java.io.File) { + val info = state.value.appUpdate ?: return + if (state.value.appUpdateProgress != null) return + mutableState.update { it.copy(appUpdateProgress = 0, appUpdateError = null, appUpdateFile = null) } + viewModelScope.launch { + val target = java.io.File(cacheDir, "updates/DeskHub-${info.version}.apk") + runCatching { repository.downloadAppUpdate(target, info.sha256) { pct -> mutableState.update { it.copy(appUpdateProgress = pct) } } } + .onSuccess { mutableState.update { it.copy(appUpdateProgress = null, appUpdateFile = target) } } + .onFailure { e -> mutableState.update { it.copy(appUpdateProgress = null, appUpdateError = (e as? de.deskhub.staff.data.remote.ApiException)?.message ?: "Update konnte nicht geladen werden.") } } + } + } + + fun appUpdateFileHandled() = mutableState.update { it.copy(appUpdateFile = null) } + + private fun startActivityPolling() { + activityPollingJob?.cancel() + activityPollingJob = viewModelScope.launch { + var since = "" + while (isActive) { + runCatching { repository.activitySince(since) }.onSuccess { update: ActivityUpdate -> + since = update.serverTime + mutableState.update { it.copy(healthIssues = update.health) } + val newCount = update.newReplies + update.newUnassigned + if (newCount > 0) { + mutableState.update { it.copy(activityBadgeCount = it.activityBadgeCount + newCount) } + } + } + delay(60_000) + } + } + } + + // Läuft dauerhaft im Hintergrund (auch außerhalb des Tickets-Tabs, analog zu + // startActivityPolling()), damit neue/geänderte Tickets von selbst auftauchen, ohne + // dass manuell auf "Aktualisieren" getippt werden muss – Pendant zum Web-Live-Refresh + // in staff/tickets.php. Aktualisiert bewusst nur leise Seite 1 (kein ticketsLoading = + // kein Flackern) und nur, solange der Nutzer nicht gerade auf einer Folgeseite ist + // ("Weitere laden") – sonst würde die Liste unter den Fingern auf Seite 1 zurückspringen. + private fun startTicketsPolling() { + ticketsPollingJob?.cancel() + ticketsPollingJob = viewModelScope.launch { + while (isActive) { + delay(15_000) + if (state.value.staff != null && state.value.ticketsPage <= 1 && !state.value.ticketsLoading) { + runCatching { repository.tickets(state.value.filters, 1) } + .onSuccess { page -> + mutableState.update { + it.copy( + tickets = page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ) + } + } + // Fehler werden bewusst verschluckt: ein einzelner Netzwerk-Hänger im + // Hintergrund soll weder die sichtbare Liste löschen noch eine + // Fehlermeldung aufploppen lassen – beim nächsten Tick wird es erneut versucht. + } + } + } + } + + fun updateFilters(filters: TicketFilters) { + mutableState.update { it.copy(filters = filters) } + } + + fun refreshTickets() { + loadTickets(reset = true) + } + + fun loadMoreTickets() { + if (!state.value.ticketsHasMore || state.value.ticketsLoading) return + loadTickets(reset = false) + } + + /** Tipp auf eine Push-Benachrichtigung: das Ticket öffnen (sobald die Anmeldung steht). */ + fun openTicketFromNotification(number: String) { + if (number.isBlank()) return + if (state.value.staff != null) openTicket(number) else pendingTicket = number + } + + private fun applyPendingTicket() { + pendingTicket?.let { pendingTicket = null; openTicket(it) } + } + + /** Administrator: Sperre eines Tickets aufheben und das Ticket danach neu laden (man übernimmt die Sperre). */ + fun unlockTicket(number: String) { + viewModelScope.launch { + runCatching { repository.unlockTicket(number) } + .onSuccess { openTicket(number) } + .onFailure { error -> mutableState.update { it.copy(mutationError = error.userMessage()) } } + } + } + + fun openTicket(number: String) { + viewModelScope.launch { + mutableState.update { + it.copy(selectedTicket = null, detailLoading = true, detailError = null) + } + runCatching { repository.ticket(number) } + .onSuccess { ticket -> + mutableState.update { + it.copy(selectedTicket = ticket, detailLoading = false) + } + } + .onFailure { error -> + mutableState.update { + it.copy(detailLoading = false, detailError = error.userMessage()) + } + } + } + } + + fun updateTicket(status: String, assignedStaffId: Int?) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + val updateAssignment = assignedStaffId != ticket.summary.assignedStaff?.id + runCatching { + repository.updateTicket( + number = ticket.summary.number, + status = status.takeIf { it != ticket.summary.status }, + assignedStaffId = assignedStaffId, + updateAssignment = updateAssignment, + ) + }.onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + openTicket(ticket.summary.number) + loadTickets(reset = true) + }.onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + // ── Digitaler Assistent (Verwaltung) ───────────────────────────────────── + + private fun updateAssistant(block: (AssistantUiState) -> AssistantUiState) = mutableState.update { it.copy(assistant = block(it.assistant)) } + + private fun assistantRun(success: String? = null, onDone: () -> Unit = {}, block: suspend () -> Unit) { + viewModelScope.launch { + updateAssistant { it.copy(loading = true, error = null) } + runCatching { block() } + .onSuccess { updateAssistant { s -> s.copy(loading = false, message = success ?: s.message) }; onDone() } + .onFailure { e -> updateAssistant { it.copy(loading = false, error = e.userMessage()) } } + } + } + + fun assistantLoadOverview() = assistantRun { val o = repository.assistantOverview(); updateAssistant { it.copy(overview = o) } } + + fun assistantLoadAnswers() = assistantRun { val a = repository.assistantAnswers(); updateAssistant { it.copy(answers = a) } } + + fun assistantLoadLog(result: String? = null, period: String? = null, query: String? = null, page: Int? = null) { + updateAssistant { it.copy(logResult = result ?: it.logResult, logPeriod = period ?: it.logPeriod, logQuery = query ?: it.logQuery, logPage = page ?: 1) } + assistantRun { + val s = state.value.assistant + val l = repository.assistantLog(mapOf("result" to s.logResult, "period" to s.logPeriod, "q" to s.logQuery, "page" to s.logPage.toString())) + updateAssistant { it.copy(log = l) } + } + } + + fun assistantSaveSettings(s: de.deskhub.staff.data.remote.dto.AssistantSettingsDto) = assistantRun(success = "Gespeichert", onDone = { assistantLoadOverview() }) { + repository.assistantAction(mapOf("action" to "save_settings", "enabled" to s.enabled, "mode" to s.mode, "name" to s.name, "greeting" to s.greeting, + "noAnswer" to s.noAnswer, "departmentId" to s.departmentId, "priorityId" to s.priorityId, "logDays" to s.logDays)) + } + + fun assistantSaveAnswer(id: Int, title: String, keywords: String, answer: String, isActive: Boolean, onDone: () -> Unit) = + assistantRun(success = "Antwort gespeichert", onDone = { onDone(); assistantLoadAnswers() }) { + repository.assistantSaveAnswer(mapOf("action" to "save_answer", "id" to id, "title" to title, "keywords" to keywords, "answer" to answer, "isActive" to isActive)) + } + + fun assistantDeleteAnswer(id: Int, onDone: () -> Unit) = assistantRun(success = "Antwort gelöscht", onDone = { onDone(); assistantLoadAnswers() }) { + repository.assistantAction(mapOf("action" to "delete_answer", "id" to id)) + } + + fun assistantSaveSynonyms(text: String) = assistantRun(success = "Gleichbedeutende Wörter gespeichert", onDone = { assistantLoadOverview() }) { + repository.assistantAction(mapOf("action" to "save_synonyms", "text" to text)) + } + + fun assistantClearLog() = assistantRun(success = "Fragen-Log geleert", onDone = { assistantLoadLog(page = 1) }) { + repository.assistantAction(mapOf("action" to "clear_log")) + } + + fun assistantTest(question: String) = assistantRun { val r = repository.assistantTest(question); updateAssistant { it.copy(testResult = r) } } + + fun requestRoute(route: String) = mutableState.update { it.copy(requestedRoute = route) } + + fun routeHandled() = mutableState.update { it.copy(requestedRoute = null) } + + fun assistantClearMessage() = updateAssistant { it.copy(message = null) } + + /** Legt das geöffnete Ticket auf Wiedervorlage (ISO-Zeit) oder hebt sie auf (null). */ + fun snoozeTicket(untilIso: String?) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.setSnooze(ticket.summary.number, untilIso) } + .onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + openTicket(ticket.summary.number) + loadTickets(reset = true) + } + .onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } } + } + } + + /** Beobachten ein- oder ausschalten. */ + fun setFollowing(following: Boolean) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.setFollowing(ticket.summary.number, following) } + .onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + openTicket(ticket.summary.number) + } + .onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } } + } + } + + /** Cc-Empfänger zum geöffneten Ticket hinzufügen. */ + fun addCollaborator(email: String) = collaboratorChange { number -> repository.collaboratorAction(number, "add", email = email.trim()) } + + /** Cc-Empfänger vom geöffneten Ticket entfernen. */ + fun removeCollaborator(id: Int) = collaboratorChange { number -> repository.collaboratorAction(number, "remove", id = id) } + + private fun collaboratorChange(block: suspend (String) -> Unit) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { block(ticket.summary.number) } + .onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + openTicket(ticket.summary.number) + } + .onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } } + } + } + + /** Ticket-Vorlagen laden (für den Bildschirm „Neues Ticket“). Fehler sind nicht schlimm: dann gibt es keine Auswahl. */ + fun loadTemplates() { + viewModelScope.launch { + runCatching { repository.ticketTemplates() }.onSuccess { list -> mutableState.update { it.copy(templates = list) } } + } + } + + /** Auswertung der Zufriedenheitsabfrage laden (für die Übersicht). */ + fun loadCsat(days: Int = 30) { + viewModelScope.launch { + runCatching { repository.csatStats(days) }.onSuccess { r -> mutableState.update { it.copy(csat = r) } } + } + } + + private fun updateStatusPage(block: (StatusUiState) -> StatusUiState) = mutableState.update { it.copy(statusPage = block(it.statusPage)) } + + fun statusLoad() { + viewModelScope.launch { + updateStatusPage { it.copy(loading = true, error = null) } + runCatching { repository.statusPage() } + .onSuccess { r -> updateStatusPage { it.copy(loading = false, data = r.status, canManage = r.canManage) } } + .onFailure { e -> updateStatusPage { it.copy(loading = false, error = e.userMessage()) } } + } + } + + /** Aktion auf der Statusseite (nur Administratoren), zum Beispiel Zustand eines Dienstes ändern oder eine Meldung veröffentlichen. */ + fun statusAct(request: Map, success: String) { + viewModelScope.launch { + updateStatusPage { it.copy(loading = true, error = null) } + runCatching { repository.statusAction(request) } + .onSuccess { r -> updateStatusPage { it.copy(loading = false, data = r.status, canManage = r.canManage, message = success) } } + .onFailure { e -> updateStatusPage { it.copy(loading = false, error = e.userMessage()) } } + } + } + + fun statusClearMessage() = updateStatusPage { it.copy(message = null, error = null) } + + /** Markiert das aktuell geöffnete Ticket als Spam, oder macht das (restore = true) rückgängig. */ + fun setTicketSpam(restore: Boolean) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.setTicketSpam(ticket.summary.number, restore) } + .onSuccess { result -> + mutableState.update { it.copy(mutationLoading = false) } + if (result.failed.isNotEmpty()) { + val verb = if (restore) "zurückgeholt" else "als Spam markiert" + mutableState.update { + it.copy(mutationError = "Ticket wurde $verb, aber nicht alle Original-Mails konnten verschoben werden: " + result.failed.joinToString(" | ")) + } + } + openTicket(ticket.summary.number) + loadTickets(reset = true) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun addMessage(type: String, message: String, setStatus: String?, signature: String?, onSuccess: () -> Unit) { + val number = state.value.selectedTicket?.summary?.number ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.addMessage(number, type, message, setStatus, signature) } + .onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + onSuccess() + openTicket(number) + loadTickets(reset = true) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun clearMutationError() { + mutableState.update { it.copy(mutationError = null) } + } + + fun searchCustomers(query: String) { + mutableState.update { it.copy(customersQuery = query) } + viewModelScope.launch { + mutableState.update { it.copy(customersLoading = true, customersError = null) } + runCatching { repository.customers(query) } + .onSuccess { list -> mutableState.update { it.copy(customers = list, customersLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(customersLoading = false, customersError = error.userMessage()) } + } + } + } + + fun openCustomer(customer: Customer) { + viewModelScope.launch { + mutableState.update { it.copy(selectedCustomer = null, customerLoading = true, customerError = null) } + runCatching { + if (customer.id != null) repository.customerProfile(customer.id, null) + else repository.customerProfile(null, customer.email) + } + .onSuccess { profile -> mutableState.update { it.copy(selectedCustomer = profile, customerLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(customerLoading = false, customerError = error.userMessage()) } + } + } + } + + fun startNewTicket(name: String = "", email: String = "") { + mutableState.update { + it.copy(newTicketPrefillName = name, newTicketPrefillEmail = email, newTicketError = null) + } + } + + fun createTicket( + requesterName: String, + requesterEmail: String, + subject: String, + message: String, + departmentId: Int, + priorityId: Int, + assignedStaffId: Int?, + notifyCustomer: Boolean, + onSuccess: (String) -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(newTicketLoading = true, newTicketError = null) } + runCatching { + repository.createTicket( + requesterName, requesterEmail, subject, message, + departmentId, priorityId, assignedStaffId, notifyCustomer, + ) + } + .onSuccess { ticket -> + mutableState.update { it.copy(newTicketLoading = false) } + onSuccess(ticket.number) + refreshTickets() + } + .onFailure { error -> + mutableState.update { it.copy(newTicketLoading = false, newTicketError = error.userMessage()) } + } + } + } + + fun loadDashboard() { + viewModelScope.launch { + mutableState.update { it.copy(dashboardLoading = true, dashboardError = null) } + runCatching { repository.dashboard() } + .onSuccess { stats -> + mutableState.update { it.copy(dashboard = stats, dashboardLoading = false) } + } + .onFailure { error -> + mutableState.update { + it.copy(dashboardLoading = false, dashboardError = error.userMessage()) + } + } + } + } + + // ─── Admin: Wissensdatenbank ────────────────────────────────────────────── + + fun loadKb() { + viewModelScope.launch { + mutableState.update { it.copy(kbLoading = true, kbError = null) } + runCatching { repository.kbData() } + .onSuccess { data -> mutableState.update { it.copy(kb = data, kbLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(kbLoading = false, kbError = error.userMessage()) } } + } + } + + fun saveKbArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbSaveArticle(id, title, categoryId, body, isPublished) } + .onSuccess { + mutableState.update { it.copy(kbMutationLoading = false) } + onSuccess() + loadKb() + } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun deleteKbArticle(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbDeleteArticle(id) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun createKbCategory(name: String) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbCreateCategory(name) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun deleteKbCategory(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbDeleteCategory(id) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun clearKbMutationError() { + mutableState.update { it.copy(kbMutationError = null) } + } + + // ─── Admin: Zeiterfassung ───────────────────────────────────────────────── + + fun loadTimeEntries(staffId: Int? = state.value.timeEntriesStaffId, from: String = state.value.timeEntriesFrom, to: String = state.value.timeEntriesTo) { + viewModelScope.launch { + mutableState.update { + it.copy( + timeEntriesLoading = true, + timeEntriesError = null, + timeEntriesStaffId = staffId, + timeEntriesFrom = from, + timeEntriesTo = to, + ) + } + runCatching { repository.timeEntries(staffId, from, to) } + .onSuccess { report -> mutableState.update { it.copy(timeEntries = report, timeEntriesLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(timeEntriesLoading = false, timeEntriesError = error.userMessage()) } + } + } + } + + // ─── Admin: Eskalationsregeln ───────────────────────────────────────────── + + fun loadEscalationRules() { + viewModelScope.launch { + mutableState.update { it.copy(escalationLoading = true, escalationError = null) } + runCatching { repository.escalationData() } + .onSuccess { data -> mutableState.update { it.copy(escalation = data, escalationLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(escalationLoading = false, escalationError = error.userMessage()) } } + } + } + + fun saveEscalationRule( + id: Int?, + name: String, + isActive: Boolean, + triggerType: String, + triggerHours: Int, + conditionStatus: String?, + conditionPriorityId: Int?, + conditionDeptId: Int?, + actionSetPriorityId: Int?, + actionAssignStaffId: Int?, + actionSetStatus: String?, + actionNotifyAdmin: Boolean, + actionNotifyStaff: Boolean, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(escalationMutationLoading = true, escalationMutationError = null) } + runCatching { + repository.escalationSave( + id = id, name = name, isActive = isActive, triggerType = triggerType, + triggerHours = triggerHours, conditionStatus = conditionStatus, + conditionPriorityId = conditionPriorityId, conditionDeptId = conditionDeptId, + actionSetPriorityId = actionSetPriorityId, actionAssignStaffId = actionAssignStaffId, + actionSetStatus = actionSetStatus, actionNotifyAdmin = actionNotifyAdmin, + actionNotifyStaff = actionNotifyStaff, sortOrder = 0, + ) + }.onSuccess { + mutableState.update { it.copy(escalationMutationLoading = false) } + onSuccess() + loadEscalationRules() + }.onFailure { error -> + mutableState.update { it.copy(escalationMutationLoading = false, escalationMutationError = error.userMessage()) } + } + } + } + + fun deleteEscalationRule(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(escalationMutationLoading = true, escalationMutationError = null) } + runCatching { repository.escalationDelete(id) } + .onSuccess { mutableState.update { it.copy(escalationMutationLoading = false) }; loadEscalationRules() } + .onFailure { error -> + mutableState.update { it.copy(escalationMutationLoading = false, escalationMutationError = error.userMessage()) } + } + } + } + + fun toggleEscalationRule(id: Int) { + viewModelScope.launch { + runCatching { repository.escalationToggle(id) } + .onSuccess { loadEscalationRules() } + .onFailure { error -> mutableState.update { it.copy(escalationMutationError = error.userMessage()) } } + } + } + + fun clearEscalationMutationError() { + mutableState.update { it.copy(escalationMutationError = null) } + } + + // ─── Admin: Sperrliste ──────────────────────────────────────────────────── + + fun loadBanlist() { + viewModelScope.launch { + mutableState.update { it.copy(banlistLoading = true, banlistError = null) } + runCatching { repository.banlist() } + .onSuccess { items -> mutableState.update { it.copy(banlist = items, banlistLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(banlistLoading = false, banlistError = error.userMessage()) } } + } + } + + fun createBanEntry(matchType: String, value: String, reason: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banCreate(matchType, value, reason) } + .onSuccess { + mutableState.update { it.copy(banlistMutationLoading = false) } + onSuccess() + loadBanlist() + } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun bulkAddBanEntries(values: String, reason: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banBulkAdd(values, reason) } + .onSuccess { result -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistLastBulkResult = result) } + onSuccess() + loadBanlist() + } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun deleteBanEntry(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banDelete(id) } + .onSuccess { mutableState.update { it.copy(banlistMutationLoading = false) }; loadBanlist() } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun clearBanlistMutationError() { + mutableState.update { it.copy(banlistMutationError = null) } + } + + fun clearBanlistBulkResult() { + mutableState.update { it.copy(banlistLastBulkResult = null) } + } + + // ─── Admin: Abteilungen ─────────────────────────────────────────────────── + + fun loadDepartments() { + viewModelScope.launch { + mutableState.update { it.copy(departments2Loading = true, departments2Error = null) } + runCatching { repository.departmentData() } + .onSuccess { d -> mutableState.update { it.copy(departments2 = d.departments, departmentEmailOptions = d.emailOptions, departmentStaffOptions = d.staffOptions, departmentSlaPlans = d.slaPlans, departments2Loading = false) } } + .onFailure { error -> mutableState.update { it.copy(departments2Loading = false, departments2Error = error.userMessage()) } } + } + } + + fun saveDepartment(id: Int?, input: DepartmentInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(departments2MutationLoading = true, departments2MutationError = null) } + runCatching { repository.departmentSave(id, input) } + .onSuccess { mutableState.update { it.copy(departments2MutationLoading = false) }; onSuccess(); loadDepartments() } + .onFailure { error -> mutableState.update { it.copy(departments2MutationLoading = false, departments2MutationError = error.userMessage()) } } + } + } + + fun deleteDepartment(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(departments2MutationLoading = true, departments2MutationError = null) } + runCatching { repository.departmentDelete(id) } + .onSuccess { mutableState.update { it.copy(departments2MutationLoading = false) }; loadDepartments() } + .onFailure { error -> mutableState.update { it.copy(departments2MutationLoading = false, departments2MutationError = error.userMessage()) } } + } + } + + fun clearDepartmentsError() = mutableState.update { it.copy(departments2MutationError = null) } + + // ─── Admin: Prioritäten ─────────────────────────────────────────────────── + + fun loadPrioritiesAdmin() { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminLoading = true, prioritiesAdminError = null) } + runCatching { repository.prioritiesAdmin() } + .onSuccess { items -> mutableState.update { it.copy(prioritiesAdmin = items, prioritiesAdminLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminLoading = false, prioritiesAdminError = error.userMessage()) } } + } + } + + fun savePriority(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminMutationLoading = true, prioritiesAdminMutationError = null) } + runCatching { repository.prioritySave(id, name, color, responseHours, resolutionHours, sortOrder) } + .onSuccess { mutableState.update { it.copy(prioritiesAdminMutationLoading = false) }; onSuccess(); loadPrioritiesAdmin() } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminMutationLoading = false, prioritiesAdminMutationError = error.userMessage()) } } + } + } + + fun deletePriority(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminMutationLoading = true, prioritiesAdminMutationError = null) } + runCatching { repository.priorityDelete(id) } + .onSuccess { mutableState.update { it.copy(prioritiesAdminMutationLoading = false) }; loadPrioritiesAdmin() } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminMutationLoading = false, prioritiesAdminMutationError = error.userMessage()) } } + } + } + + /** Bericht ausführen: mit der übergebenen Konfiguration oder als gespeicherter Bericht (savedId). */ + fun runReport(config: de.deskhub.staff.data.remote.dto.ReportConfigDto? = null, savedId: Int? = null) { + viewModelScope.launch { + mutableState.update { it.copy(reportLoading = true, reportError = null) } + runCatching { repository.report(config, savedId) } + .onSuccess { r -> mutableState.update { it.copy(report = r, reportConfig = r.config, reportLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(reportLoading = false, reportError = error.userMessage()) } } + } + } + + fun saveReport(name: String, shared: Boolean) { + viewModelScope.launch { + runCatching { repository.reportSave(name, shared, state.value.reportConfig) } + .onSuccess { runReport(state.value.reportConfig) } + .onFailure { error -> mutableState.update { it.copy(reportError = error.userMessage()) } } + } + } + + fun deleteReport(id: Int) { + viewModelScope.launch { + runCatching { repository.reportDelete(id) }.onSuccess { runReport(state.value.reportConfig) } + } + } + + fun loadSavedViews() { + viewModelScope.launch { + runCatching { repository.savedViews() } + .onSuccess { views -> mutableState.update { it.copy(savedViews = views) } } + } + } + + fun applySavedView(view: de.deskhub.staff.domain.model.SavedView) { + mutableState.update { it.copy(filters = view.filters) } + refreshTickets() + } + + fun saveCurrentView(name: String, shared: Boolean) { + viewModelScope.launch { + mutableState.update { it.copy(savedViewError = null) } + runCatching { repository.savedViewSave(name, shared, state.value.filters) } + .onSuccess { loadSavedViews() } + .onFailure { error -> mutableState.update { it.copy(savedViewError = error.userMessage()) } } + } + } + + fun deleteSavedView(id: Int) { + viewModelScope.launch { + runCatching { repository.savedViewDelete(id) }.onSuccess { loadSavedViews() } + } + } + + fun clearSavedViewError() = mutableState.update { it.copy(savedViewError = null) } + + fun loadSlaPlans() { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansLoading = true, slaPlansError = null) } + runCatching { repository.slaPlans() } + .onSuccess { items -> mutableState.update { it.copy(slaPlans = items, slaPlansLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(slaPlansLoading = false, slaPlansError = error.userMessage()) } } + } + } + + fun saveSlaPlan(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansMutationLoading = true, slaPlansMutationError = null) } + runCatching { repository.slaPlanSave(id, name, responseHours, resolutionHours, isActive, notes) } + .onSuccess { mutableState.update { it.copy(slaPlansMutationLoading = false) }; onSuccess(); loadSlaPlans() } + .onFailure { error -> mutableState.update { it.copy(slaPlansMutationLoading = false, slaPlansMutationError = error.userMessage()) } } + } + } + + fun deleteSlaPlan(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansMutationLoading = true, slaPlansMutationError = null) } + runCatching { repository.slaPlanDelete(id) } + .onSuccess { mutableState.update { it.copy(slaPlansMutationLoading = false) }; loadSlaPlans() } + .onFailure { error -> mutableState.update { it.copy(slaPlansMutationLoading = false, slaPlansMutationError = error.userMessage()) } } + } + } + + fun clearSlaPlansError() = mutableState.update { it.copy(slaPlansMutationError = null) } + + fun clearPrioritiesError() = mutableState.update { it.copy(prioritiesAdminMutationError = null) } + + // ─── Admin: Hilfe-Themen ────────────────────────────────────────────────── + + fun loadHelpTopics() { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsLoading = true, helpTopicsError = null) } + runCatching { repository.helpTopics() } + .onSuccess { data -> mutableState.update { it.copy(helpTopics = data, helpTopicsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(helpTopicsLoading = false, helpTopicsError = error.userMessage()) } } + } + } + + fun saveHelpTopic(id: Int?, input: HelpTopicInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsMutationLoading = true, helpTopicsMutationError = null) } + runCatching { repository.helpTopicSave(id, input) } + .onSuccess { mutableState.update { it.copy(helpTopicsMutationLoading = false) }; onSuccess(); loadHelpTopics() } + .onFailure { error -> mutableState.update { it.copy(helpTopicsMutationLoading = false, helpTopicsMutationError = error.userMessage()) } } + } + } + + fun deleteHelpTopic(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsMutationLoading = true, helpTopicsMutationError = null) } + runCatching { repository.helpTopicDelete(id) } + .onSuccess { mutableState.update { it.copy(helpTopicsMutationLoading = false) }; loadHelpTopics() } + .onFailure { error -> mutableState.update { it.copy(helpTopicsMutationLoading = false, helpTopicsMutationError = error.userMessage()) } } + } + } + + fun clearHelpTopicsError() = mutableState.update { it.copy(helpTopicsMutationError = null) } + + // ─── Admin: Organisationen ──────────────────────────────────────────────── + + fun loadOrganizations() { + viewModelScope.launch { + mutableState.update { it.copy(organizationsLoading = true, organizationsError = null) } + runCatching { repository.organizations() } + .onSuccess { items -> mutableState.update { it.copy(organizations = items, organizationsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(organizationsLoading = false, organizationsError = error.userMessage()) } } + } + } + + fun saveOrganization(id: Int?, name: String, notes: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(organizationsMutationLoading = true, organizationsMutationError = null) } + runCatching { repository.organizationSave(id, name, notes) } + .onSuccess { mutableState.update { it.copy(organizationsMutationLoading = false) }; onSuccess(); loadOrganizations() } + .onFailure { error -> mutableState.update { it.copy(organizationsMutationLoading = false, organizationsMutationError = error.userMessage()) } } + } + } + + fun deleteOrganization(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(organizationsMutationLoading = true, organizationsMutationError = null) } + runCatching { repository.organizationDelete(id) } + .onSuccess { mutableState.update { it.copy(organizationsMutationLoading = false) }; loadOrganizations() } + .onFailure { error -> mutableState.update { it.copy(organizationsMutationLoading = false, organizationsMutationError = error.userMessage()) } } + } + } + + fun clearOrganizationsError() = mutableState.update { it.copy(organizationsMutationError = null) } + + // ─── Admin: Teams ───────────────────────────────────────────────────────── + + fun loadTeams() { + viewModelScope.launch { + mutableState.update { it.copy(teamsLoading = true, teamsError = null) } + runCatching { repository.teams() } + .onSuccess { data -> mutableState.update { it.copy(teams = data, teamsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(teamsLoading = false, teamsError = error.userMessage()) } } + } + } + + fun saveTeam(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(teamsMutationLoading = true, teamsMutationError = null) } + runCatching { repository.teamSave(id, name, leadId, notes, memberIds) } + .onSuccess { mutableState.update { it.copy(teamsMutationLoading = false) }; onSuccess(); loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationLoading = false, teamsMutationError = error.userMessage()) } } + } + } + + fun toggleTeam(id: Int) { + viewModelScope.launch { + runCatching { repository.teamToggle(id) } + .onSuccess { loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationError = error.userMessage()) } } + } + } + + fun deleteTeam(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(teamsMutationLoading = true, teamsMutationError = null) } + runCatching { repository.teamDelete(id) } + .onSuccess { mutableState.update { it.copy(teamsMutationLoading = false) }; loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationLoading = false, teamsMutationError = error.userMessage()) } } + } + } + + fun clearTeamsError() = mutableState.update { it.copy(teamsMutationError = null) } + + // ─── Admin: Rollen ──────────────────────────────────────────────────────── + + fun loadRoles() { + viewModelScope.launch { + mutableState.update { it.copy(rolesLoading = true, rolesError = null) } + runCatching { repository.roles() } + .onSuccess { data -> mutableState.update { it.copy(roles = data, rolesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(rolesLoading = false, rolesError = error.userMessage()) } } + } + } + + fun saveRole(id: Int?, name: String, permissions: Map, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(rolesMutationLoading = true, rolesMutationError = null) } + runCatching { repository.roleSave(id, name, permissions) } + .onSuccess { mutableState.update { it.copy(rolesMutationLoading = false) }; onSuccess(); loadRoles() } + .onFailure { error -> mutableState.update { it.copy(rolesMutationLoading = false, rolesMutationError = error.userMessage()) } } + } + } + + fun deleteRole(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(rolesMutationLoading = true, rolesMutationError = null) } + runCatching { repository.roleDelete(id) } + .onSuccess { mutableState.update { it.copy(rolesMutationLoading = false) }; loadRoles() } + .onFailure { error -> mutableState.update { it.copy(rolesMutationLoading = false, rolesMutationError = error.userMessage()) } } + } + } + + fun clearRolesError() = mutableState.update { it.copy(rolesMutationError = null) } + + // ─── Admin: Mitarbeiter ─────────────────────────────────────────────────── + + fun loadStaffAdmin() { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminLoading = true, staffAdminError = null) } + runCatching { repository.staffAdminList() } + .onSuccess { data -> mutableState.update { it.copy(staffAdmin = data, staffAdminLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(staffAdminLoading = false, staffAdminError = error.userMessage()) } } + } + } + + fun saveStaffAdmin(id: Int?, input: StaffAdminInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminMutationLoading = true, staffAdminMutationError = null) } + runCatching { repository.staffAdminSave(id, input) } + .onSuccess { mutableState.update { it.copy(staffAdminMutationLoading = false) }; onSuccess(); loadStaffAdmin() } + .onFailure { error -> mutableState.update { it.copy(staffAdminMutationLoading = false, staffAdminMutationError = error.userMessage()) } } + } + } + + fun deleteStaffAdmin(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminMutationLoading = true, staffAdminMutationError = null) } + runCatching { repository.staffAdminDelete(id) } + .onSuccess { mutableState.update { it.copy(staffAdminMutationLoading = false) }; loadStaffAdmin() } + .onFailure { error -> mutableState.update { it.copy(staffAdminMutationLoading = false, staffAdminMutationError = error.userMessage()) } } + } + } + + fun clearStaffAdminError() = mutableState.update { it.copy(staffAdminMutationError = null) } + + // ─── Admin: Geschäftszeiten ─────────────────────────────────────────────── + + fun loadBusinessHours() { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursLoading = true, businessHoursError = null) } + runCatching { repository.businessHours() } + .onSuccess { data -> mutableState.update { it.copy(businessHours = data, businessHoursLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(businessHoursLoading = false, businessHoursError = error.userMessage()) } } + } + } + + fun saveBusinessHours(enabled: Boolean, hours: List, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursMutationLoading = true, businessHoursMutationError = null) } + runCatching { repository.businessHoursSave(enabled, hours) } + .onSuccess { mutableState.update { it.copy(businessHoursMutationLoading = false) }; onSuccess(); loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationLoading = false, businessHoursMutationError = error.userMessage()) } } + } + } + + fun addBusinessHoliday(date: String, name: String) { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursMutationLoading = true, businessHoursMutationError = null) } + runCatching { repository.businessHolidayAdd(date, name) } + .onSuccess { mutableState.update { it.copy(businessHoursMutationLoading = false) }; loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationLoading = false, businessHoursMutationError = error.userMessage()) } } + } + } + + fun deleteBusinessHoliday(id: Int) { + viewModelScope.launch { + runCatching { repository.businessHolidayDelete(id) } + .onSuccess { loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationError = error.userMessage()) } } + } + } + + fun clearBusinessHoursError() = mutableState.update { it.copy(businessHoursMutationError = null) } + + // ─── Admin: E-Mail-Adressen ─────────────────────────────────────────────── + + fun loadEmailAddresses() { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesLoading = true, emailAddressesError = null) } + runCatching { repository.emailAddresses() } + .onSuccess { data -> mutableState.update { it.copy(emailAddresses = data, emailAddressesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(emailAddressesLoading = false, emailAddressesError = error.userMessage()) } } + } + } + + fun saveEmailAddress(id: Int?, input: EmailAddressInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null, emailAddressesNotice = null) } + runCatching { repository.emailAddressSave(id, input) } + .onSuccess { mutableState.update { it.copy(emailAddressesMutationLoading = false) }; onSuccess(); loadEmailAddresses() } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + fun deleteEmailAddress(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null) } + runCatching { repository.emailAddressDelete(id) } + .onSuccess { mutableState.update { it.copy(emailAddressesMutationLoading = false) }; loadEmailAddresses() } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + /** Test-Mail senden ("sendTest") bzw. IMAP-Verbindung prüfen ("testImap"); Ergebnis erscheint als Hinweis im Formular. */ + fun testEmailAddress(id: Int, action: String) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null, emailAddressesNotice = null) } + runCatching { repository.emailAddressAction(action, id) } + .onSuccess { msg -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesNotice = msg.ifBlank { "OK" }) } } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + fun clearEmailAddressesError() = mutableState.update { it.copy(emailAddressesMutationError = null, emailAddressesNotice = null) } + + // ─── Admin: E-Mail-Vorlagen ─────────────────────────────────────────────── + + fun loadEmailTemplates() { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesLoading = true, emailTemplatesError = null) } + runCatching { repository.emailTemplates() } + .onSuccess { items -> mutableState.update { it.copy(emailTemplates = items, emailTemplatesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesLoading = false, emailTemplatesError = error.userMessage()) } } + } + } + + fun saveEmailTemplate(key: String, subject: String, bodyText: String, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesMutationLoading = true, emailTemplatesMutationError = null) } + runCatching { repository.emailTemplateSave(key, subject, bodyText) } + .onSuccess { mutableState.update { it.copy(emailTemplatesMutationLoading = false) }; onSuccess(); loadEmailTemplates() } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesMutationLoading = false, emailTemplatesMutationError = error.userMessage()) } } + } + } + + fun resetEmailTemplate(key: String) { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesMutationLoading = true, emailTemplatesMutationError = null) } + runCatching { repository.emailTemplateReset(key) } + .onSuccess { mutableState.update { it.copy(emailTemplatesMutationLoading = false) }; loadEmailTemplates() } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesMutationLoading = false, emailTemplatesMutationError = error.userMessage()) } } + } + } + + fun clearEmailTemplatesError() = mutableState.update { it.copy(emailTemplatesMutationError = null) } + + // ─── Admin: Admin-Protokoll ─────────────────────────────────────────────── + + fun loadAuditLog(reset: Boolean = true) { + viewModelScope.launch { + val page = if (reset) 1 else state.value.auditLogPage + 1 + mutableState.update { it.copy(auditLogLoading = true, auditLogError = null) } + runCatching { repository.auditLog(page) } + .onSuccess { result -> + mutableState.update { + it.copy( + auditLog = if (reset) result.items else it.auditLog + result.items, + auditLogPage = result.page, + auditLogHasMore = result.hasMore, + auditLogLoading = false, + ) + } + } + .onFailure { error -> mutableState.update { it.copy(auditLogLoading = false, auditLogError = error.userMessage()) } } + } + } + + // ─── Aufgaben ───────────────────────────────────────────────────────────── + + fun loadTasks(filter: String = state.value.tasksFilter) { + viewModelScope.launch { + mutableState.update { it.copy(tasksLoading = true, tasksError = null, tasksFilter = filter) } + runCatching { repository.tasks(filter) } + .onSuccess { data -> mutableState.update { it.copy(tasks = data, tasksLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(tasksLoading = false, tasksError = error.userMessage()) } } + } + } + + fun createTask(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(tasksMutationLoading = true, tasksMutationError = null) } + runCatching { repository.taskCreate(title, description, assignedToId, dueAt, ticketNumber) } + .onSuccess { mutableState.update { it.copy(tasksMutationLoading = false) }; onSuccess(); loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationLoading = false, tasksMutationError = error.userMessage()) } } + } + } + + fun updateTaskStatus(id: Int, status: String) { + viewModelScope.launch { + runCatching { repository.taskUpdateStatus(id, status) } + .onSuccess { loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationError = error.userMessage()) } } + } + } + + fun deleteTask(id: Int) { + viewModelScope.launch { + runCatching { repository.taskDelete(id) } + .onSuccess { loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationError = error.userMessage()) } } + } + } + + fun clearTasksError() = mutableState.update { it.copy(tasksMutationError = null) } + + // ─── Interner Chat ──────────────────────────────────────────────────────── + + fun loadChatContacts() { + viewModelScope.launch { + mutableState.update { it.copy(chatContactsLoading = true, chatContactsError = null) } + runCatching { repository.chatContacts() } + .onSuccess { items -> mutableState.update { it.copy(chatContacts = items, chatContactsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(chatContactsLoading = false, chatContactsError = error.userMessage()) } } + } + } + + fun openChatConversation(withId: Int) { + chatPollingJob?.cancel() + mutableState.update { it.copy(chatConversation = null, chatConversationLoading = true, chatConversationError = null) } + chatPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.chatConversation(withId) } + .onSuccess { conversation -> + mutableState.update { it.copy(chatConversation = conversation, chatConversationLoading = false) } + } + .onFailure { error -> + mutableState.update { it.copy(chatConversationLoading = false, chatConversationError = error.userMessage()) } + } + delay(8_000) + } + } + } + + fun closeChatConversation() { + chatPollingJob?.cancel() + mutableState.update { it.copy(chatConversation = null) } + loadChatContacts() + } + + fun sendChatMessage(toId: Int, message: String) { + viewModelScope.launch { + mutableState.update { it.copy(chatSendLoading = true, chatSendError = null) } + runCatching { repository.chatSend(toId, message) } + .onSuccess { + mutableState.update { it.copy(chatSendLoading = false) } + runCatching { repository.chatConversation(toId) } + .onSuccess { conversation -> mutableState.update { it.copy(chatConversation = conversation) } } + } + .onFailure { error -> mutableState.update { it.copy(chatSendLoading = false, chatSendError = error.userMessage()) } } + } + } + + fun clearChatSendError() = mutableState.update { it.copy(chatSendError = null) } + + // ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────── + // Eigener Tab, getrennt vom internen Chat oben – Chats mit Kunden über das + // Live-Chat-Widget im Kundenportal (siehe staff/live_chat.php im Web-Backend, + // gleiche Sperr-Logik über api/staff/v1/live_chat.php). + + /** Fügt neu eingetroffene Nachrichten hinzu, ohne bereits angezeigte zu duplizieren + * (der Server liefert bei jedem Poll immer die volle Historie, wie im Web-Widget). */ + private fun mergeLiveChatDetail(state: DeskHubState, detail: LiveChatDetail): LiveChatDetail { + val prevMessages = state.liveChatDetail?.messages.orEmpty() + val existingIds = prevMessages.map { it.id }.toSet() + val newOnes = detail.messages.filter { it.id !in existingIds } + return detail.copy(messages = prevMessages + newOnes) + } + + fun loadLiveChats() { + liveChatListPollingJob?.cancel() + mutableState.update { it.copy(liveChatsLoading = true, liveChatsError = null) } + liveChatListPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.liveChatList() } + .onSuccess { items -> mutableState.update { it.copy(liveChats = items, liveChatsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(liveChatsLoading = false, liveChatsError = error.userMessage()) } } + delay(8_000) + } + } + } + + fun stopLiveChatListPolling() { + liveChatListPollingJob?.cancel() + } + + fun openLiveChat(number: String) { + liveChatDetailPollingJob?.cancel() + mutableState.update { it.copy(liveChatDetail = null, liveChatDetailLoading = true, liveChatDetailError = null) } + liveChatDetailPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.liveChatMessages(number) } + .onSuccess { detail -> + mutableState.update { it.copy(liveChatDetail = mergeLiveChatDetail(it, detail), liveChatDetailLoading = false) } + } + .onFailure { error -> mutableState.update { it.copy(liveChatDetailLoading = false, liveChatDetailError = error.userMessage()) } } + delay(8_000) + } + } + } + + fun closeLiveChat() { + liveChatDetailPollingJob?.cancel() + mutableState.update { it.copy(liveChatDetail = null) } + } + + fun sendLiveChatMessage(number: String, message: String) { + viewModelScope.launch { + mutableState.update { it.copy(liveChatSendLoading = true, liveChatSendError = null) } + runCatching { repository.liveChatSend(number, message) } + .onSuccess { + mutableState.update { it.copy(liveChatSendLoading = false) } + // Sofort einmal nachladen statt bis zu 8s auf den nächsten Poll zu warten. + runCatching { repository.liveChatMessages(number) } + .onSuccess { detail -> mutableState.update { it.copy(liveChatDetail = mergeLiveChatDetail(it, detail)) } } + } + .onFailure { error -> mutableState.update { it.copy(liveChatSendLoading = false, liveChatSendError = error.userMessage()) } } + } + } + + fun clearLiveChatSendError() = mutableState.update { it.copy(liveChatSendError = null) } + + /** Signalisiert dem Kunden-Widget "Mitarbeiter tippt gerade" – höchstens alle 3s, + * damit nicht bei jedem Tastenanschlag eine eigene Anfrage rausgeht. */ + fun pingLiveChatTyping(number: String) { + val now = System.currentTimeMillis() + if (now - lastLiveChatTypingPingAt < 3_000) return + lastLiveChatTypingPingAt = now + viewModelScope.launch { runCatching { repository.liveChatTyping(number) } } + } + + fun endLiveChat(number: String, onDone: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(liveChatSendLoading = true, liveChatSendError = null) } + runCatching { repository.liveChatEnd(number) } + .onSuccess { + mutableState.update { it.copy(liveChatSendLoading = false) } + onDone() + } + .onFailure { error -> mutableState.update { it.copy(liveChatSendLoading = false, liveChatSendError = error.userMessage()) } } + } + } + + // ─── Verfügbarkeit (Status) ──────────────────────────────────────────────── + + fun loadAvailability() { + viewModelScope.launch { + mutableState.update { it.copy(availabilityLoading = true, availabilityError = null) } + runCatching { repository.availability() } + .onSuccess { data -> mutableState.update { it.copy(availability = data, availabilityLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(availabilityLoading = false, availabilityError = error.userMessage()) } } + } + } + + /** Administrator: Status eines anderen Mitarbeiters ändern. */ + fun setMemberAvailability(staffId: Int, status: String, note: String) { + viewModelScope.launch { + mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) } + runCatching { repository.setAvailability(status, note, staffId) } + .onSuccess { + mutableState.update { it.copy(availabilitySaving = false) } + loadAvailability() + } + .onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } } + } + } + + fun setMyAvailability(status: String, note: String) { + viewModelScope.launch { + mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) } + runCatching { repository.setAvailability(status, note) } + .onSuccess { + mutableState.update { it.copy(availabilitySaving = false) } + loadAvailability() + } + .onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } } + } + } + + // ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────── + + fun loadTicketRules() { + viewModelScope.launch { + mutableState.update { it.copy(ticketRulesLoading = true, ticketRulesError = null) } + runCatching { repository.ticketRules() } + .onSuccess { data -> mutableState.update { it.copy(ticketRules = data, ticketRulesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(ticketRulesLoading = false, ticketRulesError = error.userMessage()) } } + } + } + + fun saveTicketRule( + id: Int?, + name: String, + matchType: String, + isActive: Boolean, + sortOrder: Int, + conditions: List, + actions: List, + onDone: () -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(ticketRulesMutationLoading = true, ticketRulesMutationError = null) } + runCatching { repository.saveTicketRule(id, name, matchType, isActive, sortOrder, conditions, actions) } + .onSuccess { + mutableState.update { it.copy(ticketRulesMutationLoading = false) } + loadTicketRules() + onDone() + } + .onFailure { error -> mutableState.update { it.copy(ticketRulesMutationLoading = false, ticketRulesMutationError = error.userMessage()) } } + } + } + + fun toggleTicketRule(id: Int) { + viewModelScope.launch { + runCatching { repository.toggleTicketRule(id) }.onSuccess { loadTicketRules() } + } + } + + fun deleteTicketRule(id: Int) { + viewModelScope.launch { + runCatching { repository.deleteTicketRule(id) }.onSuccess { loadTicketRules() } + } + } + + fun clearTicketRulesMutationError() = mutableState.update { it.copy(ticketRulesMutationError = null) } + + fun saveAutoClose(enabled: Boolean, days: Int) { + viewModelScope.launch { + runCatching { repository.saveAutoClose(enabled, days) }.onSuccess { loadTicketRules() } + } + } + + // ─── Bild-Upload (Signaturen) ───────────────────────────────────────────── + + fun uploadSignatureImage(bytes: ByteArray, filename: String, mimeType: String, onSuccess: (String) -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(imageUploadLoading = true, imageUploadError = null) } + runCatching { repository.uploadImage(bytes, filename, mimeType) } + .onSuccess { url -> + mutableState.update { it.copy(imageUploadLoading = false) } + onSuccess(url) + } + .onFailure { error -> mutableState.update { it.copy(imageUploadLoading = false, imageUploadError = error.userMessage()) } } + } + } + + fun clearImageUploadError() = mutableState.update { it.copy(imageUploadError = null) } + + private fun restoreSession() { + viewModelScope.launch { + val restored = runCatching { repository.restoreSession() } + if (restored.isFailure || restored.getOrNull() == null) { + mutableState.update { + it.copy( + initializing = false, + authError = restored.exceptionOrNull()?.userMessage(), + savedServerUrl = repository.lastServerUrl(), + ) + } + return@launch + } + runCatching { repository.validateSession() } + .onSuccess { staff -> + mutableState.update { it.copy(initializing = false, staff = staff) } + loadWorkspace() + loadChatContacts() + startActivityPolling() + startTicketsPolling() + applyPendingTicket() + } + .onFailure { error -> + if (error is ApiException && error.status == 401) repository.clearSession() + mutableState.update { + it.copy(initializing = false, authError = error.userMessage(), savedServerUrl = repository.lastServerUrl()) + } + } + } + } + + private fun loadWorkspace() { + viewModelScope.launch { + mutableState.update { it.copy(ticketsLoading = true, ticketsError = null) } + val metadataRequest = async { repository.metadata() } + val ticketsRequest = async { repository.tickets(state.value.filters) } + val cannedRequest = async { runCatching { repository.cannedResponses() }.getOrDefault(emptyList()) } + runCatching { Triple(metadataRequest.await(), ticketsRequest.await(), cannedRequest.await()) } + .onSuccess { (metadata, page, canned) -> + mutableState.update { + it.copy( + metadata = metadata, + staff = metadata.staff, + cannedResponses = canned, + tickets = page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ticketsLoading = false, + ) + } + } + .onFailure { error -> + mutableState.update { + it.copy(ticketsLoading = false, ticketsError = error.userMessage()) + } + } + } + } + + private fun loadTickets(reset: Boolean) { + viewModelScope.launch { + val pageNumber = if (reset) 1 else state.value.ticketsPage + 1 + mutableState.update { it.copy(ticketsLoading = true, ticketsError = null) } + runCatching { repository.tickets(state.value.filters, pageNumber) } + .onSuccess { page -> + mutableState.update { + it.copy( + tickets = if (reset) page.items else it.tickets + page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ticketsLoading = false, + ) + } + } + .onFailure { error -> + mutableState.update { + it.copy(ticketsLoading = false, ticketsError = error.userMessage()) + } + } + } + } +} + +class DeskHubViewModelFactory( + private val repository: TicketRepository, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(DeskHubViewModel::class.java)) + return DeskHubViewModel(repository) as T + } +} + +private fun Throwable.userMessage(): String = when (this) { + is ApiException -> message + is IllegalArgumentException -> message ?: "Ungültige Eingabe." + else -> message ?: "Die Aktion ist fehlgeschlagen." +} diff --git a/mobile/src/main/res/xml/update_paths.xml b/mobile/src/main/res/xml/update_paths.xml new file mode 100644 index 00000000..c9cc2571 --- /dev/null +++ b/mobile/src/main/res/xml/update_paths.xml @@ -0,0 +1,4 @@ + + + +