From 1beee34c012ccfc916513a514937151b43d5476c Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 23 Oct 2026 17:24:21 +0000 Subject: [PATCH] Upload via GUI (67 Dateien) --- mobile/src/main/AndroidManifest.xml | 3 + .../java/de/deskhub/staff/DeskHubWidget.kt | 170 +- .../java/de/deskhub/staff/MainActivity.kt | 6 + .../de/deskhub/staff/data/TicketRepository.kt | 1866 +++++++++-------- .../deskhub/staff/data/remote/DeskHubApi.kt | 24 + .../deskhub/staff/data/remote/dto/ApiDtos.kt | 95 + .../de/deskhub/staff/domain/model/Models.kt | 14 + .../staff/push/DeskHubMessagingService.kt | 2 + .../de/deskhub/staff/ui/AssistantAdmin.kt | 290 +++ .../java/de/deskhub/staff/ui/DeskHubApp.kt | 981 +++------ .../de/deskhub/staff/ui/DeskHubViewModel.kt | 103 + .../main/java/de/deskhub/staff/ui/DeskNav.kt | 203 ++ .../java/de/deskhub/staff/ui/DeskTheme.kt | 276 +++ .../de/deskhub/staff/ui/PushPrefsScreen.kt | 6 +- .../res/drawable-night/widget_background.xml | 8 + .../main/res/drawable/widget_background.xml | 7 +- mobile/src/main/res/drawable/widget_chip.xml | 5 + .../src/main/res/drawable/widget_chip_on.xml | 5 + mobile/src/main/res/drawable/widget_tile.xml | 4 +- .../main/res/drawable/widget_tile_alert.xml | 5 + mobile/src/main/res/layout/widget_deskhub.xml | 141 +- .../main/res/layout/widget_deskhub_large.xml | 325 +++ .../main/res/layout/widget_deskhub_small.xml | 113 + mobile/src/main/res/values/strings.xml | 2 +- .../src/main/res/xml/deskhub_widget_info.xml | 6 +- .../data/remote/dto/ApiDtoParsingTest.kt | 27 + 26 files changed, 3009 insertions(+), 1678 deletions(-) create mode 100644 mobile/src/main/java/de/deskhub/staff/ui/AssistantAdmin.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/ui/DeskNav.kt create mode 100644 mobile/src/main/java/de/deskhub/staff/ui/DeskTheme.kt create mode 100644 mobile/src/main/res/drawable-night/widget_background.xml create mode 100644 mobile/src/main/res/drawable/widget_chip.xml create mode 100644 mobile/src/main/res/drawable/widget_chip_on.xml create mode 100644 mobile/src/main/res/drawable/widget_tile_alert.xml create mode 100644 mobile/src/main/res/layout/widget_deskhub_large.xml create mode 100644 mobile/src/main/res/layout/widget_deskhub_small.xml diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index fe80c777..a0b9eb25 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -53,5 +53,8 @@ android:name="android.appwidget.provider" android:resource="@xml/deskhub_widget_info" /> + diff --git a/mobile/src/main/java/de/deskhub/staff/DeskHubWidget.kt b/mobile/src/main/java/de/deskhub/staff/DeskHubWidget.kt index 3b0a6bf8..a5cffc1a 100644 --- a/mobile/src/main/java/de/deskhub/staff/DeskHubWidget.kt +++ b/mobile/src/main/java/de/deskhub/staff/DeskHubWidget.kt @@ -3,99 +3,195 @@ package de.deskhub.staff import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider +import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.net.Uri +import android.os.Bundle import android.view.View import android.widget.RemoteViews import de.deskhub.staff.data.remote.ApiException import de.deskhub.staff.data.remote.DeskHubApi import de.deskhub.staff.data.security.SecureSessionStore +import de.deskhub.staff.push.DeskHubPush import kotlinx.coroutines.runBlocking import java.text.SimpleDateFormat import java.util.Date import java.util.Locale /** - * Startbildschirm-Widget: Anzahl der mir zugewiesenen offenen, der nicht zugewiesenen und der überfälligen Tickets. - * Es zeigt bewusst nur Zahlen (keine Betreffzeilen oder Kundennamen), damit nichts Vertrauliches auf dem Startbildschirm steht. - * Aktualisiert sich alle 30 Minuten, beim Tippen auf ⟳ und beim Öffnen der App. + * Startbildschirm-Widget in drei Größen (klein, mittel, groß), hell und dunkel passend zum System. + * - klein: Meine offenen und Überfällige + * - mittel: vier Kennzahlen und die drei wichtigsten Tickets (nur Nummern, Tipp öffnet das Ticket) + * - groß: zusätzlich „Neues Ticket“, „Alle Tickets“ und der eigene Status zum direkten Umschalten + * Es zeigt bewusst keine Betreffzeilen oder Kundennamen, damit nichts Vertrauliches auf dem Startbildschirm steht. + * Aktualisiert sich nach Android-Vorgabe etwa alle 30 Minuten, beim Tippen auf ⟳, beim Öffnen der App und bei jeder Push-Nachricht. */ class DeskHubWidget : AppWidgetProvider() { override fun onUpdate(context: Context, manager: AppWidgetManager, appWidgetIds: IntArray) { - refresh(context, manager, appWidgetIds) + refresh(context, appWidgetIds, goAsync()) + } + + override fun onAppWidgetOptionsChanged(context: Context, manager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle) { + // Größe geändert: mit den gespeicherten Daten passend neu zeichnen (ohne Netz) + manager.updateAppWidget(appWidgetId, views(context, Cache.read(context), layoutFor(newOptions))) } override fun onReceive(context: Context, intent: Intent) { if (intent.action == ACTION_REFRESH) { - val manager = AppWidgetManager.getInstance(context) - refresh(context, manager, manager.getAppWidgetIds(ComponentName(context, DeskHubWidget::class.java))) + refresh(context, null, goAsync()) } else { super.onReceive(context, intent) } } - private fun refresh(context: Context, manager: AppWidgetManager, ids: IntArray) { - if (ids.isEmpty()) return - val pending = goAsync() - ids.forEach { manager.updateAppWidget(it, views(context, message = "Aktualisiere …")) } + /** Gespeicherter Stand (damit eine Größenänderung nicht jedes Mal das Netz braucht). */ + data class Snapshot( + val my: Int = 0, val unassigned: Int = 0, val overdue: Int = 0, val unanswered: Int = 0, + val recent: List = emptyList(), val status: String? = null, val at: Long = 0L, val message: String? = null, + ) + + private object Cache { + private const val FILE = "deskhub_widget" + fun read(context: Context): Snapshot { + val p = context.getSharedPreferences(FILE, Context.MODE_PRIVATE) + return Snapshot( + my = p.getInt("my", 0), unassigned = p.getInt("un", 0), overdue = p.getInt("od", 0), unanswered = p.getInt("ua", 0), + recent = p.getString("recent", "").orEmpty().split('|').filter { it.isNotBlank() }, + status = p.getString("status", null), at = p.getLong("at", 0L), message = p.getString("message", if (p.contains("at")) null else "Lade …"), + ) + } + fun write(context: Context, s: Snapshot) { + context.getSharedPreferences(FILE, Context.MODE_PRIVATE).edit() + .putInt("my", s.my).putInt("un", s.unassigned).putInt("od", s.overdue).putInt("ua", s.unanswered) + .putString("recent", s.recent.joinToString("|")).putString("status", s.status).putLong("at", s.at).putString("message", s.message).apply() + } + } + + private fun layoutFor(options: Bundle?): Int { + val w = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 250) ?: 250 + val h = options?.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 110) ?: 110 + return when { + w < 200 -> R.layout.widget_deskhub_small + h < 170 -> R.layout.widget_deskhub + else -> R.layout.widget_deskhub_large + } + } + + private fun refresh(context: Context, ids: IntArray?, pending: BroadcastReceiver.PendingResult?, setStatus: String? = null) { + val manager = AppWidgetManager.getInstance(context) + val all = ids ?: manager.getAppWidgetIds(ComponentName(context, DeskHubWidget::class.java)) + if (all.isEmpty()) { pending?.finish(); return } + paint(context, manager, all, Cache.read(context).copy(message = if (Cache.read(context).at == 0L) "Aktualisiere …" else null)) Thread { - val result = try { - load(context) - } catch (_: Exception) { - WidgetState(message = "Keine Verbindung zum Server.") - } - ids.forEach { manager.updateAppWidget(it, views(context, result.counts, result.message)) } - pending.finish() + val result = try { load(context, setStatus) } catch (_: Exception) { Cache.read(context).copy(message = "Keine Verbindung zum Server.") } + if (result.message == null) Cache.write(context, result) + paint(context, manager, all, result) + pending?.finish() }.start() } - private data class WidgetState(val counts: Triple? = null, val message: String? = null) + private fun paint(context: Context, manager: AppWidgetManager, ids: IntArray, s: Snapshot) { + ids.forEach { manager.updateAppWidget(it, views(context, s, layoutFor(manager.getAppWidgetOptions(it)))) } + } - private fun load(context: Context): WidgetState { + private fun load(context: Context, setStatus: String?): Snapshot { val session = SecureSessionStore(context.applicationContext).load() - if (session == null || session.token.isBlank() || session.serverUrl.isBlank()) { - return WidgetState(message = "Bitte in der App anmelden.") - } + if (session == null || session.token.isBlank() || session.serverUrl.isBlank()) return Snapshot(message = "Bitte in der App anmelden.") return try { val api = DeskHubApi(session.serverUrl) { session.token } + if (setStatus != null) runBlocking { api.setAvailability(setStatus, "") } val d = runBlocking { api.dashboard() } - WidgetState(counts = Triple(d.myOpen, d.unassigned, d.overdue)) + val status = runCatching { runBlocking { api.availability() }.myStatus }.getOrNull() + Snapshot(d.myOpen, d.unassigned, d.overdue, d.unanswered, d.recent.map { it.number }.take(3), status, System.currentTimeMillis()) } catch (e: ApiException) { - if (e.status == 401 || e.status == 403) WidgetState(message = "Bitte in der App neu anmelden.") else WidgetState(message = "Server-Fehler (${e.status}).") + if (e.status == 401 || e.status == 403) Snapshot(message = "Bitte in der App neu anmelden.") else Snapshot(message = "Server-Fehler (${e.status}).") } } - private fun views(context: Context, counts: Triple? = null, message: String? = null): RemoteViews { - val v = RemoteViews(context.packageName, R.layout.widget_deskhub) + private fun views(context: Context, s: Snapshot, layout: Int): RemoteViews { + val v = RemoteViews(context.packageName, layout) val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - val open = PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), flags) - v.setOnClickPendingIntent(R.id.widget_root, open) - val refresh = Intent(context, DeskHubWidget::class.java).setAction(ACTION_REFRESH) - v.setOnClickPendingIntent(R.id.widget_refresh, PendingIntent.getBroadcast(context, 1, refresh, flags)) - if (counts != null) { - v.setTextViewText(R.id.widget_my_open, counts.first.toString()) - v.setTextViewText(R.id.widget_unassigned, counts.second.toString()) - v.setTextViewText(R.id.widget_overdue, counts.third.toString()) + fun open(code: Int, data: String, extra: (Intent) -> Unit = {}): PendingIntent { + val i = Intent(context, MainActivity::class.java).setData(Uri.parse("deskhub://widget/$data")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + extra(i) + return PendingIntent.getActivity(context, code, i, flags) + } + fun action(code: Int, action: String, status: String? = null): PendingIntent { + val i = Intent(context, WidgetActionReceiver::class.java).setAction(action).setData(Uri.parse("deskhub://widget-action/$action/${status.orEmpty()}")) + status?.let { i.putExtra(WidgetActionReceiver.EXTRA_STATUS, it) } + return PendingIntent.getBroadcast(context, code, i, flags) + } + v.setOnClickPendingIntent(R.id.widget_root, open(0, "home")) + v.setOnClickPendingIntent(R.id.widget_refresh, action(1, ACTION_REFRESH)) + v.setOnClickPendingIntent(R.id.widget_btn_new, open(2, "new") { it.putExtra(EXTRA_ROUTE, "newTicket") }) + v.setOnClickPendingIntent(R.id.widget_btn_tickets, open(3, "tickets") { it.putExtra(EXTRA_ROUTE, "tickets") }) + listOf("available" to R.id.widget_st_available, "busy" to R.id.widget_st_busy, "break" to R.id.widget_st_break, "offline" to R.id.widget_st_offline) + .forEachIndexed { index, (status, id) -> + v.setOnClickPendingIntent(id, action(10 + index, WidgetActionReceiver.ACTION_SET_STATUS, status)) + v.setInt(id, "setBackgroundResource", if (s.status == status) R.drawable.widget_chip_on else R.drawable.widget_chip) + } + + if (s.message == null && s.at > 0) { + v.setTextViewText(R.id.widget_my_open, s.my.toString()) + v.setTextViewText(R.id.widget_unassigned, s.unassigned.toString()) + v.setTextViewText(R.id.widget_overdue, s.overdue.toString()) + v.setTextViewText(R.id.widget_unanswered, s.unanswered.toString()) + v.setInt(R.id.widget_tile_overdue, "setBackgroundResource", if (s.overdue > 0) R.drawable.widget_tile_alert else R.drawable.widget_tile) + v.setTextViewText(R.id.widget_time, "Stand " + SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(s.at))) v.setViewVisibility(R.id.widget_message, View.GONE) v.setViewVisibility(R.id.widget_tiles, View.VISIBLE) - v.setTextViewText(R.id.widget_time, "Stand " + SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date())) + listOf(R.id.widget_t1, R.id.widget_t2, R.id.widget_t3).forEachIndexed { index, id -> + val number = s.recent.getOrNull(index) + if (number == null) { + v.setViewVisibility(id, View.GONE) + } else { + v.setTextViewText(id, "#" + number.substringAfterLast('-')) + v.setViewVisibility(id, View.VISIBLE) + v.setOnClickPendingIntent(id, open(20 + index, "ticket/$number") { it.putExtra(DeskHubPush.EXTRA_TICKET, number) }) + } + } } else { - v.setTextViewText(R.id.widget_message, message ?: "") + v.setTextViewText(R.id.widget_message, s.message ?: "Lade …") v.setViewVisibility(R.id.widget_message, View.VISIBLE) v.setViewVisibility(R.id.widget_tiles, View.INVISIBLE) v.setTextViewText(R.id.widget_time, "") + listOf(R.id.widget_t1, R.id.widget_t2, R.id.widget_t3).forEach { v.setViewVisibility(it, View.GONE) } } return v } + /** Für den nicht exportierten Empfänger der Widget-Knöpfe. */ + internal fun refreshFromAction(context: Context, pending: BroadcastReceiver.PendingResult?, setStatus: String?) = refresh(context, null, pending, setStatus) + companion object { const val ACTION_REFRESH = "de.deskhub.staff.WIDGET_REFRESH" + const val EXTRA_ROUTE = "de.deskhub.staff.extra.ROUTE" - /** Stößt eine Aktualisierung aller Widgets an (zum Beispiel beim Öffnen der App). */ + /** Stößt eine Aktualisierung aller Widgets an (zum Beispiel beim Öffnen der App oder bei einer Push-Nachricht). */ fun requestUpdate(context: Context) { val intent = Intent(context, DeskHubWidget::class.java).setAction(ACTION_REFRESH) context.sendBroadcast(intent) } } } + +/** + * Empfänger für die Knöpfe im Widget (Aktualisieren, Status setzen). Nicht exportiert: Nur das Widget selbst kann ihn auslösen, + * keine andere App kann den Status des Mitarbeiters ändern. + */ +class WidgetActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pending = goAsync() + val status = if (intent.action == ACTION_SET_STATUS) intent.getStringExtra(EXTRA_STATUS)?.takeIf { it in ALLOWED } else null + if (intent.action == ACTION_SET_STATUS && status == null) { pending.finish(); return } + DeskHubWidget().refreshFromAction(context, pending, status) + } + + companion object { + const val ACTION_SET_STATUS = "de.deskhub.staff.WIDGET_SET_STATUS" + const val EXTRA_STATUS = "de.deskhub.staff.extra.STATUS" + private val ALLOWED = setOf("available", "busy", "break", "offline") + } +} diff --git a/mobile/src/main/java/de/deskhub/staff/MainActivity.kt b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt index a685d5ed..8c3b8197 100644 --- a/mobile/src/main/java/de/deskhub/staff/MainActivity.kt +++ b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt @@ -69,6 +69,12 @@ class MainActivity : ComponentActivity() { intent?.removeExtra(DeskHubPush.EXTRA_TICKET) viewModel.openTicketFromNotification(number) } + // Knöpfe im Widget: „Neues Ticket“ und „Alle Tickets" + val route = intent?.getStringExtra(DeskHubWidget.EXTRA_ROUTE)?.trim().orEmpty() + if (route == "newTicket" || route == "tickets") { + intent?.removeExtra(DeskHubWidget.EXTRA_ROUTE) + viewModel.requestRoute(route) + } } private fun requestNotificationPermissionOnce() { 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 50c35476..e1cc0f84 100644 --- a/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt +++ b/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt @@ -1,922 +1,944 @@ -package de.deskhub.staff.data - -import android.os.Build -import de.deskhub.staff.data.remote.DeskHubApi -import de.deskhub.staff.data.remote.DeskHubUrl -import de.deskhub.staff.data.remote.dto.AuditLogEntryDto -import de.deskhub.staff.data.remote.dto.BanEntryDto -import de.deskhub.staff.data.remote.dto.BusinessHolidayDto -import de.deskhub.staff.data.remote.dto.BusinessHourDto -import de.deskhub.staff.data.remote.dto.ChatContactDto -import de.deskhub.staff.data.remote.dto.ChatMessageDto -import de.deskhub.staff.data.remote.dto.KeyLabelDto -import de.deskhub.staff.data.remote.dto.LiveChatAttachmentDto -import de.deskhub.staff.data.remote.dto.LiveChatListItemDto -import de.deskhub.staff.data.remote.dto.LiveChatMessageDto -import de.deskhub.staff.data.remote.dto.TeamMemberDto -import de.deskhub.staff.data.remote.dto.TicketRuleActionDto -import de.deskhub.staff.data.remote.dto.TicketRuleConditionDto -import de.deskhub.staff.data.remote.dto.TicketRuleDto -import de.deskhub.staff.data.remote.dto.TicketRuleSaveRequest -import de.deskhub.staff.data.remote.dto.CannedResponseDto -import de.deskhub.staff.data.remote.dto.CreateTicketRequest -import de.deskhub.staff.data.remote.dto.DepartmentDto -import de.deskhub.staff.data.remote.dto.DepartmentSaveRequest -import de.deskhub.staff.data.remote.dto.EmailAddressDto -import de.deskhub.staff.data.remote.dto.EmailAddressSaveRequest -import de.deskhub.staff.data.remote.dto.EmailTemplateDto -import de.deskhub.staff.data.remote.dto.HelpTopicDto -import de.deskhub.staff.data.remote.dto.HelpTopicSaveRequest -import de.deskhub.staff.data.remote.dto.OrganizationDto -import de.deskhub.staff.data.remote.dto.OrganizationSaveRequest -import de.deskhub.staff.data.remote.dto.PriorityFullDto -import de.deskhub.staff.data.remote.dto.PrioritySaveRequest -import de.deskhub.staff.data.remote.dto.RoleDto -import de.deskhub.staff.data.remote.dto.RoleSaveRequest -import de.deskhub.staff.data.remote.dto.StaffAdminDto -import de.deskhub.staff.data.remote.dto.StaffAdminSaveRequest -import de.deskhub.staff.data.remote.dto.StaffExtraDeptDto -import de.deskhub.staff.data.remote.dto.TaskCreateRequest -import de.deskhub.staff.data.remote.dto.TaskDto -import de.deskhub.staff.data.remote.dto.TeamDto -import de.deskhub.staff.data.remote.dto.TeamSaveRequest -import de.deskhub.staff.data.remote.dto.CustomerDetailDto -import de.deskhub.staff.data.remote.dto.CustomerDto -import de.deskhub.staff.data.remote.dto.DepartmentStatDto -import de.deskhub.staff.data.remote.dto.EscalationRuleDto -import de.deskhub.staff.data.remote.dto.EscalationSaveRequest -import de.deskhub.staff.data.remote.dto.KbArticleDto -import de.deskhub.staff.data.remote.dto.KbCategoryDto -import de.deskhub.staff.data.remote.dto.KbSaveArticleRequest -import de.deskhub.staff.data.remote.dto.LoginRequest -import de.deskhub.staff.data.remote.dto.MessageRequest -import de.deskhub.staff.data.remote.dto.NamedOptionDto -import de.deskhub.staff.data.remote.dto.PriorityDto -import de.deskhub.staff.data.remote.dto.StaffDto -import de.deskhub.staff.data.remote.dto.StatusOptionDto -import de.deskhub.staff.data.remote.dto.TicketDetailDto -import de.deskhub.staff.data.remote.dto.TicketSummaryDto -import de.deskhub.staff.data.remote.dto.TimeEntryDto -import de.deskhub.staff.data.remote.dto.TimeEntryTotalDto -import de.deskhub.staff.data.security.SecureSessionStore -import de.deskhub.staff.data.security.StoredSession -import de.deskhub.staff.domain.model.ActivityUpdate -import de.deskhub.staff.domain.model.AuditLogEntry -import de.deskhub.staff.domain.model.AuditLogPage -import de.deskhub.staff.domain.model.BanEntry -import de.deskhub.staff.domain.model.BusinessHoliday -import de.deskhub.staff.domain.model.BusinessHour -import de.deskhub.staff.domain.model.BusinessHoursData -import de.deskhub.staff.domain.model.AvailabilityData -import de.deskhub.staff.domain.model.ChatContact -import de.deskhub.staff.domain.model.ChatConversation -import de.deskhub.staff.domain.model.ChatMessage -import de.deskhub.staff.domain.model.KeyLabel -import de.deskhub.staff.domain.model.LiveChatAttachment -import de.deskhub.staff.domain.model.LiveChatDetail -import de.deskhub.staff.domain.model.LiveChatMessage -import de.deskhub.staff.domain.model.LiveChatSummary -import de.deskhub.staff.domain.model.TeamMember -import de.deskhub.staff.domain.model.TicketRule -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.CannedResponse -import de.deskhub.staff.domain.model.Customer -import de.deskhub.staff.domain.model.CustomerDetail -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.DepartmentStat -import de.deskhub.staff.domain.model.DepartmentData -import de.deskhub.staff.domain.model.DepartmentEmailOption -import de.deskhub.staff.domain.model.HealthIssue -import de.deskhub.staff.domain.model.DepartmentInput -import de.deskhub.staff.domain.model.DepartmentMember -import de.deskhub.staff.domain.model.EmailAddress -import de.deskhub.staff.domain.model.EmailAddressInput -import de.deskhub.staff.domain.model.EmailAddressData -import de.deskhub.staff.domain.model.EmailTemplate -import de.deskhub.staff.domain.model.EscalationData -import de.deskhub.staff.domain.model.EscalationRule -import de.deskhub.staff.domain.model.HelpTopic -import de.deskhub.staff.domain.model.HelpTopicInput -import de.deskhub.staff.domain.model.HelpTopicData -import de.deskhub.staff.domain.model.KbArticle -import de.deskhub.staff.domain.model.KbCategory -import de.deskhub.staff.domain.model.KbData -import de.deskhub.staff.domain.model.Metadata -import de.deskhub.staff.domain.model.NamedOption -import de.deskhub.staff.domain.model.Organization -import de.deskhub.staff.domain.model.Priority -import de.deskhub.staff.domain.model.PriorityFull -import de.deskhub.staff.domain.model.Role -import de.deskhub.staff.domain.model.RoleData -import de.deskhub.staff.domain.model.SpamActionResult -import de.deskhub.staff.domain.model.Staff -import de.deskhub.staff.domain.model.StaffAdmin -import de.deskhub.staff.domain.model.StaffAdminData -import de.deskhub.staff.domain.model.StaffAdminInput -import de.deskhub.staff.domain.model.StaffExtraDept -import de.deskhub.staff.domain.model.StaffTask -import de.deskhub.staff.domain.model.StatusOption -import de.deskhub.staff.domain.model.Team -import de.deskhub.staff.domain.model.TeamData -import de.deskhub.staff.domain.model.ThreadAttachment -import de.deskhub.staff.domain.model.ThreadComment -import de.deskhub.staff.domain.model.TicketDetail -import de.deskhub.staff.domain.model.TicketFilters -import de.deskhub.staff.domain.model.TicketPage -import de.deskhub.staff.domain.model.TicketSummary -import de.deskhub.staff.domain.model.TicketThread -import de.deskhub.staff.domain.model.TimeEntriesReport -import de.deskhub.staff.domain.model.TimeEntry -import de.deskhub.staff.domain.model.TimeEntryTotal - -sealed interface LoginResult { - data object TotpRequired : LoginResult - data class Success(val staff: Staff) : LoginResult -} - -class TicketRepository( - private val sessionStore: SecureSessionStore, -) { - private var session: StoredSession? = null - private var api: DeskHubApi? = null - - fun restoreSession(): StoredSession? { - val stored = sessionStore.load() ?: return null - configure(stored) - return stored - } - - /** Zuletzt verwendete Server-Adresse, falls (noch) keine aktive Sitzung besteht – erspart erneutes Eintippen. */ - fun lastServerUrl(): String? = sessionStore.loadLastServerUrl() - - suspend fun login( - serverUrl: String, - email: String, - password: String, - totpCode: String?, - ): LoginResult { - val normalizedUrl = DeskHubUrl.normalize(serverUrl) - sessionStore.saveLastServerUrl(normalizedUrl) - val loginApi = DeskHubApi(normalizedUrl) { null } - val response = loginApi.login( - LoginRequest( - email = email.trim(), - password = password, - totpCode = totpCode?.takeIf { it.isNotBlank() }, - deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(), - ), - ) - if (response.requiresTotp) return LoginResult.TotpRequired - val token = requireNotNull(response.token) { "Die Anmeldung lieferte kein Token." } - val expiresAt = requireNotNull(response.expiresAt) { "Das Token hat kein Ablaufdatum." } - val staffDto = requireNotNull(response.staff) { "Die Anmeldung lieferte kein Mitarbeiterprofil." } - val stored = StoredSession(normalizedUrl, token, expiresAt, staffDto.name) - sessionStore.save(stored) - configure(stored) - return LoginResult.Success(staffDto.toDomain()) - } - - suspend fun validateSession(): Staff = requireApi().me().staff.toDomain() - - suspend fun metadata(): Metadata { - val response = requireApi().metadata() - return Metadata( - staff = response.staff.toDomain(), - departments = response.departments.map(NamedOptionDto::toDomain), - priorities = response.priorities.map(PriorityDto::toDomain), - agents = response.agents.map(NamedOptionDto::toDomain), - statuses = response.statuses.map(StatusOptionDto::toDomain), - tags = response.tags.map(NamedOptionDto::toDomain), - liveChatEnabled = response.liveChatEnabled, - ) - } - - suspend fun tickets(filters: TicketFilters, page: Int = 1): TicketPage { - val query = buildMap { - put("page", page.toString()) - put("perPage", "30") - put("status", filters.statuses.joinToString(",")) - if (filters.query.isNotBlank()) put("q", filters.query.trim()) - if (filters.assigned.isNotBlank()) put("assigned", filters.assigned) - filters.departmentId?.let { put("departmentId", it.toString()) } - filters.priorityId?.let { put("priorityId", it.toString()) } - filters.tagId?.let { put("tagId", it.toString()) } - if (filters.sla.isNotBlank()) put("sla", filters.sla) - } - val response = requireApi().tickets(query) - return TicketPage( - items = response.items.map(TicketSummaryDto::toDomain), - page = response.page, - total = response.total, - hasMore = response.hasMore, - ) - } - - suspend fun ticket(number: String): TicketDetail = - requireApi().ticket(number).ticket.toDomain() - - suspend fun updateTicket( - number: String, - status: String?, - assignedStaffId: Int?, - updateAssignment: Boolean, - ): TicketSummary { - val request = linkedMapOf("number" to number) - status?.let { request["status"] = it } - if (updateAssignment) request["assignedStaffId"] = assignedStaffId - return requireApi().updateTicket(request).ticket.toDomain() - } - - suspend fun setTicketSpam(number: String, restore: Boolean): SpamActionResult { - val response = requireApi().ticketSpam(number, if (restore) "restore" else "mark") - return SpamActionResult( - ticket = response.ticket.toDomain(), - moved = response.moved, - failed = response.failed, - ) - } - - suspend fun addMessage(number: String, type: String, message: String, setStatus: String?, signature: String? = null) { - requireApi().addMessage(MessageRequest(number, type, message, setStatus, signature)) - } - - suspend fun cannedResponses(): List = - requireApi().cannedResponses().items.map(CannedResponseDto::toDomain) - - suspend fun dashboard(): DashboardStats { - val response = requireApi().dashboard() - return DashboardStats( - myOpen = response.myOpen, - overdue = response.overdue, - unassigned = response.unassigned, - unanswered = response.unanswered, - resolvedToday = response.resolvedToday, - slaPercent = response.slaPercent, - recent = response.recent.map(TicketSummaryDto::toDomain), - departments = response.departments?.map(DepartmentStatDto::toDomain), - ) - } - - suspend fun customers(query: String): List = - requireApi().customers(query).items.map(CustomerDto::toDomain) - - suspend fun customerProfile(userId: Int?, email: String?): CustomerProfile { - val response = requireApi().customer(userId, email) - return CustomerProfile( - customer = response.customer.toDomain(), - tickets = response.tickets.map(TicketSummaryDto::toDomain), - ) - } - - suspend fun createTicket( - requesterName: String, - requesterEmail: String, - subject: String, - message: String, - departmentId: Int, - priorityId: Int, - assignedStaffId: Int?, - notifyCustomer: Boolean, - ): TicketSummary { - val request = CreateTicketRequest( - requesterName = requesterName, - requesterEmail = requesterEmail, - subject = subject, - message = message, - departmentId = departmentId, - priorityId = priorityId, - assignedStaffId = assignedStaffId, - notifyCustomer = notifyCustomer, - ) - return requireApi().createTicket(request).ticket.toDomain() - } - - 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) }) - } - - suspend fun logout() { - val api = requireApi() - // Dieses Gerät für Push abmelden, damit nach dem Ausloggen keine Popups mehr kommen (Fehler dabei verhindern das Ausloggen nicht) - sessionStore.loadPushToken()?.let { token -> - runCatching { api.pushDevice(de.deskhub.staff.data.remote.dto.PushDeviceRequest(action = "unregister", token = token)) } - } - api.logout() - clearSession() - } - - // ─── Admin: Wissensdatenbank ───────────────────────────────────────────── - - suspend fun kbData(): KbData { - val response = requireApi().kb() - return KbData( - categories = response.categories.map(KbCategoryDto::toDomain), - articles = response.articles.map(KbArticleDto::toDomain), - ) - } - - suspend fun kbSaveArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean): Int = - requireApi().kbSaveArticle(KbSaveArticleRequest(id = id, title = title, categoryId = categoryId, body = body, isPublished = isPublished)).id - - suspend fun kbDeleteArticle(id: Int) = requireApi().kbDeleteArticle(id) - - suspend fun kbCreateCategory(name: String): Int = requireApi().kbCreateCategory(name).id - - suspend fun kbDeleteCategory(id: Int) = requireApi().kbDeleteCategory(id) - - // ─── Admin: Zeiterfassung ──────────────────────────────────────────────── - - suspend fun timeEntries(staffId: Int?, from: String, to: String): TimeEntriesReport { - val query = buildMap { - put("from", from) - put("to", to) - staffId?.let { put("staffId", it.toString()) } - } - val response = requireApi().timeEntries(query) - return TimeEntriesReport( - staffOptions = response.staffOptions.map(NamedOptionDto::toDomain), - entries = response.entries.map(TimeEntryDto::toDomain), - totalsByStaff = response.totalsByStaff.map(TimeEntryTotalDto::toDomain), - totalMinutes = response.totalMinutes, - ) - } - - // ─── Admin: Eskalationsregeln ──────────────────────────────────────────── - - suspend fun escalationData(): EscalationData { - val response = requireApi().escalationRules() - return EscalationData( - priorities = response.priorities.map(NamedOptionDto::toDomain), - staffOptions = response.staffOptions.map(NamedOptionDto::toDomain), - departments = response.departments.map(NamedOptionDto::toDomain), - rules = response.rules.map(EscalationRuleDto::toDomain), - ) - } - - suspend fun escalationSave( - 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, - sortOrder: Int, - ): Int = requireApi().escalationSave( - EscalationSaveRequest( - 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 = sortOrder, - ), - ).id - - suspend fun escalationDelete(id: Int) = requireApi().escalationDelete(id) - - suspend fun escalationToggle(id: Int) = requireApi().escalationToggle(id) - - // ─── Admin: Sperrliste ──────────────────────────────────────────────────── - - suspend fun banlist(): List = requireApi().banlist().items.map(BanEntryDto::toDomain) - - suspend fun banCreate(matchType: String, value: String, reason: String?) = - requireApi().banCreate(matchType, value, reason?.ifBlank { null }) - - suspend fun banBulkAdd(values: String, reason: String?): Pair { - val response = requireApi().banBulkAdd(values, reason?.ifBlank { null }) - return response.added to response.skipped - } - - suspend fun banDelete(id: Int) = requireApi().banDelete(id) - - // ─── Admin: Abteilungen ─────────────────────────────────────────────────── - - suspend fun departmentData(): DepartmentData { - val r = requireApi().departments() - return DepartmentData( - departments = r.items.map(DepartmentDto::toDomain), - emailOptions = r.emailOptions.map { DepartmentEmailOption(it.email, it.departmentName) }, - staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), - slaPlans = r.slaPlans.map(NamedOptionDto::toDomain), - ) - } - - suspend fun departmentSave(id: Int?, input: DepartmentInput): Int = - requireApi().departmentSave( - DepartmentSaveRequest( - id = id, name = input.name, email = input.email, isDefault = input.isDefault, signature = input.signature, - isActive = input.isActive, managerId = input.managerId, slaPlanId = input.slaPlanId, notes = input.notes, - autoAssignMode = input.autoAssignMode, autoAssignStaffId = input.autoAssignStaffId, - ), - ).id - - suspend fun departmentDelete(id: Int) = requireApi().departmentDelete(id) - - // ─── Admin: Prioritäten ─────────────────────────────────────────────────── - - suspend fun report(config: de.deskhub.staff.data.remote.dto.ReportConfigDto?, savedId: Int?): de.deskhub.staff.data.remote.dto.ReportResponseDto { - val query = buildMap { - if (savedId != null) { - put("saved", savedId.toString()) - } else if (config != null) { - put("dimension", config.dimension) - put("metrics", config.metrics.joinToString(",")) - if (config.from.isNotBlank()) put("from", config.from) - if (config.to.isNotBlank()) put("to", config.to) - put("sort", config.sort) - if (config.desc) put("desc", "1") - } - } - return requireApi().report(query) - } - - suspend fun reportSave(name: String, shared: Boolean, config: de.deskhub.staff.data.remote.dto.ReportConfigDto): Int = - requireApi().reportSave(de.deskhub.staff.data.remote.dto.ReportSaveRequest(name = name, isShared = shared, config = config)).id - - suspend fun reportDelete(id: Int) = requireApi().reportDelete(id) - - suspend fun savedViews(): List = requireApi().savedViews().items.map { dto -> - val f = dto.filters - de.deskhub.staff.domain.model.SavedView( - id = dto.id, name = dto.name, isShared = dto.isShared, mine = dto.mine, ownerName = dto.ownerName, - filters = TicketFilters( - query = f["q"].orEmpty(), - statuses = f["status"].orEmpty().split(',').filter { it.isNotBlank() }.toSet().let { s -> - if (s.containsAll(setOf("open", "pending", "answered", "on_hold", "resolved", "closed"))) emptySet() else s - }, - assigned = f["assigned"].orEmpty(), - departmentId = f["departmentId"]?.toIntOrNull(), - priorityId = f["priorityId"]?.toIntOrNull(), - sla = f["sla"].orEmpty(), - ), - ) - } - - suspend fun savedViewSave(name: String, shared: Boolean, filters: TicketFilters): Int { - val map = buildMap { - if (filters.query.isNotBlank()) put("q", filters.query) - put("status", (if (filters.statuses.isEmpty()) setOf("open", "pending", "answered", "on_hold", "resolved", "closed") else filters.statuses).joinToString(",")) - if (filters.assigned.isNotBlank()) put("assigned", filters.assigned) - filters.departmentId?.let { put("departmentId", it.toString()) } - filters.priorityId?.let { put("priorityId", it.toString()) } - if (filters.sla.isNotBlank()) put("sla", filters.sla) - } - return requireApi().savedViewSave(de.deskhub.staff.data.remote.dto.SavedViewSaveRequest(name = name, isShared = shared, filters = map)).id - } - - suspend fun savedViewDelete(id: Int) = requireApi().savedViewDelete(id) - - suspend fun slaPlans(): List = - requireApi().slaPlans().items.map { de.deskhub.staff.domain.model.SlaPlan(it.id, it.name, it.responseHours, it.resolutionHours, it.isActive, it.notes, it.departmentCount, it.helpTopicCount) } - - suspend fun slaPlanSave(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String): Int = - requireApi().slaPlanSave(de.deskhub.staff.data.remote.dto.SlaPlanSaveRequest(id = id, name = name, responseHours = responseHours, resolutionHours = resolutionHours, isActive = isActive, notes = notes)).id - - suspend fun slaPlanDelete(id: Int) = requireApi().slaPlanDelete(id) - - suspend fun prioritiesAdmin(): List = requireApi().prioritiesAdmin().items.map(PriorityFullDto::toDomain) - - suspend fun prioritySave(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int): Int = - requireApi().prioritySave( - PrioritySaveRequest(id = id, name = name, color = color, responseHours = responseHours, resolutionHours = resolutionHours, sortOrder = sortOrder), - ).id - - suspend fun priorityDelete(id: Int) = requireApi().priorityDelete(id) - - // ─── Admin: Hilfe-Themen ────────────────────────────────────────────────── - - suspend fun helpTopics(): HelpTopicData { - val r = requireApi().helpTopics() - return HelpTopicData( - departments = r.departments.map(NamedOptionDto::toDomain), - priorities = r.priorities.map(NamedOptionDto::toDomain), - slaPlans = r.slaPlans.map(NamedOptionDto::toDomain), - topics = r.items.map(HelpTopicDto::toDomain), - ) - } - - suspend fun helpTopicSave(id: Int?, input: HelpTopicInput): Int = - requireApi().helpTopicSave( - HelpTopicSaveRequest( - id = id, name = input.name, departmentId = input.departmentId, priorityId = input.priorityId, isActive = input.isActive, - isPublic = input.isPublic, parentId = input.parentId, slaPlanId = input.slaPlanId, notes = input.notes, sortOrder = input.sortOrder, - ), - ).id - - suspend fun helpTopicDelete(id: Int) = requireApi().helpTopicDelete(id) - - // ─── Admin: Organisationen ──────────────────────────────────────────────── - - suspend fun organizations(): List = requireApi().organizations().items.map(OrganizationDto::toDomain) - - suspend fun organizationSave(id: Int?, name: String, notes: String?): Int = - requireApi().organizationSave(OrganizationSaveRequest(id = id, name = name, notes = notes)).id - - suspend fun organizationDelete(id: Int) = requireApi().organizationDelete(id) - - // ─── Admin: Teams ───────────────────────────────────────────────────────── - - suspend fun teams(): TeamData { - val r = requireApi().teams() - return TeamData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), teams = r.items.map(TeamDto::toDomain)) - } - - suspend fun teamSave(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List): Int = - requireApi().teamSave(TeamSaveRequest(id = id, name = name, leadId = leadId, notes = notes, memberIds = memberIds)).id - - suspend fun teamToggle(id: Int) = requireApi().teamToggle(id) - - suspend fun teamDelete(id: Int) = requireApi().teamDelete(id) - - // ─── Admin: Rollen ──────────────────────────────────────────────────────── - - suspend fun roles(): RoleData { - val r = requireApi().roles() - return RoleData( - permissionKeys = r.permissionKeys, - defaultAgentPermissions = r.defaultAgentPermissions, - roles = r.items.map(RoleDto::toDomain), - ) - } - - suspend fun roleSave(id: Int?, name: String, permissions: Map): Int = - requireApi().roleSave(RoleSaveRequest(id = id, name = name, permissions = permissions)).id - - suspend fun roleDelete(id: Int) = requireApi().roleDelete(id) - - // ─── Admin: Mitarbeiter ─────────────────────────────────────────────────── - - suspend fun staffAdminList(): StaffAdminData { - val r = requireApi().staffAdminList() - return StaffAdminData( - departments = r.departments.map(NamedOptionDto::toDomain), - roles = r.roles.map(NamedOptionDto::toDomain), - staff = r.items.map(StaffAdminDto::toDomain), - teams = r.teams.map(NamedOptionDto::toDomain), - canManageTeams = r.canManageTeams, - ) - } - - suspend fun staffAdminSave(id: Int?, input: StaffAdminInput): Int = requireApi().staffAdminSave( - StaffAdminSaveRequest( - id = id, name = input.name, email = input.email, role = input.role, roleId = input.roleId, - departmentId = input.departmentId, isActive = input.isActive, password = input.password, signature = input.signature, - extraDepartments = input.extraDepartments.map { StaffExtraDeptDto(it.departmentId, it.notify, it.roleId) }, - teamIds = input.teamIds, - ), - ).id - - suspend fun staffAdminDelete(id: Int) = requireApi().staffAdminDelete(id) - - // ─── Admin: Geschäftszeiten ─────────────────────────────────────────────── - - suspend fun businessHours(): BusinessHoursData { - val r = requireApi().businessHours() - return BusinessHoursData( - enabled = r.enabled, - hours = r.hours.map(BusinessHourDto::toDomain), - holidays = r.holidays.map(BusinessHolidayDto::toDomain), - ) - } - - suspend fun businessHoursSave(enabled: Boolean, hours: List) = - requireApi().businessHoursSave(enabled, hours.map { BusinessHourDto(it.weekday, it.isOpen, it.openTime, it.closeTime) }) - - suspend fun businessHolidayAdd(date: String, name: String): Int = requireApi().businessHolidayAdd(date, name).id - - suspend fun businessHolidayDelete(id: Int) = requireApi().businessHolidayDelete(id) - - // ─── Admin: E-Mail-Adressen ─────────────────────────────────────────────── - - suspend fun emailAddresses(): EmailAddressData { - val r = requireApi().emailAddresses() - return EmailAddressData( - departments = r.departments.map(NamedOptionDto::toDomain), - priorities = r.priorities.map(NamedOptionDto::toDomain), - helpTopics = r.helpTopics.map(NamedOptionDto::toDomain), - addresses = r.items.map(EmailAddressDto::toDomain), - ) - } - - suspend fun emailAddressSave(id: Int?, input: EmailAddressInput): Int = - requireApi().emailAddressSave( - EmailAddressSaveRequest( - id = id, email = input.email, label = input.label, departmentId = input.departmentId, priorityId = input.priorityId, isDefault = input.isDefault, - helpTopicId = input.helpTopicId, isActive = input.isActive, notes = input.notes, - smtpEnabled = input.smtpEnabled, smtpHost = input.smtpHost, smtpPort = input.smtpPort, smtpEncryption = input.smtpEncryption, - smtpUsername = input.smtpUsername, smtpPassword = input.smtpPassword, - imapEnabled = input.imapEnabled, imapHost = input.imapHost, imapPort = input.imapPort, imapEncryption = input.imapEncryption, - imapUsername = input.imapUsername, imapPassword = input.imapPassword, imapFolder = input.imapFolder, imapAfterFetch = input.imapAfterFetch, - imapArchiveFolder = input.imapArchiveFolder, imapSpamFolder = input.imapSpamFolder, - ), - ).id - - /** action: "sendTest" (Test-Mail an die eigene Adresse) oder "testImap" (Postfach-Verbindung). Liefert die Servermeldung. */ - suspend fun emailAddressAction(action: String, id: Int): String = requireApi().emailAddressAction(action, id).message - - suspend fun emailAddressDelete(id: Int) = requireApi().emailAddressDelete(id) - - // ─── Admin: E-Mail-Vorlagen ─────────────────────────────────────────────── - - suspend fun emailTemplates(): List = requireApi().emailTemplates().items.map(EmailTemplateDto::toDomain) - - suspend fun emailTemplateSave(key: String, subject: String, bodyText: String) = requireApi().emailTemplateSave(key, subject, bodyText) - - suspend fun emailTemplateReset(key: String) = requireApi().emailTemplateReset(key) - - // ─── Admin: Admin-Protokoll ─────────────────────────────────────────────── - - suspend fun auditLog(page: Int): AuditLogPage { - val r = requireApi().auditLog(page) - return AuditLogPage(items = r.items.map(AuditLogEntryDto::toDomain), page = r.page, hasMore = r.hasMore) - } - - // ─── Aufgaben ───────────────────────────────────────────────────────────── - - suspend fun tasks(filter: String): de.deskhub.staff.domain.model.TaskData { - val r = requireApi().tasks(filter) - return de.deskhub.staff.domain.model.TaskData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), tasks = r.items.map(TaskDto::toDomain)) - } - - suspend fun taskCreate(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?): Int = - requireApi().taskCreate( - TaskCreateRequest(title = title, description = description, assignedToId = assignedToId, dueAt = dueAt, ticketNumber = ticketNumber), - ).id - - suspend fun taskUpdateStatus(id: Int, status: String) = requireApi().taskUpdateStatus(id, status) - - suspend fun taskDelete(id: Int) = requireApi().taskDelete(id) - - // ─── Interner Chat ──────────────────────────────────────────────────────── - - suspend fun chatContacts(): List = requireApi().chatContacts().contacts.map(ChatContactDto::toDomain) - - suspend fun chatConversation(withId: Int): ChatConversation { - val r = requireApi().chatConversation(withId) - return ChatConversation(with = r.with.toDomain(), messages = r.messages.map(ChatMessageDto::toDomain)) - } - - suspend fun chatSend(toId: Int, message: String): Int = requireApi().chatSend(toId, message).id - - // ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────── - - suspend fun liveChatList(): List = - requireApi().liveChatList().items.map(LiveChatListItemDto::toDomain) - - suspend fun liveChatMessages(number: String): LiveChatDetail { - val r = requireApi().liveChatMessages(number, 0) - return LiveChatDetail( - number = number, - status = r.status, - requesterName = r.requesterName, - requesterEmail = r.requesterEmail, - departmentName = r.departmentName, - lockedByOtherName = r.lockedByOtherName, - customerTyping = r.customerTyping, - messages = r.messages.map(LiveChatMessageDto::toDomain), - ) - } - - suspend fun liveChatSend(number: String, message: String) { requireApi().liveChatSend(number, message) } - - suspend fun liveChatTyping(number: String) { requireApi().liveChatTyping(number) } - - suspend fun liveChatEnd(number: String) { requireApi().liveChatEnd(number) } - - // ─── Verfügbarkeit (Status) ──────────────────────────────────────────────── - - suspend fun availability(): AvailabilityData { - val r = requireApi().availability() - return AvailabilityData(myStatus = r.myStatus, myNote = r.myNote, team = r.team.map(TeamMemberDto::toDomain)) - } - - /** Hebt die Bearbeitungssperre eines Tickets auf (eigene, als Administrator auch die eines anderen). */ - suspend fun unlockTicket(number: String) { - requireApi().unlockTicket(number) - } - - suspend fun setAvailability(status: String, note: String, staffId: Int? = null) { requireApi().setAvailability(status, note, staffId) } - - // ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────── - - suspend fun ticketRules(): TicketRulesData { - val r = requireApi().ticketRules() - return TicketRulesData( - fieldOptions = r.fieldOptions.map(KeyLabelDto::toDomain), - operatorOptions = r.operatorOptions.map(KeyLabelDto::toDomain), - actionOptions = r.actionOptions.map(KeyLabelDto::toDomain), - departments = r.departments.map(NamedOptionDto::toDomain), - priorities = r.priorities.map(NamedOptionDto::toDomain), - helpTopics = r.helpTopics.map(NamedOptionDto::toDomain), - staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), - cannedResponses = r.cannedResponses.map(NamedOptionDto::toDomain), - rules = r.rules.map(TicketRuleDto::toDomain), - autoCloseEnabled = r.autoCloseEnabled, - autoCloseDays = r.autoCloseDays, - ) - } - - suspend fun saveTicketRule( - id: Int?, - name: String, - matchType: String, - isActive: Boolean, - sortOrder: Int, - conditions: List, - actions: List, - ): Int { - val request = TicketRuleSaveRequest( - id = id, - name = name, - matchType = matchType, - isActive = isActive, - sortOrder = sortOrder, - conditions = conditions.map { TicketRuleConditionDto(it.field, it.operator, it.value) }, - actions = actions.map { TicketRuleActionDto(it.type, it.value, it.valueLabel) }, - ) - return requireApi().saveTicketRule(request).id - } - - suspend fun toggleTicketRule(id: Int) { requireApi().toggleTicketRule(id) } - - suspend fun deleteTicketRule(id: Int) { requireApi().deleteTicketRule(id) } - - suspend fun saveAutoClose(enabled: Boolean, days: Int) { requireApi().saveAutoClose(enabled, days) } - - // ─── Bild-Upload ────────────────────────────────────────────────────────── - - suspend fun uploadImage(bytes: ByteArray, filename: String, mimeType: String): String = - requireApi().uploadImage(bytes, filename, mimeType).url - - fun clearSession() { - sessionStore.clear() - session = null - api = null - } - - private fun configure(stored: StoredSession) { - session = stored - api = DeskHubApi(stored.serverUrl) { session?.token } - } - - private fun requireApi(): DeskHubApi = - checkNotNull(api) { "Keine aktive deskhub-Sitzung." } -} - -private fun StaffDto.toDomain() = Staff(id, name, email, role, permissions) -private fun NamedOptionDto.toDomain() = NamedOption(id, name) -private fun PriorityDto.toDomain() = Priority(id, name, color) -private fun StatusOptionDto.toDomain() = StatusOption(value, label, color) -private fun CannedResponseDto.toDomain() = CannedResponse(id, title, body, departmentId) -private fun DepartmentStatDto.toDomain() = DepartmentStat(name, open, overdue) -private fun CustomerDto.toDomain() = Customer(id, name, email, organizationName, ticketCount, isGuest) -private fun CustomerDetailDto.toDomain() = CustomerDetail(id, name, email, organizationName, isGuest, createdAt) -private fun BanEntryDto.toDomain() = BanEntry(id, matchType, value, reason, addedByName, createdAt) -private fun DepartmentDto.toDomain() = Department( - id, name, email, isDefault, signature, isActive, managerId, managerName, slaPlanId, notes, - members.map { DepartmentMember(it.id, it.name, it.isPrimary) }, updatedAt, autoAssignMode, autoAssignStaffId, -) -private fun PriorityFullDto.toDomain() = PriorityFull(id, name, color, responseHours, resolutionHours, sortOrder) -private fun HelpTopicDto.toDomain() = HelpTopic(id, name, departmentId, departmentName, priorityId, priorityName, priorityColor, isActive, sortOrder, isPublic, parentId, slaPlanId, path.ifBlank { name }, notes, updatedAt) -private fun OrganizationDto.toDomain() = Organization(id, name, notes, memberCount) -private fun TeamDto.toDomain() = Team(id, name, leadId, leadName, notes, isActive, memberCount, memberIds) -private fun RoleDto.toDomain() = Role(id, name, permissions, staffCount) -private fun StaffAdminDto.toDomain() = StaffAdmin( - id, name, email, role, roleId, departmentId, departmentName, isActive, signature, - extraDepartments.map { StaffExtraDept(it.departmentId, it.notify, it.roleId) }, - teamIds, createdAt, -) -private fun BusinessHourDto.toDomain() = BusinessHour(weekday, isOpen, openTime, closeTime) -private fun BusinessHolidayDto.toDomain() = BusinessHoliday(id, date, name) -private fun EmailAddressDto.toDomain() = EmailAddress( - id, email, label, departmentId, departmentName, priorityId, priorityName, isDefault, - helpTopicId, isActive, notes, smtpEnabled, smtpHost, smtpPort, smtpEncryption, smtpUsername, smtpHasPassword, - imapEnabled, imapHost, imapPort, imapEncryption, imapUsername, imapFolder, imapAfterFetch, imapArchiveFolder, imapSpamFolder, imapHasPassword, imapLastRunAt, -) -private fun EmailTemplateDto.toDomain() = EmailTemplate(key, label, placeholders, subject, body, isCustomized) -private fun AuditLogEntryDto.toDomain() = AuditLogEntry(id, staffName, action, createdAt) -private fun TaskDto.toDomain() = StaffTask(id, title, description, status, assignedToId, assignedName, creatorName, dueAt, ticketNumber, ticketSubject) -private fun ChatContactDto.toDomain() = ChatContact(id, name, unreadCount, lastMessageAt) -private fun ChatMessageDto.toDomain() = ChatMessage(id, fromStaffId, fromName, message, createdAt) -private fun LiveChatListItemDto.toDomain() = LiveChatSummary(number, name, preview, needsReply, updatedAt) -private fun LiveChatAttachmentDto.toDomain() = LiveChatAttachment(id, name, size) -private fun LiveChatMessageDto.toDomain() = LiveChatMessage(id, authorType, authorName, message, createdAt, attachments.map(LiveChatAttachmentDto::toDomain)) -private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt, autoOffline) -private fun KeyLabelDto.toDomain() = KeyLabel(value, label) -private fun TicketRuleConditionDto.toDomain() = TicketRuleCondition(field, operator, value) -private fun TicketRuleActionDto.toDomain() = TicketRuleAction(type, value, valueLabel) -private fun TicketRuleDto.toDomain() = TicketRule( - id, name, matchType, isActive, sortOrder, - conditions.map(TicketRuleConditionDto::toDomain), - actions.map(TicketRuleActionDto::toDomain), -) -private fun KbCategoryDto.toDomain() = KbCategory(id, name) -private fun KbArticleDto.toDomain() = KbArticle(id, categoryId, categoryName, title, body, isPublished, views) -private fun TimeEntryDto.toDomain() = TimeEntry(id, staffName, ticketNumber, subject, minutes, note, loggedAt) -private fun TimeEntryTotalDto.toDomain() = TimeEntryTotal(staffName, minutes) -private fun EscalationRuleDto.toDomain() = EscalationRule( - id = id, - name = name, - isActive = isActive, - triggerType = triggerType, - triggerHours = triggerHours, - conditionStatus = conditionStatus, - conditionPriorityId = conditionPriorityId, - conditionPriorityName = conditionPriorityName, - conditionDeptId = conditionDeptId, - conditionDeptName = conditionDeptName, - actionSetPriorityId = actionSetPriorityId, - actionSetPriorityName = actionSetPriorityName, - actionAssignStaffId = actionAssignStaffId, - actionAssignStaffName = actionAssignStaffName, - actionSetStatus = actionSetStatus, - actionNotifyAdmin = actionNotifyAdmin, - actionNotifyStaff = actionNotifyStaff, - sortOrder = sortOrder, - appliedCount = appliedCount, -) - -private fun TicketSummaryDto.toDomain() = TicketSummary( - id = id, - number = number, - subject = subject, - requesterName = requesterName, - requesterEmail = requesterEmail, - status = status, - priority = priority.toDomain(), - department = department.toDomain(), - assignedStaff = assignedStaff?.toDomain(), - isOverdue = isOverdue, - dueLabel = dueLabel, - isSpam = isSpam, - createdAt = createdAt, - updatedAt = updatedAt, -) - -private fun TicketDetailDto.toDomain() = TicketDetail( - summary = TicketSummary( - id = id, - number = number, - subject = subject, - requesterName = requesterName, - requesterEmail = requesterEmail, - status = status, - priority = priority.toDomain(), - department = department.toDomain(), - assignedStaff = assignedStaff?.toDomain(), - isOverdue = isOverdue, - isSpam = isSpam, - createdAt = createdAt, - updatedAt = updatedAt, - ), - source = source, - firstResponseDueAt = firstResponseDueAt, - resolutionDueAt = resolutionDueAt, - lockedByOtherName = lockedByOtherName, - lockedBy = lockedBy, - canUnlock = canUnlock, - threads = threads.map { thread -> - TicketThread( - id = thread.id, - authorType = thread.authorType, - authorName = thread.authorName, - message = thread.message, - isInternalNote = thread.isInternalNote, - createdAt = thread.createdAt, - attachments = thread.attachments.map { ThreadAttachment(it.id, it.name, it.size) }, - comments = thread.comments.map { - ThreadComment(it.id, it.message, it.staff.toDomain(), it.createdAt) - }, - ) - }, - permissions = permissions, - customFields = customFields.map { de.deskhub.staff.domain.model.CustomFieldValue(it.label, it.value) }, - replySignatures = replySignatures?.let { sig -> - de.deskhub.staff.domain.model.ReplySignatures( - default = sig.default, - own = de.deskhub.staff.domain.model.ReplySignature(sig.own.available, sig.own.text, sig.own.hasImage, sig.own.name, sig.own.imageUrl), - department = de.deskhub.staff.domain.model.ReplySignature(sig.department.available, sig.department.text, sig.department.hasImage, sig.department.name, sig.department.imageUrl), - ) - }, -) +package de.deskhub.staff.data + +import android.os.Build +import de.deskhub.staff.data.remote.DeskHubApi +import de.deskhub.staff.data.remote.DeskHubUrl +import de.deskhub.staff.data.remote.dto.AuditLogEntryDto +import de.deskhub.staff.data.remote.dto.BanEntryDto +import de.deskhub.staff.data.remote.dto.BusinessHolidayDto +import de.deskhub.staff.data.remote.dto.BusinessHourDto +import de.deskhub.staff.data.remote.dto.ChatContactDto +import de.deskhub.staff.data.remote.dto.ChatMessageDto +import de.deskhub.staff.data.remote.dto.KeyLabelDto +import de.deskhub.staff.data.remote.dto.LiveChatAttachmentDto +import de.deskhub.staff.data.remote.dto.LiveChatListItemDto +import de.deskhub.staff.data.remote.dto.LiveChatMessageDto +import de.deskhub.staff.data.remote.dto.TeamMemberDto +import de.deskhub.staff.data.remote.dto.TicketRuleActionDto +import de.deskhub.staff.data.remote.dto.TicketRuleConditionDto +import de.deskhub.staff.data.remote.dto.TicketRuleDto +import de.deskhub.staff.data.remote.dto.TicketRuleSaveRequest +import de.deskhub.staff.data.remote.dto.CannedResponseDto +import de.deskhub.staff.data.remote.dto.CreateTicketRequest +import de.deskhub.staff.data.remote.dto.DepartmentDto +import de.deskhub.staff.data.remote.dto.DepartmentSaveRequest +import de.deskhub.staff.data.remote.dto.EmailAddressDto +import de.deskhub.staff.data.remote.dto.EmailAddressSaveRequest +import de.deskhub.staff.data.remote.dto.EmailTemplateDto +import de.deskhub.staff.data.remote.dto.HelpTopicDto +import de.deskhub.staff.data.remote.dto.HelpTopicSaveRequest +import de.deskhub.staff.data.remote.dto.OrganizationDto +import de.deskhub.staff.data.remote.dto.OrganizationSaveRequest +import de.deskhub.staff.data.remote.dto.PriorityFullDto +import de.deskhub.staff.data.remote.dto.PrioritySaveRequest +import de.deskhub.staff.data.remote.dto.RoleDto +import de.deskhub.staff.data.remote.dto.RoleSaveRequest +import de.deskhub.staff.data.remote.dto.StaffAdminDto +import de.deskhub.staff.data.remote.dto.StaffAdminSaveRequest +import de.deskhub.staff.data.remote.dto.StaffExtraDeptDto +import de.deskhub.staff.data.remote.dto.TaskCreateRequest +import de.deskhub.staff.data.remote.dto.TaskDto +import de.deskhub.staff.data.remote.dto.TeamDto +import de.deskhub.staff.data.remote.dto.TeamSaveRequest +import de.deskhub.staff.data.remote.dto.CustomerDetailDto +import de.deskhub.staff.data.remote.dto.CustomerDto +import de.deskhub.staff.data.remote.dto.DepartmentStatDto +import de.deskhub.staff.data.remote.dto.EscalationRuleDto +import de.deskhub.staff.data.remote.dto.EscalationSaveRequest +import de.deskhub.staff.data.remote.dto.KbArticleDto +import de.deskhub.staff.data.remote.dto.KbCategoryDto +import de.deskhub.staff.data.remote.dto.KbSaveArticleRequest +import de.deskhub.staff.data.remote.dto.LoginRequest +import de.deskhub.staff.data.remote.dto.MessageRequest +import de.deskhub.staff.data.remote.dto.NamedOptionDto +import de.deskhub.staff.data.remote.dto.PriorityDto +import de.deskhub.staff.data.remote.dto.StaffDto +import de.deskhub.staff.data.remote.dto.StatusOptionDto +import de.deskhub.staff.data.remote.dto.TicketDetailDto +import de.deskhub.staff.data.remote.dto.TicketSummaryDto +import de.deskhub.staff.data.remote.dto.TimeEntryDto +import de.deskhub.staff.data.remote.dto.TimeEntryTotalDto +import de.deskhub.staff.data.security.SecureSessionStore +import de.deskhub.staff.data.security.StoredSession +import de.deskhub.staff.domain.model.ActivityUpdate +import de.deskhub.staff.domain.model.AuditLogEntry +import de.deskhub.staff.domain.model.AuditLogPage +import de.deskhub.staff.domain.model.BanEntry +import de.deskhub.staff.domain.model.BusinessHoliday +import de.deskhub.staff.domain.model.BusinessHour +import de.deskhub.staff.domain.model.BusinessHoursData +import de.deskhub.staff.domain.model.AvailabilityData +import de.deskhub.staff.domain.model.ChatContact +import de.deskhub.staff.domain.model.ChatConversation +import de.deskhub.staff.domain.model.ChatMessage +import de.deskhub.staff.domain.model.KeyLabel +import de.deskhub.staff.domain.model.LiveChatAttachment +import de.deskhub.staff.domain.model.LiveChatDetail +import de.deskhub.staff.domain.model.LiveChatMessage +import de.deskhub.staff.domain.model.LiveChatSummary +import de.deskhub.staff.domain.model.TeamMember +import de.deskhub.staff.domain.model.TicketRule +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.CannedResponse +import de.deskhub.staff.domain.model.Customer +import de.deskhub.staff.domain.model.CustomerDetail +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.DepartmentStat +import de.deskhub.staff.domain.model.DepartmentData +import de.deskhub.staff.domain.model.DepartmentEmailOption +import de.deskhub.staff.domain.model.HealthIssue +import de.deskhub.staff.domain.model.DepartmentInput +import de.deskhub.staff.domain.model.DepartmentMember +import de.deskhub.staff.domain.model.EmailAddress +import de.deskhub.staff.domain.model.EmailAddressInput +import de.deskhub.staff.domain.model.EmailAddressData +import de.deskhub.staff.domain.model.EmailTemplate +import de.deskhub.staff.domain.model.EscalationData +import de.deskhub.staff.domain.model.EscalationRule +import de.deskhub.staff.domain.model.HelpTopic +import de.deskhub.staff.domain.model.HelpTopicInput +import de.deskhub.staff.domain.model.HelpTopicData +import de.deskhub.staff.domain.model.KbArticle +import de.deskhub.staff.domain.model.KbCategory +import de.deskhub.staff.domain.model.KbData +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.NamedOption +import de.deskhub.staff.domain.model.Organization +import de.deskhub.staff.domain.model.Priority +import de.deskhub.staff.domain.model.PriorityFull +import de.deskhub.staff.domain.model.Role +import de.deskhub.staff.domain.model.RoleData +import de.deskhub.staff.domain.model.SpamActionResult +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StaffAdmin +import de.deskhub.staff.domain.model.StaffAdminData +import de.deskhub.staff.domain.model.StaffAdminInput +import de.deskhub.staff.domain.model.StaffExtraDept +import de.deskhub.staff.domain.model.StaffTask +import de.deskhub.staff.domain.model.StatusOption +import de.deskhub.staff.domain.model.Team +import de.deskhub.staff.domain.model.TeamData +import de.deskhub.staff.domain.model.ThreadAttachment +import de.deskhub.staff.domain.model.ThreadComment +import de.deskhub.staff.domain.model.TicketDetail +import de.deskhub.staff.domain.model.TicketFilters +import de.deskhub.staff.domain.model.TicketPage +import de.deskhub.staff.domain.model.SimilarTickets +import de.deskhub.staff.domain.model.TicketSummary +import de.deskhub.staff.domain.model.TicketThread +import de.deskhub.staff.domain.model.TimeEntriesReport +import de.deskhub.staff.domain.model.TimeEntry +import de.deskhub.staff.domain.model.TimeEntryTotal + +sealed interface LoginResult { + data object TotpRequired : LoginResult + data class Success(val staff: Staff) : LoginResult +} + +class TicketRepository( + private val sessionStore: SecureSessionStore, +) { + private var session: StoredSession? = null + private var api: DeskHubApi? = null + + fun restoreSession(): StoredSession? { + val stored = sessionStore.load() ?: return null + configure(stored) + return stored + } + + /** Zuletzt verwendete Server-Adresse, falls (noch) keine aktive Sitzung besteht – erspart erneutes Eintippen. */ + fun lastServerUrl(): String? = sessionStore.loadLastServerUrl() + + suspend fun login( + serverUrl: String, + email: String, + password: String, + totpCode: String?, + ): LoginResult { + val normalizedUrl = DeskHubUrl.normalize(serverUrl) + sessionStore.saveLastServerUrl(normalizedUrl) + val loginApi = DeskHubApi(normalizedUrl) { null } + val response = loginApi.login( + LoginRequest( + email = email.trim(), + password = password, + totpCode = totpCode?.takeIf { it.isNotBlank() }, + deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(), + ), + ) + if (response.requiresTotp) return LoginResult.TotpRequired + val token = requireNotNull(response.token) { "Die Anmeldung lieferte kein Token." } + val expiresAt = requireNotNull(response.expiresAt) { "Das Token hat kein Ablaufdatum." } + val staffDto = requireNotNull(response.staff) { "Die Anmeldung lieferte kein Mitarbeiterprofil." } + val stored = StoredSession(normalizedUrl, token, expiresAt, staffDto.name) + sessionStore.save(stored) + configure(stored) + return LoginResult.Success(staffDto.toDomain()) + } + + suspend fun validateSession(): Staff = requireApi().me().staff.toDomain() + + suspend fun metadata(): Metadata { + val response = requireApi().metadata() + return Metadata( + staff = response.staff.toDomain(), + departments = response.departments.map(NamedOptionDto::toDomain), + priorities = response.priorities.map(PriorityDto::toDomain), + agents = response.agents.map(NamedOptionDto::toDomain), + statuses = response.statuses.map(StatusOptionDto::toDomain), + tags = response.tags.map(NamedOptionDto::toDomain), + liveChatEnabled = response.liveChatEnabled, + ) + } + + suspend fun tickets(filters: TicketFilters, page: Int = 1): TicketPage { + val query = buildMap { + put("page", page.toString()) + put("perPage", "30") + put("status", filters.statuses.joinToString(",")) + if (filters.query.isNotBlank()) put("q", filters.query.trim()) + if (filters.assigned.isNotBlank()) put("assigned", filters.assigned) + filters.departmentId?.let { put("departmentId", it.toString()) } + filters.priorityId?.let { put("priorityId", it.toString()) } + filters.tagId?.let { put("tagId", it.toString()) } + if (filters.sla.isNotBlank()) put("sla", filters.sla) + if (filters.snoozed) put("snoozed", "1") + if (filters.following) put("following", "1") + } + val response = requireApi().tickets(query) + return TicketPage( + items = response.items.map(TicketSummaryDto::toDomain), + page = response.page, + total = response.total, + hasMore = response.hasMore, + ) + } + + suspend fun ticket(number: String): TicketDetail = + requireApi().ticket(number).ticket.toDomain() + + suspend fun updateTicket( + number: String, + status: String?, + assignedStaffId: Int?, + updateAssignment: Boolean, + ): TicketSummary { + val request = linkedMapOf("number" to number) + status?.let { request["status"] = it } + if (updateAssignment) request["assignedStaffId"] = assignedStaffId + return requireApi().updateTicket(request).ticket.toDomain() + } + + suspend fun assistantOverview() = requireApi().assistantOverview() + suspend fun assistantAnswers() = requireApi().assistantAnswers().items + suspend fun assistantLog(query: Map) = requireApi().assistantLog(query) + suspend fun assistantAction(request: Map) = requireApi().assistantAction(request) + suspend fun assistantSaveAnswer(request: Map) = requireApi().assistantSaveAnswer(request).item + suspend fun assistantTest(question: String) = requireApi().assistantTest(question) + + /** Legt das Ticket auf Wiedervorlage (ISO-Zeit) oder hebt sie auf (null). */ + suspend fun setSnooze(number: String, untilIso: String?): TicketSummary = + requireApi().updateTicket(linkedMapOf("number" to number, "snoozeUntil" to untilIso)).ticket.toDomain() + + suspend fun setFollowing(number: String, following: Boolean): TicketSummary = + requireApi().updateTicket(linkedMapOf("number" to number, "following" to following)).ticket.toDomain() + + suspend fun setTicketSpam(number: String, restore: Boolean): SpamActionResult { + val response = requireApi().ticketSpam(number, if (restore) "restore" else "mark") + return SpamActionResult( + ticket = response.ticket.toDomain(), + moved = response.moved, + failed = response.failed, + ) + } + + suspend fun addMessage(number: String, type: String, message: String, setStatus: String?, signature: String? = null) { + requireApi().addMessage(MessageRequest(number, type, message, setStatus, signature)) + } + + suspend fun cannedResponses(): List = + requireApi().cannedResponses().items.map(CannedResponseDto::toDomain) + + suspend fun dashboard(): DashboardStats { + val response = requireApi().dashboard() + return DashboardStats( + myOpen = response.myOpen, + overdue = response.overdue, + unassigned = response.unassigned, + unanswered = response.unanswered, + resolvedToday = response.resolvedToday, + slaPercent = response.slaPercent, + recent = response.recent.map(TicketSummaryDto::toDomain), + departments = response.departments?.map(DepartmentStatDto::toDomain), + ) + } + + suspend fun customers(query: String): List = + requireApi().customers(query).items.map(CustomerDto::toDomain) + + suspend fun customerProfile(userId: Int?, email: String?): CustomerProfile { + val response = requireApi().customer(userId, email) + return CustomerProfile( + customer = response.customer.toDomain(), + tickets = response.tickets.map(TicketSummaryDto::toDomain), + ) + } + + suspend fun createTicket( + requesterName: String, + requesterEmail: String, + subject: String, + message: String, + departmentId: Int, + priorityId: Int, + assignedStaffId: Int?, + notifyCustomer: Boolean, + ): TicketSummary { + val request = CreateTicketRequest( + requesterName = requesterName, + requesterEmail = requesterEmail, + subject = subject, + message = message, + departmentId = departmentId, + priorityId = priorityId, + assignedStaffId = assignedStaffId, + notifyCustomer = notifyCustomer, + ) + return requireApi().createTicket(request).ticket.toDomain() + } + + 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) }) + } + + suspend fun logout() { + val api = requireApi() + // Dieses Gerät für Push abmelden, damit nach dem Ausloggen keine Popups mehr kommen (Fehler dabei verhindern das Ausloggen nicht) + sessionStore.loadPushToken()?.let { token -> + runCatching { api.pushDevice(de.deskhub.staff.data.remote.dto.PushDeviceRequest(action = "unregister", token = token)) } + } + api.logout() + clearSession() + } + + // ─── Admin: Wissensdatenbank ───────────────────────────────────────────── + + suspend fun kbData(): KbData { + val response = requireApi().kb() + return KbData( + categories = response.categories.map(KbCategoryDto::toDomain), + articles = response.articles.map(KbArticleDto::toDomain), + ) + } + + suspend fun kbSaveArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean): Int = + requireApi().kbSaveArticle(KbSaveArticleRequest(id = id, title = title, categoryId = categoryId, body = body, isPublished = isPublished)).id + + suspend fun kbDeleteArticle(id: Int) = requireApi().kbDeleteArticle(id) + + suspend fun kbCreateCategory(name: String): Int = requireApi().kbCreateCategory(name).id + + suspend fun kbDeleteCategory(id: Int) = requireApi().kbDeleteCategory(id) + + // ─── Admin: Zeiterfassung ──────────────────────────────────────────────── + + suspend fun timeEntries(staffId: Int?, from: String, to: String): TimeEntriesReport { + val query = buildMap { + put("from", from) + put("to", to) + staffId?.let { put("staffId", it.toString()) } + } + val response = requireApi().timeEntries(query) + return TimeEntriesReport( + staffOptions = response.staffOptions.map(NamedOptionDto::toDomain), + entries = response.entries.map(TimeEntryDto::toDomain), + totalsByStaff = response.totalsByStaff.map(TimeEntryTotalDto::toDomain), + totalMinutes = response.totalMinutes, + ) + } + + // ─── Admin: Eskalationsregeln ──────────────────────────────────────────── + + suspend fun escalationData(): EscalationData { + val response = requireApi().escalationRules() + return EscalationData( + priorities = response.priorities.map(NamedOptionDto::toDomain), + staffOptions = response.staffOptions.map(NamedOptionDto::toDomain), + departments = response.departments.map(NamedOptionDto::toDomain), + rules = response.rules.map(EscalationRuleDto::toDomain), + ) + } + + suspend fun escalationSave( + 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, + sortOrder: Int, + ): Int = requireApi().escalationSave( + EscalationSaveRequest( + 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 = sortOrder, + ), + ).id + + suspend fun escalationDelete(id: Int) = requireApi().escalationDelete(id) + + suspend fun escalationToggle(id: Int) = requireApi().escalationToggle(id) + + // ─── Admin: Sperrliste ──────────────────────────────────────────────────── + + suspend fun banlist(): List = requireApi().banlist().items.map(BanEntryDto::toDomain) + + suspend fun banCreate(matchType: String, value: String, reason: String?) = + requireApi().banCreate(matchType, value, reason?.ifBlank { null }) + + suspend fun banBulkAdd(values: String, reason: String?): Pair { + val response = requireApi().banBulkAdd(values, reason?.ifBlank { null }) + return response.added to response.skipped + } + + suspend fun banDelete(id: Int) = requireApi().banDelete(id) + + // ─── Admin: Abteilungen ─────────────────────────────────────────────────── + + suspend fun departmentData(): DepartmentData { + val r = requireApi().departments() + return DepartmentData( + departments = r.items.map(DepartmentDto::toDomain), + emailOptions = r.emailOptions.map { DepartmentEmailOption(it.email, it.departmentName) }, + staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), + slaPlans = r.slaPlans.map(NamedOptionDto::toDomain), + ) + } + + suspend fun departmentSave(id: Int?, input: DepartmentInput): Int = + requireApi().departmentSave( + DepartmentSaveRequest( + id = id, name = input.name, email = input.email, isDefault = input.isDefault, signature = input.signature, + isActive = input.isActive, managerId = input.managerId, slaPlanId = input.slaPlanId, notes = input.notes, + autoAssignMode = input.autoAssignMode, autoAssignStaffId = input.autoAssignStaffId, + ), + ).id + + suspend fun departmentDelete(id: Int) = requireApi().departmentDelete(id) + + // ─── Admin: Prioritäten ─────────────────────────────────────────────────── + + suspend fun report(config: de.deskhub.staff.data.remote.dto.ReportConfigDto?, savedId: Int?): de.deskhub.staff.data.remote.dto.ReportResponseDto { + val query = buildMap { + if (savedId != null) { + put("saved", savedId.toString()) + } else if (config != null) { + put("dimension", config.dimension) + put("metrics", config.metrics.joinToString(",")) + if (config.from.isNotBlank()) put("from", config.from) + if (config.to.isNotBlank()) put("to", config.to) + put("sort", config.sort) + if (config.desc) put("desc", "1") + } + } + return requireApi().report(query) + } + + suspend fun reportSave(name: String, shared: Boolean, config: de.deskhub.staff.data.remote.dto.ReportConfigDto): Int = + requireApi().reportSave(de.deskhub.staff.data.remote.dto.ReportSaveRequest(name = name, isShared = shared, config = config)).id + + suspend fun reportDelete(id: Int) = requireApi().reportDelete(id) + + suspend fun savedViews(): List = requireApi().savedViews().items.map { dto -> + val f = dto.filters + de.deskhub.staff.domain.model.SavedView( + id = dto.id, name = dto.name, isShared = dto.isShared, mine = dto.mine, ownerName = dto.ownerName, + filters = TicketFilters( + query = f["q"].orEmpty(), + statuses = f["status"].orEmpty().split(',').filter { it.isNotBlank() }.toSet().let { s -> + if (s.containsAll(setOf("open", "pending", "answered", "on_hold", "resolved", "closed"))) emptySet() else s + }, + assigned = f["assigned"].orEmpty(), + departmentId = f["departmentId"]?.toIntOrNull(), + priorityId = f["priorityId"]?.toIntOrNull(), + sla = f["sla"].orEmpty(), + ), + ) + } + + suspend fun savedViewSave(name: String, shared: Boolean, filters: TicketFilters): Int { + val map = buildMap { + if (filters.query.isNotBlank()) put("q", filters.query) + put("status", (if (filters.statuses.isEmpty()) setOf("open", "pending", "answered", "on_hold", "resolved", "closed") else filters.statuses).joinToString(",")) + if (filters.assigned.isNotBlank()) put("assigned", filters.assigned) + filters.departmentId?.let { put("departmentId", it.toString()) } + filters.priorityId?.let { put("priorityId", it.toString()) } + if (filters.sla.isNotBlank()) put("sla", filters.sla) + } + return requireApi().savedViewSave(de.deskhub.staff.data.remote.dto.SavedViewSaveRequest(name = name, isShared = shared, filters = map)).id + } + + suspend fun savedViewDelete(id: Int) = requireApi().savedViewDelete(id) + + suspend fun slaPlans(): List = + requireApi().slaPlans().items.map { de.deskhub.staff.domain.model.SlaPlan(it.id, it.name, it.responseHours, it.resolutionHours, it.isActive, it.notes, it.departmentCount, it.helpTopicCount) } + + suspend fun slaPlanSave(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String): Int = + requireApi().slaPlanSave(de.deskhub.staff.data.remote.dto.SlaPlanSaveRequest(id = id, name = name, responseHours = responseHours, resolutionHours = resolutionHours, isActive = isActive, notes = notes)).id + + suspend fun slaPlanDelete(id: Int) = requireApi().slaPlanDelete(id) + + suspend fun prioritiesAdmin(): List = requireApi().prioritiesAdmin().items.map(PriorityFullDto::toDomain) + + suspend fun prioritySave(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int): Int = + requireApi().prioritySave( + PrioritySaveRequest(id = id, name = name, color = color, responseHours = responseHours, resolutionHours = resolutionHours, sortOrder = sortOrder), + ).id + + suspend fun priorityDelete(id: Int) = requireApi().priorityDelete(id) + + // ─── Admin: Hilfe-Themen ────────────────────────────────────────────────── + + suspend fun helpTopics(): HelpTopicData { + val r = requireApi().helpTopics() + return HelpTopicData( + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + slaPlans = r.slaPlans.map(NamedOptionDto::toDomain), + topics = r.items.map(HelpTopicDto::toDomain), + ) + } + + suspend fun helpTopicSave(id: Int?, input: HelpTopicInput): Int = + requireApi().helpTopicSave( + HelpTopicSaveRequest( + id = id, name = input.name, departmentId = input.departmentId, priorityId = input.priorityId, isActive = input.isActive, + isPublic = input.isPublic, parentId = input.parentId, slaPlanId = input.slaPlanId, notes = input.notes, sortOrder = input.sortOrder, + ), + ).id + + suspend fun helpTopicDelete(id: Int) = requireApi().helpTopicDelete(id) + + // ─── Admin: Organisationen ──────────────────────────────────────────────── + + suspend fun organizations(): List = requireApi().organizations().items.map(OrganizationDto::toDomain) + + suspend fun organizationSave(id: Int?, name: String, notes: String?): Int = + requireApi().organizationSave(OrganizationSaveRequest(id = id, name = name, notes = notes)).id + + suspend fun organizationDelete(id: Int) = requireApi().organizationDelete(id) + + // ─── Admin: Teams ───────────────────────────────────────────────────────── + + suspend fun teams(): TeamData { + val r = requireApi().teams() + return TeamData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), teams = r.items.map(TeamDto::toDomain)) + } + + suspend fun teamSave(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List): Int = + requireApi().teamSave(TeamSaveRequest(id = id, name = name, leadId = leadId, notes = notes, memberIds = memberIds)).id + + suspend fun teamToggle(id: Int) = requireApi().teamToggle(id) + + suspend fun teamDelete(id: Int) = requireApi().teamDelete(id) + + // ─── Admin: Rollen ──────────────────────────────────────────────────────── + + suspend fun roles(): RoleData { + val r = requireApi().roles() + return RoleData( + permissionKeys = r.permissionKeys, + defaultAgentPermissions = r.defaultAgentPermissions, + roles = r.items.map(RoleDto::toDomain), + ) + } + + suspend fun roleSave(id: Int?, name: String, permissions: Map): Int = + requireApi().roleSave(RoleSaveRequest(id = id, name = name, permissions = permissions)).id + + suspend fun roleDelete(id: Int) = requireApi().roleDelete(id) + + // ─── Admin: Mitarbeiter ─────────────────────────────────────────────────── + + suspend fun staffAdminList(): StaffAdminData { + val r = requireApi().staffAdminList() + return StaffAdminData( + departments = r.departments.map(NamedOptionDto::toDomain), + roles = r.roles.map(NamedOptionDto::toDomain), + staff = r.items.map(StaffAdminDto::toDomain), + teams = r.teams.map(NamedOptionDto::toDomain), + canManageTeams = r.canManageTeams, + ) + } + + suspend fun staffAdminSave(id: Int?, input: StaffAdminInput): Int = requireApi().staffAdminSave( + StaffAdminSaveRequest( + id = id, name = input.name, email = input.email, role = input.role, roleId = input.roleId, + departmentId = input.departmentId, isActive = input.isActive, password = input.password, signature = input.signature, + extraDepartments = input.extraDepartments.map { StaffExtraDeptDto(it.departmentId, it.notify, it.roleId) }, + teamIds = input.teamIds, + ), + ).id + + suspend fun staffAdminDelete(id: Int) = requireApi().staffAdminDelete(id) + + // ─── Admin: Geschäftszeiten ─────────────────────────────────────────────── + + suspend fun businessHours(): BusinessHoursData { + val r = requireApi().businessHours() + return BusinessHoursData( + enabled = r.enabled, + hours = r.hours.map(BusinessHourDto::toDomain), + holidays = r.holidays.map(BusinessHolidayDto::toDomain), + ) + } + + suspend fun businessHoursSave(enabled: Boolean, hours: List) = + requireApi().businessHoursSave(enabled, hours.map { BusinessHourDto(it.weekday, it.isOpen, it.openTime, it.closeTime) }) + + suspend fun businessHolidayAdd(date: String, name: String): Int = requireApi().businessHolidayAdd(date, name).id + + suspend fun businessHolidayDelete(id: Int) = requireApi().businessHolidayDelete(id) + + // ─── Admin: E-Mail-Adressen ─────────────────────────────────────────────── + + suspend fun emailAddresses(): EmailAddressData { + val r = requireApi().emailAddresses() + return EmailAddressData( + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + helpTopics = r.helpTopics.map(NamedOptionDto::toDomain), + addresses = r.items.map(EmailAddressDto::toDomain), + ) + } + + suspend fun emailAddressSave(id: Int?, input: EmailAddressInput): Int = + requireApi().emailAddressSave( + EmailAddressSaveRequest( + id = id, email = input.email, label = input.label, departmentId = input.departmentId, priorityId = input.priorityId, isDefault = input.isDefault, + helpTopicId = input.helpTopicId, isActive = input.isActive, notes = input.notes, + smtpEnabled = input.smtpEnabled, smtpHost = input.smtpHost, smtpPort = input.smtpPort, smtpEncryption = input.smtpEncryption, + smtpUsername = input.smtpUsername, smtpPassword = input.smtpPassword, + imapEnabled = input.imapEnabled, imapHost = input.imapHost, imapPort = input.imapPort, imapEncryption = input.imapEncryption, + imapUsername = input.imapUsername, imapPassword = input.imapPassword, imapFolder = input.imapFolder, imapAfterFetch = input.imapAfterFetch, + imapArchiveFolder = input.imapArchiveFolder, imapSpamFolder = input.imapSpamFolder, + ), + ).id + + /** action: "sendTest" (Test-Mail an die eigene Adresse) oder "testImap" (Postfach-Verbindung). Liefert die Servermeldung. */ + suspend fun emailAddressAction(action: String, id: Int): String = requireApi().emailAddressAction(action, id).message + + suspend fun emailAddressDelete(id: Int) = requireApi().emailAddressDelete(id) + + // ─── Admin: E-Mail-Vorlagen ─────────────────────────────────────────────── + + suspend fun emailTemplates(): List = requireApi().emailTemplates().items.map(EmailTemplateDto::toDomain) + + suspend fun emailTemplateSave(key: String, subject: String, bodyText: String) = requireApi().emailTemplateSave(key, subject, bodyText) + + suspend fun emailTemplateReset(key: String) = requireApi().emailTemplateReset(key) + + // ─── Admin: Admin-Protokoll ─────────────────────────────────────────────── + + suspend fun auditLog(page: Int): AuditLogPage { + val r = requireApi().auditLog(page) + return AuditLogPage(items = r.items.map(AuditLogEntryDto::toDomain), page = r.page, hasMore = r.hasMore) + } + + // ─── Aufgaben ───────────────────────────────────────────────────────────── + + suspend fun tasks(filter: String): de.deskhub.staff.domain.model.TaskData { + val r = requireApi().tasks(filter) + return de.deskhub.staff.domain.model.TaskData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), tasks = r.items.map(TaskDto::toDomain)) + } + + suspend fun taskCreate(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?): Int = + requireApi().taskCreate( + TaskCreateRequest(title = title, description = description, assignedToId = assignedToId, dueAt = dueAt, ticketNumber = ticketNumber), + ).id + + suspend fun taskUpdateStatus(id: Int, status: String) = requireApi().taskUpdateStatus(id, status) + + suspend fun taskDelete(id: Int) = requireApi().taskDelete(id) + + // ─── Interner Chat ──────────────────────────────────────────────────────── + + suspend fun chatContacts(): List = requireApi().chatContacts().contacts.map(ChatContactDto::toDomain) + + suspend fun chatConversation(withId: Int): ChatConversation { + val r = requireApi().chatConversation(withId) + return ChatConversation(with = r.with.toDomain(), messages = r.messages.map(ChatMessageDto::toDomain)) + } + + suspend fun chatSend(toId: Int, message: String): Int = requireApi().chatSend(toId, message).id + + // ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────── + + suspend fun liveChatList(): List = + requireApi().liveChatList().items.map(LiveChatListItemDto::toDomain) + + suspend fun liveChatMessages(number: String): LiveChatDetail { + val r = requireApi().liveChatMessages(number, 0) + return LiveChatDetail( + number = number, + status = r.status, + requesterName = r.requesterName, + requesterEmail = r.requesterEmail, + departmentName = r.departmentName, + lockedByOtherName = r.lockedByOtherName, + customerTyping = r.customerTyping, + messages = r.messages.map(LiveChatMessageDto::toDomain), + ) + } + + suspend fun liveChatSend(number: String, message: String) { requireApi().liveChatSend(number, message) } + + suspend fun liveChatTyping(number: String) { requireApi().liveChatTyping(number) } + + suspend fun liveChatEnd(number: String) { requireApi().liveChatEnd(number) } + + // ─── Verfügbarkeit (Status) ──────────────────────────────────────────────── + + suspend fun availability(): AvailabilityData { + val r = requireApi().availability() + return AvailabilityData(myStatus = r.myStatus, myNote = r.myNote, team = r.team.map(TeamMemberDto::toDomain)) + } + + /** Hebt die Bearbeitungssperre eines Tickets auf (eigene, als Administrator auch die eines anderen). */ + suspend fun unlockTicket(number: String) { + requireApi().unlockTicket(number) + } + + suspend fun setAvailability(status: String, note: String, staffId: Int? = null) { requireApi().setAvailability(status, note, staffId) } + + // ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────── + + suspend fun ticketRules(): TicketRulesData { + val r = requireApi().ticketRules() + return TicketRulesData( + fieldOptions = r.fieldOptions.map(KeyLabelDto::toDomain), + operatorOptions = r.operatorOptions.map(KeyLabelDto::toDomain), + actionOptions = r.actionOptions.map(KeyLabelDto::toDomain), + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + helpTopics = r.helpTopics.map(NamedOptionDto::toDomain), + staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), + cannedResponses = r.cannedResponses.map(NamedOptionDto::toDomain), + rules = r.rules.map(TicketRuleDto::toDomain), + autoCloseEnabled = r.autoCloseEnabled, + autoCloseDays = r.autoCloseDays, + ) + } + + suspend fun saveTicketRule( + id: Int?, + name: String, + matchType: String, + isActive: Boolean, + sortOrder: Int, + conditions: List, + actions: List, + ): Int { + val request = TicketRuleSaveRequest( + id = id, + name = name, + matchType = matchType, + isActive = isActive, + sortOrder = sortOrder, + conditions = conditions.map { TicketRuleConditionDto(it.field, it.operator, it.value) }, + actions = actions.map { TicketRuleActionDto(it.type, it.value, it.valueLabel) }, + ) + return requireApi().saveTicketRule(request).id + } + + suspend fun toggleTicketRule(id: Int) { requireApi().toggleTicketRule(id) } + + suspend fun deleteTicketRule(id: Int) { requireApi().deleteTicketRule(id) } + + suspend fun saveAutoClose(enabled: Boolean, days: Int) { requireApi().saveAutoClose(enabled, days) } + + // ─── Bild-Upload ────────────────────────────────────────────────────────── + + suspend fun uploadImage(bytes: ByteArray, filename: String, mimeType: String): String = + requireApi().uploadImage(bytes, filename, mimeType).url + + fun clearSession() { + sessionStore.clear() + session = null + api = null + } + + private fun configure(stored: StoredSession) { + session = stored + api = DeskHubApi(stored.serverUrl) { session?.token } + } + + private fun requireApi(): DeskHubApi = + checkNotNull(api) { "Keine aktive deskhub-Sitzung." } +} + +private fun StaffDto.toDomain() = Staff(id, name, email, role, permissions) +private fun NamedOptionDto.toDomain() = NamedOption(id, name) +private fun PriorityDto.toDomain() = Priority(id, name, color) +private fun StatusOptionDto.toDomain() = StatusOption(value, label, color) +private fun CannedResponseDto.toDomain() = CannedResponse(id, title, body, departmentId) +private fun DepartmentStatDto.toDomain() = DepartmentStat(name, open, overdue) +private fun CustomerDto.toDomain() = Customer(id, name, email, organizationName, ticketCount, isGuest) +private fun CustomerDetailDto.toDomain() = CustomerDetail(id, name, email, organizationName, isGuest, createdAt) +private fun BanEntryDto.toDomain() = BanEntry(id, matchType, value, reason, addedByName, createdAt) +private fun DepartmentDto.toDomain() = Department( + id, name, email, isDefault, signature, isActive, managerId, managerName, slaPlanId, notes, + members.map { DepartmentMember(it.id, it.name, it.isPrimary) }, updatedAt, autoAssignMode, autoAssignStaffId, +) +private fun PriorityFullDto.toDomain() = PriorityFull(id, name, color, responseHours, resolutionHours, sortOrder) +private fun HelpTopicDto.toDomain() = HelpTopic(id, name, departmentId, departmentName, priorityId, priorityName, priorityColor, isActive, sortOrder, isPublic, parentId, slaPlanId, path.ifBlank { name }, notes, updatedAt) +private fun OrganizationDto.toDomain() = Organization(id, name, notes, memberCount) +private fun TeamDto.toDomain() = Team(id, name, leadId, leadName, notes, isActive, memberCount, memberIds) +private fun RoleDto.toDomain() = Role(id, name, permissions, staffCount) +private fun StaffAdminDto.toDomain() = StaffAdmin( + id, name, email, role, roleId, departmentId, departmentName, isActive, signature, + extraDepartments.map { StaffExtraDept(it.departmentId, it.notify, it.roleId) }, + teamIds, createdAt, +) +private fun BusinessHourDto.toDomain() = BusinessHour(weekday, isOpen, openTime, closeTime) +private fun BusinessHolidayDto.toDomain() = BusinessHoliday(id, date, name) +private fun EmailAddressDto.toDomain() = EmailAddress( + id, email, label, departmentId, departmentName, priorityId, priorityName, isDefault, + helpTopicId, isActive, notes, smtpEnabled, smtpHost, smtpPort, smtpEncryption, smtpUsername, smtpHasPassword, + imapEnabled, imapHost, imapPort, imapEncryption, imapUsername, imapFolder, imapAfterFetch, imapArchiveFolder, imapSpamFolder, imapHasPassword, imapLastRunAt, +) +private fun EmailTemplateDto.toDomain() = EmailTemplate(key, label, placeholders, subject, body, isCustomized) +private fun AuditLogEntryDto.toDomain() = AuditLogEntry(id, staffName, action, createdAt) +private fun TaskDto.toDomain() = StaffTask(id, title, description, status, assignedToId, assignedName, creatorName, dueAt, ticketNumber, ticketSubject) +private fun ChatContactDto.toDomain() = ChatContact(id, name, unreadCount, lastMessageAt) +private fun ChatMessageDto.toDomain() = ChatMessage(id, fromStaffId, fromName, message, createdAt) +private fun LiveChatListItemDto.toDomain() = LiveChatSummary(number, name, preview, needsReply, updatedAt) +private fun LiveChatAttachmentDto.toDomain() = LiveChatAttachment(id, name, size) +private fun LiveChatMessageDto.toDomain() = LiveChatMessage(id, authorType, authorName, message, createdAt, attachments.map(LiveChatAttachmentDto::toDomain)) +private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt, autoOffline) +private fun KeyLabelDto.toDomain() = KeyLabel(value, label) +private fun TicketRuleConditionDto.toDomain() = TicketRuleCondition(field, operator, value) +private fun TicketRuleActionDto.toDomain() = TicketRuleAction(type, value, valueLabel) +private fun TicketRuleDto.toDomain() = TicketRule( + id, name, matchType, isActive, sortOrder, + conditions.map(TicketRuleConditionDto::toDomain), + actions.map(TicketRuleActionDto::toDomain), +) +private fun KbCategoryDto.toDomain() = KbCategory(id, name) +private fun KbArticleDto.toDomain() = KbArticle(id, categoryId, categoryName, title, body, isPublished, views) +private fun TimeEntryDto.toDomain() = TimeEntry(id, staffName, ticketNumber, subject, minutes, note, loggedAt) +private fun TimeEntryTotalDto.toDomain() = TimeEntryTotal(staffName, minutes) +private fun EscalationRuleDto.toDomain() = EscalationRule( + id = id, + name = name, + isActive = isActive, + triggerType = triggerType, + triggerHours = triggerHours, + conditionStatus = conditionStatus, + conditionPriorityId = conditionPriorityId, + conditionPriorityName = conditionPriorityName, + conditionDeptId = conditionDeptId, + conditionDeptName = conditionDeptName, + actionSetPriorityId = actionSetPriorityId, + actionSetPriorityName = actionSetPriorityName, + actionAssignStaffId = actionAssignStaffId, + actionAssignStaffName = actionAssignStaffName, + actionSetStatus = actionSetStatus, + actionNotifyAdmin = actionNotifyAdmin, + actionNotifyStaff = actionNotifyStaff, + sortOrder = sortOrder, + appliedCount = appliedCount, +) + +private fun TicketSummaryDto.toDomain() = TicketSummary( + id = id, + number = number, + subject = subject, + requesterName = requesterName, + requesterEmail = requesterEmail, + status = status, + priority = priority.toDomain(), + department = department.toDomain(), + assignedStaff = assignedStaff?.toDomain(), + isOverdue = isOverdue, + dueLabel = dueLabel, + isSpam = isSpam, + createdAt = createdAt, + updatedAt = updatedAt, + snoozedUntil = snoozedUntil, + following = following, +) + +private fun TicketDetailDto.toDomain() = TicketDetail( + summary = TicketSummary( + id = id, + number = number, + subject = subject, + requesterName = requesterName, + requesterEmail = requesterEmail, + status = status, + priority = priority.toDomain(), + department = department.toDomain(), + assignedStaff = assignedStaff?.toDomain(), + isOverdue = isOverdue, + isSpam = isSpam, + createdAt = createdAt, + updatedAt = updatedAt, + snoozedUntil = snoozedUntil, + following = following, + ), + similar = similar?.let { s -> SimilarTickets(s.articles.map { it.title to it.url }, s.tickets.map { it.number to it.subject }) }, + source = source, + firstResponseDueAt = firstResponseDueAt, + resolutionDueAt = resolutionDueAt, + lockedByOtherName = lockedByOtherName, + lockedBy = lockedBy, + canUnlock = canUnlock, + threads = threads.map { thread -> + TicketThread( + id = thread.id, + authorType = thread.authorType, + authorName = thread.authorName, + message = thread.message, + isInternalNote = thread.isInternalNote, + createdAt = thread.createdAt, + attachments = thread.attachments.map { ThreadAttachment(it.id, it.name, it.size) }, + comments = thread.comments.map { + ThreadComment(it.id, it.message, it.staff.toDomain(), it.createdAt) + }, + ) + }, + permissions = permissions, + customFields = customFields.map { de.deskhub.staff.domain.model.CustomFieldValue(it.label, it.value) }, + replySignatures = replySignatures?.let { sig -> + de.deskhub.staff.domain.model.ReplySignatures( + default = sig.default, + own = de.deskhub.staff.domain.model.ReplySignature(sig.own.available, sig.own.text, sig.own.hasImage, sig.own.name, sig.own.imageUrl), + department = de.deskhub.staff.domain.model.ReplySignature(sig.department.available, sig.department.text, sig.department.hasImage, sig.department.name, sig.department.imageUrl), + ) + }, +) diff --git a/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt b/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt index b030d293..f4ecfe7f 100644 --- a/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt +++ b/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt @@ -2,6 +2,12 @@ package de.deskhub.staff.data.remote import com.google.gson.GsonBuilder import de.deskhub.staff.data.remote.dto.ActivityResponseDto +import de.deskhub.staff.data.remote.dto.AssistantAnswerResponseDto +import de.deskhub.staff.data.remote.dto.AssistantAnswersDto +import de.deskhub.staff.data.remote.dto.AssistantLogDto +import de.deskhub.staff.data.remote.dto.AssistantOkDto +import de.deskhub.staff.data.remote.dto.AssistantOverviewDto +import de.deskhub.staff.data.remote.dto.AssistantTestDto import de.deskhub.staff.data.remote.dto.CannedResponseListResponse import de.deskhub.staff.data.remote.dto.CreateTicketRequest import de.deskhub.staff.data.remote.dto.CreateTicketResponse @@ -166,6 +172,24 @@ class DeskHubApi( query = mapOf("number" to number), ) + suspend fun assistantOverview(): AssistantOverviewDto = + execute("assistant.php", "GET", null, AssistantOverviewDto::class.java, query = mapOf("section" to "overview")) + + suspend fun assistantAnswers(): AssistantAnswersDto = + execute("assistant.php", "GET", null, AssistantAnswersDto::class.java, query = mapOf("section" to "answers")) + + suspend fun assistantLog(query: Map): AssistantLogDto = + execute("assistant.php", "GET", null, AssistantLogDto::class.java, query = query + ("section" to "log")) + + suspend fun assistantAction(request: Map): AssistantOkDto = + execute("assistant.php", "POST", request, AssistantOkDto::class.java) + + suspend fun assistantSaveAnswer(request: Map): AssistantAnswerResponseDto = + execute("assistant.php", "POST", request, AssistantAnswerResponseDto::class.java) + + suspend fun assistantTest(question: String): AssistantTestDto = + execute("assistant.php", "POST", mapOf("action" to "test", "question" to question), AssistantTestDto::class.java) + suspend fun updateTicket(request: Map): TicketSummaryResponse = execute("ticket_update.php", "PATCH", request, TicketSummaryResponse::class.java) diff --git a/mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt b/mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt index 3a2f33d4..ec99215b 100644 --- a/mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt +++ b/mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt @@ -1,5 +1,84 @@ package de.deskhub.staff.data.remote.dto +// ── Digitaler Assistent (Verwaltung) ───────────────────────────────────────── + +data class AssistantSettingsDto( + val enabled: Boolean = false, + val mode: String = "offline_only", + val name: String = "", + val greeting: String = "", + val noAnswer: String = "", + val departmentId: Int = 0, + val priorityId: Int = 0, + val logDays: Int = 90, + val synonyms: String = "", +) + +data class AssistantDefaultsDto(val name: String = "", val greeting: String = "", val noAnswer: String = "") + +data class AssistantStateDto( + val activeNow: Boolean = false, + val liveChatEnabled: Boolean = false, + val kbEnabled: Boolean = true, + val helpdeskOffline: Boolean = false, +) + +data class AssistantOverviewDto( + val dbReady: Boolean = true, + val settings: AssistantSettingsDto = AssistantSettingsDto(), + val defaults: AssistantDefaultsDto = AssistantDefaultsDto(), + val state: AssistantStateDto = AssistantStateDto(), + val departments: List = emptyList(), + val priorities: List = emptyList(), +) + +data class AssistantAnswerDto( + val id: Int = 0, + val title: String = "", + val keywords: String = "", + val answer: String = "", + val isActive: Boolean = true, + val hits: Int = 0, + val updatedAt: String = "", +) + +data class AssistantAnswersDto(val items: List = emptyList()) + +data class AssistantAnswerResponseDto(val item: AssistantAnswerDto = AssistantAnswerDto()) + +data class AssistantLogItemDto( + val id: Int = 0, + val question: String = "", + val result: String = "", + val matchedTitle: String? = null, + val ticketNumber: String? = null, + val helpful: Boolean? = null, + val createdAt: String = "", +) + +data class AssistantTopDto(val question: String = "", val n: Int = 0) + +data class AssistantLogDto( + val filters: Map = emptyMap(), + val stats: Map = emptyMap(), + val top: List = emptyList(), + val total: Int = 0, + val pages: Int = 1, + val page: Int = 1, + val items: List = emptyList(), +) + +data class AssistantTestResultDto( + val source: String = "", + val title: String = "", + val text: String = "", + val others: List = emptyList(), +) + +data class AssistantTestDto(val found: Boolean = false, val result: AssistantTestResultDto? = null) + +data class AssistantOkDto(val ok: Boolean = true, val groups: Int = 0, val deleted: Int = 0) + data class StaffDto( val id: Int = 0, val name: String = "", @@ -40,6 +119,9 @@ data class TicketSummaryDto( val isSpam: Boolean = false, val createdAt: String = "", val updatedAt: String = "", + // Wiedervorlage (Zeitpunkt, bis zu dem das Ticket ausgeblendet ist) und ob der Mitarbeiter das Ticket beobachtet + val snoozedUntil: String? = null, + val following: Boolean = false, ) data class AttachmentDto( @@ -93,6 +175,19 @@ data class TicketDetailDto( val replySignatures: ReplySignaturesDto? = null, // Vom Kunden im Formular ausgefüllte Zusatzfelder; leer bei älteren Servern val customFields: List = emptyList(), + val snoozedUntil: String? = null, + val following: Boolean = false, + // Passende Artikel und ähnliche gelöste Tickets; null bei älteren Servern + val similar: SimilarDto? = null, +) + +data class SimilarArticleDto(val title: String = "", val url: String = "") + +data class SimilarTicketDto(val number: String = "", val subject: String = "") + +data class SimilarDto( + val articles: List = emptyList(), + val tickets: List = emptyList(), ) data class CustomFieldValueDto( diff --git a/mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt b/mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt index ae118520..32606b17 100644 --- a/mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt +++ b/mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt @@ -43,6 +43,10 @@ data class TicketSummary( val isSpam: Boolean, val createdAt: String, val updatedAt: String, + /** Wiedervorlage: Das Ticket ist bis zu diesem Zeitpunkt ausgeblendet. */ + val snoozedUntil: String? = null, + /** Der angemeldete Mitarbeiter beobachtet dieses Ticket. */ + val following: Boolean = false, ) data class ThreadAttachment( @@ -88,6 +92,13 @@ data class TicketDetail( // Administrator: Name des Mitarbeiters, der dieses Ticket gerade bearbeitet (Sperre), und ob die Sperre aufgehoben werden darf val lockedBy: String? = null, val canUnlock: Boolean = false, + /** Passende Wissensdatenbank-Artikel und ähnliche gelöste Tickets (null bei älteren Servern). */ + val similar: SimilarTickets? = null, +) + +data class SimilarTickets( + val articles: List> = emptyList(), // Titel, Adresse + val tickets: List> = emptyList(), // Nummer, Betreff ) data class CustomFieldValue(val label: String, val value: String) @@ -198,6 +209,9 @@ data class TicketFilters( val tagId: Int? = null, // "" (alle) | "overdue" | "at_risk" | "on_time" – siehe staff/tickets.php für dieselbe Logik. val sla: String = "", + /** Nur Tickets in der Wiedervorlage / nur beobachtete Tickets. */ + val snoozed: Boolean = false, + val following: Boolean = false, ) /** Gespeicherte Ticket-Ansicht (Filterkombination), eigene oder von Kollegen geteilte. */ diff --git a/mobile/src/main/java/de/deskhub/staff/push/DeskHubMessagingService.kt b/mobile/src/main/java/de/deskhub/staff/push/DeskHubMessagingService.kt index 1e90955c..0dad2a8a 100644 --- a/mobile/src/main/java/de/deskhub/staff/push/DeskHubMessagingService.kt +++ b/mobile/src/main/java/de/deskhub/staff/push/DeskHubMessagingService.kt @@ -1,12 +1,14 @@ package de.deskhub.staff.push import com.google.firebase.messaging.FirebaseMessagingService +import de.deskhub.staff.DeskHubWidget import com.google.firebase.messaging.RemoteMessage /** Empfängt Nachrichten von Firebase Cloud Messaging (auch wenn die App geschlossen ist). */ class DeskHubMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { DeskHubPush.show(applicationContext, message.data) + DeskHubWidget.requestUpdate(applicationContext) // Widget zeigt sofort den neuen Stand } /** Firebase vergibt ein neues Geräte-Token (zum Beispiel nach Neuinstallation): beim Server melden. */ diff --git a/mobile/src/main/java/de/deskhub/staff/ui/AssistantAdmin.kt b/mobile/src/main/java/de/deskhub/staff/ui/AssistantAdmin.kt new file mode 100644 index 00000000..37ac79a9 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/AssistantAdmin.kt @@ -0,0 +1,290 @@ +package de.deskhub.staff.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import de.deskhub.staff.data.remote.dto.AssistantAnswerDto +import de.deskhub.staff.data.remote.dto.AssistantSettingsDto + +private val ASSISTANT_TABS = listOf("Einstellungen", "Antworten", "Fragen-Log", "Wörter", "Ausprobieren") + +/** Verwaltung des digitalen Assistenten: Einstellungen, zusätzliche Antworten, Fragen-Log, Gleichbedeutende Wörter, Ausprobieren. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdminAssistantScreen(state: AssistantUiState, viewModel: DeskHubViewModel, onBack: () -> Unit) { + var tab by rememberSaveable { mutableStateOf(0) } + // Aus dem Fragen-Log: eine neue Antwort zu dieser Frage anlegen + var prefill by rememberSaveable { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { viewModel.assistantLoadOverview() } + LaunchedEffect(tab) { + when (tab) { + 1 -> viewModel.assistantLoadAnswers() + 2 -> viewModel.assistantLoadLog() + 3 -> viewModel.assistantLoadOverview() + } + } + LaunchedEffect(state.message) { + state.message?.let { snackbar.showSnackbar(it); viewModel.assistantClearMessage() } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Digitaler Assistent", fontWeight = FontWeight.Bold) }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } }, + colors = deskTopBarColors(), + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 8.dp) { + ASSISTANT_TABS.forEachIndexed { i, label -> Tab(selected = tab == i, onClick = { tab = i }, text = { Text(label) }) } + } + if (state.error != null) Text(state.error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) + when (tab) { + 0 -> AssistantSettingsTab(state, viewModel) + 1 -> AssistantAnswersTab(state, viewModel, prefill) { prefill = null } + 2 -> AssistantLogTab(state, viewModel) { q -> prefill = q; tab = 1 } + 3 -> AssistantSynonymsTab(state, viewModel) + else -> AssistantTestTab(state, viewModel) + } + } + } +} + +@Composable +private fun PickField(label: String, options: List>, value: String, onPick: (String) -> Unit) { + var open by remember { mutableStateOf(false) } + Column { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) { Text(options.firstOrNull { it.first == value }?.second ?: "—", maxLines = 2) } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + options.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { onPick(v); open = false }) } + } + } +} + +@Composable +private fun AssistantSettingsTab(state: AssistantUiState, vm: DeskHubViewModel) { + val o = state.overview + if (o == null) { Text("Wird geladen …", modifier = Modifier.padding(16.dp)); return } + var s by remember(o) { mutableStateOf(o.settings) } + var days by remember(o) { mutableStateOf(o.settings.logDays.toString()) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (!o.dbReady) Text("Die Datenbank ist noch nicht aktuell. Bitte im Adminportal unter System-Update „Datenbank aktualisieren“ klicken.", color = MaterialTheme.colorScheme.error) + Text("Der Assistent beantwortet Fragen von Kunden aus der Wissensdatenbank und den zusätzlichen Antworten. Findet er nichts, bietet er ein Ticket an. Es wird kein externer KI-Dienst verwendet.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + val zustand = if (!o.settings.enabled) "ausgeschaltet" else if (o.state.activeNow) "aktiv (Kunden sehen den Assistenten)" else "bereit, aber gerade nicht sichtbar" + Text("Zustand jetzt: $zustand", fontWeight = FontWeight.SemiBold) + Text("Live-Chat ${if (o.state.liveChatEnabled) "an" else "aus"} · Wissensdatenbank im Portal ${if (o.state.kbEnabled) "an" else "aus"}", style = MaterialTheme.typography.bodySmall) + Row(verticalAlignment = Alignment.CenterVertically) { Switch(checked = s.enabled, onCheckedChange = { s = s.copy(enabled = it) }); Text(" Digitalen Assistenten einschalten") } + PickField("Wann ist er aktiv?", listOf("offline_only" to "Nur wenn der Live-Chat aus oder niemand erreichbar ist (Standard)", "always" to "Immer (zum Testen)"), s.mode) { s = s.copy(mode = it) } + OutlinedTextField(s.name, { s = s.copy(name = it.take(60)) }, label = { Text("Name des Assistenten") }, placeholder = { Text(o.defaults.name) }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(s.greeting, { s = s.copy(greeting = it.take(500)) }, label = { Text("Begrüßung") }, placeholder = { Text(o.defaults.greeting) }, modifier = Modifier.fillMaxWidth(), minLines = 3) + OutlinedTextField(s.noAnswer, { s = s.copy(noAnswer = it.take(500)) }, label = { Text("Text, wenn er keine Antwort findet") }, placeholder = { Text(o.defaults.noAnswer) }, modifier = Modifier.fillMaxWidth(), minLines = 2) + PickField("Abteilung für Tickets des Assistenten", listOf("0" to "Standardabteilung (bzw. Abteilung des Themas)") + o.departments.map { it.id.toString() to it.name }, s.departmentId.toString()) { s = s.copy(departmentId = it.toInt()) } + PickField("Priorität für Tickets des Assistenten", listOf("0" to "Standard") + o.priorities.map { it.id.toString() to it.name }, s.priorityId.toString()) { s = s.copy(priorityId = it.toInt()) } + OutlinedTextField(days, { days = it.filter(Char::isDigit).take(4) }, label = { Text("Fragen-Log: Aufbewahrung in Tagen (0 = nichts speichern)") }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)) + Button(onClick = { vm.assistantSaveSettings(s.copy(logDays = days.toIntOrNull() ?: 90)) }, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { Text("Speichern") } + } +} + +@Composable +private fun AssistantAnswersTab(state: AssistantUiState, vm: DeskHubViewModel, prefill: String?, onPrefillUsed: () -> Unit) { + var editing by remember { mutableStateOf(null) } + var creating by remember { mutableStateOf(false) } + LaunchedEffect(prefill) { if (prefill != null) creating = true } + val answers = state.answers + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Diese Antworten haben Vorrang vor der Wissensdatenbank. Der Assistent findet sie über die Wörter in der Frage und in den Stichwörtern.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = { creating = true }, modifier = Modifier.fillMaxWidth()) { Text("+ Antwort hinzufügen") } + if (answers == null) Text("Wird geladen …") + else if (answers.isEmpty()) Text("Noch keine zusätzlichen Antworten.") + else LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 24.dp)) { + items(answers) { a -> + Card(onClick = { editing = a }, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(a.title, fontWeight = FontWeight.SemiBold) + if (a.keywords.isNotBlank()) Text("Stichwörter: ${a.keywords}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(a.answer.take(120), style = MaterialTheme.typography.bodySmall) + Text("${a.hits}× genutzt · ${if (a.isActive) "Aktiv" else "Aus"}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + if (creating || editing != null) { + val a = editing + var title by remember(a, prefill) { mutableStateOf(a?.title ?: prefill.orEmpty()) } + var kw by remember(a) { mutableStateOf(a?.keywords.orEmpty()) } + var text by remember(a) { mutableStateOf(a?.answer.orEmpty()) } + var active by remember(a) { mutableStateOf(a?.isActive ?: true) } + val close = { creating = false; editing = null; onPrefillUsed() } + AlertDialog( + onDismissRequest = close, + title = { Text(if (a == null) "Neue Antwort" else "Antwort bearbeiten") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(title, { title = it.take(255) }, label = { Text("Frage / Thema *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(kw, { kw = it.take(1000) }, label = { Text("Stichwörter (durch Komma)") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(text, { text = it.take(4000) }, label = { Text("Antwort *") }, modifier = Modifier.fillMaxWidth(), minLines = 4) + Row(verticalAlignment = Alignment.CenterVertically) { Switch(active, { active = it }); Text(" Aktiv") } + if (a != null) TextButton(onClick = { vm.assistantDeleteAnswer(a.id) { close() } }) { Text("Antwort löschen", color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { TextButton(onClick = { vm.assistantSaveAnswer(a?.id ?: 0, title, kw, text, active) { close() } }, enabled = title.isNotBlank() && text.isNotBlank() && !state.loading) { Text("Speichern") } }, + dismissButton = { TextButton(onClick = close) { Text("Abbrechen") } }, + ) + } +} + +@Composable +private fun AssistantLogTab(state: AssistantUiState, vm: DeskHubViewModel, onCreateAnswer: (String) -> Unit) { + val log = state.log + var q by remember(state.logQuery) { mutableStateOf(state.logQuery) } + var confirmClear by remember { mutableStateOf(false) } + if (log == null) { Text("Wird geladen …", modifier = Modifier.padding(16.dp)); return } + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + item { + Text("Zeigt, was Kunden gefragt haben und ob der Assistent eine Antwort fand. Gespeichert werden nur Frage und Ergebnis.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + item { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + log.filters.entries.chunked(2).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + row.forEach { (key, label) -> + FilterChip(selected = state.logResult == key, onClick = { vm.assistantLoadLog(result = if (state.logResult == key) "" else key) }, label = { Text("${log.stats[key] ?: 0} · $label") }) + } + } + } + } + } + item { + PickField("Zeitraum", listOf("today" to "Heute", "7" to "Letzte 7 Tage", "30" to "Letzte 30 Tage", "all" to "Gesamter Zeitraum"), state.logPeriod) { vm.assistantLoadLog(period = it) } + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField(q, { q = it.take(100) }, label = { Text("Suchen …") }, singleLine = true, modifier = Modifier.weight(1f)) + OutlinedButton(onClick = { vm.assistantLoadLog(query = q) }) { Text("Filtern") } + } + } + if (log.top.isNotEmpty()) item { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Häufige Fragen ohne Antwort", fontWeight = FontWeight.SemiBold) + log.top.forEach { t -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text("${t.question} (${t.n}×)", Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = { onCreateAnswer(t.question) }) { Text("Antwort anlegen") } + } + } + } + } + } + items(log.items) { x -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Text(x.question, fontWeight = FontWeight.Medium) + val res = (log.filters[x.result] ?: x.result) + (when (x.helpful) { true -> " 👍"; false -> " 👎"; null -> "" }) + Text(res + (x.ticketNumber?.let { " · #$it" } ?: x.matchedTitle?.let { " · $it" } ?: ""), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(x.createdAt.take(16), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (x.result == "none") TextButton(onClick = { onCreateAnswer(x.question) }) { Text("Antwort anlegen") } + } + } + } + item { + Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { vm.assistantLoadLog(page = log.page - 1) }, enabled = log.page > 1) { Text("‹") } + Text("${log.total} Einträge · Seite ${log.page}/${log.pages}", style = MaterialTheme.typography.bodySmall) + OutlinedButton(onClick = { vm.assistantLoadLog(page = log.page + 1) }, enabled = log.page < log.pages) { Text("›") } + } + } + item { TextButton(onClick = { confirmClear = true }) { Text("Log leeren", color = MaterialTheme.colorScheme.error) } } + } + if (confirmClear) AlertDialog( + onDismissRequest = { confirmClear = false }, + title = { Text("Fragen-Log leeren?") }, text = { Text("Das gesamte Fragen-Log wird gelöscht.") }, + confirmButton = { TextButton(onClick = { confirmClear = false; vm.assistantClearLog() }) { Text("Löschen", color = MaterialTheme.colorScheme.error) } }, + dismissButton = { TextButton(onClick = { confirmClear = false }) { Text("Abbrechen") } }, + ) +} + +@Composable +private fun AssistantSynonymsTab(state: AssistantUiState, vm: DeskHubViewModel) { + val o = state.overview + if (o == null) { Text("Wird geladen …", modifier = Modifier.padding(16.dp)); return } + var text by remember(o) { mutableStateOf(o.settings.synonyms) } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Wörter, die Kunden und Artikel unterschiedlich nennen, aber dasselbe meinen. Eine Gruppe je Zeile, durch Komma getrennt, zum Beispiel „Zugang, Einladung, beitreten“. Einige Gruppen sind eingebaut, deine ergänzen oder überschreiben sie.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField(text, { text = it.take(5000) }, modifier = Modifier.fillMaxWidth(), minLines = 8, placeholder = { Text("Zugang, Einladung, beitreten\nGeld zurück, Erstattung, Rückzahlung") }) + Button(onClick = { vm.assistantSaveSynonyms(text) }, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) { Text("Speichern") } + } +} + +@Composable +private fun AssistantTestTab(state: AssistantUiState, vm: DeskHubViewModel) { + var q by rememberSaveable { mutableStateOf("") } + val r = state.testResult + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Zeigt, was der Assistent auf eine Kundenfrage antworten würde (ohne Zähler und ohne Ticket).", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField(q, { q = it.take(300) }, label = { Text("z. B. Wie setze ich mein Passwort zurück?") }, modifier = Modifier.fillMaxWidth()) + Button(onClick = { vm.assistantTest(q) }, enabled = q.isNotBlank() && !state.loading, modifier = Modifier.fillMaxWidth()) { Text("Testen") } + if (r != null) { + if (r.found && r.result != null) Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text((if (r.result.source == "custom") "Zusätzliche Antwort" else "Wissensdatenbank") + ": " + r.result.title, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(r.result.text) + r.result.others.forEach { Text("Weiterer Treffer: $it", style = MaterialTheme.typography.bodySmall) } + } + } else Text("Keine passende Antwort: Der Assistent würde anbieten, ein Ticket zu erstellen. Lege dafür eine zusätzliche Antwort an oder ergänze die Wissensdatenbank.", style = MaterialTheme.typography.bodySmall) + } + } +} 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 76926137..e55c6c42 100644 --- a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt @@ -4,6 +4,7 @@ package de.deskhub.staff.ui import android.graphics.Color.parseColor import android.net.Uri +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -58,6 +60,8 @@ import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ConfirmationNumber import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.ErrorOutline @@ -180,156 +184,15 @@ import de.deskhub.staff.domain.model.TicketRuleCondition import de.deskhub.staff.domain.model.TicketRulesData import de.deskhub.staff.domain.model.TicketSummary -// ─── Brand palette ────────────────────────────────────────────────────────── -private val BrandTeal = Color(0xFF1F8FE0) // Blau (Akzent) -private val BrandTealDark = Color(0xFF28405A) // dunkles Marineblau (Kopf-/Navigationsleisten) -private val BrandInk = Color(0xFF1C2733) -private val BrandAmber = Color(0xFFD98200) - -private val DeskHubLightColors = lightColorScheme( - primary = Color(0xFF1F8FE0), - onPrimary = Color.White, - primaryContainer = Color(0xFFD6EAFB), - onPrimaryContainer = Color(0xFF0B3A63), - secondary = Color(0xFF5B6B7D), - onSecondary = Color.White, - secondaryContainer = Color(0xFFE4EAF1), - onSecondaryContainer = Color(0xFF1F2A36), - tertiary = BrandAmber, - onTertiary = Color.White, - tertiaryContainer = Color(0xFFFBE4C4), - onTertiaryContainer = Color(0xFF5A3200), - background = Color(0xFFEEF1F5), - onBackground = BrandInk, - surface = Color.White, - onSurface = BrandInk, - surfaceVariant = Color(0xFFE9EDF2), - onSurfaceVariant = Color(0xFF667384), - surfaceTint = Color(0xFF1F8FE0), - surfaceContainerLowest = Color.White, - surfaceContainerLow = Color(0xFFF7F9FB), - surfaceContainer = Color(0xFFF1F4F7), - surfaceContainerHigh = Color(0xFFEBEFF3), - surfaceContainerHighest = Color.White, // Standard-Karten: weiß auf hellem Grund - outline = Color(0xFFD0D7DF), - outlineVariant = Color(0xFFE3E8EE), - error = Color(0xFFD63B3B), - onError = Color.White, - errorContainer = Color(0xFFFDE2E2), - onErrorContainer = Color(0xFF7A1F1F), -) - -private val DeskHubDarkColors = darkColorScheme( - primary = Color(0xFF3EA6F0), - onPrimary = Color(0xFF06192B), - primaryContainer = Color(0xFF123B5E), - onPrimaryContainer = Color(0xFFD3E9FB), - secondary = Color(0xFFA9B9CB), - onSecondary = Color(0xFF16212D), - secondaryContainer = Color(0xFF22313F), - onSecondaryContainer = Color(0xFFDCE6F0), - tertiary = Color(0xFFF0A63A), - onTertiary = Color(0xFF3A2600), - tertiaryContainer = Color(0xFF5C3D08), - onTertiaryContainer = Color(0xFFFFDDB0), - background = Color(0xFF0D141D), - onBackground = Color(0xFFE7EDF4), - surface = Color(0xFF16212D), - onSurface = Color(0xFFE7EDF4), - surfaceVariant = Color(0xFF1B2836), - onSurfaceVariant = Color(0xFF8C9BAD), - surfaceTint = Color(0xFF3EA6F0), - surfaceContainerLowest = Color(0xFF0A1119), - surfaceContainerLow = Color(0xFF101A25), - surfaceContainer = Color(0xFF141F2B), - surfaceContainerHigh = Color(0xFF1B2836), - surfaceContainerHighest = Color(0xFF1A2634), // Standard-Karten: leicht heller als der Grund - outline = Color(0xFF2A3B4E), - outlineVariant = Color(0xFF213040), - error = Color(0xFFF06B6B), - onError = Color(0xFF3A0A0A), - errorContainer = Color(0xFF5E1713), - onErrorContainer = Color(0xFFFFDAD6), -) - -/** Farbe der Kopf- und Navigationsleisten: dunkles Marineblau (im Dunkelmodus noch dunkler). */ -val LocalChrome = staticCompositionLocalOf { Color(0xFF28405A) } -private val ChromeLight = Color(0xFF28405A) -private val ChromeDark = Color(0xFF0A1119) -private val ChromeMuted = Color(0xFFA9B9CB) - -/** Design-Modus der App: folgt dem System oder ist fest Hell/Dunkel (wird lokal gespeichert). */ -enum class ThemeMode(val label: String) { System("Wie System"), Light("Hell"), Dark("Dunkel") } - -class ThemeController(val mode: ThemeMode, val setMode: (ThemeMode) -> Unit) - -val LocalThemeController = staticCompositionLocalOf { ThemeController(ThemeMode.System) { } } - -private fun loadThemeMode(context: android.content.Context): ThemeMode = - runCatching { ThemeMode.valueOf(context.getSharedPreferences("deskhub_ui", android.content.Context.MODE_PRIVATE).getString("theme", null) ?: "System") } - .getOrDefault(ThemeMode.System) - -private fun saveThemeMode(context: android.content.Context, mode: ThemeMode) { - context.getSharedPreferences("deskhub_ui", android.content.Context.MODE_PRIVATE).edit().putString("theme", mode.name).apply() -} - -private val DeskHubShapes = Shapes( - extraSmall = RoundedCornerShape(6.dp), - small = RoundedCornerShape(10.dp), - medium = RoundedCornerShape(14.dp), - large = RoundedCornerShape(20.dp), - extraLarge = RoundedCornerShape(28.dp), -) - -// Ruhigere, klarere Schrift: kräftigere Titel, großzügigere Zeilenhöhe im Fließtext. -private val DeskHubTypography = Typography( - headlineMedium = TextStyle(fontSize = 26.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp), - headlineSmall = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp), - titleLarge = TextStyle(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.SemiBold, letterSpacing = (-0.1).sp), - titleMedium = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold), - titleSmall = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), - bodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp), - bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 21.sp), - bodySmall = TextStyle(fontSize = 12.sp, lineHeight = 17.sp), - labelLarge = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), - labelMedium = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium), - labelSmall = TextStyle(fontSize = 11.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.2.sp), -) - @Composable fun DeskHubApp(viewModel: DeskHubViewModel) { val state by viewModel.state.collectAsState() - val context = androidx.compose.ui.platform.LocalContext.current - var themeMode by remember { mutableStateOf(loadThemeMode(context)) } - val dark = when (themeMode) { - ThemeMode.System -> isSystemInDarkTheme() - ThemeMode.Light -> false - ThemeMode.Dark -> true - } - val colorScheme = if (dark) DeskHubDarkColors else DeskHubLightColors - val chrome = if (dark) ChromeDark else ChromeLight - val controller = remember(themeMode) { ThemeController(themeMode) { themeMode = it; saveThemeMode(context, it) } } - - // Statusleiste/Navigationsleiste an das gewählte Design anpassen (auch wenn es vom System abweicht) - val view = androidx.compose.ui.platform.LocalView.current - androidx.compose.runtime.SideEffect { - (view.context as? android.app.Activity)?.window?.let { window -> - window.statusBarColor = chrome.toArgb() - window.navigationBarColor = chrome.toArgb() - val insets = androidx.core.view.WindowCompat.getInsetsController(window, view) - insets.isAppearanceLightStatusBars = false - insets.isAppearanceLightNavigationBars = false - } - } - - androidx.compose.runtime.CompositionLocalProvider(LocalThemeController provides controller, LocalChrome provides chrome) { - MaterialTheme(colorScheme = colorScheme, shapes = DeskHubShapes, typography = DeskHubTypography) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - when { - state.initializing -> LoadingState("Anmeldung wird geprüft …") - state.staff == null -> LoginScreen(state, viewModel) - else -> StaffNavigation(state, viewModel) - } + DeskTheme { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + when { + state.initializing -> LoadingState("Anmeldung wird geprüft …") + state.staff == null -> LoginScreen(state, viewModel) + else -> StaffNavigation(state, viewModel) } } } @@ -363,91 +226,43 @@ internal fun ThemeMenuButton() { } -@Composable -private fun chromeNavColors() = androidx.compose.material3.NavigationBarItemDefaults.colors( - selectedIconColor = Color.White, - selectedTextColor = Color.White, - indicatorColor = MaterialTheme.colorScheme.primary, - unselectedIconColor = ChromeMuted, - unselectedTextColor = ChromeMuted, -) - -private val TopLevelRoutes = setOf("tickets", "dashboard", "customers", "chat", "admin") +private val TopLevelRoutes = setOf("tickets", "dashboard", "customers", "chat", "more") @Composable private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) { val navController = rememberNavController() + val context = androidx.compose.ui.platform.LocalContext.current + androidx.compose.runtime.LaunchedEffect(state.requestedRoute) { + when (state.requestedRoute) { + "newTicket" -> { viewModel.startNewTicket(); navController.navigate("newTicket") } + "tickets" -> navController.navigateToTab("tickets") + else -> return@LaunchedEffect + } + viewModel.routeHandled() + } val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route Scaffold( + contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), bottomBar = { if (currentRoute in TopLevelRoutes) { - // Seit Chat + Live-Chat zu einem Tab zusammengelegt wurden (ChatHubScreen), - // sind es nur noch 5 statt 6 Einträge – dadurch passt auf normalen Handy- - // Breiten wieder ein Text-Label neben jedes Icon, ohne dass etwas umbricht. - // Übersicht steht bewusst an erster Stelle (und ist auch Startbildschirm, - // siehe startDestination unten) – das ist der Einstiegspunkt in den Tag. - NavigationBar(containerColor = LocalChrome.current, tonalElevation = 0.dp) { - NavigationBarItem( - colors = chromeNavColors(), - selected = currentRoute == "dashboard", - onClick = { navController.navigateToTab("dashboard") }, - icon = { Icon(Icons.Filled.Dashboard, contentDescription = "Übersicht") }, - label = { Text("Übersicht") }, - ) - NavigationBarItem( - colors = chromeNavColors(), - selected = currentRoute == "tickets", - onClick = { - viewModel.clearActivityBadge() - navController.navigateToTab("tickets") - }, - icon = { - BadgedBox(badge = { - if (state.activityBadgeCount > 0) { - Badge { Text(state.activityBadgeCount.toString()) } - } - }) { - Icon(Icons.Filled.ConfirmationNumber, contentDescription = "Tickets") - } - }, - label = { Text("Tickets") }, - ) - NavigationBarItem( - colors = chromeNavColors(), - selected = currentRoute == "customers", - onClick = { navController.navigateToTab("customers") }, - icon = { Icon(Icons.Filled.People, contentDescription = "Kunden") }, - label = { Text("Kunden") }, - ) - NavigationBarItem( - colors = chromeNavColors(), - selected = currentRoute == "chat", - onClick = { navController.navigateToTab("chat") }, - icon = { - // Interner Chat + Live-Chat teilen sich jetzt einen Tab (siehe - // ChatHubScreen) – das Badge zählt deshalb beides zusammen, damit - // man auch von "Team" aus sieht, dass ein Kunde wartet (und umgekehrt). - val unread = state.chatContacts?.sumOf { it.unreadCount } ?: 0 - val needsReply = state.liveChats?.count { it.needsReply } ?: 0 - val total = unread + needsReply - BadgedBox(badge = { if (total > 0) Badge { Text(total.toString()) } }) { - Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = "Chat") - } - }, - label = { Text("Chat") }, - ) - if (state.staff.hasAnyAdminAccess()) { - NavigationBarItem( - colors = chromeNavColors(), - selected = currentRoute == "admin", - onClick = { navController.navigateToTab("admin") }, - icon = { Icon(Icons.Filled.AdminPanelSettings, contentDescription = "Verwaltung") }, - label = { Text("Verwaltung") }, - ) - } - } + val unread = state.chatContacts?.sumOf { it.unreadCount } ?: 0 + val needsReply = state.liveChats?.count { it.needsReply } ?: 0 + DeskBottomBar( + entries = listOf( + NavEntry("dashboard", "Start", Icons.Filled.Dashboard), + NavEntry("tickets", "Tickets", Icons.Filled.ConfirmationNumber, state.activityBadgeCount), + NavEntry("chat", "Chat", Icons.AutoMirrored.Filled.Chat, unread + needsReply), + NavEntry("customers", "Kunden", Icons.Filled.People), + NavEntry("more", "Mehr", Icons.Filled.MoreHoriz), + ), + current = currentRoute, + onSelect = { route -> + if (route == "tickets") viewModel.clearActivityBadge() + navController.navigateToTab(route) + }, + ) } }, ) { outerPadding -> @@ -577,11 +392,13 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) { onUpdate = viewModel::updateTicket, onMessage = viewModel::addMessage, onSpamAction = viewModel::setTicketSpam, + onSnooze = viewModel::snoozeTicket, + onFollow = viewModel::setFollowing, onUnlock = { state.selectedTicket?.summary?.number?.let(viewModel::unlockTicket) }, ) } - composable("admin") { - AdminScreen(staff = state.staff, onNavigate = { route -> navController.navigate(route) }) + composable("more") { + MoreScreen(staff = state.staff, onNavigate = { route -> navController.navigate(route) }, onLogout = viewModel::logout) } composable("adminKb") { AdminKbScreen( @@ -635,6 +452,9 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) { onSetMemberStatus = viewModel::setMemberAvailability, ) } + composable("adminAssistant") { + AdminAssistantScreen(state = state.assistant, viewModel = viewModel, onBack = { navController.popBackStack() }) + } composable("adminBanlist") { AdminBanlistScreen( state = state, @@ -800,7 +620,7 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) { internal fun StatusBanners(state: DeskHubState, onRetry: () -> Unit) { if (state.offline) { Surface(color = MaterialTheme.colorScheme.error, modifier = Modifier.fillMaxWidth()) { - Row(Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.statusBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Text( "Keine Verbindung zum Server. Es wird automatisch erneut versucht.", modifier = Modifier.weight(1f), @@ -818,7 +638,7 @@ internal fun StatusBanners(state: DeskHubState, onRetry: () -> Unit) { ) { Text( (if (issue.isError) "Störung: " else "Hinweis: ") + issue.message, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.statusBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), color = if (issue.isError) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onTertiaryContainer, style = MaterialTheme.typography.bodySmall, ) @@ -837,7 +657,7 @@ private fun androidx.navigation.NavController.navigateToTab(route: String) { // ─── Login ────────────────────────────────────────────────────────────────── @Composable -private fun LoginScreen(state: DeskHubState, viewModel: DeskHubViewModel) { +internal fun LoginScreen(state: DeskHubState, viewModel: DeskHubViewModel) { var serverUrl by rememberSaveable { mutableStateOf(state.savedServerUrl ?: "") } var email by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") } @@ -851,10 +671,9 @@ private fun LoginScreen(state: DeskHubState, viewModel: DeskHubViewModel) { Box( modifier = Modifier .fillMaxWidth() - .height(220.dp) - .background( - Brush.linearGradient(listOf(Color(0xFF14202E), BrandTealDark, Color(0xFF1F6FB5))), - ), + .height(260.dp) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(bottomStart = 44.dp, bottomEnd = 44.dp)) + .background(LocalDeskExtras.current.gradient), contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -886,8 +705,9 @@ private fun LoginScreen(state: DeskHubState, viewModel: DeskHubViewModel) { modifier = Modifier .fillMaxWidth() .padding(20.dp), - shape = MaterialTheme.shapes.large, - elevation = CardDefaults.cardElevation(defaultElevation = 3.dp), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), ) { Column( modifier = Modifier.padding(24.dp), @@ -1022,53 +842,33 @@ private fun TicketListScreen( LaunchedEffect(Unit) { onLoadViews() } val canCreateTicket = state.metadata?.staff?.permissions?.get("create_ticket") == true Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text("Tickets", fontWeight = FontWeight.Bold) - Text( - "${state.ticketsTotal} gesamt", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - state.staff?.let { staff -> - Text( - "Angemeldet als ${staff.name} (${staff.email}) · ${if (staff.role == "admin") "Administrator" else "Mitarbeiter"} · App v${de.deskhub.staff.BuildConfig.VERSION_NAME}", - style = MaterialTheme.typography.labelSmall, - color = Color.White.copy(alpha = 0.65f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - }, - actions = { - ThemeMenuButton() - IconButton(onClick = onLogout, enabled = !state.mutationLoading) { - Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Abmelden") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - actionIconContentColor = Color.White, - ), - ) - }, + containerColor = MaterialTheme.colorScheme.background, + contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0), floatingActionButton = { if (canCreateTicket) { - FloatingActionButton(onClick = onNewTicket) { - Icon(Icons.Filled.Add, contentDescription = "Neues Ticket") - } + androidx.compose.material3.ExtendedFloatingActionButton( + onClick = onNewTicket, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text("Neues Ticket") }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) } }, ) { padding -> Column( modifier = Modifier .padding(padding) + .statusBarsPadding() .fillMaxSize(), ) { + Row(Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 12.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Tickets", style = MaterialTheme.typography.headlineMedium) + Text("${state.ticketsTotal} insgesamt", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = onRefresh) { Icon(Icons.Filled.Refresh, contentDescription = "Aktualisieren") } + } TicketFilterBar( filters = state.filters, metadata = state.metadata, @@ -1083,10 +883,10 @@ private fun TicketListScreen( when { state.ticketsLoading && state.tickets.isEmpty() -> LoadingState("Tickets werden geladen …") state.ticketsError != null && state.tickets.isEmpty() -> ErrorState(state.ticketsError, onRefresh) - state.tickets.isEmpty() -> EmptyState("Keine Tickets für diese Filter.") + state.tickets.isEmpty() -> EmptyState(if (state.filters.query.isBlank()) "Keine Tickets mit diesen Filtern. Unter „Aktiv“ kannst du „Alle Status“ wählen, um auch erledigte Tickets zu sehen." else "Keine Treffer für diese Suche.") else -> LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { items(state.tickets, key = TicketSummary::id) { ticket -> @@ -1100,17 +900,11 @@ private fun TicketListScreen( OutlinedButton( onClick = onLoadMore, modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, + shape = MaterialTheme.shapes.large, enabled = !state.ticketsLoading, ) { Text(if (state.ticketsLoading) "Wird geladen …" else "Weitere laden") } - } else { - TextButton(onClick = onRefresh, modifier = Modifier.fillMaxWidth()) { - Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Aktualisieren") - } } } } @@ -1162,21 +956,22 @@ private fun TicketFilterBar( else -> "SLA: Alle" } - Surface(color = MaterialTheme.colorScheme.surface, shadowElevation = 1.dp) { + Surface(color = MaterialTheme.colorScheme.background) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { OutlinedTextField( value = filters.query, onValueChange = { onFiltersChanged(filters.copy(query = it)) }, modifier = Modifier.fillMaxWidth(), - label = { Text("Ticketnummer, Betreff, Person oder Inhalt") }, + placeholder = { Text("Tickets durchsuchen", maxLines = 1) }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, singleLine = true, - shape = MaterialTheme.shapes.medium, + shape = CircleShape, + colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSearch() }), trailingIcon = { @@ -1212,7 +1007,7 @@ private fun TicketFilterBar( selected = false, onClick = { viewsExpanded = true }, label = { Text("Ansichten") }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, ) DropdownMenu(expanded = viewsExpanded, onDismissRequest = { viewsExpanded = false }) { savedViews.forEach { view -> @@ -1239,7 +1034,7 @@ private fun TicketFilterBar( selected = filters.statuses != activeStatuses, onClick = { statusExpanded = true }, label = { Text(statusLabel) }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, ) DropdownMenu(expanded = statusExpanded, onDismissRequest = { statusExpanded = false }) { DropdownMenuItem( @@ -1277,7 +1072,7 @@ private fun TicketFilterBar( selected = filters.assigned.isNotEmpty(), onClick = { assignedExpanded = true }, label = { Text(assignedLabel) }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, ) DropdownMenu(expanded = assignedExpanded, onDismissRequest = { assignedExpanded = false }) { listOf( @@ -1303,7 +1098,7 @@ private fun TicketFilterBar( selected = filters.departmentId != null, onClick = { departmentExpanded = true }, label = { Text(departmentLabel) }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, ) DropdownMenu(expanded = departmentExpanded, onDismissRequest = { departmentExpanded = false }) { DropdownMenuItem( @@ -1333,7 +1128,7 @@ private fun TicketFilterBar( selected = filters.priorityId != null, onClick = { priorityExpanded = true }, label = { Text(priorityLabel) }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, ) DropdownMenu(expanded = priorityExpanded, onDismissRequest = { priorityExpanded = false }) { DropdownMenuItem( @@ -1364,7 +1159,7 @@ private fun TicketFilterBar( selected = filters.tagId != null, onClick = { tagExpanded = true }, label = { Text(tagLabel) }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, ) DropdownMenu(expanded = tagExpanded, onDismissRequest = { tagExpanded = false }) { DropdownMenuItem( @@ -1395,7 +1190,7 @@ private fun TicketFilterBar( selected = filters.sla.isNotEmpty(), onClick = { slaExpanded = true }, label = { Text(slaLabel) }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, ) DropdownMenu(expanded = slaExpanded, onDismissRequest = { slaExpanded = false }) { listOf( @@ -1416,6 +1211,22 @@ private fun TicketFilterBar( } } } + item { + FilterChip( + selected = filters.snoozed, + onClick = { onFiltersChanged(filters.copy(snoozed = !filters.snoozed)); onSearch() }, + label = { Text("💤 Wiedervorlage") }, + shape = CircleShape, + ) + } + item { + FilterChip( + selected = filters.following, + onClick = { onFiltersChanged(filters.copy(following = !filters.following)); onSearch() }, + label = { Text("👁 Beobachtet") }, + shape = CircleShape, + ) + } } } } @@ -1429,97 +1240,34 @@ internal fun formatTicketDate(raw: String): String { } @Composable -private fun TicketCard( +internal fun TicketCard( ticket: TicketSummary, metadata: Metadata?, onTicket: (String) -> Unit, ) { val priorityColor = parseHexColor(ticket.priority.color, MaterialTheme.colorScheme.outline) - Card( - onClick = { onTicket(ticket.number) }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - ) { - Row(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .width(4.dp) - .fillMaxHeight() - .background(priorityColor), - ) - Column( - modifier = Modifier - .padding(14.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(7.dp), - ) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - "#${ticket.number}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold, - ) + DeskCard(onClick = { onTicket(ticket.number) }) { + Row(Modifier.fillMaxWidth().padding(14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + DeskAvatar(ticket.requesterName, size = 44, color = priorityColor) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text("#${ticket.number}", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(formatTicketDate(ticket.createdAt).substringBefore(' ').take(6) + " " + formatTicketDate(ticket.createdAt).substringAfter(' '), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text(ticket.subject, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text("${ticket.requesterName} · ${ticket.department.name}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { StatusPill(ticket.status, metadata) + DeskChip(ticket.priority.name, priorityColor) + if (ticket.isOverdue) DeskChip("Überfällig", MaterialTheme.colorScheme.error) + if (ticket.snoozedUntil != null) DeskChip("💤", MaterialTheme.colorScheme.onSurfaceVariant) + if (ticket.following) DeskChip("👁", MaterialTheme.colorScheme.onSurfaceVariant) } - Text( - ticket.subject, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - "${ticket.requesterName} · ${ticket.department.name}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - "Eingang: " + formatTicketDate(ticket.createdAt), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(priorityColor), - ) - Spacer(Modifier.width(5.dp)) - Text(ticket.priority.name, style = MaterialTheme.typography.labelSmall) - } - ticket.assignedStaff?.let { InitialsAvatarChip(it.name) } - if (ticket.isOverdue) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.extraSmall, - ) { - Text( - "Überfällig", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onErrorContainer, - fontWeight = FontWeight.Bold, - ) - } - } - } + ticket.assignedStaff?.let { InitialsAvatarChip(it.name) } } } } } - @Composable private fun InitialsAvatarChip(name: String) { val initials = name.trim().split(" ").mapNotNull { it.firstOrNull()?.uppercaseChar() }.take(2).joinToString("") @@ -1553,91 +1301,68 @@ private fun DashboardScreen( onTicket: (String) -> Unit, ) { LaunchedEffect(Unit) { onLoad() } - Scaffold( - topBar = { - TopAppBar( - title = { Text("Übersicht", fontWeight = FontWeight.Bold) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - ), - ) - }, - ) { padding -> - val stats = state.dashboard - when { - state.dashboardLoading && stats == null -> - Box(Modifier.padding(padding)) { LoadingState("Statistiken werden geladen …") } - state.dashboardError != null && stats == null -> - Box(Modifier.padding(padding)) { ErrorState(state.dashboardError, onLoad) } - stats != null -> LazyColumn( - modifier = Modifier - .padding(padding) - .fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - // Mein Status: derselbe Schnellwechsler wie im Web-Dashboard, direkt oben auf - // dem Startbildschirm statt erst über Verwaltung → Mein Status erreichbar. - state.availability?.let { availability -> - item { DashboardStatusRow(myStatus = availability.myStatus, onSetStatus = onSetStatus) } - } - item { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - StatTile("Offen (mir)", stats.myOpen.toString(), Modifier.weight(1f)) - StatTile("Überfällig", stats.overdue.toString(), Modifier.weight(1f), highlight = stats.overdue > 0) + val stats = state.dashboard + when { + state.dashboardLoading && stats == null -> LoadingState("Statistiken werden geladen …") + state.dashboardError != null && stats == null -> ErrorState(state.dashboardError, onLoad) + stats != null -> LazyColumn( + modifier = Modifier.fillMaxSize().statusBarsPadding(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) + val greeting = when { hour < 11 -> "Guten Morgen"; hour < 18 -> "Guten Tag"; else -> "Guten Abend" } + val first = state.staff?.name?.substringBefore(' ').orEmpty() + GradientHeader { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("$greeting, $first", style = MaterialTheme.typography.headlineSmall, color = Color.White) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + HeroStat(stats.myOpen.toString(), "Meine offenen", Modifier.weight(1f)) + HeroStat(stats.overdue.toString(), "Überfällig", Modifier.weight(1f), alert = stats.overdue > 0) + HeroStat(stats.unassigned.toString(), "Nicht zugew.", Modifier.weight(1f)) + } } } - item { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - StatTile("Nicht zugewiesen", stats.unassigned.toString(), Modifier.weight(1f)) - StatTile("Unbeantwortet", stats.unanswered.toString(), Modifier.weight(1f)) - } - } - item { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - StatTile("Heute gelöst", stats.resolvedToday.toString(), Modifier.weight(1f)) - StatTile("SLA-Quote", stats.slaPercent?.let { "$it%" } ?: "–", Modifier.weight(1f)) - } - } - if (stats.recent.isNotEmpty()) { - item { - Text( - "Priorisiert (mir zugewiesen oder unbearbeitet)", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - items(stats.recent, key = TicketSummary::id) { ticket -> - TicketCard(ticket, state.metadata, onTicket) - } - } - stats.departments?.let { departments -> - item { - Text( - "Abteilungen", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - items(departments) { department -> DepartmentRow(department) } - } } - else -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") } + // Mein Status: derselbe Schnellwechsler wie im Web-Dashboard + state.availability?.let { availability -> + item { DashboardStatusRow(myStatus = availability.myStatus, onSetStatus = onSetStatus) } + } + item { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile("Unbeantwortet", stats.unanswered.toString(), Modifier.weight(1f)) + StatTile("Heute gelöst", stats.resolvedToday.toString(), Modifier.weight(1f)) + StatTile("SLA-Quote", stats.slaPercent?.let { "${it.toInt()}%" } ?: "–", Modifier.weight(1f)) + } + } + if (stats.recent.isNotEmpty()) { + item { SectionTitle("Priorisiert: mir zugewiesen oder unbearbeitet") } + items(stats.recent, key = TicketSummary::id) { ticket -> TicketCard(ticket, state.metadata, onTicket) } + } + stats.departments?.let { departments -> + item { SectionTitle("Abteilungen") } + items(departments) { department -> DepartmentRow(department) } + } } + else -> EmptyState("Keine Daten verfügbar.") } } +@Composable +private fun HeroStat(value: String, label: String, modifier: Modifier = Modifier, alert: Boolean = false) { + Column( + modifier.clip(MaterialTheme.shapes.medium).background(Color.White.copy(alpha = if (alert) 0.30f else 0.18f)).padding(12.dp), + ) { + Text(value, style = MaterialTheme.typography.headlineMedium, color = Color.White) + Text(label, style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.9f), maxLines = 1) + } +} @Composable private fun DashboardStatusRow(myStatus: String, onSetStatus: (String, String) -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - "Mein Status", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text("Mein Status", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { AvailabilityStatusLabels.forEach { (value, meta) -> val (label, emoji, color) = meta @@ -1645,143 +1370,41 @@ private fun DashboardStatusRow(myStatus: String, onSetStatus: (String, String) - selected = myStatus == value, onClick = { onSetStatus(value, "") }, label = { Text("$emoji $label") }, - shape = MaterialTheme.shapes.small, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = color.copy(alpha = 0.18f), - selectedLabelColor = color, - ), + shape = CircleShape, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = color.copy(alpha = 0.18f), selectedLabelColor = color), ) } } } } } - @Composable private fun StatTile(label: String, value: String, modifier: Modifier = Modifier, highlight: Boolean = false) { - Card( - modifier = modifier, - shape = MaterialTheme.shapes.medium, - colors = CardDefaults.cardColors( - containerColor = if (highlight) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface, - ), + Column( + modifier + .clip(MaterialTheme.shapes.large) + .background(if (highlight) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.large) + .padding(14.dp), ) { - Column(Modifier.padding(16.dp)) { - Text( - value, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = if (highlight) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface, - ) - Text( - label, - style = MaterialTheme.typography.bodySmall, - color = if (highlight) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Text(value, style = MaterialTheme.typography.headlineSmall, color = if (highlight) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurface) + Text(label, style = MaterialTheme.typography.bodySmall, color = if (highlight) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) } } - @Composable private fun DepartmentRow(department: de.deskhub.staff.domain.model.DepartmentStat) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { - Row( - modifier = Modifier - .padding(14.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(department.name, fontWeight = FontWeight.Medium) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - "${department.open} offen", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (department.overdue > 0) { - Text( - "${department.overdue} überfällig", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - fontWeight = FontWeight.Bold, - ) - } + DeskCard { + Row(Modifier.padding(16.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Text(department.name, style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DeskChip("${department.open} offen", MaterialTheme.colorScheme.primary) + if (department.overdue > 0) DeskChip("${department.overdue} überfällig", MaterialTheme.colorScheme.error) } } } } - // ─── Admin: Verwaltung (Hub) ──────────────────────────────────────────────── -@Composable -private fun AdminScreen(staff: Staff?, onNavigate: (String) -> Unit) { - val isFullAdmin = staff?.role == "admin" - fun allowed(permission: String) = staff?.permissions?.get(permission) == true - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Verwaltung", fontWeight = FontWeight.Bold) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - ), - ) - }, - ) { padding -> - LazyColumn( - modifier = Modifier - .padding(padding) - .fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val verwaltenVisible = allowed("manage_departments") || allowed("manage_priorities") || - allowed("manage_help_topics") || allowed("manage_kb") || allowed("manage_email_addresses") || allowed("manage_email_templates") - item { AdminMenuRow(Icons.Filled.Notifications, "Benachrichtigungen", "Welche Popups, stille Zeiten") { onNavigate("pushPrefs") } } - if (verwaltenVisible) item { AdminSectionLabel("Verwalten") } - if (allowed("manage_departments")) item { AdminMenuRow(Icons.Filled.Business, "Abteilungen", "Status, Leiter, Mitglieder, E-Mail & Signatur") { onNavigate("adminDepartments") } } - if (allowed("manage_priorities")) item { AdminMenuRow(Icons.Filled.Flag, "Prioritäten / SLA", "Reaktions- und Lösungszeiten") { onNavigate("adminPriorities") } } - if (allowed("view_reports")) item { AdminMenuRow(Icons.Filled.Assessment, "Berichte", "Frei konfigurierbare Auswertungen") { onNavigate("adminReports") } } - if (allowed("manage_priorities")) item { AdminMenuRow(Icons.Filled.Schedule, "SLA-Pläne", "Benannte Zeiten je Abteilung oder Hilfethema") { onNavigate("adminSlaPlans") } } - if (allowed("manage_help_topics")) item { AdminMenuRow(Icons.AutoMirrored.Filled.Help, "Hilfethemen", "Thema, Typ, Abteilung & Priorität") { onNavigate("adminHelpTopics") } } - if (allowed("manage_kb")) item { AdminMenuRow(Icons.AutoMirrored.Filled.MenuBook, "Wissensdatenbank", "Artikel und Kategorien verwalten") { onNavigate("adminKb") } } - if (allowed("manage_email_addresses")) item { AdminMenuRow(Icons.Filled.Email, "E-Mail-Adressen", "Adresse, Abteilung & Postfach-Abruf (IMAP)") { onNavigate("adminEmailAddresses") } } - if (allowed("manage_email_templates")) item { AdminMenuRow(Icons.Filled.Description, "E-Mail-Vorlagen", "Texte der automatischen E-Mails") { onNavigate("adminEmailTemplates") } } - - if (allowed("manage_organizations")) { - item { AdminSectionLabel("Kunden") } - item { AdminMenuRow(Icons.Filled.Apartment, "Organisationen", "Kunden zu Firmen bündeln") { onNavigate("adminOrganizations") } } - } - - val agentenVisible = isFullAdmin || allowed("manage_staff") || allowed("manage_teams") || allowed("manage_roles") - if (agentenVisible) item { AdminSectionLabel("Agenten") } - if (allowed("manage_staff")) item { AdminMenuRow(Icons.Filled.Person, "Mitarbeiter", "Konten, Rollen, Abteilungszugriff") { onNavigate("adminStaff") } } - if (allowed("manage_teams")) item { AdminMenuRow(Icons.Filled.Groups, "Teams", "Mitarbeiter-Gruppen") { onNavigate("adminTeams") } } - if (allowed("manage_roles")) item { AdminMenuRow(Icons.Filled.Lock, "Rollen", "Individuelle Berechtigungssets") { onNavigate("adminRoles") } } - // Admins: Status der Mitarbeiter ändern (wie im Web unter Agenten) - if (isFullAdmin) item { AdminMenuRow(Icons.Filled.Circle, "Status der Mitarbeiter", "Eigenen Status setzen und den Status der Mitarbeiter ändern") { onNavigate("adminAvailability") } } - - item { AdminSectionLabel("System") } - if (allowed("view_audit_log")) item { AdminMenuRow(Icons.Filled.History, "Admin-Protokoll", "Änderungen & fehlgeschlagene Mails") { onNavigate("adminAuditLog") } } - if (allowed("manage_escalations")) item { AdminMenuRow(Icons.AutoMirrored.Filled.Rule, "Eskalationsregeln", "Automatische Regeln für überfällige Tickets") { onNavigate("adminEscalation") } } - if (allowed("view_time_entries")) item { AdminMenuRow(Icons.Filled.AccessTime, "Zeiterfassung", "Erfasste Zeiten je Mitarbeiter einsehen") { onNavigate("adminTimeEntries") } } - if (allowed("manage_business_hours")) item { AdminMenuRow(Icons.Filled.Schedule, "Geschäftszeiten", "SLA-Zeitfenster & Feiertage") { onNavigate("adminBusinessHours") } } - // Ticket-Regeln (Automatisierung) wie im Web bewusst echten Admins vorbehalten - // (kein Co-Admin-Recht dafür, siehe api/staff/v1/ticket_rules.php). - if (isFullAdmin) item { AdminMenuRow(Icons.Filled.Bolt, "Automatisierung", "Ticket-Regeln, Auto-Antworten, automatisches Schließen") { onNavigate("adminTicketRules") } } - // Sperrliste hat (noch) kein eigenes Co-Admin-Recht – bleibt wie bisher echten Admins vorbehalten. - if (isFullAdmin) item { AdminMenuRow(Icons.Filled.Block, "Sperrliste", "E-Mail-Adressen oder ganze Mailanbieter sperren") { onNavigate("adminBanlist") } } - // Aufgaben sind für alle Mitarbeiter da – hier trotzdem gelistet, da wer die Verwaltung sieht, immer auch Mitarbeiter ist. - item { AdminMenuRow(Icons.AutoMirrored.Filled.Assignment, "Aufgaben", "To-dos für dich und dein Team") { onNavigate("adminTasks") } } - // Eigener Status ist für alle Mitarbeiter da (kein Admin-Recht nötig, wie im - // Web staff/availability.php) – hier trotzdem gelistet, aus demselben Grund wie Aufgaben oben. - if (!isFullAdmin) item { AdminMenuRow(Icons.Filled.Circle, "Mein Status", "Verfügbarkeit setzen & Team-Übersicht") { onNavigate("adminAvailability") } } - } - } -} - @Composable private fun AdminSectionLabel(label: String) { Text( @@ -1835,11 +1458,7 @@ private fun AdminTopBar(title: String, onBack: () -> Unit) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - ), + colors = deskTopBarColors(), ) } @@ -1872,10 +1491,7 @@ private fun ChatHubScreen( topBar = { TopAppBar( title = { Text("Chat", fontWeight = FontWeight.Bold) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - ), + colors = deskTopBarColors(), ) }, ) { padding -> @@ -1928,7 +1544,7 @@ private fun ChatContactsBody( verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(contacts, key = { it.id }) { contact -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = { onOpenContact(contact) }) { + DeskCard(onClick = { onOpenContact(contact) }) { Row( modifier = Modifier.fillMaxWidth().padding(14.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -2056,7 +1672,7 @@ private fun LiveChatListBody( verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(chats, key = { it.number }) { chat -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = { onOpenChat(chat.number) }) { + DeskCard(onClick = { onOpenChat(chat.number) }) { Row( modifier = Modifier.fillMaxWidth().padding(14.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -2137,11 +1753,7 @@ private fun LiveChatConversationScreen( } } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - actionIconContentColor = Color.White, - ), + colors = deskTopBarColors(), ) }, ) { padding -> @@ -2343,7 +1955,7 @@ private fun AdminKbScreen( item { Text("Kategorien", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) } items(kb.categories) { category -> val articleCount = kb.articles.count { it.categoryId == category.id } - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Row( modifier = Modifier .fillMaxWidth() @@ -2392,7 +2004,7 @@ private fun AdminKbScreen( item { EmptyState("Noch keine Artikel.") } } items(kb.articles) { article -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(article.title, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -2564,7 +2176,7 @@ private fun AdminTimeEntriesScreen( } if (report.totalsByStaff.size > 1) { items(report.totalsByStaff.drop(1)) { total -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Row( modifier = Modifier.fillMaxWidth().padding(14.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -2580,7 +2192,7 @@ private fun AdminTimeEntriesScreen( item { EmptyState("Keine Zeiteinträge im gewählten Zeitraum.") } } items(report.entries) { entry -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("#${entry.ticketNumber} · ${entry.subject}", modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -2685,7 +2297,7 @@ private fun AdminEscalationScreen( @Composable private fun EscalationRuleRow(rule: EscalationRule, onEdit: () -> Unit, onDelete: () -> Unit, onToggle: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(rule.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -2956,7 +2568,7 @@ private fun AdminAvailabilityScreen( verticalArrangement = Arrangement.spacedBy(14.dp), ) { item { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Mein Status", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -2966,7 +2578,7 @@ private fun AdminAvailabilityScreen( selected = selectedStatus == value, onClick = { selectedStatus = value }, label = { Text("$emoji $label") }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, colors = FilterChipDefaults.filterChipColors( selectedContainerColor = color.copy(alpha = 0.18f), selectedLabelColor = color, @@ -3114,7 +2726,7 @@ private fun AutoCloseCard(enabled: Boolean, days: Int, onSave: (Boolean, Int) -> var localDays by rememberSaveable(days) { mutableStateOf(days.toString()) } val changed = localEnabled != enabled || localDays != days.toString() - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( Modifier.fillMaxWidth().clickable { localEnabled = !localEnabled }, @@ -3153,7 +2765,7 @@ private fun AutoCloseCard(enabled: Boolean, days: Int, onSave: (Boolean, Int) -> @Composable private fun TicketRuleRow(rule: TicketRule, onEdit: () -> Unit, onDelete: () -> Unit, onToggle: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(rule.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -3434,7 +3046,7 @@ private fun AdminBanlistScreen( @Composable private fun BanEntryRow(entry: BanEntry, onDelete: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -3705,7 +3317,7 @@ private fun AdminDepartmentsScreen( ) } else { members.forEach { m -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(12.dp)) { Text(m.name + if (m.id == managerId) " (Leiter)" else "", fontWeight = FontWeight.SemiBold) Text(if (m.isPrimary) "Hauptabteilung" else "Zusätzlich", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -3739,7 +3351,7 @@ private fun AdminDepartmentsScreen( @Composable private fun DepartmentRowAdmin(dept: Department, onEdit: () -> Unit, onDelete: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(dept.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -3848,7 +3460,7 @@ private fun AdminReportsScreen( selected = m.key in metrics, onClick = { metrics = if (m.key in metrics) metrics - m.key else metrics + m.key }, label = { Text(m.label) }, - shape = MaterialTheme.shapes.small, + shape = CircleShape, ) } } @@ -4062,7 +3674,7 @@ private fun AdminPrioritiesScreen( @Composable private fun PriorityRowAdmin(prio: PriorityFull, onEdit: () -> Unit, onDelete: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Surface(color = parseHexColor(prio.color, MaterialTheme.colorScheme.primary).copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) { @@ -4198,7 +3810,7 @@ private fun AdminHelpTopicsScreen( @Composable private fun HelpTopicRowAdmin(topic: HelpTopic, onEdit: () -> Unit, onDelete: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(topic.path, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -4277,7 +3889,7 @@ private fun AdminOrganizationsScreen( items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Organisationen.") } else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { items(items) { org -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Text(org.name, fontWeight = FontWeight.SemiBold) Text("${org.memberCount} Kontakte", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -4376,7 +3988,7 @@ private fun AdminTeamsScreen( @Composable private fun TeamRowAdmin(team: Team, onEdit: () -> Unit, onToggle: () -> Unit, onDelete: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(team.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -4513,7 +4125,7 @@ private fun AdminRolesScreen( @Composable private fun RoleRowAdmin(role: Role, onEdit: () -> Unit, onDelete: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Text(role.name, fontWeight = FontWeight.SemiBold) Text("${role.staffCount} Mitarbeiter", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -4726,7 +4338,7 @@ private fun AdminStaffScreen( @Composable private fun StaffRowAdmin(s: StaffAdmin, onEdit: () -> Unit) { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = onEdit) { + DeskCard(onClick = onEdit) { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -4784,7 +4396,7 @@ private fun AdminBusinessHoursScreen( } items(hours) { h -> val idx = hours.indexOf(h) - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = h.isOpen, onCheckedChange = { on -> hours = hours.toMutableList().also { it[idx] = h.copy(isOpen = on) } }) @@ -4819,7 +4431,7 @@ private fun AdminBusinessHoursScreen( item { EmptyState("Noch keine Feiertage eingetragen.") } } items(data.holidays) { h -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Row(Modifier.fillMaxWidth().padding(14.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Column { Text(h.name, fontWeight = FontWeight.Medium); Text(h.date, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } TextButton(onClick = { onDeleteHoliday(h.id) }) { Text("Entfernen") } @@ -5049,7 +4661,7 @@ private fun AdminEmailAddressesScreen( data.addresses.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine E-Mail-Adressen angelegt.") } else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { items(data.addresses) { addr -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(addr.email, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -5112,7 +4724,7 @@ private fun AdminEmailTemplatesScreen( items(items, key = { it.key }) { tpl -> var subject by rememberSaveable(tpl.key, tpl.subject) { mutableStateOf(tpl.subject) } var body by rememberSaveable(tpl.key, tpl.body) { mutableStateOf(tpl.body) } - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Text(tpl.label, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) @@ -5165,7 +4777,7 @@ private fun AdminAuditLogScreen( state.auditLog.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Einträge.") } else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { items(state.auditLog, key = AuditLogEntry::id) { entry -> - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(12.dp)) { Text(entry.action, style = MaterialTheme.typography.bodyMedium) Text( @@ -5278,7 +4890,7 @@ private fun AdminTasksScreen( @Composable private fun TaskRowAdmin(task: StaffTask, onUpdateStatus: (String) -> Unit, onDelete: () -> Unit) { var statusExpanded by remember { mutableStateOf(false) } - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(14.dp)) { Text( task.title, @@ -5327,10 +4939,7 @@ private fun CustomersScreen( topBar = { TopAppBar( title = { Text("Kunden", fontWeight = FontWeight.Bold) }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - ), + colors = deskTopBarColors(), ) }, ) { padding -> @@ -5435,11 +5044,7 @@ private fun CustomerProfileScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - ), + colors = deskTopBarColors(), ) }, ) { padding -> @@ -5542,11 +5147,7 @@ private fun NewTicketScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - ), + colors = deskTopBarColors(), ) }, ) { padding -> @@ -5666,6 +5267,8 @@ private fun TicketDetailScreen( onUpdate: (String, Int?) -> Unit, onMessage: (String, String, String?, String?, () -> Unit) -> Unit, onSpamAction: (Boolean) -> Unit, + onSnooze: (String?) -> Unit = {}, + onFollow: (Boolean) -> Unit = {}, onUnlock: () -> Unit = {}, ) { Scaffold( @@ -5677,11 +5280,7 @@ private fun TicketDetailScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - ), + colors = deskTopBarColors(), ) }, ) { padding -> @@ -5700,6 +5299,8 @@ private fun TicketDetailScreen( onUpdate = onUpdate, onMessage = onMessage, onSpamAction = onSpamAction, + onSnooze = onSnooze, + onFollow = onFollow, onUnlock = onUnlock, ) else -> Box(Modifier.padding(padding)) { EmptyState("Kein Ticket ausgewählt.") } @@ -5718,6 +5319,8 @@ private fun TicketDetailContent( onUpdate: (String, Int?) -> Unit, onMessage: (String, String, String?, String?, () -> Unit) -> Unit, onSpamAction: (Boolean) -> Unit, + onSnooze: (String?) -> Unit = {}, + onFollow: (Boolean) -> Unit = {}, onUnlock: () -> Unit = {}, ) { var selectedStatus by remember(ticket.summary.number) { mutableStateOf(ticket.summary.status) } @@ -5770,17 +5373,13 @@ private fun TicketDetailContent( } } item { - Card( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), - ) { - Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + GradientHeader { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( "#${ticket.summary.number}", fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, + color = Color.White, ) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { if (ticket.summary.isSpam) { @@ -5801,20 +5400,20 @@ private fun TicketDetailContent( ticket.summary.subject, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, + color = Color.White, ) Row(verticalAlignment = Alignment.CenterVertically) { Icon( Icons.Filled.Person, contentDescription = null, modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, + tint = Color.White, ) Spacer(Modifier.width(6.dp)) Text( "${ticket.summary.requesterName} · ${ticket.summary.requesterEmail}", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, + color = Color.White, ) } Row(verticalAlignment = Alignment.CenterVertically) { @@ -5828,7 +5427,7 @@ private fun TicketDetailContent( Text( "${ticket.summary.department.name} · ${ticket.summary.priority.name} · ${ticketSourceLabel(ticket.source)}", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f), + color = Color.White.copy(alpha = 0.85f), ) } ticket.resolutionDueAt?.let { due -> @@ -5837,13 +5436,13 @@ private fun TicketDetailContent( Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer, + tint = Color.White, ) Spacer(Modifier.width(6.dp)) Text( "Lösung fällig: $due", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer, + color = Color.White, ) } } @@ -5852,7 +5451,7 @@ private fun TicketDetailContent( } if (ticket.customFields.isNotEmpty()) { item { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Angaben des Kunden", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) ticket.customFields.forEach { field -> @@ -5884,7 +5483,42 @@ private fun TicketDetailContent( } } item { - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Wiedervorlage und Beobachten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + val snoozedUntil = ticket.summary.snoozedUntil + if (snoozedUntil != null) { + Text("💤 Ausgeblendet bis ${snoozedUntil.take(16).replace('T', ' ')} Uhr, dann kommt das Ticket zurück.", style = MaterialTheme.typography.bodyMedium) + OutlinedButton(onClick = { onSnooze(null) }, enabled = !mutationLoading, modifier = Modifier.fillMaxWidth()) { Text("Wiedervorlage aufheben") } + } else { + Text("Auf später legen: das Ticket verschwindet aus den Listen und kommt zurück, mit Benachrichtigung.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { onSnooze(java.time.Instant.now().plusSeconds(3600).truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString()) }, enabled = !mutationLoading, modifier = Modifier.weight(1f)) { Text("1 Std") } + OutlinedButton(onClick = { onSnooze(java.time.Instant.now().plusSeconds(3 * 3600).truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString()) }, enabled = !mutationLoading, modifier = Modifier.weight(1f)) { Text("3 Std") } + OutlinedButton(onClick = { + val tomorrow = java.time.ZonedDateTime.now().plusDays(1).withHour(8).withMinute(0).withSecond(0).withNano(0) + onSnooze(tomorrow.toInstant().toString()) + }, enabled = !mutationLoading, modifier = Modifier.weight(1f)) { Text("Morgen 8:00") } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { onSnooze(java.time.Instant.now().plusSeconds(3 * 86400L).truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString()) }, enabled = !mutationLoading, modifier = Modifier.weight(1f)) { Text("3 Tage") } + OutlinedButton(onClick = { onSnooze(java.time.Instant.now().plusSeconds(7 * 86400L).truncatedTo(java.time.temporal.ChronoUnit.SECONDS).toString()) }, enabled = !mutationLoading, modifier = Modifier.weight(1f)) { Text("1 Woche") } + } + } + OutlinedButton(onClick = { onFollow(!ticket.summary.following) }, enabled = !mutationLoading, modifier = Modifier.fillMaxWidth()) { + Text(if (ticket.summary.following) "👁 Nicht mehr beobachten" else "👁 Ticket beobachten") + } + val similar = ticket.similar + if (similar != null && (similar.articles.isNotEmpty() || similar.tickets.isNotEmpty())) { + Text("Dazu passend", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + similar.articles.forEach { (title, _) -> Text("📘 $title", style = MaterialTheme.typography.bodySmall) } + similar.tickets.forEach { (number, subject) -> Text("#$number · $subject", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + } + } + } + item { + DeskCard { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Bearbeitung", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) SelectionField( @@ -5956,7 +5590,7 @@ private fun TicketDetailContent( LaunchedEffect(chatLockedByOther, canNote) { if (chatLockedByOther && messageType == "reply" && canNote) messageType = "note" } - Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) { + DeskCard { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Nachricht verfassen", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) if (chatLockedByOther) { @@ -6141,14 +5775,14 @@ private fun ThreadBubble(thread: de.deskhub.staff.domain.model.TicketThread) { } val bubbleColor = when { - thread.isInternalNote -> MaterialTheme.colorScheme.secondaryContainer - isStaff -> MaterialTheme.colorScheme.primaryContainer - else -> MaterialTheme.colorScheme.surfaceVariant + thread.isInternalNote -> MaterialTheme.colorScheme.tertiaryContainer + isStaff -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surface } val onBubbleColor = when { - thread.isInternalNote -> MaterialTheme.colorScheme.onSecondaryContainer - isStaff -> MaterialTheme.colorScheme.onPrimaryContainer - else -> MaterialTheme.colorScheme.onSurfaceVariant + thread.isInternalNote -> MaterialTheme.colorScheme.onTertiaryContainer + isStaff -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface } val bubbleShape = if (isStaff) { RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 4.dp) @@ -6182,7 +5816,7 @@ private fun ThreadBubble(thread: de.deskhub.staff.domain.model.TicketThread) { } Spacer(Modifier.height(2.dp)) } - Surface(color = bubbleColor, shape = bubbleShape) { + Surface(color = bubbleColor, shape = bubbleShape, border = if (!isStaff && !thread.isInternalNote) androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) else null) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(thread.authorName, fontWeight = FontWeight.Bold, color = onBubbleColor, style = MaterialTheme.typography.bodySmall) @@ -6299,18 +5933,8 @@ private fun SelectionField( @Composable internal fun StatusPill(status: String, metadata: Metadata?) { val option = metadata?.statuses?.firstOrNull { it.value == status } - val color = parseHexColor(option?.color, Color(0xFF64748B)) - Surface(color = color.copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) { - Text( - option?.label ?: status, - modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelMedium, - color = color, - fontWeight = FontWeight.SemiBold, - ) - } + DeskChip(option?.label ?: status, parseHexColor(option?.color, Color(0xFF64748B))) } - private fun parseHexColor(hex: String?, fallback: Color): Color = runCatching { Color(parseColor(hex ?: return fallback)) }.getOrDefault(fallback) @@ -6328,38 +5952,37 @@ private fun ticketSourceLabel(source: String): String = when (source) { private fun LoadingState(message: String) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - CircularProgressIndicator() - Spacer(Modifier.height(12.dp)) - Text(message) + CircularProgressIndicator(strokeWidth = 3.dp) + Spacer(Modifier.height(14.dp)) + Text(message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @Composable private fun EmptyState(message: String) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant) - } -} - -@Composable -private fun ErrorState(message: String, retry: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(Icons.Filled.ErrorOutline, contentDescription = null, tint = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - ErrorText(message) - Spacer(Modifier.height(12.dp)) - Button(onClick = retry, shape = MaterialTheme.shapes.medium) { Text("Erneut versuchen") } + Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.size(64.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.Inbox, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(30.dp)) + } + Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = androidx.compose.ui.text.style.TextAlign.Center) } } } +@Composable +private fun ErrorState(message: String, retry: () -> Unit) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.size(64.dp).clip(CircleShape).background(MaterialTheme.colorScheme.errorContainer), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.ErrorOutline, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(30.dp)) + } + ErrorText(message) + Button(onClick = retry, shape = CircleShape) { Text("Erneut versuchen") } + } + } +} @Composable private fun ErrorText(message: String, modifier: Modifier = Modifier) { Text(message, modifier = modifier, color = MaterialTheme.colorScheme.error) 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 110f06cc..41d32a97 100644 --- a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt @@ -58,6 +58,21 @@ 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, +) + data class DeskHubState( val initializing: Boolean = true, val staff: Staff? = null, @@ -67,6 +82,9 @@ data class DeskHubState( /** 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(), + /** 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(), @@ -476,6 +494,91 @@ class DeskHubViewModel( } } + // ── 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()) } } + } + } + /** 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 diff --git a/mobile/src/main/java/de/deskhub/staff/ui/DeskNav.kt b/mobile/src/main/java/de/deskhub/staff/ui/DeskNav.kt new file mode 100644 index 00000000..3050a8df --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskNav.kt @@ -0,0 +1,203 @@ +package de.deskhub.staff.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Assignment +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.automirrored.filled.Rule +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.Apartment +import androidx.compose.material.icons.filled.Assessment +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import de.deskhub.staff.domain.model.Staff + +/** Ein Eintrag der schwebenden Navigationsleiste. */ +data class NavEntry(val route: String, val label: String, val icon: ImageVector, val badge: Int = 0) + +/** Schwebende Navigationsleiste: der gewählte Eintrag zeigt Symbol und Text in einer farbigen Pille, die anderen nur das Symbol. */ +@Composable +fun DeskBottomBar(entries: List, current: String?, onSelect: (String) -> Unit) { + Box(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 10.dp), contentAlignment = Alignment.Center) { + Surface( + modifier = Modifier.fillMaxWidth().shadow(10.dp, CircleShape, ambientColor = Color(0x33000000), spotColor = Color(0x33000000)), + shape = CircleShape, + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + entries.forEach { e -> + val selected = current == e.route + Row( + Modifier.clip(CircleShape) + .background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent) + .clickable { onSelect(e.route) } + .padding(horizontal = if (selected) 16.dp else 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BadgedBox(badge = { if (e.badge > 0) Badge { Text(if (e.badge > 99) "99+" else e.badge.toString()) } }) { + Icon(e.icon, contentDescription = e.label, tint = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) { + Spacer(Modifier.width(8.dp)) + Text(e.label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimaryContainer, maxLines = 1) + } + } + } + } + } + } +} + +private data class MoreEntry(val icon: ImageVector, val title: String, val subtitle: String, val route: String) + +/** „Mehr“: Profil, persönliche Bereiche, Verwaltung nach Rechten, Design, Abmelden. */ +@Composable +fun MoreScreen(staff: Staff?, onNavigate: (String) -> Unit, onLogout: () -> Unit) { + val isFullAdmin = staff?.role == "admin" + fun allowed(permission: String) = isFullAdmin || staff?.permissions?.get(permission) == true + val context = LocalContext.current + val version = remember(context) { runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull().orEmpty() } + + val personal = listOf( + MoreEntry(Icons.AutoMirrored.Filled.Assignment, "Aufgaben", "To-dos für dich und dein Team", "adminTasks"), + MoreEntry(Icons.Filled.Circle, if (isFullAdmin) "Status der Mitarbeiter" else "Mein Status", "Verfügbarkeit und Team-Übersicht", "adminAvailability"), + MoreEntry(Icons.Filled.Notifications, "Benachrichtigungen", "Popups und stille Zeiten", "pushPrefs"), + ) + val manage = buildList { + if (allowed("manage_departments")) add(MoreEntry(Icons.Filled.Business, "Abteilungen", "Leiter, Mitglieder, E-Mail", "adminDepartments")) + if (allowed("manage_priorities")) add(MoreEntry(Icons.Filled.Flag, "Prioritäten / SLA", "Reaktions- und Lösungszeiten", "adminPriorities")) + if (allowed("manage_priorities")) add(MoreEntry(Icons.Filled.Schedule, "SLA-Pläne", "Zeiten je Abteilung oder Thema", "adminSlaPlans")) + if (allowed("manage_help_topics")) add(MoreEntry(Icons.AutoMirrored.Filled.Help, "Hilfethemen", "Thema, Abteilung, Priorität", "adminHelpTopics")) + if (allowed("manage_kb")) add(MoreEntry(Icons.AutoMirrored.Filled.MenuBook, "Wissensdatenbank", "Artikel und Kategorien", "adminKb")) + if (allowed("manage_email_addresses")) add(MoreEntry(Icons.Filled.Email, "E-Mail-Adressen", "Adressen und Postfach-Abruf", "adminEmailAddresses")) + if (allowed("manage_email_templates")) add(MoreEntry(Icons.Filled.Description, "E-Mail-Vorlagen", "Texte der automatischen Mails", "adminEmailTemplates")) + if (allowed("manage_assistant")) add(MoreEntry(Icons.Filled.Bolt, "Digitaler Assistent", "Antworten, Fragen-Log, Einstellungen", "adminAssistant")) + if (allowed("manage_organizations")) add(MoreEntry(Icons.Filled.Apartment, "Organisationen", "Kunden zu Firmen bündeln", "adminOrganizations")) + } + val people = buildList { + if (allowed("manage_staff")) add(MoreEntry(Icons.Filled.Person, "Mitarbeiter", "Konten und Zugriff", "adminStaff")) + if (allowed("manage_teams")) add(MoreEntry(Icons.Filled.Groups, "Teams", "Mitarbeiter-Gruppen", "adminTeams")) + if (allowed("manage_roles")) add(MoreEntry(Icons.Filled.Lock, "Rollen", "Berechtigungssets", "adminRoles")) + } + val system = buildList { + if (allowed("view_reports")) add(MoreEntry(Icons.Filled.Assessment, "Berichte", "Auswertungen", "adminReports")) + if (allowed("view_time_entries")) add(MoreEntry(Icons.Filled.AccessTime, "Zeiterfassung", "Zeiten je Mitarbeiter", "adminTimeEntries")) + if (allowed("manage_escalations")) add(MoreEntry(Icons.AutoMirrored.Filled.Rule, "Eskalationsregeln", "Regeln für überfällige Tickets", "adminEscalation")) + if (allowed("manage_business_hours")) add(MoreEntry(Icons.Filled.Schedule, "Geschäftszeiten", "SLA-Zeitfenster, Feiertage", "adminBusinessHours")) + if (isFullAdmin) add(MoreEntry(Icons.Filled.Bolt, "Automatisierung", "Ticket-Regeln, Auto-Antworten", "adminTicketRules")) + if (isFullAdmin) add(MoreEntry(Icons.Filled.Block, "Sperrliste", "Absender sperren", "adminBanlist")) + if (allowed("view_audit_log")) add(MoreEntry(Icons.Filled.History, "Admin-Protokoll", "Änderungen und Mailfehler", "adminAuditLog")) + } + + LazyColumn(Modifier.fillMaxSize().statusBarsPadding(), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { + GradientHeader { + Row(verticalAlignment = Alignment.CenterVertically) { + DeskAvatar(staff?.name.orEmpty(), size = 56, color = Color.White) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(staff?.name.orEmpty(), style = MaterialTheme.typography.titleLarge, color = Color.White) + Text(staff?.email.orEmpty(), style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.85f)) + Text(if (isFullAdmin) "Administrator" else "Mitarbeiter", style = MaterialTheme.typography.labelMedium, color = Color.White.copy(alpha = 0.85f)) + } + } + } + } + item { SectionTitle("Für dich") } + item { MoreGrid(personal, onNavigate) } + if (manage.isNotEmpty()) { item { SectionTitle("Verwalten") }; item { MoreGrid(manage, onNavigate) } } + if (people.isNotEmpty()) { item { SectionTitle("Agenten") }; item { MoreGrid(people, onNavigate) } } + if (system.isNotEmpty()) { item { SectionTitle("System") }; item { MoreGrid(system, onNavigate) } } + item { SectionTitle("Design") } + item { + val controller = LocalThemeController.current + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ThemeMode.values().forEach { m -> FilterChip(selected = controller.mode == m, onClick = { controller.setMode(m) }, label = { Text(m.label) }) } + } + } + item { + OutlinedButton(onClick = onLogout, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Abmelden") + } + } + item { Text("DeskHub $version", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth().padding(top = 4.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center) } + } +} + +@Composable +private fun MoreGrid(entries: List, onNavigate: (String) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + entries.chunked(2).forEach { row -> + Row(Modifier.height(IntrinsicSize.Min), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + row.forEach { e -> + DeskCard(Modifier.weight(1f).fillMaxHeight(), onClick = { onNavigate(e.route) }) { + Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.size(40.dp).clip(MaterialTheme.shapes.small).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) { + Icon(e.icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer) + } + Column { + Text(e.title, style = MaterialTheme.typography.titleSmall, maxLines = 1) + Text(e.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) + } + } + } + } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + } +} diff --git a/mobile/src/main/java/de/deskhub/staff/ui/DeskTheme.kt b/mobile/src/main/java/de/deskhub/staff/ui/DeskTheme.kt new file mode 100644 index 00000000..acffd661 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskTheme.kt @@ -0,0 +1,276 @@ +package de.deskhub.staff.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat + +/* + * Neues Design (Version 3): helle, luftige Oberfläche mit großen runden Karten, Farbverlauf nur in Kopfbereichen und frischem Blau/Türkis. + * Keine dunklen Kopfleisten mehr: Titel sitzen direkt auf dem Hintergrund, die Navigation schwebt als Pille am unteren Rand. + */ + +/** Design-Modus der App: folgt dem System oder ist fest Hell/Dunkel (wird lokal gespeichert). */ +enum class ThemeMode(val label: String) { System("Wie System"), Light("Hell"), Dark("Dunkel") } + +class ThemeController(val mode: ThemeMode, val setMode: (ThemeMode) -> Unit) + +val LocalThemeController = staticCompositionLocalOf { ThemeController(ThemeMode.System) { } } + +/** Zusatzfarben, die Material nicht kennt: Verlauf für Kopfbereiche, Erfolg, Warnung. */ +class DeskExtras(val gradient: Brush, val success: Color, val onSuccess: Color, val warning: Color, val onWarning: Color, val dark: Boolean) + +val LocalDeskExtras = staticCompositionLocalOf { + DeskExtras(Brush.linearGradient(listOf(Color(0xFF0E8AC9), Color(0xFF12A594))), Color(0xFF1E9E6A), Color.White, Color(0xFFE8920B), Color.White, false) +} + +/** Kompatibilität für ältere Bildschirme: früher die dunkle Kopfleistenfarbe, jetzt die Hintergrundfarbe. */ +val LocalChrome = staticCompositionLocalOf { Color(0xFFF4F8FB) } + +private fun loadThemeMode(context: android.content.Context): ThemeMode = + runCatching { ThemeMode.valueOf(context.getSharedPreferences("deskhub_ui", android.content.Context.MODE_PRIVATE).getString("theme", null) ?: "System") } + .getOrDefault(ThemeMode.System) + +private fun saveThemeMode(context: android.content.Context, mode: ThemeMode) { + context.getSharedPreferences("deskhub_ui", android.content.Context.MODE_PRIVATE).edit().putString("theme", mode.name).apply() +} + +private val Azure = Color(0xFF0E8AC9) +private val Teal = Color(0xFF12A594) +private val Ink = Color(0xFF10212B) + +private val DeskLight = lightColorScheme( + primary = Azure, + onPrimary = Color.White, + primaryContainer = Color(0xFFD5EEFA), + onPrimaryContainer = Color(0xFF053B57), + secondary = Teal, + onSecondary = Color.White, + secondaryContainer = Color(0xFFD5EEFA), + onSecondaryContainer = Color(0xFF053B57), + tertiary = Color(0xFFE8920B), + onTertiary = Color.White, + tertiaryContainer = Color(0xFFFFE8C7), + onTertiaryContainer = Color(0xFF5B3500), + background = Color(0xFFF3F8FB), + onBackground = Ink, + surface = Color(0xFFFFFFFF), + onSurface = Ink, + surfaceVariant = Color(0xFFE6EEF3), + onSurfaceVariant = Color(0xFF5B6E7B), + surfaceTint = Azure, + surfaceContainerLowest = Color.White, + surfaceContainerLow = Color(0xFFF8FBFD), + surfaceContainer = Color(0xFFF0F5F8), + surfaceContainerHigh = Color(0xFFE9F0F4), + surfaceContainerHighest = Color.White, + outline = Color(0xFFC9D6DE), + outlineVariant = Color(0xFFE1EAF0), + error = Color(0xFFD64545), + onError = Color.White, + errorContainer = Color(0xFFFDE3E3), + onErrorContainer = Color(0xFF7A1F1F), +) + +private val DeskDark = darkColorScheme( + primary = Color(0xFF52C2F2), + onPrimary = Color(0xFF032A3D), + primaryContainer = Color(0xFF0C4560), + onPrimaryContainer = Color(0xFFD5EEFA), + secondary = Color(0xFF4FD6C3), + onSecondary = Color(0xFF032E28), + secondaryContainer = Color(0xFF0C4560), + onSecondaryContainer = Color(0xFFD5EEFA), + tertiary = Color(0xFFF2AE4D), + onTertiary = Color(0xFF3A2400), + tertiaryContainer = Color(0xFF5B3A0A), + onTertiaryContainer = Color(0xFFFFE8C7), + background = Color(0xFF0B1319), + onBackground = Color(0xFFE5EEF3), + surface = Color(0xFF131E26), + onSurface = Color(0xFFE5EEF3), + surfaceVariant = Color(0xFF1B2A34), + onSurfaceVariant = Color(0xFF93A7B4), + surfaceTint = Color(0xFF52C2F2), + surfaceContainerLowest = Color(0xFF080F14), + surfaceContainerLow = Color(0xFF0F1920), + surfaceContainer = Color(0xFF16222B), + surfaceContainerHigh = Color(0xFF1B2A34), + surfaceContainerHighest = Color(0xFF1A2832), + outline = Color(0xFF2E4350), + outlineVariant = Color(0xFF22333E), + error = Color(0xFFF27A7A), + onError = Color(0xFF3A0A0A), + errorContainer = Color(0xFF5E1713), + onErrorContainer = Color(0xFFFFDAD6), +) + +private val DeskShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(18.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(32.dp), +) + +private val DeskTypography = Typography( + displaySmall = TextStyle(fontSize = 34.sp, lineHeight = 40.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.5).sp), + headlineMedium = TextStyle(fontSize = 28.sp, lineHeight = 34.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.4).sp), + headlineSmall = TextStyle(fontSize = 24.sp, lineHeight = 30.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.3).sp), + titleLarge = TextStyle(fontSize = 21.sp, lineHeight = 27.sp, fontWeight = FontWeight.Bold, letterSpacing = (-0.2).sp), + titleMedium = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold), + titleSmall = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), + bodyLarge = TextStyle(fontSize = 16.sp, lineHeight = 24.sp), + bodyMedium = TextStyle(fontSize = 14.sp, lineHeight = 21.sp), + bodySmall = TextStyle(fontSize = 12.sp, lineHeight = 17.sp), + labelLarge = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold), + labelMedium = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold), + labelSmall = TextStyle(fontSize = 11.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.3.sp), +) + +/** Das Design der App: Farben, Formen, Schrift, Statusleiste. */ +@Composable +fun DeskTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + var mode by remember { mutableStateOf(loadThemeMode(context)) } + val dark = when (mode) { + ThemeMode.System -> isSystemInDarkTheme() + ThemeMode.Light -> false + ThemeMode.Dark -> true + } + val scheme = if (dark) DeskDark else DeskLight + val controller = remember(mode) { ThemeController(mode) { mode = it; saveThemeMode(context, it) } } + val extras = remember(dark) { + DeskExtras( + gradient = Brush.linearGradient(if (dark) listOf(Color(0xFF0C4560), Color(0xFF0C4A42)) else listOf(Color(0xFF0E8AC9), Color(0xFF12A594))), + success = if (dark) Color(0xFF58D49B) else Color(0xFF1E9E6A), onSuccess = if (dark) Color(0xFF03281A) else Color.White, + warning = if (dark) Color(0xFFF2AE4D) else Color(0xFFE8920B), onWarning = if (dark) Color(0xFF3A2400) else Color.White, + dark = dark, + ) + } + val view = LocalView.current + SideEffect { + (view.context as? android.app.Activity)?.window?.let { window -> + window.statusBarColor = scheme.background.toArgb() + window.navigationBarColor = scheme.background.toArgb() + val insets = WindowCompat.getInsetsController(window, view) + insets.isAppearanceLightStatusBars = !dark + insets.isAppearanceLightNavigationBars = !dark + } + } + CompositionLocalProvider(LocalThemeController provides controller, LocalChrome provides scheme.background, LocalDeskExtras provides extras) { + MaterialTheme(colorScheme = scheme, shapes = DeskShapes, typography = DeskTypography, content = content) + } +} + +/** Kopfleiste ohne eigene Farbe: Titel liegt auf dem Hintergrund. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun deskTopBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + scrolledContainerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + navigationIconContentColor = MaterialTheme.colorScheme.onBackground, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, +) + +// ─── Gemeinsame Bausteine ───────────────────────────────────────────────────── + +/** Weiße, runde Karte mit feiner Linie (statt Schatten): der Grundbaustein der Oberfläche. */ +@Composable +fun DeskCard(modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) { + val shape = MaterialTheme.shapes.large + val base = modifier + .fillMaxWidth() + .clip(shape) + .background(MaterialTheme.colorScheme.surface) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + Box(if (onClick != null) base.clickable(onClick = onClick) else base) { content() } +} + +/** Abschnittsüberschrift über Karten. */ +@Composable +fun SectionTitle(text: String, modifier: Modifier = Modifier) { + Text(text.uppercase(), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = modifier.padding(start = 6.dp, top = 8.dp, bottom = 2.dp)) +} + +/** Farbige Kapsel (Status, Priorität, Tags). */ +@Composable +fun DeskChip(text: String, color: Color, modifier: Modifier = Modifier, filled: Boolean = false) { + val fg = if (filled) Color.White else color + Box( + modifier + .clip(CircleShape) + .background(if (filled) color else color.copy(alpha = 0.14f)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { Text(text, style = MaterialTheme.typography.labelMedium, color = fg, maxLines = 1) } +} + +/** Runder Kreis mit Initialen. */ +@Composable +fun DeskAvatar(name: String, modifier: Modifier = Modifier, size: Int = 40, color: Color = MaterialTheme.colorScheme.primary) { + val initials = name.trim().split(' ', '.', '-', '@').filter { it.isNotBlank() }.take(2).joinToString("") { it.first().uppercase() }.ifBlank { "?" } + Box(modifier.size(size.dp).clip(CircleShape).background(color.copy(alpha = 0.16f)), contentAlignment = Alignment.Center) { + Text(initials, color = color, fontWeight = FontWeight.Bold, fontSize = (size * 0.38f).sp) + } +} + +/** Kopfbereich mit Farbverlauf (Übersicht, Mehr, Anmeldung). */ +@Composable +fun GradientHeader(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box(modifier.fillMaxWidth().clip(MaterialTheme.shapes.extraLarge).background(LocalDeskExtras.current.gradient).padding(20.dp)) { content() } +} + +/** Zeile mit Beschriftung links und Wert rechts (Eigenschaften). */ +@Composable +fun KeyValueRow(label: String, value: String, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } +} + +/** Standard-Spalte für Bildschirminhalte. */ +@Composable +fun ScreenColumn(modifier: Modifier = Modifier, content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { + Column(modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), content = content) +} diff --git a/mobile/src/main/java/de/deskhub/staff/ui/PushPrefsScreen.kt b/mobile/src/main/java/de/deskhub/staff/ui/PushPrefsScreen.kt index 6fa996f3..029c35e5 100644 --- a/mobile/src/main/java/de/deskhub/staff/ui/PushPrefsScreen.kt +++ b/mobile/src/main/java/de/deskhub/staff/ui/PushPrefsScreen.kt @@ -79,11 +79,7 @@ internal fun PushPrefsScreen(onBack: () -> Unit) { TopAppBar( title = { Text("Benachrichtigungen", fontWeight = FontWeight.Bold) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = LocalChrome.current, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - ), + colors = deskTopBarColors(), ) }, ) { padding -> diff --git a/mobile/src/main/res/drawable-night/widget_background.xml b/mobile/src/main/res/drawable-night/widget_background.xml new file mode 100644 index 00000000..00287135 --- /dev/null +++ b/mobile/src/main/res/drawable-night/widget_background.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/mobile/src/main/res/drawable/widget_background.xml b/mobile/src/main/res/drawable/widget_background.xml index a8c355fb..41f6710c 100644 --- a/mobile/src/main/res/drawable/widget_background.xml +++ b/mobile/src/main/res/drawable/widget_background.xml @@ -1,5 +1,8 @@ - - + + diff --git a/mobile/src/main/res/drawable/widget_chip.xml b/mobile/src/main/res/drawable/widget_chip.xml new file mode 100644 index 00000000..520e08c9 --- /dev/null +++ b/mobile/src/main/res/drawable/widget_chip.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/drawable/widget_chip_on.xml b/mobile/src/main/res/drawable/widget_chip_on.xml new file mode 100644 index 00000000..42ab9c6b --- /dev/null +++ b/mobile/src/main/res/drawable/widget_chip_on.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/drawable/widget_tile.xml b/mobile/src/main/res/drawable/widget_tile.xml index acbb5dfd..47423fd8 100644 --- a/mobile/src/main/res/drawable/widget_tile.xml +++ b/mobile/src/main/res/drawable/widget_tile.xml @@ -1,5 +1,5 @@ - - + + diff --git a/mobile/src/main/res/drawable/widget_tile_alert.xml b/mobile/src/main/res/drawable/widget_tile_alert.xml new file mode 100644 index 00000000..7eb6d46c --- /dev/null +++ b/mobile/src/main/res/drawable/widget_tile_alert.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/layout/widget_deskhub.xml b/mobile/src/main/res/layout/widget_deskhub.xml index 851c259c..fa74b589 100644 --- a/mobile/src/main/res/layout/widget_deskhub.xml +++ b/mobile/src/main/res/layout/widget_deskhub.xml @@ -5,8 +5,9 @@ android:layout_height="match_parent" android:background="@drawable/widget_background" android:orientation="vertical" - android:padding="12dp"> + android:padding="14dp"> + + + + android:textSize="18sp" /> + @@ -65,21 +76,21 @@ android:layout_height="wrap_content" android:text="–" android:textColor="#FFFFFF" - android:textSize="24sp" + android:textSize="28sp" android:textStyle="bold" /> + + + + + + + - + + android:layout_marginTop="10dp" + android:orientation="horizontal"> + + + + + + + diff --git a/mobile/src/main/res/layout/widget_deskhub_large.xml b/mobile/src/main/res/layout/widget_deskhub_large.xml new file mode 100644 index 00000000..b5a348a1 --- /dev/null +++ b/mobile/src/main/res/layout/widget_deskhub_large.xml @@ -0,0 +1,325 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/layout/widget_deskhub_small.xml b/mobile/src/main/res/layout/widget_deskhub_small.xml new file mode 100644 index 00000000..014d7341 --- /dev/null +++ b/mobile/src/main/res/layout/widget_deskhub_small.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index e94b54f8..7bcfb114 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ deskhub deskhub-Server - Zeigt deine offenen, nicht zugewiesenen und überfälligen Tickets. + Offene, nicht zugewiesene und überfällige Tickets, die wichtigsten Tickets zum Antippen und dein Status. In drei Größen. diff --git a/mobile/src/main/res/xml/deskhub_widget_info.xml b/mobile/src/main/res/xml/deskhub_widget_info.xml index dff0c3ec..84a656ee 100644 --- a/mobile/src/main/res/xml/deskhub_widget_info.xml +++ b/mobile/src/main/res/xml/deskhub_widget_info.xml @@ -2,9 +2,11 @@ diff --git a/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt b/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt index fd659aa2..9753c4bf 100644 --- a/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt +++ b/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt @@ -105,4 +105,31 @@ class ApiDtoParsingTest { assertEquals("FORBIDDEN", e.error.code) assertEquals("UNKNOWN", gson.fromJson("""{"error":{}}""", ErrorEnvelope::class.java).error.code) } + + @Test + fun `assistant log with stats filters and feedback parses`() { + val json = """{"filters":{"custom":"Meine Antwort gefunden","none":"Keine Antwort","helpful":"Hat geholfen"},"stats":{"custom":4,"none":1,"helpful":3}, + "top":[{"question":"Wie kündige ich?","n":2,"last":"2026-10-22 09:00:00"}],"total":2,"pages":1,"page":1, + "items":[{"id":1,"question":"Wo ist mein Paket?","result":"custom","matchedTitle":"Versand","ticketNumber":null,"helpful":true,"createdAt":"2026-10-22 09:00:00"}, + {"id":2,"question":"Wie kündige ich?","result":"none","matchedTitle":null,"ticketNumber":null,"helpful":null,"createdAt":"2026-10-22 09:01:00"}]}""" + val l = gson.fromJson(json, AssistantLogDto::class.java) + assertEquals(listOf("custom", "none", "helpful"), l.filters.keys.toList()) + assertEquals(3, l.stats["helpful"]) + assertEquals(2, l.top[0].n) + assertEquals(true, l.items[0].helpful) + assertNull(l.items[1].helpful) + } + + @Test + fun `assistant overview with settings and options parses`() { + val json = """{"dbReady":true,"settings":{"enabled":true,"mode":"always","name":"Bot","greeting":"Moin","noAnswer":"","departmentId":2,"priorityId":0,"logDays":30,"synonyms":"a, b"}, + "defaults":{"name":"Digitaler Assistent","greeting":"Hallo","noAnswer":"Nix"},"state":{"activeNow":true,"liveChatEnabled":false,"kbEnabled":true,"helpdeskOffline":false}, + "departments":[{"id":2,"name":"Technik"}],"priorities":[{"id":1,"name":"Normal"}]}""" + val o = gson.fromJson(json, AssistantOverviewDto::class.java) + assertTrue(o.settings.enabled) + assertEquals("always", o.settings.mode) + assertEquals(30, o.settings.logDays) + assertEquals("Technik", o.departments[0].name) + assertTrue(o.state.activeNow) + } }