diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml
index 1fbf4964..fe80c777 100644
--- a/mobile/src/main/AndroidManifest.xml
+++ b/mobile/src/main/AndroidManifest.xml
@@ -1,8 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+ android:exported="true"
+ android:launchMode="singleTop">
+
+
+
+
+
+
diff --git a/mobile/src/main/java/de/deskhub/staff/CrashReporter.kt b/mobile/src/main/java/de/deskhub/staff/CrashReporter.kt
new file mode 100644
index 00000000..c0f4aef2
--- /dev/null
+++ b/mobile/src/main/java/de/deskhub/staff/CrashReporter.kt
@@ -0,0 +1,60 @@
+package de.deskhub.staff
+
+import android.app.Application
+import android.content.Context
+import android.os.Build
+import de.deskhub.staff.data.remote.DeskHubApi
+import de.deskhub.staff.data.remote.dto.CrashReportRequest
+import de.deskhub.staff.data.security.SecureSessionStore
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Merkt sich einen Absturz (nur Fehlerart und die ersten Stack-Zeilen, keine Ticketinhalte) und meldet ihn beim nächsten Start nach
+ * der Anmeldung an den eigenen DeskHub-Server. Dort steht er im Aktivitätsprotokoll des Adminportals. Nichts geht an Dritte.
+ */
+object CrashReporter {
+ private const val PREFS = "deskhub_crash"
+ private const val KEY = "last_trace"
+
+ fun install(app: Application) {
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, error ->
+ runCatching {
+ val trace = buildString {
+ append(error.javaClass.name).append(": ").append(error.message.orEmpty().take(120))
+ error.stackTrace.take(6).forEach { append(" | ").append(it.toString()) }
+ }
+ app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, trace).commit()
+ }
+ previous?.uncaughtException(thread, error)
+ }
+ }
+
+ /** Sendet einen gemerkten Absturz (falls vorhanden) und löscht ihn danach. Fehler werden still ignoriert. */
+ suspend fun sendPending(context: Context) {
+ val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val trace = prefs.getString(KEY, null)?.takeIf { it.isNotBlank() } ?: return
+ try {
+ val session = SecureSessionStore(context.applicationContext).load() ?: return
+ if (session.token.isBlank() || session.serverUrl.isBlank()) return
+ withContext(Dispatchers.IO) {
+ DeskHubApi(session.serverUrl) { session.token }.crashReport(
+ CrashReportRequest(BuildConfig.VERSION_NAME, "${Build.MANUFACTURER} ${Build.MODEL}".trim(), trace),
+ )
+ }
+ prefs.edit().remove(KEY).apply()
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ // beim nächsten Start erneut versuchen
+ }
+ }
+}
+
+class DeskHubApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ CrashReporter.install(this)
+ }
+}
diff --git a/mobile/src/main/java/de/deskhub/staff/DeskHubWidget.kt b/mobile/src/main/java/de/deskhub/staff/DeskHubWidget.kt
new file mode 100644
index 00000000..3b0a6bf8
--- /dev/null
+++ b/mobile/src/main/java/de/deskhub/staff/DeskHubWidget.kt
@@ -0,0 +1,101 @@
+package de.deskhub.staff
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+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 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.
+ */
+class DeskHubWidget : AppWidgetProvider() {
+ override fun onUpdate(context: Context, manager: AppWidgetManager, appWidgetIds: IntArray) {
+ refresh(context, manager, appWidgetIds)
+ }
+
+ 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)))
+ } 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 …")) }
+ 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()
+ }.start()
+ }
+
+ private data class WidgetState(val counts: Triple? = null, val message: String? = null)
+
+ private fun load(context: Context): WidgetState {
+ val session = SecureSessionStore(context.applicationContext).load()
+ if (session == null || session.token.isBlank() || session.serverUrl.isBlank()) {
+ return WidgetState(message = "Bitte in der App anmelden.")
+ }
+ return try {
+ val api = DeskHubApi(session.serverUrl) { session.token }
+ val d = runBlocking { api.dashboard() }
+ WidgetState(counts = Triple(d.myOpen, d.unassigned, d.overdue))
+ } 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}).")
+ }
+ }
+
+ private fun views(context: Context, counts: Triple? = null, message: String? = null): RemoteViews {
+ val v = RemoteViews(context.packageName, R.layout.widget_deskhub)
+ 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())
+ 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()))
+ } else {
+ v.setTextViewText(R.id.widget_message, message ?: "")
+ v.setViewVisibility(R.id.widget_message, View.VISIBLE)
+ v.setViewVisibility(R.id.widget_tiles, View.INVISIBLE)
+ v.setTextViewText(R.id.widget_time, "")
+ }
+ return v
+ }
+
+ companion object {
+ const val ACTION_REFRESH = "de.deskhub.staff.WIDGET_REFRESH"
+
+ /** Stößt eine Aktualisierung aller Widgets an (zum Beispiel beim Öffnen der App). */
+ fun requestUpdate(context: Context) {
+ val intent = Intent(context, DeskHubWidget::class.java).setAction(ACTION_REFRESH)
+ context.sendBroadcast(intent)
+ }
+ }
+}
diff --git a/mobile/src/main/java/de/deskhub/staff/MainActivity.kt b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt
index ef234487..a685d5ed 100644
--- a/mobile/src/main/java/de/deskhub/staff/MainActivity.kt
+++ b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt
@@ -1,23 +1,82 @@
-package de.deskhub.staff
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import androidx.lifecycle.viewmodel.compose.viewModel
-import de.deskhub.staff.data.TicketRepository
-import de.deskhub.staff.data.security.SecureSessionStore
-import de.deskhub.staff.ui.DeskHubApp
-import de.deskhub.staff.ui.DeskHubViewModel
-import de.deskhub.staff.ui.DeskHubViewModelFactory
-
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- val repository = TicketRepository(SecureSessionStore(applicationContext))
- val factory = DeskHubViewModelFactory(repository)
- setContent {
- val viewModel: DeskHubViewModel = viewModel(factory = factory)
- DeskHubApp(viewModel)
- }
- }
-}
+package de.deskhub.staff
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.core.content.ContextCompat
+import de.deskhub.staff.data.TicketRepository
+import de.deskhub.staff.data.security.SecureSessionStore
+import de.deskhub.staff.push.DeskHubPush
+import de.deskhub.staff.push.PushRegistration
+import de.deskhub.staff.ui.DeskHubApp
+import de.deskhub.staff.ui.DeskHubViewModel
+import de.deskhub.staff.ui.DeskHubViewModelFactory
+
+class MainActivity : ComponentActivity() {
+ private val viewModel: DeskHubViewModel by viewModels {
+ DeskHubViewModelFactory(TicketRepository(SecureSessionStore(applicationContext)))
+ }
+ private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
+
+ /** Jede Berührung meldet (höchstens einmal pro Minute) dem Server, dass die App benutzt wird. */
+ override fun onUserInteraction() {
+ super.onUserInteraction()
+ PresencePing.onUserInteraction(applicationContext)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ DeskHubWidget.requestUpdate(applicationContext)
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ handleNotificationIntent(intent)
+ setContent {
+ val state by viewModel.state.collectAsState()
+ val signedIn = state.staff != null
+ // Nach der Anmeldung: um Erlaubnis für Benachrichtigungen bitten (einmal) und das Gerät für Push anmelden
+ LaunchedEffect(signedIn) {
+ if (signedIn) {
+ requestNotificationPermissionOnce()
+ PushRegistration.registerCurrent(applicationContext)
+ CrashReporter.sendPending(applicationContext)
+ }
+ }
+ DeskHubApp(viewModel)
+ }
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handleNotificationIntent(intent)
+ }
+
+ /** Tipp auf eine Benachrichtigung: das Ticket aus dem Intent öffnen. */
+ private fun handleNotificationIntent(intent: Intent?) {
+ val number = intent?.getStringExtra(DeskHubPush.EXTRA_TICKET)?.trim().orEmpty()
+ if (number.isNotEmpty()) {
+ intent?.removeExtra(DeskHubPush.EXTRA_TICKET)
+ viewModel.openTicketFromNotification(number)
+ }
+ }
+
+ private fun requestNotificationPermissionOnce() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return
+ val prefs = getSharedPreferences("deskhub_push", MODE_PRIVATE)
+ if (prefs.getBoolean("asked_notifications", false)) return
+ prefs.edit().putBoolean("asked_notifications", true).apply()
+ notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+}
\ No newline at end of file
diff --git a/mobile/src/main/java/de/deskhub/staff/PresencePing.kt b/mobile/src/main/java/de/deskhub/staff/PresencePing.kt
new file mode 100644
index 00000000..ce48a0cf
--- /dev/null
+++ b/mobile/src/main/java/de/deskhub/staff/PresencePing.kt
@@ -0,0 +1,33 @@
+package de.deskhub.staff
+
+import android.content.Context
+import de.deskhub.staff.data.remote.DeskHubApi
+import de.deskhub.staff.data.security.SecureSessionStore
+import kotlinx.coroutines.runBlocking
+import kotlin.concurrent.thread
+
+/**
+ * Meldet dem Server höchstens einmal pro Minute, dass die App gerade bedient wird. Der Server setzt Mitarbeiter nach längerer
+ * Inaktivität (Standard 30 Minuten) automatisch auf "Offline" und holt sie bei dieser Meldung wieder zurück. Fehler werden still
+ * ignoriert. Wird nur bei echter Bedienung aufgerufen (Berührung), nicht bei Hintergrund-Abfragen.
+ */
+object PresencePing {
+ private const val MIN_INTERVAL_MS = 60_000L
+ @Volatile private var lastPing = 0L
+
+ fun onUserInteraction(context: Context) {
+ val now = System.currentTimeMillis()
+ if (now - lastPing < MIN_INTERVAL_MS) return
+ lastPing = now
+ val app = context.applicationContext
+ thread(name = "presence-ping") {
+ try {
+ val session = SecureSessionStore(app).load() ?: return@thread
+ if (session.token.isBlank() || session.serverUrl.isBlank()) return@thread
+ runBlocking { DeskHubApi(session.serverUrl) { session.token }.presence() }
+ } catch (e: Exception) {
+ lastPing = 0L // beim nächsten Tipp erneut versuchen
+ }
+ }
+ }
+}
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 b5b27305..ec3a4abf 100644
--- a/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt
+++ b/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt
@@ -240,8 +240,8 @@ class TicketRepository(
)
}
- suspend fun addMessage(number: String, type: String, message: String, setStatus: String?) {
- requireApi().addMessage(MessageRequest(number, type, message, setStatus))
+ 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 =
@@ -301,7 +301,12 @@ class TicketRepository(
}
suspend fun logout() {
- requireApi().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()
}
@@ -705,7 +710,7 @@ class TicketRepository(
return AvailabilityData(myStatus = r.myStatus, myNote = r.myNote, team = r.team.map(TeamMemberDto::toDomain))
}
- suspend fun setAvailability(status: String, note: String) { requireApi().setAvailability(status, note) }
+ suspend fun setAvailability(status: String, note: String, staffId: Int? = null) { requireApi().setAvailability(status, note, staffId) }
// ─── Ticket-Regeln (Automatisierung) ───────────────────────────────────────
@@ -811,7 +816,7 @@ private fun ChatMessageDto.toDomain() = ChatMessage(id, fromStaffId, fromName, m
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)
+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)
@@ -898,4 +903,12 @@ private fun TicketDetailDto.toDomain() = TicketDetail(
)
},
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 8f0756ba..0c28ea6d 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
@@ -207,6 +207,19 @@ class DeskHubApi(
execute("logout.php", "POST", emptyMap(), Map::class.java)
}
+ suspend fun pushPrefs(): de.deskhub.staff.data.remote.dto.PushPrefsDto =
+ execute("push_prefs.php", "GET", null, de.deskhub.staff.data.remote.dto.PushPrefsDto::class.java)
+
+ suspend fun savePushPrefs(request: de.deskhub.staff.data.remote.dto.PushPrefsRequest): de.deskhub.staff.data.remote.dto.PushPrefsDto =
+ execute("push_prefs.php", "POST", request, de.deskhub.staff.data.remote.dto.PushPrefsDto::class.java)
+
+ suspend fun crashReport(request: de.deskhub.staff.data.remote.dto.CrashReportRequest) {
+ execute("crash_report.php", "POST", request, Map::class.java)
+ }
+
+ suspend fun pushDevice(request: de.deskhub.staff.data.remote.dto.PushDeviceRequest): de.deskhub.staff.data.remote.dto.PushDeviceResponse =
+ execute("push_device.php", "POST", request, de.deskhub.staff.data.remote.dto.PushDeviceResponse::class.java)
+
// ─── Admin: Wissensdatenbank ─────────────────────────────────────────────
suspend fun kb(): KbResponse =
@@ -484,8 +497,13 @@ class DeskHubApi(
suspend fun availability(): AvailabilityResponse =
execute("availability.php", "GET", null, AvailabilityResponse::class.java)
- suspend fun setAvailability(status: String, note: String): AvailabilitySetResponse =
- execute("availability.php", "POST", AvailabilitySetRequest(status, note), AvailabilitySetResponse::class.java)
+ suspend fun setAvailability(status: String, note: String, staffId: Int? = null): AvailabilitySetResponse =
+ execute("availability.php", "POST", AvailabilitySetRequest(status, note, staffId), AvailabilitySetResponse::class.java)
+
+ /** Berührungs-Ping: die Person nutzt die App gerade (verhindert den automatischen Offline-Status). */
+ suspend fun presence() {
+ execute("presence.php", "POST", emptyMap(), Map::class.java)
+ }
// ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────
@@ -526,6 +544,7 @@ class DeskHubApi(
val token = tokenProvider()
?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.")
requestBuilder.header("Authorization", "Bearer $token")
+ requestBuilder.header("X-DeskHub-Authorization", "Bearer $token") // Ersatz-Header: viele Hoster entfernen "Authorization"
}
val body = payload?.let { gson.toJson(it).toRequestBody(jsonType) }
when (method) {
@@ -574,6 +593,7 @@ class DeskHubApi(
.url("$baseUrl/api/staff/v1/upload_image.php".toHttpUrl())
.header("Accept", "application/json")
.header("Authorization", "Bearer $token")
+ .header("X-DeskHub-Authorization", "Bearer $token")
.post(body)
.build()
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 5a2a813d..d3c07ad6 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
@@ -86,6 +86,16 @@ data class TicketDetailDto(
val lockedByOtherName: String? = null,
val threads: List = emptyList(),
val permissions: Map? = null,
+ // Signaturen zur Auswahl beim Antworten; null = ältere Serverversion
+ val replySignatures: ReplySignaturesDto? = null,
+ // Vom Kunden im Formular ausgefüllte Zusatzfelder; leer bei älteren Servern
+ val customFields: List = emptyList(),
+)
+
+data class CustomFieldValueDto(
+ val label: String = "",
+ val type: String = "text",
+ val value: String = "",
)
data class TicketSpamRequest(
@@ -149,10 +159,54 @@ data class MessageRequest(
val type: String,
val message: String,
val setStatus: String? = null,
+ // Welche Signatur angehängt wird: "own" (eigene), "dept" (Abteilung), "none"; null = Standard des Servers
+ val signature: String? = null,
+)
+
+data class ReplySignatureDto(
+ val available: Boolean = false,
+ val text: String = "",
+ val hasImage: Boolean = false,
+ val imageUrl: String = "",
+ val name: String = "",
+)
+
+data class ReplySignaturesDto(
+ val default: String = "none",
+ val own: ReplySignatureDto = ReplySignatureDto(),
+ val department: ReplySignatureDto = ReplySignatureDto(),
)
data class MessageResponse(val threadId: Int = 0)
+/** Gerät für Push-Benachrichtigungen beim Server an- oder abmelden (action: "register" oder "unregister"). */
+data class PushDeviceRequest(
+ val action: String,
+ val token: String,
+ val deviceName: String = "",
+ val appVersion: String = "",
+)
+
+/** Push-Einstellungen: welche Popups, stille Zeiten (Server: push_prefs.php). */
+data class PushEventTypeDto(val key: String = "", val label: String = "")
+
+data class PushPrefsDto(
+ val events: List = emptyList(),
+ val eventTypes: List = emptyList(),
+ val quietFrom: String = "",
+ val quietTo: String = "",
+)
+
+data class PushPrefsRequest(val events: List, val quietFrom: String, val quietTo: String)
+
+/** Absturzbericht (Server: crash_report.php). */
+data class CrashReportRequest(val appVersion: String, val device: String, val trace: String)
+
+data class PushDeviceResponse(
+ val registered: Boolean = false,
+ val serverConfigured: Boolean = false,
+)
+
data class CannedResponseDto(
val id: Int = 0,
val title: String = "",
@@ -975,6 +1029,8 @@ data class TeamMemberDto(
val note: String = "",
val isOnVacation: Boolean = false,
val updatedAt: String? = null,
+ val lastActiveAt: String? = null,
+ val autoOffline: Boolean = false,
)
data class AvailabilityResponse(
@@ -983,7 +1039,8 @@ data class AvailabilityResponse(
val team: List = emptyList(),
)
-data class AvailabilitySetRequest(val status: String, val note: String)
+// staffId nur für Administratoren: Status eines anderen Mitarbeiters ändern (leer = eigener Status)
+data class AvailabilitySetRequest(val status: String, val note: String, val staffId: Int? = null)
data class AvailabilitySetResponse(val ok: Boolean = false, val status: String = "")
// ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────────
diff --git a/mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt b/mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt
index 1a96a333..702bb28b 100644
--- a/mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt
+++ b/mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt
@@ -24,6 +24,7 @@ class SessionStorageException(message: String, cause: Throwable) : Exception(mes
class SecureSessionStore(context: Context) {
private val preferences = context.getSharedPreferences("deskhub_secure_session", Context.MODE_PRIVATE)
+ private val pushPreferences = context.getSharedPreferences("deskhub_push", Context.MODE_PRIVATE)
private val gson = Gson()
fun save(session: StoredSession) {
@@ -62,6 +63,9 @@ class SecureSessionStore(context: Context) {
fun loadLastServerUrl(): String? = preferences.getString(LAST_SERVER_URL_KEY, null)
+ /** Firebase-Token dieses Geräts (für das Abmelden beim Ausloggen); steht getrennt von der geschützten Sitzung. */
+ fun loadPushToken(): String? = pushPreferences.getString("fcm_token", null)?.takeIf { it.isNotBlank() }
+
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
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 016b29f7..d0e674a4 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
@@ -81,6 +81,26 @@ data class TicketDetail(
val threads: List,
// Rechte für dieses Ticket (gelten pro Abteilung); null = ältere Serverversion -> globale Rechte
val permissions: Map? = null,
+ // Signaturen zur Auswahl beim Antworten; null = ältere Serverversion (keine Auswahl)
+ val replySignatures: ReplySignatures? = null,
+ // Vom Kunden im Formular ausgefüllte Zusatzfelder (Bezeichnung, Wert)
+ val customFields: List = emptyList(),
+)
+
+data class CustomFieldValue(val label: String, val value: String)
+
+data class ReplySignature(
+ val available: Boolean,
+ val text: String,
+ val hasImage: Boolean,
+ val name: String,
+ val imageUrl: String = "",
+)
+
+data class ReplySignatures(
+ val default: String,
+ val own: ReplySignature,
+ val department: ReplySignature,
)
data class SpamActionResult(
@@ -667,6 +687,7 @@ data class TeamMember(
val note: String,
val isOnVacation: Boolean,
val updatedAt: String?,
+ val autoOffline: Boolean = false,
)
data class AvailabilityData(
diff --git a/mobile/src/main/java/de/deskhub/staff/push/DeskHubMessagingService.kt b/mobile/src/main/java/de/deskhub/staff/push/DeskHubMessagingService.kt
new file mode 100644
index 00000000..1e90955c
--- /dev/null
+++ b/mobile/src/main/java/de/deskhub/staff/push/DeskHubMessagingService.kt
@@ -0,0 +1,16 @@
+package de.deskhub.staff.push
+
+import com.google.firebase.messaging.FirebaseMessagingService
+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)
+ }
+
+ /** Firebase vergibt ein neues Geräte-Token (zum Beispiel nach Neuinstallation): beim Server melden. */
+ override fun onNewToken(token: String) {
+ PushRegistration.onNewToken(applicationContext, token)
+ }
+}
diff --git a/mobile/src/main/java/de/deskhub/staff/push/DeskHubPush.kt b/mobile/src/main/java/de/deskhub/staff/push/DeskHubPush.kt
new file mode 100644
index 00000000..6dda5c91
--- /dev/null
+++ b/mobile/src/main/java/de/deskhub/staff/push/DeskHubPush.kt
@@ -0,0 +1,104 @@
+package de.deskhub.staff.push
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import de.deskhub.staff.MainActivity
+import de.deskhub.staff.R
+
+/**
+ * Benachrichtigungen (Popups) für neue Tickets, Kundenantworten und Zuweisungen.
+ *
+ * Der Server schickt über Firebase Cloud Messaging nur eine kurze Daten-Nachricht (Titel, Text, Ticketnummer). Daraus baut die App
+ * hier die Benachrichtigung selbst. Ein Tipp darauf öffnet das Ticket in der App.
+ */
+object DeskHubPush {
+ const val CHANNEL_TICKETS = "tickets"
+ const val EXTRA_TICKET = "de.deskhub.staff.extra.TICKET"
+
+ /** Nachrichten, die der Server schickt (Feld "type"). */
+ private val KNOWN_TYPES = setOf("ticket_new", "ticket_reply", "ticket_assigned", "chat_new", "ticket_sla", "ticket_mention", "test")
+
+ fun ensureChannel(context: Context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
+ if (manager.getNotificationChannel(CHANNEL_TICKETS) != null) return
+ manager.createNotificationChannel(
+ NotificationChannel(CHANNEL_TICKETS, "Tickets", NotificationManager.IMPORTANCE_HIGH).apply {
+ description = "Neue Tickets, Kundenantworten und Zuweisungen"
+ },
+ )
+ }
+
+ /** Ob die App Benachrichtigungen anzeigen darf (ab Android 13 muss der Nutzer zustimmen). */
+ fun canNotify(context: Context): Boolean {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return false
+ }
+ return NotificationManagerCompat.from(context).areNotificationsEnabled()
+ }
+
+ /** Zeigt die Benachrichtigung zu einer Nachricht des Servers. Gibt zurück, ob etwas angezeigt wurde. */
+ fun show(context: Context, data: Map): Boolean {
+ val type = data["type"].orEmpty()
+ val title = data["title"].orEmpty().trim()
+ if (type !in KNOWN_TYPES || title.isEmpty()) return false
+ if (!canNotify(context)) return false
+ ensureChannel(context)
+
+ val ticket = data["ticket"].orEmpty().trim().take(60)
+ val open = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ if (ticket.isNotEmpty()) putExtra(EXTRA_TICKET, ticket)
+ }
+ val id = notificationId(type, ticket)
+ val pending = PendingIntent.getActivity(
+ context, id, open,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ val builder = NotificationCompat.Builder(context, CHANNEL_TICKETS)
+ .setSmallIcon(R.drawable.ic_stat_notification)
+ .setContentTitle(title.take(120))
+ .setContentText(data["body"].orEmpty().take(240))
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
+ .setAutoCancel(true)
+ .setContentIntent(pending)
+ // Frei wählbarer Name des Servers (Einstellung des Admins) neben dem App-Namen
+ data["brand"].orEmpty().trim().take(40).takeIf { it.isNotEmpty() }?.let { builder.setSubText(it) }
+ // Kundenantwort: direkt aus der Benachrichtigung antworten
+ if (type == "ticket_reply" && ticket.isNotEmpty()) builder.addAction(replyAction(context, id, ticket))
+ val notification = builder.build()
+ // Dieselbe Ticketnummer und Art ersetzt die frühere Benachrichtigung, statt den Bildschirm zu füllen
+ NotificationManagerCompat.from(context).notify(id, notification)
+ return true
+ }
+
+ private fun replyAction(context: Context, id: Int, ticket: String): NotificationCompat.Action {
+ val intent = Intent(context, PushReplyReceiver::class.java).apply {
+ putExtra(EXTRA_TICKET, ticket)
+ putExtra(PushReplyReceiver.EXTRA_NOTIFICATION_ID, id)
+ }
+ // Muss veränderbar sein, damit Android den eingegebenen Text einfügen kann
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
+ val pending = PendingIntent.getBroadcast(context, id, intent, flags)
+ val input = androidx.core.app.RemoteInput.Builder(PushReplyReceiver.KEY_REPLY).setLabel("Antwort schreiben").build()
+ return NotificationCompat.Action.Builder(R.drawable.ic_stat_notification, "Antworten", pending)
+ .addRemoteInput(input)
+ .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
+ .setShowsUserInterface(false)
+ .build()
+ }
+
+ fun notificationId(type: String, ticket: String): Int = (type + ":" + ticket).hashCode() and 0x7fffffff
+}
diff --git a/mobile/src/main/java/de/deskhub/staff/push/PushRegistration.kt b/mobile/src/main/java/de/deskhub/staff/push/PushRegistration.kt
new file mode 100644
index 00000000..5f83010c
--- /dev/null
+++ b/mobile/src/main/java/de/deskhub/staff/push/PushRegistration.kt
@@ -0,0 +1,76 @@
+package de.deskhub.staff.push
+
+import android.content.Context
+import android.os.Build
+import com.google.firebase.messaging.FirebaseMessaging
+import de.deskhub.staff.BuildConfig
+import de.deskhub.staff.data.remote.DeskHubApi
+import de.deskhub.staff.data.remote.dto.PushDeviceRequest
+import de.deskhub.staff.data.security.SecureSessionStore
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.resume
+
+/**
+ * Meldet das Gerät für Push-Benachrichtigungen beim DeskHub-Server an und ab.
+ * Das Firebase-Token liegt in eigenen Einstellungen (nicht in den geschützten Sitzungsdaten, die bei einem Fehler gelöscht werden).
+ */
+object PushRegistration {
+ private const val PREFS = "deskhub_push"
+ private const val KEY_TOKEN = "fcm_token"
+
+ fun savedToken(context: Context): String? =
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_TOKEN, null)?.takeIf { it.isNotBlank() }
+
+ private fun saveToken(context: Context, token: String) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_TOKEN, token).apply()
+ }
+
+ /** Holt das aktuelle Firebase-Token (null, wenn Firebase nicht erreichbar oder nicht eingerichtet ist). */
+ suspend fun fetchToken(): String? = suspendCancellableCoroutine { cont ->
+ runCatching {
+ FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
+ if (cont.isActive) cont.resume(if (task.isSuccessful) task.result else null)
+ }
+ }.onFailure { if (cont.isActive) cont.resume(null) }
+ }
+
+ /** Nach der Anmeldung: Berechtigungskanal anlegen, Token holen und beim Server anmelden. Fehler werden still ignoriert (kein Push, App läuft normal). */
+ suspend fun registerCurrent(context: Context) {
+ // Push darf die App nie zum Absturz bringen (Server ohne Push-Schnittstelle, kein Netz, Firebase fehlt ...)
+ try {
+ DeskHubPush.ensureChannel(context)
+ val token = fetchToken() ?: return
+ saveToken(context, token)
+ withContext(Dispatchers.IO) { registerWithServer(context, token) }
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ // still ignorieren
+ }
+ }
+
+ /** Firebase hat ein neues Token vergeben. */
+ fun onNewToken(context: Context, token: String) {
+ saveToken(context, token)
+ runCatching { registerWithServer(context, token) }
+ }
+
+ private fun registerWithServer(context: Context, token: String): Boolean {
+ val session = SecureSessionStore(context.applicationContext).load() ?: return false
+ if (session.token.isBlank() || session.serverUrl.isBlank()) return false
+ val api = DeskHubApi(session.serverUrl) { session.token }
+ val response = kotlinx.coroutines.runBlocking {
+ api.pushDevice(
+ PushDeviceRequest(
+ action = "register",
+ token = token,
+ deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(),
+ appVersion = BuildConfig.VERSION_NAME,
+ ),
+ )
+ }
+ return response.registered
+ }
+}
diff --git a/mobile/src/main/java/de/deskhub/staff/push/PushReplyReceiver.kt b/mobile/src/main/java/de/deskhub/staff/push/PushReplyReceiver.kt
new file mode 100644
index 00000000..0625d7e5
--- /dev/null
+++ b/mobile/src/main/java/de/deskhub/staff/push/PushReplyReceiver.kt
@@ -0,0 +1,68 @@
+package de.deskhub.staff.push
+
+import android.app.NotificationManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.RemoteInput
+import de.deskhub.staff.R
+import de.deskhub.staff.data.remote.DeskHubApi
+import de.deskhub.staff.data.remote.dto.MessageRequest
+import de.deskhub.staff.data.security.SecureSessionStore
+import kotlinx.coroutines.runBlocking
+import kotlin.concurrent.thread
+
+/** Schnellantwort direkt aus der Benachrichtigung: sendet den eingegebenen Text als Antwort auf das Ticket. */
+class PushReplyReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val ticket = intent.getStringExtra(DeskHubPush.EXTRA_TICKET).orEmpty()
+ val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
+ val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_REPLY)?.toString()?.trim().orEmpty()
+ if (ticket.isEmpty() || text.isEmpty()) return
+ val pending = goAsync()
+ thread(name = "push-reply") {
+ val ok = try {
+ send(context.applicationContext, ticket, text)
+ } catch (e: Exception) {
+ false
+ }
+ try {
+ confirm(context, notificationId, ticket, ok)
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+
+ private fun send(context: Context, ticket: String, text: String): Boolean {
+ val session = SecureSessionStore(context).load() ?: return false
+ if (session.token.isBlank() || session.serverUrl.isBlank()) return false
+ val api = DeskHubApi(session.serverUrl) { session.token }
+ runBlocking { api.addMessage(MessageRequest(number = ticket, type = "reply", message = toHtml(text))) }
+ return true
+ }
+
+ /** Ersetzt die Benachrichtigung durch eine Bestätigung (sonst bleibt die Eingabe-Anzeige hängen). */
+ private fun confirm(context: Context, id: Int, ticket: String, ok: Boolean) {
+ if (!DeskHubPush.canNotify(context)) return
+ val n = NotificationCompat.Builder(context, DeskHubPush.CHANNEL_TICKETS)
+ .setSmallIcon(R.drawable.ic_stat_notification)
+ .setContentTitle(if (ok) "Antwort gesendet" else "Antwort nicht gesendet")
+ .setContentText("#$ticket" + if (ok) "" else ": bitte in der App erneut versuchen")
+ .setTimeoutAfter(if (ok) 4000 else 0)
+ .setAutoCancel(true)
+ .build()
+ NotificationManagerCompat.from(context).notify(id, n)
+ }
+
+ companion object {
+ const val KEY_REPLY = "reply_text"
+ const val EXTRA_NOTIFICATION_ID = "de.deskhub.staff.extra.NOTIFICATION_ID"
+
+ /** Reiner Text wird für die Nachricht als HTML maskiert, Zeilenumbrüche bleiben erhalten. */
+ fun toHtml(text: String): String =
+ text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\r\n", "\n").replace("\n", "
")
+ }
+}
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 c438f5c1..dcc6f4d5 100644
--- a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt
+++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt
@@ -1,6206 +1,6318 @@
-@file:OptIn(ExperimentalMaterial3Api::class)
-
-package de.deskhub.staff.ui
-
-import android.graphics.Color.parseColor
-import android.net.Uri
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-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.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-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.width
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.KeyboardActions
-import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.automirrored.filled.Assignment
-import androidx.compose.material.icons.automirrored.filled.Chat
-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.automirrored.filled.Send
-import androidx.compose.material.icons.filled.AccessTime
-import androidx.compose.material.icons.filled.AdminPanelSettings
-import androidx.compose.material.icons.filled.Apartment
-import androidx.compose.material.icons.filled.AttachFile
-import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Block
-import androidx.compose.material.icons.filled.Bolt
-import androidx.compose.material.icons.filled.Assessment
-import androidx.compose.material.icons.filled.Business
-import androidx.compose.material.icons.filled.Close
-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.Description
-import androidx.compose.material.icons.filled.Email
-import androidx.compose.material.icons.filled.ErrorOutline
-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.People
-import androidx.compose.material.icons.filled.Person
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material.icons.filled.Schedule
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material.icons.filled.SupportAgent
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Badge
-import androidx.compose.material3.BadgedBox
-import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.Checkbox
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FilterChip
-import androidx.compose.material3.FilterChipDefaults
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.NavigationBar
-import androidx.compose.material3.NavigationBarItem
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.RadioButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.ScrollableTabRow
-import androidx.compose.material3.SegmentedButton
-import androidx.compose.material3.SegmentedButtonDefaults
-import androidx.compose.material3.Shapes
-import androidx.compose.material3.SingleChoiceSegmentedButtonRow
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Switch
-import androidx.compose.material3.Tab
-import androidx.compose.material3.Text
-import androidx.compose.runtime.staticCompositionLocalOf
-import androidx.compose.ui.graphics.toArgb
-import androidx.compose.material.icons.filled.DarkMode
-import androidx.compose.material.icons.filled.LightMode
-import androidx.compose.material.icons.filled.BrightnessMedium
-import androidx.compose.material3.Typography
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
-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.platform.LocalContext
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.unit.sp
-import androidx.compose.ui.text.input.ImeAction
-import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.input.PasswordVisualTransformation
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.navigation.NavGraph.Companion.findStartDestination
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.currentBackStackEntryAsState
-import androidx.navigation.compose.rememberNavController
-import de.deskhub.staff.R
-import de.deskhub.staff.domain.model.AuditLogEntry
-import de.deskhub.staff.domain.model.BanEntry
-import de.deskhub.staff.domain.model.BusinessHour
-import de.deskhub.staff.domain.model.Department
-import de.deskhub.staff.domain.model.DepartmentInput
-import de.deskhub.staff.domain.model.EmailAddress
-import de.deskhub.staff.domain.model.EmailAddressInput
-import de.deskhub.staff.domain.model.EmailTemplate
-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.KeyLabel
-import de.deskhub.staff.domain.model.Organization
-import de.deskhub.staff.domain.model.PriorityFull
-import de.deskhub.staff.domain.model.Role
-import de.deskhub.staff.domain.model.Staff
-import de.deskhub.staff.domain.model.StaffAdmin
-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.Team
-import de.deskhub.staff.domain.model.KbArticle
-import de.deskhub.staff.domain.model.Metadata
-import de.deskhub.staff.domain.model.NamedOption
-import de.deskhub.staff.domain.model.TicketDetail
-import de.deskhub.staff.domain.model.TicketFilters
-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.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)
- }
- }
- }
- }
-}
-
-/** Kleines Menü zum Umschalten zwischen System/Hell/Dunkel (für Kopfleisten). */
-@Composable
-internal fun ThemeMenuButton() {
- val controller = LocalThemeController.current
- var open by remember { mutableStateOf(false) }
- Box {
- IconButton(onClick = { open = true }) {
- Icon(
- when (controller.mode) {
- ThemeMode.Dark -> Icons.Filled.DarkMode
- ThemeMode.Light -> Icons.Filled.LightMode
- ThemeMode.System -> Icons.Filled.BrightnessMedium
- },
- contentDescription = "Design wechseln",
- )
- }
- DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
- ThemeMode.values().forEach { mode ->
- DropdownMenuItem(
- text = { Text(mode.label, fontWeight = if (mode == controller.mode) FontWeight.Bold else FontWeight.Normal) },
- onClick = { controller.setMode(mode); open = false },
- )
- }
- }
- }
-}
-
-
-@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")
-
-@Composable
-private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
- val navController = rememberNavController()
- val backStackEntry by navController.currentBackStackEntryAsState()
- val currentRoute = backStackEntry?.destination?.route
-
- Scaffold(
- 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") },
- )
- }
- }
- }
- },
- ) { outerPadding ->
- Column(Modifier.padding(outerPadding)) {
- StatusBanners(state, onRetry = viewModel::retryConnection)
- NavHost(
- navController = navController,
- startDestination = "dashboard",
- modifier = Modifier.weight(1f),
- ) {
- composable("tickets") {
- TicketListScreen(
- state = state,
- onFiltersChanged = viewModel::updateFilters,
- onSearch = viewModel::refreshTickets,
- onRefresh = viewModel::refreshTickets,
- onLoadMore = viewModel::loadMoreTickets,
- onApplyView = viewModel::applySavedView,
- onSaveView = viewModel::saveCurrentView,
- onDeleteView = viewModel::deleteSavedView,
- onLoadViews = viewModel::loadSavedViews,
- onTicket = { number ->
- viewModel.openTicket(number)
- navController.navigate("detail")
- },
- onLogout = viewModel::logout,
- onNewTicket = {
- viewModel.startNewTicket()
- navController.navigate("newTicket")
- },
- )
- }
- composable("dashboard") {
- DashboardScreen(
- state = state,
- onLoad = {
- viewModel.loadDashboard()
- viewModel.loadAvailability()
- },
- onSetStatus = viewModel::setMyAvailability,
- onTicket = { number ->
- viewModel.openTicket(number)
- navController.navigate("detail")
- },
- )
- }
- composable("customers") {
- CustomersScreen(
- state = state,
- onSearch = viewModel::searchCustomers,
- onCustomer = { customer ->
- viewModel.openCustomer(customer)
- navController.navigate("customerProfile")
- },
- )
- }
- composable("customerProfile") {
- CustomerProfileScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onTicket = { number ->
- viewModel.openTicket(number)
- navController.navigate("detail")
- },
- onNewTicket = { name, email ->
- viewModel.startNewTicket(name, email)
- navController.navigate("newTicket")
- },
- )
- }
- composable("chat") {
- ChatHubScreen(
- state = state,
- onLoadContacts = viewModel::loadChatContacts,
- onOpenContact = { contact ->
- viewModel.openChatConversation(contact.id)
- navController.navigate("chatConversation")
- },
- onLoadLiveChats = viewModel::loadLiveChats,
- onStopLiveChatPolling = viewModel::stopLiveChatListPolling,
- onOpenLiveChat = { number ->
- viewModel.openLiveChat(number)
- navController.navigate("liveChatConversation")
- },
- )
- }
- composable("chatConversation") {
- ChatConversationScreen(
- state = state,
- onBack = { viewModel.closeChatConversation(); navController.popBackStack() },
- onSend = viewModel::sendChatMessage,
- onClearError = viewModel::clearChatSendError,
- )
- }
- composable("liveChatConversation") {
- LiveChatConversationScreen(
- state = state,
- onBack = { viewModel.closeLiveChat(); navController.popBackStack() },
- onSend = viewModel::sendLiveChatMessage,
- onTyping = viewModel::pingLiveChatTyping,
- onClearError = viewModel::clearLiveChatSendError,
- onEnd = { number -> viewModel.endLiveChat(number) { navController.popBackStack() } },
- )
- }
- composable("newTicket") {
- NewTicketScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onCreate = { requesterName, requesterEmail, subject, message, departmentId, priorityId, assignedStaffId, notify ->
- viewModel.createTicket(
- requesterName, requesterEmail, subject, message,
- departmentId, priorityId, assignedStaffId, notify,
- ) { number ->
- viewModel.openTicket(number)
- navController.navigate("detail") {
- popUpTo("tickets")
- }
- }
- },
- )
- }
- composable("detail") {
- TicketDetailScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onRetry = { state.selectedTicket?.summary?.number?.let(viewModel::openTicket) },
- onUpdate = viewModel::updateTicket,
- onMessage = viewModel::addMessage,
- onSpamAction = viewModel::setTicketSpam,
- )
- }
- composable("admin") {
- AdminScreen(staff = state.staff, onNavigate = { route -> navController.navigate(route) })
- }
- composable("adminKb") {
- AdminKbScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadKb,
- onSaveArticle = viewModel::saveKbArticle,
- onDeleteArticle = viewModel::deleteKbArticle,
- onCreateCategory = viewModel::createKbCategory,
- onDeleteCategory = viewModel::deleteKbCategory,
- onClearError = viewModel::clearKbMutationError,
- )
- }
- composable("adminTimeEntries") {
- AdminTimeEntriesScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadTimeEntries,
- )
- }
- composable("adminEscalation") {
- AdminEscalationScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadEscalationRules,
- onSave = viewModel::saveEscalationRule,
- onDelete = viewModel::deleteEscalationRule,
- onToggle = viewModel::toggleEscalationRule,
- onClearError = viewModel::clearEscalationMutationError,
- )
- }
- composable("adminTicketRules") {
- AdminTicketRulesScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadTicketRules,
- onSave = viewModel::saveTicketRule,
- onDelete = viewModel::deleteTicketRule,
- onToggle = viewModel::toggleTicketRule,
- onClearError = viewModel::clearTicketRulesMutationError,
- onSaveAutoClose = viewModel::saveAutoClose,
- )
- }
- composable("adminAvailability") {
- AdminAvailabilityScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadAvailability,
- onSetStatus = viewModel::setMyAvailability,
- )
- }
- composable("adminBanlist") {
- AdminBanlistScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadBanlist,
- onCreate = viewModel::createBanEntry,
- onBulkAdd = viewModel::bulkAddBanEntries,
- onDelete = viewModel::deleteBanEntry,
- onClearError = viewModel::clearBanlistMutationError,
- onClearBulkResult = viewModel::clearBanlistBulkResult,
- )
- }
- composable("adminDepartments") {
- AdminDepartmentsScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadDepartments,
- onSave = viewModel::saveDepartment,
- onDelete = viewModel::deleteDepartment,
- onClearError = viewModel::clearDepartmentsError,
- onUploadImage = viewModel::uploadSignatureImage,
- )
- }
- composable("adminReports") {
- AdminReportsScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onRun = viewModel::runReport,
- onSave = viewModel::saveReport,
- onDelete = viewModel::deleteReport,
- )
- }
- composable("adminSlaPlans") {
- AdminSlaPlansScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadSlaPlans,
- onSave = viewModel::saveSlaPlan,
- onDelete = viewModel::deleteSlaPlan,
- onClearError = viewModel::clearSlaPlansError,
- )
- }
- composable("adminPriorities") {
- AdminPrioritiesScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadPrioritiesAdmin,
- onSave = viewModel::savePriority,
- onDelete = viewModel::deletePriority,
- onClearError = viewModel::clearPrioritiesError,
- )
- }
- composable("adminHelpTopics") {
- AdminHelpTopicsScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadHelpTopics,
- onSave = viewModel::saveHelpTopic,
- onDelete = viewModel::deleteHelpTopic,
- onClearError = viewModel::clearHelpTopicsError,
- )
- }
- composable("adminOrganizations") {
- AdminOrganizationsScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadOrganizations,
- onSave = viewModel::saveOrganization,
- onDelete = viewModel::deleteOrganization,
- onClearError = viewModel::clearOrganizationsError,
- )
- }
- composable("adminTeams") {
- AdminTeamsScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadTeams,
- onSave = viewModel::saveTeam,
- onToggle = viewModel::toggleTeam,
- onDelete = viewModel::deleteTeam,
- onClearError = viewModel::clearTeamsError,
- )
- }
- composable("adminRoles") {
- AdminRolesScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadRoles,
- onSave = viewModel::saveRole,
- onDelete = viewModel::deleteRole,
- onClearError = viewModel::clearRolesError,
- )
- }
- composable("adminStaff") {
- AdminStaffScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadStaffAdmin,
- onSave = viewModel::saveStaffAdmin,
- onDelete = viewModel::deleteStaffAdmin,
- onClearError = viewModel::clearStaffAdminError,
- onUploadImage = viewModel::uploadSignatureImage,
- )
- }
- composable("adminBusinessHours") {
- AdminBusinessHoursScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadBusinessHours,
- onSaveHours = viewModel::saveBusinessHours,
- onAddHoliday = viewModel::addBusinessHoliday,
- onDeleteHoliday = viewModel::deleteBusinessHoliday,
- )
- }
- composable("adminEmailAddresses") {
- AdminEmailAddressesScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadEmailAddresses,
- onSave = viewModel::saveEmailAddress,
- onDelete = viewModel::deleteEmailAddress,
- onTest = viewModel::testEmailAddress,
- onClearError = viewModel::clearEmailAddressesError,
- )
- }
- composable("adminEmailTemplates") {
- AdminEmailTemplatesScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadEmailTemplates,
- onSave = { key, subject, bodyText -> viewModel.saveEmailTemplate(key, subject, bodyText) {} },
- onReset = viewModel::resetEmailTemplate,
- )
- }
- composable("adminAuditLog") {
- AdminAuditLogScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadAuditLog,
- )
- }
- composable("adminTasks") {
- AdminTasksScreen(
- state = state,
- onBack = { navController.popBackStack() },
- onLoad = viewModel::loadTasks,
- onCreate = viewModel::createTask,
- onUpdateStatus = viewModel::updateTaskStatus,
- onDelete = viewModel::deleteTask,
- onClearError = viewModel::clearTasksError,
- )
- }
- }
- }
- }
-}
-
-/** Banner unter der Kopfleiste: Verbindungsausfall und Systemhinweise des Servers (z. B. Mail-Abruf). */
-@Composable
-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) {
- Text(
- "Keine Verbindung zum Server. Es wird automatisch erneut versucht.",
- modifier = Modifier.weight(1f),
- color = MaterialTheme.colorScheme.onError,
- style = MaterialTheme.typography.labelLarge,
- )
- TextButton(onClick = onRetry) { Text("Erneut", color = MaterialTheme.colorScheme.onError) }
- }
- }
- }
- state.healthIssues.forEach { issue ->
- Surface(
- color = if (issue.isError) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.tertiaryContainer,
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(
- (if (issue.isError) "Störung: " else "Hinweis: ") + issue.message,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- color = if (issue.isError) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onTertiaryContainer,
- style = MaterialTheme.typography.bodySmall,
- )
- }
- }
-}
-
-private fun androidx.navigation.NavController.navigateToTab(route: String) {
- navigate(route) {
- popUpTo(graph.findStartDestination().id) { saveState = true }
- launchSingleTop = true
- restoreState = true
- }
-}
-
-// ─── Login ──────────────────────────────────────────────────────────────────
-
-@Composable
-private fun LoginScreen(state: DeskHubState, viewModel: DeskHubViewModel) {
- var serverUrl by rememberSaveable { mutableStateOf(state.savedServerUrl ?: "") }
- var email by rememberSaveable { mutableStateOf("") }
- var password by rememberSaveable { mutableStateOf("") }
- var totpCode by rememberSaveable { mutableStateOf("") }
-
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.background),
- ) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(220.dp)
- .background(
- Brush.linearGradient(listOf(Color(0xFF14202E), BrandTealDark, Color(0xFF1F6FB5))),
- ),
- contentAlignment = Alignment.Center,
- ) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Box(
- modifier = Modifier
- .size(56.dp)
- .clip(CircleShape)
- .background(Color.White.copy(alpha = 0.14f)),
- contentAlignment = Alignment.Center,
- ) {
- Icon(Icons.Filled.SupportAgent, contentDescription = null, tint = Color.White)
- }
- Spacer(Modifier.height(12.dp))
- Text(
- "DeskHub",
- style = MaterialTheme.typography.headlineMedium,
- fontWeight = FontWeight.Bold,
- color = Color.White,
- )
- Text(
- "Mitarbeiter-Bereich",
- style = MaterialTheme.typography.bodyMedium,
- color = Color.White.copy(alpha = 0.75f),
- )
- }
- }
-
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(20.dp),
- shape = MaterialTheme.shapes.large,
- elevation = CardDefaults.cardElevation(defaultElevation = 3.dp),
- ) {
- Column(
- modifier = Modifier.padding(24.dp),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- Text(
- if (state.totpRequired) "Zweiten Faktor bestätigen" else "Mit deinem Mitarbeiterkonto anmelden",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- )
- if (!state.totpRequired) {
- OutlinedTextField(
- value = serverUrl,
- onValueChange = { serverUrl = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text(stringResource(R.string.server_label)) },
- placeholder = { Text("https://support.example.de") },
- leadingIcon = { Icon(Icons.Filled.Business, contentDescription = null) },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Uri,
- imeAction = ImeAction.Next,
- ),
- )
- OutlinedTextField(
- value = email,
- onValueChange = { email = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("E-Mail-Adresse") },
- leadingIcon = { Icon(Icons.Filled.Email, contentDescription = null) },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Email,
- imeAction = ImeAction.Next,
- ),
- )
- OutlinedTextField(
- value = password,
- onValueChange = { password = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Passwort") },
- leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
- visualTransformation = PasswordVisualTransformation(),
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Password,
- imeAction = ImeAction.Done,
- ),
- keyboardActions = KeyboardActions(
- onDone = { viewModel.login(serverUrl, email, password, "") },
- ),
- )
- } else {
- OutlinedTextField(
- value = totpCode,
- onValueChange = { value -> totpCode = value.filter(Char::isDigit).take(6) },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Authenticator-Code") },
- leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.NumberPassword,
- imeAction = ImeAction.Done,
- ),
- keyboardActions = KeyboardActions(
- onDone = { viewModel.login(serverUrl, email, password, totpCode) },
- ),
- )
- }
- state.authError?.let { ErrorText(it) }
- Button(
- onClick = {
- viewModel.login(
- serverUrl = serverUrl,
- email = email,
- password = password,
- totpCode = if (state.totpRequired) totpCode else "",
- )
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(50.dp),
- shape = MaterialTheme.shapes.medium,
- enabled = !state.authLoading &&
- serverUrl.isNotBlank() && email.isNotBlank() && password.isNotBlank() &&
- (!state.totpRequired || totpCode.length == 6),
- ) {
- if (state.authLoading) {
- CircularProgressIndicator(modifier = Modifier.width(20.dp), strokeWidth = 2.dp)
- } else {
- Text(if (state.totpRequired) "Bestätigen" else "Anmelden")
- }
- }
- if (state.totpRequired) {
- TextButton(
- onClick = {
- totpCode = ""
- password = ""
- viewModel.cancelTotp()
- },
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text("Zurück")
- }
- }
- }
- }
- }
-}
-
-// ─── Ticket list ────────────────────────────────────────────────────────────
-
-@Composable
-private fun TicketListScreen(
- state: DeskHubState,
- onFiltersChanged: (TicketFilters) -> Unit,
- onSearch: () -> Unit,
- onRefresh: () -> Unit,
- onLoadMore: () -> Unit,
- onApplyView: (de.deskhub.staff.domain.model.SavedView) -> Unit,
- onSaveView: (String, Boolean) -> Unit,
- onDeleteView: (Int) -> Unit,
- onLoadViews: () -> Unit,
- onTicket: (String) -> Unit,
- onLogout: () -> Unit,
- onNewTicket: () -> Unit,
-) {
- 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,
- ),
- )
- },
- floatingActionButton = {
- if (canCreateTicket) {
- FloatingActionButton(onClick = onNewTicket) {
- Icon(Icons.Filled.Add, contentDescription = "Neues Ticket")
- }
- }
- },
- ) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize(),
- ) {
- TicketFilterBar(
- filters = state.filters,
- metadata = state.metadata,
- onFiltersChanged = onFiltersChanged,
- onSearch = onSearch,
- savedViews = state.savedViews,
- onApplyView = onApplyView,
- onSaveView = onSaveView,
- onDeleteView = onDeleteView,
- )
- state.mutationError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) }
- 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.")
- else -> LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- items(state.tickets, key = TicketSummary::id) { ticket ->
- TicketCard(ticket, state.metadata, onTicket)
- }
- item {
- if (state.ticketsError != null) {
- ErrorText(state.ticketsError)
- }
- if (state.ticketsHasMore) {
- OutlinedButton(
- onClick = onLoadMore,
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- 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")
- }
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun TicketFilterBar(
- filters: TicketFilters,
- metadata: Metadata?,
- onFiltersChanged: (TicketFilters) -> Unit,
- onSearch: () -> Unit,
- savedViews: List = emptyList(),
- onApplyView: (de.deskhub.staff.domain.model.SavedView) -> Unit = {},
- onSaveView: (String, Boolean) -> Unit = { _, _ -> },
- onDeleteView: (Int) -> Unit = {},
-) {
- var viewsExpanded by remember { mutableStateOf(false) }
- var showSaveDialog by remember { mutableStateOf(false) }
- var statusExpanded by remember { mutableStateOf(false) }
- var assignedExpanded by remember { mutableStateOf(false) }
- var departmentExpanded by remember { mutableStateOf(false) }
- var priorityExpanded by remember { mutableStateOf(false) }
- var tagExpanded by remember { mutableStateOf(false) }
- var slaExpanded by remember { mutableStateOf(false) }
- val activeStatuses = setOf("open", "pending", "answered", "on_hold")
- val statusLabel = when (filters.statuses) {
- activeStatuses -> "Aktiv"
- emptySet() -> "Alle Status"
- else -> metadata?.statuses?.firstOrNull { it.value in filters.statuses }?.label ?: "Status"
- }
- val assignedLabel = when (filters.assigned) {
- "me" -> "Mir zugewiesen"
- "unassigned" -> "Nicht zugewiesen"
- else -> "Alle Zuständigkeiten"
- }
- val departmentLabel = metadata?.departments
- ?.firstOrNull { it.id == filters.departmentId }?.name ?: "Alle Abteilungen"
- val priorityLabel = metadata?.priorities
- ?.firstOrNull { it.id == filters.priorityId }?.name ?: "Alle Prioritäten"
- val tagLabel = metadata?.tags
- ?.firstOrNull { it.id == filters.tagId }?.name ?: "Alle Tags"
- val slaLabel = when (filters.sla) {
- "overdue" -> "🔴 Überfällig"
- "at_risk" -> "🟡 Bald fällig"
- "on_time" -> "🟢 Im Zeitplan"
- else -> "SLA: Alle"
- }
-
- Surface(color = MaterialTheme.colorScheme.surface, shadowElevation = 1.dp) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.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") },
- leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
- keyboardActions = KeyboardActions(onSearch = { onSearch() }),
- trailingIcon = {
- TextButton(onClick = onSearch) { Text("Suchen") }
- },
- )
- if (showSaveDialog) {
- var viewName by remember { mutableStateOf("") }
- var viewShared by remember { mutableStateOf(false) }
- AlertDialog(
- onDismissRequest = { showSaveDialog = false },
- title = { Text("Ansicht speichern") },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Text("Speichert die aktuellen Filter.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- OutlinedTextField(value = viewName, onValueChange = { viewName = it }, label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = viewShared, onCheckedChange = { viewShared = it })
- Text("Für alle Mitarbeiter sichtbar")
- }
- }
- },
- confirmButton = {
- TextButton(enabled = viewName.isNotBlank(), onClick = { onSaveView(viewName.trim(), viewShared); showSaveDialog = false }) { Text("Speichern") }
- },
- dismissButton = { TextButton(onClick = { showSaveDialog = false }) { Text("Abbrechen") } },
- )
- }
- LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- item {
- Box {
- FilterChip(
- selected = false,
- onClick = { viewsExpanded = true },
- label = { Text("Ansichten") },
- shape = MaterialTheme.shapes.small,
- )
- DropdownMenu(expanded = viewsExpanded, onDismissRequest = { viewsExpanded = false }) {
- savedViews.forEach { view ->
- DropdownMenuItem(
- text = { Text(view.name + if (view.isShared) " ⇄" else "") },
- onClick = { onApplyView(view); viewsExpanded = false },
- trailingIcon = {
- if (view.mine) {
- IconButton(onClick = { onDeleteView(view.id); viewsExpanded = false }) {
- Icon(Icons.Filled.Close, contentDescription = "Ansicht löschen")
- }
- }
- },
- )
- }
- if (savedViews.isNotEmpty()) HorizontalDivider()
- DropdownMenuItem(text = { Text("Aktuelle Filter speichern …") }, onClick = { viewsExpanded = false; showSaveDialog = true })
- }
- }
- }
- item {
- Box {
- FilterChip(
- selected = filters.statuses != activeStatuses,
- onClick = { statusExpanded = true },
- label = { Text(statusLabel) },
- shape = MaterialTheme.shapes.small,
- )
- DropdownMenu(expanded = statusExpanded, onDismissRequest = { statusExpanded = false }) {
- DropdownMenuItem(
- text = { Text("Aktiv") },
- onClick = {
- onFiltersChanged(filters.copy(statuses = activeStatuses))
- statusExpanded = false
- onSearch()
- },
- )
- DropdownMenuItem(
- text = { Text("Alle Status") },
- onClick = {
- onFiltersChanged(filters.copy(statuses = emptySet()))
- statusExpanded = false
- onSearch()
- },
- )
- metadata?.statuses?.forEach { status ->
- DropdownMenuItem(
- text = { Text(status.label) },
- onClick = {
- onFiltersChanged(filters.copy(statuses = setOf(status.value)))
- statusExpanded = false
- onSearch()
- },
- )
- }
- }
- }
- }
- item {
- Box {
- FilterChip(
- selected = filters.assigned.isNotEmpty(),
- onClick = { assignedExpanded = true },
- label = { Text(assignedLabel) },
- shape = MaterialTheme.shapes.small,
- )
- DropdownMenu(expanded = assignedExpanded, onDismissRequest = { assignedExpanded = false }) {
- listOf(
- "" to "Alle Zuständigkeiten",
- "me" to "Mir zugewiesen",
- "unassigned" to "Nicht zugewiesen",
- ).forEach { (value, label) ->
- DropdownMenuItem(
- text = { Text(label) },
- onClick = {
- onFiltersChanged(filters.copy(assigned = value))
- assignedExpanded = false
- onSearch()
- },
- )
- }
- }
- }
- }
- item {
- Box {
- FilterChip(
- selected = filters.departmentId != null,
- onClick = { departmentExpanded = true },
- label = { Text(departmentLabel) },
- shape = MaterialTheme.shapes.small,
- )
- DropdownMenu(expanded = departmentExpanded, onDismissRequest = { departmentExpanded = false }) {
- DropdownMenuItem(
- text = { Text("Alle Abteilungen") },
- onClick = {
- onFiltersChanged(filters.copy(departmentId = null))
- departmentExpanded = false
- onSearch()
- },
- )
- metadata?.departments?.forEach { department ->
- DropdownMenuItem(
- text = { Text(department.name) },
- onClick = {
- onFiltersChanged(filters.copy(departmentId = department.id))
- departmentExpanded = false
- onSearch()
- },
- )
- }
- }
- }
- }
- item {
- Box {
- FilterChip(
- selected = filters.priorityId != null,
- onClick = { priorityExpanded = true },
- label = { Text(priorityLabel) },
- shape = MaterialTheme.shapes.small,
- )
- DropdownMenu(expanded = priorityExpanded, onDismissRequest = { priorityExpanded = false }) {
- DropdownMenuItem(
- text = { Text("Alle Prioritäten") },
- onClick = {
- onFiltersChanged(filters.copy(priorityId = null))
- priorityExpanded = false
- onSearch()
- },
- )
- metadata?.priorities?.forEach { priority ->
- DropdownMenuItem(
- text = { Text(priority.name) },
- onClick = {
- onFiltersChanged(filters.copy(priorityId = priority.id))
- priorityExpanded = false
- onSearch()
- },
- )
- }
- }
- }
- }
- if (metadata?.tags?.isNotEmpty() == true) {
- item {
- Box {
- FilterChip(
- selected = filters.tagId != null,
- onClick = { tagExpanded = true },
- label = { Text(tagLabel) },
- shape = MaterialTheme.shapes.small,
- )
- DropdownMenu(expanded = tagExpanded, onDismissRequest = { tagExpanded = false }) {
- DropdownMenuItem(
- text = { Text("Alle Tags") },
- onClick = {
- onFiltersChanged(filters.copy(tagId = null))
- tagExpanded = false
- onSearch()
- },
- )
- metadata.tags.forEach { tag ->
- DropdownMenuItem(
- text = { Text(tag.name) },
- onClick = {
- onFiltersChanged(filters.copy(tagId = tag.id))
- tagExpanded = false
- onSearch()
- },
- )
- }
- }
- }
- }
- }
- item {
- Box {
- FilterChip(
- selected = filters.sla.isNotEmpty(),
- onClick = { slaExpanded = true },
- label = { Text(slaLabel) },
- shape = MaterialTheme.shapes.small,
- )
- DropdownMenu(expanded = slaExpanded, onDismissRequest = { slaExpanded = false }) {
- listOf(
- "" to "SLA: Alle",
- "overdue" to "🔴 Überfällig",
- "at_risk" to "🟡 Bald fällig (< 4 Std.)",
- "on_time" to "🟢 Im Zeitplan",
- ).forEach { (value, label) ->
- DropdownMenuItem(
- text = { Text(label) },
- onClick = {
- onFiltersChanged(filters.copy(sla = value))
- slaExpanded = false
- onSearch()
- },
- )
- }
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private 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,
- )
- StatusPill(ticket.status, metadata)
- }
- 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,
- )
- 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,
- )
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun InitialsAvatarChip(name: String) {
- val initials = name.trim().split(" ").mapNotNull { it.firstOrNull()?.uppercaseChar() }.take(2).joinToString("")
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(
- modifier = Modifier
- .size(18.dp)
- .clip(CircleShape)
- .background(MaterialTheme.colorScheme.primaryContainer),
- contentAlignment = Alignment.Center,
- ) {
- Text(
- initials,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- fontWeight = FontWeight.Bold,
- )
- }
- Spacer(Modifier.width(5.dp))
- Text(name, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
-}
-
-// ─── Dashboard ──────────────────────────────────────────────────────────────
-
-@Composable
-private fun DashboardScreen(
- state: DeskHubState,
- onLoad: () -> Unit,
- onSetStatus: (String, String) -> Unit,
- 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)
- }
- }
- 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.") }
- }
- }
-}
-
-@Composable
-private fun DashboardStatusRow(myStatus: String, onSetStatus: (String, String) -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Text(
- "Mein Status",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- AvailabilityStatusLabels.forEach { (value, meta) ->
- val (label, emoji, color) = meta
- FilterChip(
- 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,
- ),
- )
- }
- }
- }
- }
-}
-
-@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.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,
- )
- }
- }
-}
-
-@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,
- )
- }
- }
- }
- }
-}
-
-// ─── 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")
- 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 = 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") } }
-
- 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.
- item { AdminMenuRow(Icons.Filled.Circle, "Mein Status", "Verfügbarkeit setzen & Team-Übersicht") { onNavigate("adminAvailability") } }
- }
- }
-}
-
-@Composable
-private fun AdminSectionLabel(label: String) {
- Text(
- label.uppercase(),
- style = MaterialTheme.typography.labelSmall,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 10.dp, bottom = 2.dp, start = 4.dp),
- )
-}
-
-@Composable
-private fun AdminMenuRow(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, subtitle: String, onClick: () -> Unit) {
- Card(
- onClick = onClick,
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 14.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Box(
- modifier = Modifier
- .size(40.dp)
- .clip(MaterialTheme.shapes.small)
- .background(MaterialTheme.colorScheme.primaryContainer),
- contentAlignment = Alignment.Center,
- ) {
- Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer)
- }
- Spacer(Modifier.width(14.dp))
- Column(Modifier.weight(1f)) {
- Text(title, style = MaterialTheme.typography.titleSmall)
- Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- }
-}
-
-@Composable
-private fun AdminTopBar(title: String, onBack: () -> Unit) {
- TopAppBar(
- title = { Text(title, 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,
- ),
- )
-}
-
-// ─── Interner Chat ───────────────────────────────────────────────────────────
-
-// ─── Chat-Hub: interner Chat + Live-Chat unter einem Tab ────────────────────
-// Vorher zwei eigene Bottom-Nav-Einträge – bei 6 Tabs wurde die Leiste auf normalen
-// Handy-Breiten zu voll (siehe Nutzer-Feedback). Beide Funktionen bleiben komplett
-// erhalten, teilen sich jetzt nur die Navigation über einen Umschalter oben.
-
-@Composable
-private fun ChatHubScreen(
- state: DeskHubState,
- onLoadContacts: () -> Unit,
- onOpenContact: (de.deskhub.staff.domain.model.ChatContact) -> Unit,
- onLoadLiveChats: () -> Unit,
- onStopLiveChatPolling: () -> Unit,
- onOpenLiveChat: (String) -> Unit,
-) {
- val liveChatAvailable = state.metadata?.liveChatEnabled == true
- var showLiveChat by rememberSaveable { mutableStateOf(false) }
-
- DisposableEffect(Unit) {
- onLoadContacts()
- if (liveChatAvailable) onLoadLiveChats()
- onDispose { if (liveChatAvailable) onStopLiveChatPolling() }
- }
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("Chat", fontWeight = FontWeight.Bold) },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = LocalChrome.current,
- titleContentColor = Color.White,
- ),
- )
- },
- ) { padding ->
- Column(Modifier.padding(padding).fillMaxSize()) {
- if (liveChatAvailable) {
- val needsReply = state.liveChats?.count { it.needsReply } ?: 0
- SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
- SegmentedButton(
- selected = !showLiveChat,
- onClick = { showLiveChat = false },
- shape = SegmentedButtonDefaults.itemShape(0, 2),
- ) { Text("Team") }
- SegmentedButton(
- selected = showLiveChat,
- onClick = { showLiveChat = true },
- shape = SegmentedButtonDefaults.itemShape(1, 2),
- ) {
- BadgedBox(badge = { if (needsReply > 0) Badge { Text(needsReply.toString()) } }) {
- Text("Kunden")
- }
- }
- }
- }
- Box(Modifier.weight(1f).fillMaxWidth()) {
- if (showLiveChat && liveChatAvailable) {
- LiveChatListBody(state = state, onLoad = onLoadLiveChats, onOpenChat = onOpenLiveChat)
- } else {
- ChatContactsBody(state = state, onLoad = onLoadContacts, onOpenContact = onOpenContact)
- }
- }
- }
- }
-}
-
-@Composable
-private fun ChatContactsBody(
- state: DeskHubState,
- onLoad: () -> Unit,
- onOpenContact: (de.deskhub.staff.domain.model.ChatContact) -> Unit,
-) {
- val contacts = state.chatContacts
- when {
- state.chatContactsLoading && contacts == null -> LoadingState("Kontakte werden geladen …")
- state.chatContactsError != null && contacts == null -> ErrorState(state.chatContactsError, onLoad)
- contacts == null -> EmptyState("Keine Daten verfügbar.")
- contacts.isEmpty() -> EmptyState("Keine weiteren Mitarbeiter vorhanden.")
- else -> LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- items(contacts, key = { it.id }) { contact ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = { onOpenContact(contact) }) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(14.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column {
- Text(contact.name, fontWeight = FontWeight.Medium)
- contact.lastMessageAt?.let {
- Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.75f))
- }
- }
- if (contact.unreadCount > 0) {
- Badge { Text(contact.unreadCount.toString()) }
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun ChatConversationScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onSend: (Int, String) -> Unit,
- onClearError: () -> Unit,
-) {
- val conversation = state.chatConversation
- var draft by rememberSaveable { mutableStateOf("") }
- val listState = rememberLazyListState()
-
- LaunchedEffect(conversation?.messages?.size) {
- val count = conversation?.messages?.size ?: 0
- if (count > 0) listState.animateScrollToItem(count - 1)
- }
-
- Scaffold(
- topBar = { AdminTopBar(conversation?.with?.name ?: "Chat", onBack) },
- ) { padding ->
- Column(modifier = Modifier.padding(padding).fillMaxSize()) {
- when {
- state.chatConversationLoading && conversation == null -> Box(Modifier.weight(1f)) { LoadingState("Unterhaltung wird geladen …") }
- state.chatConversationError != null && conversation == null -> Box(Modifier.weight(1f)) { ErrorState(state.chatConversationError) {} }
- conversation == null -> Box(Modifier.weight(1f)) { EmptyState("Keine Daten verfügbar.") }
- conversation.messages.isEmpty() -> Box(Modifier.weight(1f)) { EmptyState("Noch keine Nachrichten. Schreib die erste!") }
- else -> LazyColumn(
- state = listState,
- modifier = Modifier.weight(1f).fillMaxWidth(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- items(conversation.messages, key = { it.id }) { msg ->
- val isOwn = msg.fromStaffId == state.staff?.id
- Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOwn) Arrangement.End else Arrangement.Start) {
- Surface(
- color = if (isOwn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
- shape = MaterialTheme.shapes.medium,
- ) {
- Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp).widthIn(max = 280.dp)) {
- Text(
- msg.message,
- color = if (isOwn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- msg.createdAt,
- style = MaterialTheme.typography.labelSmall,
- color = (if (isOwn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant).copy(alpha = 0.7f),
- )
- }
- }
- }
- }
- }
- }
- state.chatSendError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) }
- Row(
- modifier = Modifier.fillMaxWidth().padding(12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- OutlinedTextField(
- value = draft,
- onValueChange = { draft = it },
- modifier = Modifier.weight(1f),
- placeholder = { Text("Nachricht …") },
- shape = MaterialTheme.shapes.medium,
- )
- IconButton(
- onClick = {
- val withId = conversation?.with?.id
- if (withId != null && draft.isNotBlank()) {
- onClearError()
- onSend(withId, draft.trim())
- draft = ""
- }
- },
- enabled = !state.chatSendLoading && draft.isNotBlank() && conversation != null,
- ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden") }
- }
- }
- }
-}
-
-// ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────────
-// Eigener Tab, getrennt vom internen Chat oben – Chats mit Kunden über das
-// Live-Chat-Widget im Kundenportal, gespiegelt vom Web-Postfach staff/live_chat.php
-// (gleiche Sperr-Logik: ein zweiter Mitarbeiter kann mitlesen, aber nicht schreiben).
-
-@Composable
-private fun LiveChatListBody(
- state: DeskHubState,
- onLoad: () -> Unit,
- onOpenChat: (String) -> Unit,
-) {
- val chats = state.liveChats
- when {
- state.liveChatsLoading && chats == null -> LoadingState("Chats werden geladen …")
- state.liveChatsError != null && chats == null -> ErrorState(state.liveChatsError, onLoad)
- chats == null -> EmptyState("Keine Daten verfügbar.")
- chats.isEmpty() -> EmptyState("Aktuell keine offenen Live-Chats.")
- else -> LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- items(chats, key = { it.number }) { chat ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = { onOpenChat(chat.number) }) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(14.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(Modifier.weight(1f)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (chat.needsReply) {
- Box(
- Modifier
- .size(8.dp)
- .background(Color(0xFFDC2626), shape = androidx.compose.foundation.shape.CircleShape),
- )
- Spacer(Modifier.width(6.dp))
- }
- Text(chat.name, fontWeight = FontWeight.Medium)
- }
- Text(
- chat.preview,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- Text(
- chat.updatedAt.takeLast(5),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun LiveChatConversationScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onSend: (String, String) -> Unit,
- onTyping: (String) -> Unit,
- onClearError: () -> Unit,
- onEnd: (String) -> Unit,
-) {
- val detail = state.liveChatDetail
- val number = detail?.number
- var draft by rememberSaveable { mutableStateOf("") }
- var showEndConfirm by rememberSaveable { mutableStateOf(false) }
- val listState = rememberLazyListState()
- val lockedByOther = detail?.lockedByOtherName
- val isClosed = detail?.status == "closed"
-
- LaunchedEffect(detail?.messages?.size) {
- val count = detail?.messages?.size ?: 0
- if (count > 0) listState.animateScrollToItem(count - 1)
- }
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = {
- Column {
- Text(detail?.requesterName ?: "Live-Chat", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
- detail?.requesterEmail?.let {
- Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- }
- },
- navigationIcon = {
- IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
- },
- actions = {
- if (!isClosed && lockedByOther == null && number != null) {
- TextButton(onClick = { showEndConfirm = true }) {
- Text("Beenden", color = Color.White)
- }
- }
- },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = LocalChrome.current,
- titleContentColor = Color.White,
- actionIconContentColor = Color.White,
- ),
- )
- },
- ) { padding ->
- Column(modifier = Modifier.padding(padding).fillMaxSize()) {
- if (lockedByOther != null) {
- Surface(color = Color(0xFFFFFBEB)) {
- Text(
- "🔒 Wird gerade von $lockedByOther bearbeitet – Sie sehen live mit, können hier aber nicht schreiben.",
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- style = MaterialTheme.typography.bodySmall,
- color = Color(0xFF92400E),
- )
- }
- }
- when {
- state.liveChatDetailLoading && detail == null -> Box(Modifier.weight(1f)) { LoadingState("Chat wird geladen …") }
- state.liveChatDetailError != null && detail == null -> Box(Modifier.weight(1f)) { ErrorState(state.liveChatDetailError) {} }
- detail == null -> Box(Modifier.weight(1f)) { EmptyState("Keine Daten verfügbar.") }
- detail.messages.isEmpty() -> Box(Modifier.weight(1f)) { EmptyState("Noch keine Nachrichten.") }
- else -> LazyColumn(
- state = listState,
- modifier = Modifier.weight(1f).fillMaxWidth(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- items(detail.messages, key = { it.id }) { msg ->
- val isStaff = msg.authorType == "staff"
- Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start) {
- Column(horizontalAlignment = if (isStaff) Alignment.End else Alignment.Start) {
- if (isStaff && msg.authorName != null) {
- Text(
- msg.authorName,
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(bottom = 2.dp, end = 4.dp),
- )
- }
- if (msg.message.isNotBlank()) {
- Surface(
- color = if (isStaff) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
- shape = MaterialTheme.shapes.medium,
- ) {
- Text(
- msg.message,
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp).widthIn(max = 280.dp),
- color = if (isStaff) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- msg.attachments.forEach { att ->
- Surface(
- color = MaterialTheme.colorScheme.surfaceVariant,
- shape = MaterialTheme.shapes.small,
- modifier = Modifier.padding(top = 4.dp),
- ) {
- Text(
- "📎 ${att.name}",
- modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- Text(
- msg.createdAt.takeLast(5),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
- )
- }
- }
- }
- if (detail.customerTyping) {
- item {
- Text(
- "Kunde tippt …",
- style = MaterialTheme.typography.labelSmall,
- fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
- }
- state.liveChatSendError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) }
- when {
- isClosed -> Text(
- "Dieser Chat wurde beendet.",
- modifier = Modifier.fillMaxWidth().padding(16.dp),
- textAlign = androidx.compose.ui.text.style.TextAlign.Center,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- lockedByOther != null -> Text(
- "Nur Lesezugriff, solange $lockedByOther diesen Chat bearbeitet.",
- modifier = Modifier.fillMaxWidth().padding(16.dp),
- textAlign = androidx.compose.ui.text.style.TextAlign.Center,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- else -> Row(
- modifier = Modifier.fillMaxWidth().padding(12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- OutlinedTextField(
- value = draft,
- onValueChange = { draft = it; number?.let(onTyping) },
- modifier = Modifier.weight(1f),
- placeholder = { Text("Nachricht …") },
- shape = MaterialTheme.shapes.medium,
- )
- IconButton(
- onClick = {
- if (number != null && draft.isNotBlank()) {
- onClearError()
- onSend(number, draft.trim())
- draft = ""
- }
- },
- enabled = !state.liveChatSendLoading && draft.isNotBlank() && number != null,
- ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden") }
- }
- }
- }
- }
-
- if (showEndConfirm) {
- AlertDialog(
- onDismissRequest = { showEndConfirm = false },
- title = { Text("Chat wirklich beenden?") },
- text = { Text("Er bleibt als Ticket erhalten, verschwindet aber aus den aktiven Live-Chats.") },
- confirmButton = {
- TextButton(onClick = { showEndConfirm = false; number?.let(onEnd) }) { Text("Beenden") }
- },
- dismissButton = {
- TextButton(onClick = { showEndConfirm = false }) { Text("Abbrechen") }
- },
- )
- }
-}
-
-// ─── Admin: Wissensdatenbank ────────────────────────────────────────────────
-
-@Composable
-private fun AdminKbScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSaveArticle: (Int?, String, Int, String, Boolean, () -> Unit) -> Unit,
- onDeleteArticle: (Int) -> Unit,
- onCreateCategory: (String) -> Unit,
- onDeleteCategory: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingArticle by rememberSaveable { mutableStateOf(null) }
- var isCreatingArticle by rememberSaveable { mutableStateOf(false) }
- var isAddingCategory by rememberSaveable { mutableStateOf(false) }
-
- val kb = state.kb
- val showForm = isCreatingArticle || editingArticle != null
-
- Scaffold(
- topBar = { AdminTopBar("Wissensdatenbank", onBack) },
- floatingActionButton = {
- if (kb != null && !showForm) {
- FloatingActionButton(onClick = { isCreatingArticle = true }) {
- Icon(Icons.Filled.Add, contentDescription = "Neuer Artikel")
- }
- }
- },
- ) { padding ->
- when {
- state.kbLoading && kb == null -> Box(Modifier.padding(padding)) { LoadingState("Wissensdatenbank wird geladen …") }
- state.kbError != null && kb == null -> Box(Modifier.padding(padding)) { ErrorState(state.kbError, onLoad) }
- kb == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val article = kb.articles.firstOrNull { it.id == editingArticle }
- KbArticleForm(
- modifier = Modifier.padding(padding),
- article = article,
- categories = kb.categories,
- saving = state.kbMutationLoading,
- error = state.kbMutationError,
- onCancel = { editingArticle = null; isCreatingArticle = false; onClearError() },
- onSave = { title, categoryId, body, published ->
- onSaveArticle(article?.id, title, categoryId, body, published) {
- editingArticle = null
- isCreatingArticle = false
- }
- },
- )
- }
- else -> LazyColumn(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- 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) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 14.dp, vertical = 10.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(category.name, fontWeight = FontWeight.Medium)
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text("$articleCount Artikel", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- if (articleCount == 0) {
- TextButton(onClick = { onDeleteCategory(category.id) }) { Text("Löschen") }
- }
- }
- }
- }
- }
- item {
- if (isAddingCategory) {
- var newCategoryName by rememberSaveable { mutableStateOf("") }
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
- OutlinedTextField(
- value = newCategoryName,
- onValueChange = { newCategoryName = it },
- modifier = Modifier.weight(1f),
- label = { Text("Neue Kategorie") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- TextButton(onClick = {
- if (newCategoryName.isNotBlank()) {
- onCreateCategory(newCategoryName.trim())
- newCategoryName = ""
- isAddingCategory = false
- }
- }) { Text("Hinzufügen") }
- }
- } else {
- OutlinedButton(onClick = { isAddingCategory = true }, modifier = Modifier.fillMaxWidth()) {
- Text("+ Kategorie hinzufügen")
- }
- }
- }
- item { Spacer(Modifier.height(8.dp)); Text("Artikel", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) }
- if (kb.articles.isEmpty()) {
- item { EmptyState("Noch keine Artikel.") }
- }
- items(kb.articles) { article ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(article.title, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
- Surface(
- color = if (article.isPublished) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
- shape = MaterialTheme.shapes.extraSmall,
- ) {
- Text(
- if (article.isPublished) "Live" else "Entwurf",
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
- style = MaterialTheme.typography.labelSmall,
- )
- }
- }
- Text(article.categoryName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Row(Modifier.padding(top = 8.dp)) {
- TextButton(onClick = { editingArticle = article.id }) { Text("Bearbeiten") }
- TextButton(onClick = { onDeleteArticle(article.id) }) { Text("Löschen") }
- }
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun KbArticleForm(
- modifier: Modifier = Modifier,
- article: KbArticle?,
- categories: List,
- saving: Boolean,
- error: String?,
- onCancel: () -> Unit,
- onSave: (String, Int, String, Boolean) -> Unit,
-) {
- var title by rememberSaveable(article?.id) { mutableStateOf(article?.title ?: "") }
- var body by rememberSaveable(article?.id) { mutableStateOf(article?.body ?: "") }
- var isPublished by rememberSaveable(article?.id) { mutableStateOf(article?.isPublished ?: true) }
- var categoryId by rememberSaveable(article?.id) { mutableStateOf(article?.categoryId ?: categories.firstOrNull()?.id ?: 0) }
- var categoryExpanded by remember { mutableStateOf(false) }
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (article == null) "Neuer Artikel" else "Artikel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(
- value = title,
- onValueChange = { title = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Titel") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- SelectionField(
- label = "Kategorie",
- value = categories.firstOrNull { it.id == categoryId }?.name ?: "–",
- expanded = categoryExpanded,
- onExpand = { categoryExpanded = true },
- onDismiss = { categoryExpanded = false },
- ) {
- categories.forEach { category ->
- DropdownMenuItem(text = { Text(category.name) }, onClick = { categoryId = category.id; categoryExpanded = false })
- }
- }
- OutlinedTextField(
- value = body,
- onValueChange = { body = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Inhalt") },
- shape = MaterialTheme.shapes.medium,
- minLines = 8,
- )
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = isPublished, onCheckedChange = { isPublished = it })
- Text("Veröffentlicht")
- }
- error?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onSave(title.trim(), categoryId, body.trim(), isPublished) },
- shape = MaterialTheme.shapes.medium,
- enabled = !saving && title.isNotBlank() && body.isNotBlank() && categoryId != 0,
- ) {
- Text(if (saving) "Wird gespeichert …" else "Speichern")
- }
- OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
-}
-
-// ─── Admin: Zeiterfassung ────────────────────────────────────────────────────
-
-@Composable
-private fun AdminTimeEntriesScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: (Int?, String, String) -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad(state.timeEntriesStaffId, state.timeEntriesFrom, state.timeEntriesTo) }
- var from by rememberSaveable { mutableStateOf(state.timeEntriesFrom) }
- var to by rememberSaveable { mutableStateOf(state.timeEntriesTo) }
- var staffId by rememberSaveable { mutableStateOf(state.timeEntriesStaffId) }
- var staffExpanded by remember { mutableStateOf(false) }
- val report = state.timeEntries
-
- Scaffold(topBar = { AdminTopBar("Zeiterfassung", onBack) }) { padding ->
- Column(modifier = Modifier.padding(padding).fillMaxSize()) {
- Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedTextField(
- value = from,
- onValueChange = { from = it },
- modifier = Modifier.weight(1f),
- label = { Text("Von (JJJJ-MM-TT)") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- OutlinedTextField(
- value = to,
- onValueChange = { to = it },
- modifier = Modifier.weight(1f),
- label = { Text("Bis (JJJJ-MM-TT)") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- }
- SelectionField(
- label = "Mitarbeiter",
- value = report?.staffOptions?.firstOrNull { it.id == staffId }?.name ?: "Alle",
- expanded = staffExpanded,
- onExpand = { staffExpanded = true },
- onDismiss = { staffExpanded = false },
- ) {
- DropdownMenuItem(text = { Text("Alle") }, onClick = { staffId = null; staffExpanded = false })
- report?.staffOptions.orEmpty().forEach { option ->
- DropdownMenuItem(text = { Text(option.name) }, onClick = { staffId = option.id; staffExpanded = false })
- }
- }
- Button(
- onClick = { onLoad(staffId, from, to) },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- ) { Text("Filtern") }
- }
- when {
- state.timeEntriesLoading && report == null -> LoadingState("Zeiten werden geladen …")
- state.timeEntriesError != null && report == null -> ErrorState(state.timeEntriesError) { onLoad(staffId, from, to) }
- report != null -> LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- if (report.totalsByStaff.isNotEmpty()) {
- item {
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- StatTile("Gesamt", formatMinutes(report.totalMinutes), Modifier.weight(1f))
- StatTile(
- report.totalsByStaff.first().staffName,
- formatMinutes(report.totalsByStaff.first().minutes),
- Modifier.weight(1f),
- )
- }
- }
- if (report.totalsByStaff.size > 1) {
- items(report.totalsByStaff.drop(1)) { total ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(14.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(total.staffName)
- Text(formatMinutes(total.minutes), fontWeight = FontWeight.SemiBold)
- }
- }
- }
- }
- }
- if (report.entries.isEmpty()) {
- item { EmptyState("Keine Zeiteinträge im gewählten Zeitraum.") }
- }
- items(report.entries) { entry ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- 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)
- Text(formatMinutes(entry.minutes), fontWeight = FontWeight.SemiBold)
- }
- Text(
- "${entry.staffName} · ${entry.loggedAt}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- entry.note?.takeIf { it.isNotBlank() }?.let {
- Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- }
- }
- }
- }
- }
- }
- }
-}
-
-private fun formatMinutes(minutes: Int): String = "${minutes / 60}h ${minutes % 60}min"
-
-// ─── Admin: Eskalationsregeln ────────────────────────────────────────────────
-
-private val EscalationTriggerLabels = linkedMapOf(
- "no_reply" to "Keine Mitarbeiter-Antwort seit X Stunden",
- "no_update" to "Kein Update seit X Stunden",
- "overdue" to "Ticket ist überfällig (SLA überschritten)",
- "open_too_long" to "Ticket offen seit X Stunden",
-)
-
-private val EscalationStatusLabels = linkedMapOf(
- "open" to "Offen",
- "pending" to "Wartet auf Kunde",
- "on_hold" to "Pausiert",
-)
-
-@Composable
-private fun AdminEscalationScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, String, Boolean, String, Int, String?, Int?, Int?, Int?, Int?, String?, Boolean, Boolean, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onToggle: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingRuleId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val data = state.escalation
- val showForm = isCreating || editingRuleId != null
-
- Scaffold(
- topBar = { AdminTopBar("Eskalationsregeln", onBack) },
- floatingActionButton = {
- if (data != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) {
- Icon(Icons.Filled.Add, contentDescription = "Neue Regel")
- }
- }
- },
- ) { padding ->
- when {
- state.escalationLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Regeln werden geladen …") }
- state.escalationError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.escalationError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val rule = data.rules.firstOrNull { it.id == editingRuleId }
- EscalationRuleForm(
- modifier = Modifier.padding(padding),
- rule = rule,
- priorities = data.priorities,
- staffOptions = data.staffOptions,
- departments = data.departments,
- saving = state.escalationMutationLoading,
- error = state.escalationMutationError,
- onCancel = { editingRuleId = null; isCreating = false; onClearError() },
- onSave = { name, isActive, triggerType, triggerHours, condStatus, condPrio, condDept, actPrio, actStaff, actStatus, notAdmin, notStaff ->
- onSave(rule?.id, name, isActive, triggerType, triggerHours, condStatus, condPrio, condDept, actPrio, actStaff, actStatus, notAdmin, notStaff) {
- editingRuleId = null
- isCreating = false
- }
- },
- )
- }
- data.rules.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Eskalationsregeln. Mit + eine neue Regel anlegen.") }
- else -> LazyColumn(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- items(data.rules) { rule -> EscalationRuleRow(rule, onEdit = { editingRuleId = rule.id }, onDelete = { onDelete(rule.id) }, onToggle = { onToggle(rule.id) }) }
- }
- }
- }
-}
-
-@Composable
-private fun EscalationRuleRow(rule: EscalationRule, onEdit: () -> Unit, onDelete: () -> Unit, onToggle: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(rule.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
- Surface(
- color = if (rule.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
- shape = MaterialTheme.shapes.extraSmall,
- ) {
- Text(
- if (rule.isActive) "Aktiv" else "Inaktiv",
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
- style = MaterialTheme.typography.labelSmall,
- )
- }
- }
- Text(
- EscalationTriggerLabels[rule.triggerType] ?: rule.triggerType,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- val actions = buildList {
- rule.actionSetPriorityName?.let { add("↑ Priorität: $it") }
- rule.actionAssignStaffName?.let { add("→ $it") }
- rule.actionSetStatus?.let { add("⚑ ${EscalationStatusLabels[it] ?: it}") }
- if (rule.actionNotifyAdmin) add("📧 Admin")
- if (rule.actionNotifyStaff) add("📧 Mitarbeiter")
- }
- Text(
- if (actions.isEmpty()) "Nur benachrichtigen" else actions.joinToString(", "),
- style = MaterialTheme.typography.bodySmall,
- )
- Text(
- "${rule.appliedCount}× angewendet",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = onEdit) { Text("Bearbeiten") }
- TextButton(onClick = onToggle) { Text(if (rule.isActive) "Deaktivieren" else "Aktivieren") }
- TextButton(onClick = onDelete) { Text("Löschen") }
- }
- }
- }
-}
-
-@Composable
-private fun EscalationRuleForm(
- modifier: Modifier = Modifier,
- rule: EscalationRule?,
- priorities: List,
- staffOptions: List,
- departments: List,
- saving: Boolean,
- error: String?,
- onCancel: () -> Unit,
- onSave: (String, Boolean, String, Int, String?, Int?, Int?, Int?, Int?, String?, Boolean, Boolean) -> Unit,
-) {
- var name by rememberSaveable(rule?.id) { mutableStateOf(rule?.name ?: "") }
- var isActive by rememberSaveable(rule?.id) { mutableStateOf(rule?.isActive ?: true) }
- var triggerType by rememberSaveable(rule?.id) { mutableStateOf(rule?.triggerType ?: "no_reply") }
- var triggerHours by rememberSaveable(rule?.id) { mutableStateOf((rule?.triggerHours ?: 24).toString()) }
- var conditionStatus by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionStatus ?: "") }
- var conditionPriorityId by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionPriorityId) }
- var conditionDeptId by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionDeptId) }
- var actionSetPriorityId by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionSetPriorityId) }
- var actionAssignStaffId by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionAssignStaffId) }
- var actionSetStatus by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionSetStatus) }
- var notifyAdmin by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionNotifyAdmin ?: true) }
- var notifyStaff by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionNotifyStaff ?: false) }
-
- var triggerExpanded by remember { mutableStateOf(false) }
- var condPrioExpanded by remember { mutableStateOf(false) }
- var condDeptExpanded by remember { mutableStateOf(false) }
- var actPrioExpanded by remember { mutableStateOf(false) }
- var actStaffExpanded by remember { mutableStateOf(false) }
- var actStatusExpanded by remember { mutableStateOf(false) }
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (rule == null) "Neue Eskalationsregel" else "Regel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(
- value = name,
- onValueChange = { name = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Name") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = isActive, onCheckedChange = { isActive = it })
- Text("Aktiv")
- }
-
- Text("Auslöser", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
- SelectionField(
- label = "Trigger",
- value = EscalationTriggerLabels[triggerType] ?: triggerType,
- expanded = triggerExpanded,
- onExpand = { triggerExpanded = true },
- onDismiss = { triggerExpanded = false },
- ) {
- EscalationTriggerLabels.forEach { (value, label) ->
- DropdownMenuItem(text = { Text(label) }, onClick = { triggerType = value; triggerExpanded = false })
- }
- }
- if (triggerType != "overdue") {
- OutlinedTextField(
- value = triggerHours,
- onValueChange = { triggerHours = it.filter(Char::isDigit) },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Stunden") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- )
- }
-
- Text("Bedingungen (optional – leer = alle Tickets)", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(
- value = conditionStatus,
- onValueChange = { conditionStatus = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Nur Status (kommagetrennt, z.B. open,pending)") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- SelectionField(
- label = "Nur Priorität",
- value = priorities.firstOrNull { it.id == conditionPriorityId }?.name ?: "Alle",
- expanded = condPrioExpanded,
- onExpand = { condPrioExpanded = true },
- onDismiss = { condPrioExpanded = false },
- ) {
- DropdownMenuItem(text = { Text("Alle") }, onClick = { conditionPriorityId = null; condPrioExpanded = false })
- priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { conditionPriorityId = p.id; condPrioExpanded = false }) }
- }
- SelectionField(
- label = "Nur Abteilung",
- value = departments.firstOrNull { it.id == conditionDeptId }?.name ?: "Alle",
- expanded = condDeptExpanded,
- onExpand = { condDeptExpanded = true },
- onDismiss = { condDeptExpanded = false },
- ) {
- DropdownMenuItem(text = { Text("Alle") }, onClick = { conditionDeptId = null; condDeptExpanded = false })
- departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { conditionDeptId = d.id; condDeptExpanded = false }) }
- }
-
- Text("Aktionen", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
- SelectionField(
- label = "Priorität setzen",
- value = priorities.firstOrNull { it.id == actionSetPriorityId }?.name ?: "– keine Änderung –",
- expanded = actPrioExpanded,
- onExpand = { actPrioExpanded = true },
- onDismiss = { actPrioExpanded = false },
- ) {
- DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionSetPriorityId = null; actPrioExpanded = false })
- priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { actionSetPriorityId = p.id; actPrioExpanded = false }) }
- }
- SelectionField(
- label = "Mitarbeiter zuweisen",
- value = staffOptions.firstOrNull { it.id == actionAssignStaffId }?.name ?: "– keine Änderung –",
- expanded = actStaffExpanded,
- onExpand = { actStaffExpanded = true },
- onDismiss = { actStaffExpanded = false },
- ) {
- DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionAssignStaffId = null; actStaffExpanded = false })
- staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { actionAssignStaffId = s.id; actStaffExpanded = false }) }
- }
- SelectionField(
- label = "Status setzen",
- value = EscalationStatusLabels[actionSetStatus] ?: "– keine Änderung –",
- expanded = actStatusExpanded,
- onExpand = { actStatusExpanded = true },
- onDismiss = { actStatusExpanded = false },
- ) {
- DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionSetStatus = null; actStatusExpanded = false })
- EscalationStatusLabels.forEach { (value, label) ->
- DropdownMenuItem(text = { Text(label) }, onClick = { actionSetStatus = value; actStatusExpanded = false })
- }
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = notifyAdmin, onCheckedChange = { notifyAdmin = it })
- Text("Admin benachrichtigen")
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = notifyStaff, onCheckedChange = { notifyStaff = it })
- Text("Zugewiesenen Mitarbeiter benachrichtigen")
- }
-
- error?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = {
- onSave(
- name.trim(), isActive, triggerType, triggerHours.toIntOrNull() ?: 24,
- conditionStatus.trim().ifBlank { null }, conditionPriorityId, conditionDeptId,
- actionSetPriorityId, actionAssignStaffId, actionSetStatus, notifyAdmin, notifyStaff,
- )
- },
- shape = MaterialTheme.shapes.medium,
- enabled = !saving && name.isNotBlank(),
- ) { Text(if (saving) "Wird gespeichert …" else "Speichern") }
- OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
-}
-
-// ─── Mein Status (Verfügbarkeit) ────────────────────────────────────────────
-// Bewusst NICHT admin-gated (siehe AdminScreen oben) – jeder Mitarbeiter darf seinen
-// eigenen Status setzen, genau wie unter staff/availability.php im Web.
-
-private val AvailabilityStatusLabels = mapOf(
- "available" to Triple("Verfügbar", "🟢", Color(0xFF22C55E)),
- "busy" to Triple("Beschäftigt", "🟡", Color(0xFFF59E0B)),
- "break" to Triple("Pause", "🟣", Color(0xFF6366F1)),
- "offline" to Triple("Offline", "⚫", Color(0xFF94A3B8)),
-)
-
-@Composable
-private fun AdminAvailabilityScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSetStatus: (String, String) -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- val data = state.availability
- var selectedStatus by rememberSaveable(data?.myStatus) { mutableStateOf(data?.myStatus ?: "available") }
- var note by rememberSaveable(data?.myNote) { mutableStateOf(data?.myNote ?: "") }
-
- Scaffold(
- topBar = { AdminTopBar("Mein Status", onBack) },
- ) { padding ->
- when {
- state.availabilityLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Status wird geladen …") }
- state.availabilityError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.availabilityError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- else -> LazyColumn(
- modifier = Modifier.padding(padding).fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- item {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- Text("Mein Status", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- AvailabilityStatusLabels.forEach { (value, meta) ->
- val (label, emoji, color) = meta
- FilterChip(
- selected = selectedStatus == value,
- onClick = { selectedStatus = value },
- label = { Text("$emoji $label") },
- shape = MaterialTheme.shapes.small,
- colors = FilterChipDefaults.filterChipColors(
- selectedContainerColor = color.copy(alpha = 0.18f),
- selectedLabelColor = color,
- ),
- )
- }
- }
- OutlinedTextField(
- value = note,
- onValueChange = { note = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Notiz (optional)") },
- placeholder = { Text("z.B. „In Besprechung bis 14:00“") },
- shape = MaterialTheme.shapes.medium,
- singleLine = true,
- )
- state.availabilityError?.let { ErrorText(it) }
- Button(
- onClick = { onSetStatus(selectedStatus, note.trim()) },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- enabled = !state.availabilitySaving,
- ) { Text(if (state.availabilitySaving) "Wird gespeichert …" else "Speichern") }
- }
- }
- }
- item { AdminSectionLabel("Team-Übersicht") }
- items(data.team, key = { it.id }) { member ->
- val meta = AvailabilityStatusLabels[member.status] ?: Triple("Unbekannt", "⚫", Color(0xFF94A3B8))
- val (label, emoji, color) = meta
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Row(
- modifier = Modifier.fillMaxWidth().padding(12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Column {
- Text(member.name, fontWeight = FontWeight.Medium)
- if (member.note.isNotBlank()) {
- Text(member.note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- }
- Surface(color = color.copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) {
- Text(
- "$emoji $label",
- modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
- style = MaterialTheme.typography.labelSmall,
- color = color,
- fontWeight = FontWeight.SemiBold,
- )
- }
- }
- }
- }
- }
- }
- }
-}
-
-// ─── Automatisierung (Ticket-Regeln + Auto-Close) ───────────────────────────
-
-@Composable
-private fun AdminTicketRulesScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, String, String, Boolean, Int, List, List, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onToggle: (Int) -> Unit,
- onClearError: () -> Unit,
- onSaveAutoClose: (Boolean, Int) -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingRuleId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val data = state.ticketRules
- val showForm = isCreating || editingRuleId != null
-
- Scaffold(
- topBar = { AdminTopBar("Automatisierung", onBack) },
- floatingActionButton = {
- if (data != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) {
- Icon(Icons.Filled.Add, contentDescription = "Neue Regel")
- }
- }
- },
- ) { padding ->
- when {
- state.ticketRulesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Regeln werden geladen …") }
- state.ticketRulesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.ticketRulesError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val rule = data.rules.firstOrNull { it.id == editingRuleId }
- TicketRuleForm(
- modifier = Modifier.padding(padding),
- rule = rule,
- data = data,
- saving = state.ticketRulesMutationLoading,
- error = state.ticketRulesMutationError,
- onCancel = { editingRuleId = null; isCreating = false; onClearError() },
- onSave = { name, matchType, isActive, sortOrder, conditions, actions ->
- onSave(rule?.id, name, matchType, isActive, sortOrder, conditions, actions) {
- editingRuleId = null
- isCreating = false
- }
- },
- )
- }
- else -> LazyColumn(
- modifier = Modifier.padding(padding).fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- item {
- AutoCloseCard(
- enabled = data.autoCloseEnabled,
- days = data.autoCloseDays,
- onSave = onSaveAutoClose,
- )
- }
- item { AdminSectionLabel("Ticket-Regeln") }
- if (data.rules.isEmpty()) {
- item { EmptyState("Noch keine Ticket-Regeln. Mit + eine neue Regel anlegen.") }
- } else {
- items(data.rules, key = { it.id }) { rule ->
- TicketRuleRow(rule, onEdit = { editingRuleId = rule.id }, onDelete = { onDelete(rule.id) }, onToggle = { onToggle(rule.id) })
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun AutoCloseCard(enabled: Boolean, days: Int, onSave: (Boolean, Int) -> Unit) {
- var localEnabled by rememberSaveable(enabled) { mutableStateOf(enabled) }
- var localDays by rememberSaveable(days) { mutableStateOf(days.toString()) }
- val changed = localEnabled != enabled || localDays != days.toString()
-
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Row(
- Modifier.fillMaxWidth().clickable { localEnabled = !localEnabled },
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Column(Modifier.weight(1f)) {
- Text("Automatisches Schließen", fontWeight = FontWeight.SemiBold)
- Text(
- "Schließt Tickets, auf die der Kunde nach einer Antwort nicht mehr reagiert.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Switch(checked = localEnabled, onCheckedChange = { localEnabled = it })
- }
- if (localEnabled) {
- OutlinedTextField(
- value = localDays,
- onValueChange = { v -> if (v.all(Char::isDigit)) localDays = v },
- label = { Text("Tage ohne Reaktion") },
- modifier = Modifier.width(160.dp),
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- )
- }
- if (changed) {
- Button(
- onClick = { onSave(localEnabled, localDays.toIntOrNull()?.coerceIn(1, 90) ?: 7) },
- shape = MaterialTheme.shapes.medium,
- ) { Text("Speichern") }
- }
- }
- }
-}
-
-@Composable
-private fun TicketRuleRow(rule: TicketRule, onEdit: () -> Unit, onDelete: () -> Unit, onToggle: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(rule.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
- Surface(
- color = if (rule.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
- shape = MaterialTheme.shapes.extraSmall,
- ) {
- Text(
- if (rule.isActive) "Aktiv" else "Inaktiv",
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
- style = MaterialTheme.typography.labelSmall,
- )
- }
- }
- Text(
- "${rule.conditions.size} Bedingung(en) (${if (rule.matchType == "any") "eine trifft" else "alle treffen"})",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Text(
- rule.actions.joinToString(", ") { it.valueLabel ?: it.type },
- style = MaterialTheme.typography.bodySmall,
- )
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = onEdit) { Text("Bearbeiten") }
- TextButton(onClick = onToggle) { Text(if (rule.isActive) "Deaktivieren" else "Aktivieren") }
- TextButton(onClick = onDelete) { Text("Löschen") }
- }
- }
- }
-}
-
-@Composable
-private fun TicketRuleForm(
- modifier: Modifier = Modifier,
- rule: TicketRule?,
- data: TicketRulesData,
- saving: Boolean,
- error: String?,
- onCancel: () -> Unit,
- onSave: (String, String, Boolean, Int, List, List) -> Unit,
-) {
- var name by rememberSaveable(rule?.id) { mutableStateOf(rule?.name ?: "") }
- var matchType by rememberSaveable(rule?.id) { mutableStateOf(rule?.matchType ?: "all") }
- var isActive by rememberSaveable(rule?.id) { mutableStateOf(rule?.isActive ?: true) }
- var sortOrder by rememberSaveable(rule?.id) { mutableStateOf((rule?.sortOrder ?: 0).toString()) }
- val conditions = remember(rule?.id) {
- mutableStateListOf(*(rule?.conditions?.toTypedArray() ?: arrayOf(TicketRuleCondition("subject", "contains", ""))))
- }
- val actions = remember(rule?.id) {
- mutableStateListOf(*(rule?.actions?.toTypedArray() ?: arrayOf(TicketRuleAction("set_priority", "", null))))
- }
-
- Column(
- modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (rule == null) "Neue Ticket-Regel" else "Ticket-Regel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
- OutlinedTextField(
- value = name,
- onValueChange = { name = it },
- label = { Text("Name") },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- )
- SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
- SegmentedButton(selected = matchType == "all", onClick = { matchType = "all" }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { Text("Alle Bedingungen") }
- SegmentedButton(selected = matchType == "any", onClick = { matchType = "any" }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { Text("Eine Bedingung") }
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- Switch(checked = isActive, onCheckedChange = { isActive = it })
- Spacer(Modifier.width(8.dp))
- Text("Aktiv")
- }
-
- Text("Bedingungen", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
- conditions.forEachIndexed { index, cond ->
- RuleConditionRow(
- condition = cond,
- fieldOptions = data.fieldOptions,
- operatorOptions = data.operatorOptions,
- onChange = { conditions[index] = it },
- onRemove = { if (conditions.size > 1) conditions.removeAt(index) },
- )
- }
- OutlinedButton(onClick = { conditions.add(TicketRuleCondition("subject", "contains", "")) }, shape = MaterialTheme.shapes.medium) {
- Text("+ Bedingung hinzufügen")
- }
-
- Text("Aktionen", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
- actions.forEachIndexed { index, act ->
- RuleActionRow(
- action = act,
- data = data,
- onChange = { actions[index] = it },
- onRemove = { if (actions.size > 1) actions.removeAt(index) },
- )
- }
- OutlinedButton(onClick = { actions.add(TicketRuleAction("set_priority", "", null)) }, shape = MaterialTheme.shapes.medium) {
- Text("+ Aktion hinzufügen")
- }
-
- error?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onSave(name.trim(), matchType, isActive, sortOrder.toIntOrNull() ?: 0, conditions.toList(), actions.toList()) },
- shape = MaterialTheme.shapes.medium,
- enabled = !saving && name.isNotBlank(),
- ) { Text(if (saving) "Wird gespeichert …" else "Speichern") }
- OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
-}
-
-@Composable
-private fun RuleConditionRow(
- condition: TicketRuleCondition,
- fieldOptions: List,
- operatorOptions: List,
- onChange: (TicketRuleCondition) -> Unit,
- onRemove: () -> Unit,
-) {
- var fieldExpanded by remember { mutableStateOf(false) }
- var operatorExpanded by remember { mutableStateOf(false) }
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
- Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
- SelectionField(
- label = "Feld",
- value = fieldOptions.firstOrNull { it.value == condition.field }?.label ?: condition.field,
- expanded = fieldExpanded,
- onExpand = { fieldExpanded = true },
- onDismiss = { fieldExpanded = false },
- ) {
- fieldOptions.forEach { opt ->
- DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(condition.copy(field = opt.value)); fieldExpanded = false })
- }
- }
- SelectionField(
- label = "Operator",
- value = operatorOptions.firstOrNull { it.value == condition.operator }?.label ?: condition.operator,
- expanded = operatorExpanded,
- onExpand = { operatorExpanded = true },
- onDismiss = { operatorExpanded = false },
- ) {
- operatorOptions.forEach { opt ->
- DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(condition.copy(operator = opt.value)); operatorExpanded = false })
- }
- }
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- OutlinedTextField(
- value = condition.value,
- onValueChange = { onChange(condition.copy(value = it)) },
- label = { Text("Wert") },
- modifier = Modifier.weight(1f),
- shape = MaterialTheme.shapes.medium,
- singleLine = true,
- )
- IconButton(onClick = onRemove) { Icon(Icons.Filled.Block, contentDescription = "Entfernen") }
- }
- }
- }
-}
-
-@Composable
-private fun RuleActionRow(
- action: TicketRuleAction,
- data: TicketRulesData,
- onChange: (TicketRuleAction) -> Unit,
- onRemove: () -> Unit,
-) {
- var typeExpanded by remember { mutableStateOf(false) }
- var valueExpanded by remember { mutableStateOf(false) }
- val valueOptions: List = when (action.type) {
- "set_department" -> data.departments
- "set_priority" -> data.priorities
- "set_help_topic" -> data.helpTopics
- "assign_staff" -> data.staffOptions
- "auto_reply" -> data.cannedResponses
- else -> emptyList()
- }
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
- Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- Box(Modifier.weight(1f)) {
- SelectionField(
- label = "Aktion",
- value = data.actionOptions.firstOrNull { it.value == action.type }?.label ?: action.type,
- expanded = typeExpanded,
- onExpand = { typeExpanded = true },
- onDismiss = { typeExpanded = false },
- ) {
- data.actionOptions.forEach { opt ->
- DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(TicketRuleAction(opt.value, "", null)); typeExpanded = false })
- }
- }
- }
- IconButton(onClick = onRemove) { Icon(Icons.Filled.Block, contentDescription = "Entfernen") }
- }
- when (action.type) {
- "reject" -> Text("Keine weitere Einstellung nötig.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- "add_tag" -> OutlinedTextField(
- value = action.value,
- onValueChange = { onChange(action.copy(value = it)) },
- label = { Text("Tag-Name") },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- singleLine = true,
- )
- else -> SelectionField(
- label = "Wert",
- value = valueOptions.firstOrNull { it.id.toString() == action.value }?.name ?: "– wählen –",
- expanded = valueExpanded,
- onExpand = { valueExpanded = true },
- onDismiss = { valueExpanded = false },
- ) {
- valueOptions.forEach { opt ->
- DropdownMenuItem(text = { Text(opt.name) }, onClick = { onChange(action.copy(value = opt.id.toString(), valueLabel = opt.name)); valueExpanded = false })
- }
- }
- }
- }
- }
-}
-
-// ─── Admin: Sperrliste ───────────────────────────────────────────────────────
-
-@Composable
-private fun AdminBanlistScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onCreate: (String, String, String?, () -> Unit) -> Unit,
- onBulkAdd: (String, String?, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
- onClearBulkResult: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var showForm by rememberSaveable { mutableStateOf(false) }
- val entries = state.banlist
-
- Scaffold(
- topBar = { AdminTopBar("Sperrliste", onBack) },
- floatingActionButton = {
- if (entries != null && !showForm) {
- FloatingActionButton(onClick = { showForm = true }) {
- Icon(Icons.Filled.Add, contentDescription = "Sperren")
- }
- }
- },
- ) { padding ->
- when {
- state.banlistLoading && entries == null -> Box(Modifier.padding(padding)) { LoadingState("Sperrliste wird geladen …") }
- state.banlistError != null && entries == null -> Box(Modifier.padding(padding)) { ErrorState(state.banlistError, onLoad) }
- entries == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> BanlistForm(
- modifier = Modifier.padding(padding),
- saving = state.banlistMutationLoading,
- error = state.banlistMutationError,
- bulkResult = state.banlistLastBulkResult,
- onCancel = { showForm = false; onClearError(); onClearBulkResult() },
- onCreate = { matchType, value, reason -> onCreate(matchType, value, reason) { showForm = false } },
- onBulkAdd = { values, reason -> onBulkAdd(values, reason) { } },
- )
- entries.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Sperrliste ist leer. Mit + eine E-Mail-Adresse oder Domain sperren.") }
- else -> LazyColumn(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- items(entries) { entry -> BanEntryRow(entry, onDelete = { onDelete(entry.id) }) }
- }
- }
- }
-}
-
-@Composable
-private fun BanEntryRow(entry: BanEntry, onDelete: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Surface(
- color = if (entry.matchType == "domain") Color(0xFFF3E8FF) else MaterialTheme.colorScheme.surfaceVariant,
- shape = MaterialTheme.shapes.extraSmall,
- ) {
- Text(
- if (entry.matchType == "domain") "Domain" else "E-Mail",
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
- style = MaterialTheme.typography.labelSmall,
- color = if (entry.matchType == "domain") Color(0xFF7E22CE) else MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- Spacer(Modifier.width(8.dp))
- Text(entry.value, fontWeight = FontWeight.SemiBold)
- }
- TextButton(onClick = onDelete) { Text("Entsperren") }
- }
- entry.reason?.takeIf { it.isNotBlank() }?.let {
- Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
- }
- Text(
- buildString {
- append(entry.createdAt)
- entry.addedByName?.let { append(" · $it") }
- },
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 4.dp),
- )
- }
- }
-}
-
-@Composable
-private fun BanlistForm(
- modifier: Modifier = Modifier,
- saving: Boolean,
- error: String?,
- bulkResult: Pair?,
- onCancel: () -> Unit,
- onCreate: (String, String, String?) -> Unit,
- onBulkAdd: (String, String?) -> Unit,
-) {
- var bulkMode by rememberSaveable { mutableStateOf(false) }
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
- SegmentedButton(
- selected = !bulkMode,
- onClick = { bulkMode = false },
- shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
- ) { Text("Einzeln") }
- SegmentedButton(
- selected = bulkMode,
- onClick = { bulkMode = true },
- shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
- ) { Text("Mehrere") }
- }
-
- if (!bulkMode) {
- var matchType by rememberSaveable { mutableStateOf("email") }
- var value by rememberSaveable { mutableStateOf("") }
- var reason by rememberSaveable { mutableStateOf("") }
- var typeExpanded by remember { mutableStateOf(false) }
-
- SelectionField(
- label = "Typ",
- value = if (matchType == "domain") "Domain (ganzer Mailanbieter)" else "E-Mail-Adresse",
- expanded = typeExpanded,
- onExpand = { typeExpanded = true },
- onDismiss = { typeExpanded = false },
- ) {
- DropdownMenuItem(text = { Text("E-Mail-Adresse") }, onClick = { matchType = "email"; typeExpanded = false })
- DropdownMenuItem(text = { Text("Domain (ganzer Mailanbieter)") }, onClick = { matchType = "domain"; typeExpanded = false })
- }
- OutlinedTextField(
- value = value,
- onValueChange = { value = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text(if (matchType == "domain") "Domain, z.B. spam.de" else "E-Mail, z.B. spam@beispiel.de") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- OutlinedTextField(
- value = reason,
- onValueChange = { reason = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Grund (optional)") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- error?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onCreate(matchType, value.trim(), reason.trim().ifBlank { null }) },
- shape = MaterialTheme.shapes.medium,
- enabled = !saving && value.isNotBlank(),
- ) { Text(if (saving) "Wird gesperrt …" else "Sperren") }
- OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- } else {
- var values by rememberSaveable { mutableStateOf("") }
- var reason by rememberSaveable { mutableStateOf("") }
-
- Text(
- "Eine E-Mail oder Domain pro Zeile. Typ wird automatisch erkannt.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- OutlinedTextField(
- value = values,
- onValueChange = { values = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("E-Mails / Domains") },
- shape = MaterialTheme.shapes.medium,
- minLines = 6,
- )
- OutlinedTextField(
- value = reason,
- onValueChange = { reason = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Grund für alle (optional)") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- bulkResult?.let { (added, skipped) ->
- Text(
- "$added gesperrt, $skipped übersprungen.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.primary,
- )
- }
- error?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onBulkAdd(values, reason.trim().ifBlank { null }) },
- shape = MaterialTheme.shapes.medium,
- enabled = !saving && values.isNotBlank(),
- ) { Text(if (saving) "Wird gesperrt …" else "Alle sperren") }
- OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Fertig") }
- }
- }
- }
-}
-
-// ─── Admin: Abteilungen ──────────────────────────────────────────────────────
-
-@Composable
-private fun AdminDepartmentsScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, DepartmentInput, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
- onUploadImage: (ByteArray, String, String, (String) -> Unit) -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val items = state.departments2
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("Abteilungen", onBack) },
- floatingActionButton = {
- if (items != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Abteilung") }
- }
- },
- ) { padding ->
- when {
- state.departments2Loading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Abteilungen werden geladen …") }
- state.departments2Error != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.departments2Error, onLoad) }
- items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val dept = items.firstOrNull { it.id == editingId }
- var tab by rememberSaveable { mutableStateOf(0) }
- var name by rememberSaveable(dept?.id) { mutableStateOf(dept?.name ?: "") }
- var email by rememberSaveable(dept?.id) { mutableStateOf(dept?.email ?: "") }
- var isDefault by rememberSaveable(dept?.id) { mutableStateOf(dept?.isDefault ?: false) }
- var isActive by rememberSaveable(dept?.id) { mutableStateOf(dept?.isActive ?: true) }
- var managerId by rememberSaveable(dept?.id) { mutableStateOf(dept?.managerId) }
- var notes by rememberSaveable(dept?.id) { mutableStateOf(dept?.notes ?: "") }
- var signature by rememberSaveable(dept?.id) { mutableStateOf(dept?.signature ?: "") }
- var emailExpanded by remember { mutableStateOf(false) }
- var managerExpanded by remember { mutableStateOf(false) }
- var slaPlanId by rememberSaveable(dept?.id) { mutableStateOf(dept?.slaPlanId) }
- var slaExpanded by remember { mutableStateOf(false) }
- Column(Modifier.padding(padding).fillMaxSize()) {
- ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
- Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Abteilung") })
- Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Mitglieder" + (dept?.let { " (${it.members.size})" } ?: "")) })
- }
- Column(
- modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (dept == null) "Neue Abteilung" else "Abteilung aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- if (tab == 0) {
- OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- Text("Status", style = MaterialTheme.typography.labelLarge)
- Row(verticalAlignment = Alignment.CenterVertically) {
- RadioButton(selected = isActive, onClick = { isActive = true })
- Text("Aktiv", Modifier.clickable { isActive = true })
- Spacer(Modifier.width(16.dp))
- RadioButton(selected = !isActive, onClick = { isActive = false })
- Text("Deaktiviert", Modifier.clickable { isActive = false })
- }
- SelectionField(label = "Abteilungsleiter", value = state.departmentStaffOptions.firstOrNull { it.id == managerId }?.name ?: "— Keine(r) —", expanded = managerExpanded, onExpand = { managerExpanded = true }, onDismiss = { managerExpanded = false }) {
- DropdownMenuItem(text = { Text("— Keine(r) —") }, onClick = { managerId = null; managerExpanded = false })
- state.departmentStaffOptions.forEach { m -> DropdownMenuItem(text = { Text(m.name) }, onClick = { managerId = m.id; managerExpanded = false }) }
- }
- SelectionField(label = "SLA-Plan", value = state.departmentSlaPlans.firstOrNull { it.id == slaPlanId }?.name ?: "Zeiten der Priorität verwenden", expanded = slaExpanded, onExpand = { slaExpanded = true }, onDismiss = { slaExpanded = false }) {
- DropdownMenuItem(text = { Text("Zeiten der Priorität verwenden") }, onClick = { slaPlanId = null; slaExpanded = false })
- state.departmentSlaPlans.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { slaPlanId = p.id; slaExpanded = false }) }
- }
- SelectionField(label = "Abteilungs-E-Mail (optional)", value = email.ifBlank { "Automatisch (Standardadresse der Abteilung)" }, expanded = emailExpanded, onExpand = { emailExpanded = true }, onDismiss = { emailExpanded = false }) {
- DropdownMenuItem(text = { Text("Automatisch (Standardadresse der Abteilung)") }, onClick = { email = ""; emailExpanded = false })
- state.departmentEmailOptions.forEach { o ->
- DropdownMenuItem(text = { Text("${o.email} (${o.departmentName})") }, onClick = { email = o.email; emailExpanded = false })
- }
- if (email.isNotBlank() && state.departmentEmailOptions.none { it.email.equals(email, ignoreCase = true) }) {
- DropdownMenuItem(text = { Text(email) }, onClick = { emailExpanded = false })
- }
- }
- Text("Adressen verwaltest du unter „E-Mail-Adressen“.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = isDefault, onCheckedChange = { isDefault = it })
- Text("Standardabteilung")
- }
- SignatureField(
- value = signature,
- onValueChange = { signature = it },
- uploading = state.imageUploadLoading,
- uploadError = state.imageUploadError,
- onUpload = onUploadImage,
- )
- OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Administratornotizen (intern)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
- } else {
- val members = dept?.members.orEmpty()
- if (members.isEmpty()) {
- Text(
- if (dept == null) "Mitglieder lassen sich nach dem Erstellen unter „Mitarbeiter“ zuweisen." else "Dieser Abteilung sind noch keine Mitarbeiter zugewiesen. Die Zuweisung erfolgt unter „Mitarbeiter“.",
- style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- } else {
- members.forEach { m ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- 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)
- }
- }
- }
- Text("Zuweisungen änderst du unter „Mitarbeiter“.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- }
- state.departments2MutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = {
- onSave(dept?.id, DepartmentInput(name.trim(), email.trim(), isDefault, isActive, managerId, slaPlanId, notes.trim(), signature.trim().ifBlank { null })) { editingId = null; isCreating = false; tab = 0 }
- },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.departments2MutationLoading && name.isNotBlank(),
- ) { Text(if (state.departments2MutationLoading) "Wird gespeichert …" else "Änderungen speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
- }
- }
- items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Abteilungen.") }
- else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- items(items) { dept -> DepartmentRowAdmin(dept, onEdit = { editingId = dept.id }, onDelete = { onDelete(dept.id) }) }
- }
- }
- }
-}
-
-@Composable
-private fun DepartmentRowAdmin(dept: Department, onEdit: () -> Unit, onDelete: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(dept.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
- if (!dept.isActive) {
- Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
- Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- Spacer(Modifier.width(6.dp))
- }
- if (dept.isDefault) {
- Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) {
- Text("Standard", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- }
- }
- Text(
- "${dept.members.size} Mitglied(er)" + (dept.managerName?.let { " · Leiter: $it" } ?: "") + " · " + dept.email.ifBlank { "keine Abteilungs-E-Mail" },
- style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = onEdit) { Text("Bearbeiten") }
- TextButton(onClick = onDelete) { Text("Löschen") }
- }
- }
- }
-}
-
-// ─── Berichte ────────────────────────────────────────────────────────────────
-
-@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
-@Composable
-private fun AdminReportsScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onRun: (de.deskhub.staff.data.remote.dto.ReportConfigDto?, Int?) -> Unit,
- onSave: (String, Boolean) -> Unit,
- onDelete: (Int) -> Unit,
-) {
- LaunchedEffect(Unit) { if (state.report == null) onRun(null, null) }
- val report = state.report
- var dimension by remember(report?.config?.dimension) { mutableStateOf(state.reportConfig.dimension) }
- var from by remember(report?.config?.from) { mutableStateOf(state.reportConfig.from) }
- var to by remember(report?.config?.to) { mutableStateOf(state.reportConfig.to) }
- var metrics by remember(report?.config?.metrics) { mutableStateOf(state.reportConfig.metrics.toSet()) }
- var dimExpanded by remember { mutableStateOf(false) }
- var showSave by remember { mutableStateOf(false) }
-
- fun format(value: Any?, unit: String): String {
- val n = (value as? Number)?.toDouble() ?: return "–"
- return when (unit) {
- "%" -> String.format(java.util.Locale.GERMANY, "%.1f %%", n)
- "Std" -> String.format(java.util.Locale.GERMANY, "%.1f Std", n)
- else -> n.toLong().toString()
- }
- }
-
- if (showSave) {
- var name by remember { mutableStateOf("") }
- var shared by remember { mutableStateOf(false) }
- AlertDialog(
- onDismissRequest = { showSave = false },
- title = { Text("Bericht speichern") },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = shared, onCheckedChange = { shared = it })
- Text("Für alle Mitarbeiter sichtbar")
- }
- }
- },
- confirmButton = { TextButton(enabled = name.isNotBlank(), onClick = { onSave(name.trim(), shared); showSave = false }) { Text("Speichern") } },
- dismissButton = { TextButton(onClick = { showSave = false }) { Text("Abbrechen") } },
- )
- }
-
- Scaffold(topBar = { AdminTopBar("Berichte", onBack) }) { padding ->
- Column(
- modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- if (report?.saved?.isNotEmpty() == true) {
- Text("Gespeicherte Berichte", style = MaterialTheme.typography.labelLarge)
- LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- items(report.saved) { sr ->
- androidx.compose.material3.InputChip(
- selected = false,
- onClick = { onRun(null, sr.id) },
- label = { Text(sr.name + if (sr.isShared) " ⇄" else "") },
- trailingIcon = if (sr.mine) ({ Icon(Icons.Filled.Close, contentDescription = "Löschen", modifier = Modifier.size(16.dp).clickable { onDelete(sr.id) }) }) else null,
- )
- }
- }
- }
- SelectionField(label = "Gruppieren nach", value = report?.dimensions?.firstOrNull { it.key == dimension }?.label ?: dimension, expanded = dimExpanded, onExpand = { dimExpanded = true }, onDismiss = { dimExpanded = false }) {
- report?.dimensions?.forEach { d -> DropdownMenuItem(text = { Text(d.label) }, onClick = { dimension = d.key; dimExpanded = false }) }
- }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedTextField(value = from, onValueChange = { from = it }, modifier = Modifier.weight(1f), label = { Text("Von (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = to, onValueChange = { to = it }, modifier = Modifier.weight(1f), label = { Text("Bis (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- }
- Text("Kennzahlen", style = MaterialTheme.typography.labelLarge)
- androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- report?.metrics?.forEach { m ->
- FilterChip(
- 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,
- )
- }
- }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onRun(state.reportConfig.copy(dimension = dimension, from = from, to = to, metrics = metrics.toList().ifEmpty { listOf("total") }), null) },
- enabled = !state.reportLoading,
- shape = MaterialTheme.shapes.medium,
- ) { Text(if (state.reportLoading) "Wird berechnet …" else "Bericht erstellen") }
- OutlinedButton(onClick = { showSave = true }, enabled = report != null, shape = MaterialTheme.shapes.medium) { Text("Speichern") }
- }
- state.reportError?.let { ErrorText(it) }
- if (report != null) {
- if (report.rows.isEmpty()) {
- Text("Keine Tickets im gewählten Zeitraum.", color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- report.rows.forEach { row ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {
- Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
- Text(row["label"]?.toString() ?: "–", style = MaterialTheme.typography.titleSmall)
- report.columns.drop(1).forEach { c ->
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Text(c.label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Text(format(row[c.key], c.unit), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
- }
- }
- }
- }
- }
- if (report.rows.isNotEmpty()) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
- Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
- Text("Gesamt", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onPrimaryContainer)
- report.columns.drop(1).forEach { c ->
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Text(c.label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer)
- Text(format(report.totals[c.key], c.unit), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer)
- }
- }
- }
- }
- }
- }
- }
- }
-}
-
-// ─── Admin: SLA-Pläne ────────────────────────────────────────────────────────
-
-@Composable
-private fun AdminSlaPlansScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, String, Int, Int, Boolean, String, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val items = state.slaPlans
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("SLA-Pläne", onBack) },
- floatingActionButton = {
- if (items != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neuer SLA-Plan") }
- }
- },
- ) { padding ->
- when {
- state.slaPlansLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("SLA-Pläne werden geladen …") }
- state.slaPlansError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.slaPlansError, onLoad) }
- items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val plan = items.firstOrNull { it.id == editingId }
- var name by rememberSaveable(plan?.id) { mutableStateOf(plan?.name ?: "") }
- var isActive by rememberSaveable(plan?.id) { mutableStateOf(plan?.isActive ?: true) }
- var responseHours by rememberSaveable(plan?.id) { mutableStateOf((plan?.responseHours ?: 4).toString()) }
- var resolutionHours by rememberSaveable(plan?.id) { mutableStateOf((plan?.resolutionHours ?: 24).toString()) }
- var notes by rememberSaveable(plan?.id) { mutableStateOf(plan?.notes ?: "") }
- Column(
- modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (plan == null) "Neuer SLA-Plan" else "SLA-Plan aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- Row(verticalAlignment = Alignment.CenterVertically) {
- RadioButton(selected = isActive, onClick = { isActive = true })
- Text("Aktiv", Modifier.clickable { isActive = true })
- Spacer(Modifier.width(16.dp))
- RadioButton(selected = !isActive, onClick = { isActive = false })
- Text("Deaktiviert", Modifier.clickable { isActive = false })
- }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedTextField(value = responseHours, onValueChange = { responseHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Reaktion (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- OutlinedTextField(value = resolutionHours, onValueChange = { resolutionHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Lösung (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- }
- Text("Ersetzt die Zeiten der Priorität für Tickets, deren Hilfethema oder Abteilung diesen Plan nutzt (Hilfethema vor Abteilung).", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Administratornotizen (intern)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
- state.slaPlansMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onSave(plan?.id, name.trim(), responseHours.toIntOrNull() ?: 0, resolutionHours.toIntOrNull() ?: 0, isActive, notes.trim()) { editingId = null; isCreating = false } },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.slaPlansMutationLoading && name.isNotBlank(),
- ) { Text(if (state.slaPlansMutationLoading) "Wird gespeichert …" else "Änderungen speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
- }
- items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine SLA-Pläne angelegt.") }
- else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- items(items) { plan ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(plan.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
- if (!plan.isActive) {
- Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
- Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- }
- }
- Text("Reaktion ${plan.responseHours} Std · Lösung ${plan.resolutionHours} Std", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Text("${plan.departmentCount} Abteilung(en) · ${plan.helpTopicCount} Hilfethema/-themen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = { editingId = plan.id }) { Text("Bearbeiten") }
- TextButton(onClick = { onDelete(plan.id) }) { Text("Löschen") }
- }
- }
- }
- }
- }
- }
- }
-}
-
-// ─── Admin: Prioritäten ──────────────────────────────────────────────────────
-
-@Composable
-private fun AdminPrioritiesScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, String, String, Int, Int, Int, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val items = state.prioritiesAdmin
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("Prioritäten / SLA", onBack) },
- floatingActionButton = {
- if (items != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Priorität") }
- }
- },
- ) { padding ->
- when {
- state.prioritiesAdminLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Prioritäten werden geladen …") }
- state.prioritiesAdminError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.prioritiesAdminError, onLoad) }
- items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val prio = items.firstOrNull { it.id == editingId }
- var name by rememberSaveable(prio?.id) { mutableStateOf(prio?.name ?: "") }
- var color by rememberSaveable(prio?.id) { mutableStateOf(prio?.color ?: "#2563eb") }
- var responseHours by rememberSaveable(prio?.id) { mutableStateOf((prio?.responseHours ?: 24).toString()) }
- var resolutionHours by rememberSaveable(prio?.id) { mutableStateOf((prio?.resolutionHours ?: 72).toString()) }
- var sortOrder by rememberSaveable(prio?.id) { mutableStateOf((prio?.sortOrder ?: 0).toString()) }
- Column(
- modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (prio == null) "Neue Priorität" else "Priorität bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = color, onValueChange = { color = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Farbe (#RRGGBB)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedTextField(value = responseHours, onValueChange = { responseHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Reaktion (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- OutlinedTextField(value = resolutionHours, onValueChange = { resolutionHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Lösung (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- }
- OutlinedTextField(value = sortOrder, onValueChange = { sortOrder = it.filter(Char::isDigit) }, modifier = Modifier.fillMaxWidth(), label = { Text("Sortierung") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- state.prioritiesAdminMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = {
- onSave(prio?.id, name.trim(), color.trim(), responseHours.toIntOrNull() ?: 24, resolutionHours.toIntOrNull() ?: 72, sortOrder.toIntOrNull() ?: 0) {
- editingId = null; isCreating = false
- }
- },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.prioritiesAdminMutationLoading && name.isNotBlank(),
- ) { Text(if (state.prioritiesAdminMutationLoading) "Wird gespeichert …" else "Speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
- }
- items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Prioritäten.") }
- else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- items(items) { prio -> PriorityRowAdmin(prio, onEdit = { editingId = prio.id }, onDelete = { onDelete(prio.id) }) }
- }
- }
- }
-}
-
-@Composable
-private fun PriorityRowAdmin(prio: PriorityFull, onEdit: () -> Unit, onDelete: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- 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) {
- Text(prio.name, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelMedium, color = parseHexColor(prio.color, MaterialTheme.colorScheme.primary), fontWeight = FontWeight.SemiBold)
- }
- }
- Text("Reaktion: ${prio.responseHours}h · Lösung: ${prio.resolutionHours}h", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp))
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = onEdit) { Text("Bearbeiten") }
- TextButton(onClick = onDelete) { Text("Löschen") }
- }
- }
- }
-}
-
-// ─── Admin: Hilfe-Themen ─────────────────────────────────────────────────────
-
-@Composable
-private fun AdminHelpTopicsScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, HelpTopicInput, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val data = state.helpTopics
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("Hilfethemen", onBack) },
- floatingActionButton = {
- if (data != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neues Hilfethema") }
- }
- },
- ) { padding ->
- when {
- state.helpTopicsLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Hilfethemen werden geladen …") }
- state.helpTopicsError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.helpTopicsError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val topic = data.topics.firstOrNull { it.id == editingId }
- var tab by rememberSaveable { mutableStateOf(0) }
- var name by rememberSaveable(topic?.id) { mutableStateOf(topic?.name ?: "") }
- var isActive by rememberSaveable(topic?.id) { mutableStateOf(topic?.isActive ?: true) }
- var isPublic by rememberSaveable(topic?.id) { mutableStateOf(topic?.isPublic ?: true) }
- var parentId by rememberSaveable(topic?.id) { mutableStateOf(topic?.parentId) }
- var notes by rememberSaveable(topic?.id) { mutableStateOf(topic?.notes ?: "") }
- var departmentId by rememberSaveable(topic?.id) { mutableStateOf(topic?.departmentId ?: data.departments.firstOrNull()?.id ?: 0) }
- var priorityId by rememberSaveable(topic?.id) { mutableStateOf(topic?.priorityId ?: data.priorities.firstOrNull()?.id ?: 0) }
- var sortOrder by rememberSaveable(topic?.id) { mutableStateOf((topic?.sortOrder ?: 0).toString()) }
- var deptExpanded by remember { mutableStateOf(false) }
- var prioExpanded by remember { mutableStateOf(false) }
- var parentExpanded by remember { mutableStateOf(false) }
- var statusExpanded by remember { mutableStateOf(false) }
- var topicSlaPlanId by rememberSaveable(topic?.id) { mutableStateOf(topic?.slaPlanId) }
- var topicSlaExpanded by remember { mutableStateOf(false) }
- // Eigenes Thema und dessen Unterthemen kommen als Elternthema nicht in Frage
- val descendants = mutableSetOf().also { set ->
- if (topic != null) {
- set.add(topic.id)
- var grew = true
- while (grew) { grew = false; data.topics.forEach { t -> if (t.parentId in set && set.add(t.id)) grew = true } }
- }
- }
- Column(Modifier.padding(padding).fillMaxSize()) {
- ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
- Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Informationen") })
- Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Optionen bei neuem Ticket") })
- }
- Column(
- modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (topic == null) "Neues Hilfethema" else "Hilfethema aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- if (tab == 0) {
- OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Thema") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- SelectionField(label = "Status", value = if (isActive) "Aktiv" else "Deaktiviert", expanded = statusExpanded, onExpand = { statusExpanded = true }, onDismiss = { statusExpanded = false }) {
- DropdownMenuItem(text = { Text("Aktiv") }, onClick = { isActive = true; statusExpanded = false })
- DropdownMenuItem(text = { Text("Deaktiviert") }, onClick = { isActive = false; statusExpanded = false })
- }
- Text("Typ", style = MaterialTheme.typography.labelLarge)
- Row(verticalAlignment = Alignment.CenterVertically) {
- RadioButton(selected = isPublic, onClick = { isPublic = true })
- Text("Öffentlich", Modifier.clickable { isPublic = true })
- Spacer(Modifier.width(16.dp))
- RadioButton(selected = !isPublic, onClick = { isPublic = false })
- Text("Vertraulich/Intern", Modifier.clickable { isPublic = false })
- }
- SelectionField(label = "Übergeordnetes Thema", value = data.topics.firstOrNull { it.id == parentId }?.path ?: "— Oberste Hilfethemenebene —", expanded = parentExpanded, onExpand = { parentExpanded = true }, onDismiss = { parentExpanded = false }) {
- DropdownMenuItem(text = { Text("— Oberste Hilfethemenebene —") }, onClick = { parentId = null; parentExpanded = false })
- data.topics.filter { it.id !in descendants }.forEach { t -> DropdownMenuItem(text = { Text(t.path) }, onClick = { parentId = t.id; parentExpanded = false }) }
- }
- OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Interne Notizen (vertraulich)") }, minLines = 4, shape = MaterialTheme.shapes.medium)
- } else {
- SelectionField(label = "Abteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) {
- data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; deptExpanded = false }) }
- }
- SelectionField(label = "Priorität", value = data.priorities.firstOrNull { it.id == priorityId }?.name ?: "–", expanded = prioExpanded, onExpand = { prioExpanded = true }, onDismiss = { prioExpanded = false }) {
- data.priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { priorityId = p.id; prioExpanded = false }) }
- }
- SelectionField(label = "SLA-Plan", value = data.slaPlans.firstOrNull { it.id == topicSlaPlanId }?.name ?: "Abteilung/Priorität bestimmt die Zeiten", expanded = topicSlaExpanded, onExpand = { topicSlaExpanded = true }, onDismiss = { topicSlaExpanded = false }) {
- DropdownMenuItem(text = { Text("Abteilung/Priorität bestimmt die Zeiten") }, onClick = { topicSlaPlanId = null; topicSlaExpanded = false })
- data.slaPlans.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { topicSlaPlanId = p.id; topicSlaExpanded = false }) }
- }
- OutlinedTextField(value = sortOrder, onValueChange = { sortOrder = it.filter(Char::isDigit) }, modifier = Modifier.fillMaxWidth(), label = { Text("Sortierung") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- }
- state.helpTopicsMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = {
- onSave(topic?.id, HelpTopicInput(name.trim(), isActive, isPublic, parentId, topicSlaPlanId, notes.trim(), departmentId, priorityId, sortOrder.toIntOrNull() ?: 0)) { editingId = null; isCreating = false; tab = 0 }
- },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.helpTopicsMutationLoading && name.isNotBlank() && departmentId != 0 && priorityId != 0,
- ) { Text(if (state.helpTopicsMutationLoading) "Wird gespeichert …" else "Änderungen speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
- }
- }
- data.topics.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Hilfethemen.") }
- else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- items(data.topics.sortedBy { it.path.lowercase() }) { topic -> HelpTopicRowAdmin(topic, onEdit = { editingId = topic.id }, onDelete = { onDelete(topic.id) }) }
- }
- }
- }
-}
-
-@Composable
-private fun HelpTopicRowAdmin(topic: HelpTopic, onEdit: () -> Unit, onDelete: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(topic.path, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
- if (!topic.isPublic) {
- Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.extraSmall) {
- Text("Intern", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- Spacer(Modifier.width(6.dp))
- }
- if (!topic.isActive) {
- Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
- Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- }
- }
- Text("${topic.departmentName} · ${topic.priorityName}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = onEdit) { Text("Bearbeiten") }
- TextButton(onClick = onDelete) { Text("Löschen") }
- }
- }
- }
-}
-
-// ─── Admin: Organisationen ───────────────────────────────────────────────────
-
-@Composable
-private fun AdminOrganizationsScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, String, String?, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val items = state.organizations
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("Organisationen", onBack) },
- floatingActionButton = {
- if (items != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Organisation") }
- }
- },
- ) { padding ->
- when {
- state.organizationsLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Organisationen werden geladen …") }
- state.organizationsError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.organizationsError, onLoad) }
- items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val org = items.firstOrNull { it.id == editingId }
- var name by rememberSaveable(org?.id) { mutableStateOf(org?.name ?: "") }
- var notes by rememberSaveable(org?.id) { mutableStateOf(org?.notes ?: "") }
- Column(
- modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (org == null) "Neue Organisation" else "Organisation bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Notizen (optional)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
- state.organizationsMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onSave(org?.id, name.trim(), notes.trim().ifBlank { null }) { editingId = null; isCreating = false } },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.organizationsMutationLoading && name.isNotBlank(),
- ) { Text(if (state.organizationsMutationLoading) "Wird gespeichert …" else "Speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
- }
- 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) {
- Column(Modifier.padding(14.dp)) {
- Text(org.name, fontWeight = FontWeight.SemiBold)
- Text("${org.memberCount} Kontakte", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = { editingId = org.id }) { Text("Bearbeiten") }
- TextButton(onClick = { onDelete(org.id) }) { Text("Löschen") }
- }
- }
- }
- }
- }
- }
- }
-}
-
-// ─── Admin: Teams ────────────────────────────────────────────────────────────
-
-@Composable
-private fun AdminTeamsScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, String, Int?, String?, List, () -> Unit) -> Unit,
- onToggle: (Int) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val data = state.teams
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("Teams", onBack) },
- floatingActionButton = {
- if (data != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neues Team") }
- }
- },
- ) { padding ->
- when {
- state.teamsLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Teams werden geladen …") }
- state.teamsError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.teamsError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val team = data.teams.firstOrNull { it.id == editingId }
- var name by rememberSaveable(team?.id) { mutableStateOf(team?.name ?: "") }
- var leadId by rememberSaveable(team?.id) { mutableStateOf(team?.leadId) }
- var notes by rememberSaveable(team?.id) { mutableStateOf(team?.notes ?: "") }
- var memberIds by rememberSaveable(team?.id) { mutableStateOf(team?.memberIds?.toSet() ?: emptySet()) }
- var leadExpanded by remember { mutableStateOf(false) }
- Column(
- modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (team == null) "Neues Team" else "Team bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- SelectionField(label = "Teamleiter", value = data.staffOptions.firstOrNull { it.id == leadId }?.name ?: "– kein Leiter –", expanded = leadExpanded, onExpand = { leadExpanded = true }, onDismiss = { leadExpanded = false }) {
- DropdownMenuItem(text = { Text("– kein Leiter –") }, onClick = { leadId = null; leadExpanded = false })
- data.staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { leadId = s.id; leadExpanded = false }) }
- }
- Text("Mitglieder", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
- Column(
- Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.medium).padding(10.dp).heightIn(max = 220.dp).verticalScroll(rememberScrollState()),
- ) {
- data.staffOptions.forEach { s ->
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
- Checkbox(
- checked = memberIds.contains(s.id),
- onCheckedChange = { checked -> memberIds = if (checked) memberIds + s.id else memberIds - s.id },
- )
- Text(s.name)
- }
- }
- }
- OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Notizen (optional)") }, minLines = 2, shape = MaterialTheme.shapes.medium)
- state.teamsMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onSave(team?.id, name.trim(), leadId, notes.trim().ifBlank { null }, memberIds.toList()) { editingId = null; isCreating = false } },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.teamsMutationLoading && name.isNotBlank(),
- ) { Text(if (state.teamsMutationLoading) "Wird gespeichert …" else "Speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
- }
- data.teams.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Teams.") }
- else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- items(data.teams) { team -> TeamRowAdmin(team, onEdit = { editingId = team.id }, onToggle = { onToggle(team.id) }, onDelete = { onDelete(team.id) }) }
- }
- }
- }
-}
-
-@Composable
-private fun TeamRowAdmin(team: Team, onEdit: () -> Unit, onToggle: () -> Unit, onDelete: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(team.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
- Surface(color = if (team.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
- Text(if (team.isActive) "Aktiv" else "Inaktiv", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- }
- Text("Leiter: ${team.leadName ?: "–"} · ${team.memberCount} Mitglieder", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = onEdit) { Text("Bearbeiten") }
- TextButton(onClick = onToggle) { Text(if (team.isActive) "Deaktivieren" else "Aktivieren") }
- TextButton(onClick = onDelete) { Text("Löschen") }
- }
- }
- }
-}
-
-// ─── Admin: Rollen ───────────────────────────────────────────────────────────
-
-private val TicketPermissionLabels = linkedMapOf(
- "reply_ticket" to "Tickets beantworten",
- "close_ticket" to "Tickets schließen/lösen",
- "delete_ticket" to "Tickets löschen",
- "transfer_ticket" to "Tickets übertragen",
- "post_note" to "Interne Notizen schreiben",
- "assign_ticket" to "Tickets zuweisen",
- "manage_canned" to "Textbausteine verwalten",
- "view_reports" to "Berichte einsehen",
- "manage_kb" to "Wissensdatenbank verwalten",
- "create_ticket" to "Tickets im Namen von Kunden erstellen",
-)
-
-/** Feinkörnige Adminportal-Bereichsrechte – muss zu admin_section_permissions() in includes/functions.php passen. */
-private val AdminSectionPermissionLabels = linkedMapOf(
- "manage_departments" to "Abteilungen verwalten",
- "manage_priorities" to "Prioritäten / SLA verwalten",
- "manage_help_topics" to "Hilfe-Themen verwalten",
- "manage_email_addresses" to "E-Mail-Adressen verwalten",
- "manage_email_templates" to "E-Mail-Vorlagen verwalten",
- "manage_organizations" to "Organisationen verwalten",
- "manage_teams" to "Teams verwalten",
- "manage_roles" to "Rollen verwalten",
- "manage_staff" to "Mitarbeiter verwalten",
- "view_audit_log" to "Admin-Protokoll einsehen",
- "manage_escalations" to "Eskalationsregeln verwalten",
- "view_time_entries" to "Zeiterfassung einsehen",
- "manage_business_hours" to "Geschäftszeiten verwalten",
-)
-
-private val RolePermissionLabels = TicketPermissionLabels + AdminSectionPermissionLabels
-
-/** Ist true für echte Admins UND für Agenten, denen eine der obigen Berechtigungen zugewiesen wurde. */
-private fun Staff?.hasAnyAdminAccess(): Boolean {
- if (this == null) return false
- if (role == "admin") return true
- return AdminSectionPermissionLabels.keys.any { permissions[it] == true } || permissions["view_reports"] == true
-}
-
-@Composable
-private fun AdminRolesScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, String, Map, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val data = state.roles
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("Rollen", onBack) },
- floatingActionButton = {
- if (data != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Rolle") }
- }
- },
- ) { padding ->
- when {
- state.rolesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Rollen werden geladen …") }
- state.rolesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.rolesError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val role = data.roles.firstOrNull { it.id == editingId }
- var name by rememberSaveable(role?.id) { mutableStateOf(role?.name ?: "") }
- var perms by rememberSaveable(role?.id) { mutableStateOf(role?.permissions ?: emptyMap()) }
- Column(
- modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (role == null) "Neue Rolle" else "Rolle bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- Text("Ticket-Berechtigungen", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
- TicketPermissionLabels.forEach { (key, label) ->
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
- Checkbox(
- checked = perms[key] == true,
- onCheckedChange = { checked -> perms = perms + (key to checked) },
- )
- Text(label, style = MaterialTheme.typography.bodyMedium)
- }
- }
- Text("Adminportal-Bereiche (\"Co-Admin\")", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
- AdminSectionPermissionLabels.forEach { (key, label) ->
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
- Checkbox(
- checked = perms[key] == true,
- onCheckedChange = { checked -> perms = perms + (key to checked) },
- )
- Text(label, style = MaterialTheme.typography.bodyMedium)
- }
- }
- state.rolesMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onSave(role?.id, name.trim(), perms) { editingId = null; isCreating = false } },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.rolesMutationLoading && name.isNotBlank(),
- ) { Text(if (state.rolesMutationLoading) "Wird gespeichert …" else "Speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
- }
- data.roles.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine benutzerdefinierten Rollen. Ohne eigene Rolle gelten die Standard-Agentenrechte.") }
- else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- items(data.roles) { role -> RoleRowAdmin(role, onEdit = { editingId = role.id }, onDelete = { onDelete(role.id) }) }
- }
- }
- }
-}
-
-@Composable
-private fun RoleRowAdmin(role: Role, onEdit: () -> Unit, onDelete: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(14.dp)) {
- Text(role.name, fontWeight = FontWeight.SemiBold)
- Text("${role.staffCount} Mitarbeiter", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- val granted = role.permissions.filterValues { it }.keys.mapNotNull { RolePermissionLabels[it] }
- Text(
- if (granted.isEmpty()) "Keine Berechtigungen" else granted.joinToString(", "),
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.padding(top = 4.dp),
- )
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = onEdit) { Text("Bearbeiten") }
- TextButton(onClick = onDelete) { Text("Löschen") }
- }
- }
- }
-}
-
-// ─── Admin: Mitarbeiter ──────────────────────────────────────────────────────
-
-@Composable
-private fun AdminStaffScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, StaffAdminInput, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
- onUploadImage: (ByteArray, String, String, (String) -> Unit) -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val data = state.staffAdmin
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("Mitarbeiter", onBack) },
- floatingActionButton = {
- if (data != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neuer Mitarbeiter") }
- }
- },
- ) { padding ->
- when {
- state.staffAdminLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Mitarbeiter werden geladen …") }
- state.staffAdminError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.staffAdminError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val person = data.staff.firstOrNull { it.id == editingId }
- var tab by rememberSaveable { mutableStateOf(0) }
- var name by rememberSaveable(person?.id) { mutableStateOf(person?.name ?: "") }
- var email by rememberSaveable(person?.id) { mutableStateOf(person?.email ?: "") }
- var role by rememberSaveable(person?.id) { mutableStateOf(person?.role ?: "agent") }
- var roleId by rememberSaveable(person?.id) { mutableStateOf(person?.roleId) }
- var departmentId by rememberSaveable(person?.id) { mutableStateOf(person?.departmentId) }
- var isActive by rememberSaveable(person?.id) { mutableStateOf(person?.isActive ?: true) }
- var password by rememberSaveable(person?.id) { mutableStateOf("") }
- var signature by rememberSaveable(person?.id) { mutableStateOf(person?.signature ?: "") }
- var roleExpanded by remember { mutableStateOf(false) }
- var customRoleExpanded by remember { mutableStateOf(false) }
- var deptExpanded by remember { mutableStateOf(false) }
- var addExpanded by remember { mutableStateOf(false) }
- var extraDepts by rememberSaveable(person?.id) {
- mutableStateOf(person?.extraDepartments?.associate { it.departmentId to it.notify } ?: emptyMap())
- }
- // Rolle pro erweiterter Abteilung (0 = Hauptrolle)
- var extraRoles by rememberSaveable(person?.id) {
- mutableStateOf(person?.extraDepartments?.associate { it.departmentId to (it.roleId ?: 0) } ?: emptyMap())
- }
- var roleMenuFor by remember { mutableStateOf(null) }
- var teamIds by rememberSaveable(person?.id) { mutableStateOf(person?.teamIds ?: emptyList()) }
-
- Column(Modifier.padding(padding).fillMaxSize()) {
- ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
- Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Konto") })
- Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Zugang") })
- Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Berechtigungen") })
- Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Teams") })
- }
- Column(
- modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (person == null) "Neuer Agent" else "Agenten verwalten — ${person.name}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- when (tab) {
- 0 -> {
- OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = email, onValueChange = { email = it }, modifier = Modifier.fillMaxWidth(), label = { Text("E-Mail-Adresse") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email))
- Text("Status", style = MaterialTheme.typography.labelLarge)
- Row(verticalAlignment = Alignment.CenterVertically) {
- RadioButton(selected = isActive, onClick = { isActive = true })
- Text("Aktiv", Modifier.clickable { isActive = true })
- Spacer(Modifier.width(16.dp))
- RadioButton(selected = !isActive, onClick = { isActive = false })
- Text("Gesperrt", Modifier.clickable { isActive = false })
- }
- OutlinedTextField(
- value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth(),
- label = { Text(if (person == null) "Passwort" else "Neues Passwort (leer = unverändert)") },
- singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(),
- )
- SignatureField(
- value = signature,
- onValueChange = { signature = it },
- uploading = state.imageUploadLoading,
- uploadError = state.imageUploadError,
- onUpload = onUploadImage,
- minLines = 2,
- )
- }
- 1 -> {
- Text("Wähle die Abteilungen, zu denen der Agent Zugang haben soll.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- SelectionField(label = "Hauptabteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) {
- DropdownMenuItem(text = { Text("–") }, onClick = { departmentId = null; deptExpanded = false })
- data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; extraDepts = extraDepts - d.id; deptExpanded = false }) }
- }
- Text("Erweiterter Zugriff", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
- data.departments.filter { extraDepts.containsKey(it.id) && it.id != departmentId }.forEach { d ->
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
- Text(d.name, modifier = Modifier.weight(1f))
- Box {
- TextButton(onClick = { roleMenuFor = d.id }) { Text(data.roles.firstOrNull { it.id == extraRoles[d.id] }?.name ?: "Hauptrolle") }
- DropdownMenu(expanded = roleMenuFor == d.id, onDismissRequest = { roleMenuFor = null }) {
- DropdownMenuItem(text = { Text("Hauptrolle") }, onClick = { extraRoles = extraRoles + (d.id to 0); roleMenuFor = null })
- data.roles.forEach { r -> DropdownMenuItem(text = { Text(r.name) }, onClick = { extraRoles = extraRoles + (d.id to r.id); roleMenuFor = null }) }
- }
- }
- Checkbox(checked = extraDepts[d.id] == true, onCheckedChange = { notify -> extraDepts = extraDepts + (d.id to notify) })
- Text("Benachr.", style = MaterialTheme.typography.labelSmall)
- TextButton(onClick = { extraDepts = extraDepts - d.id; extraRoles = extraRoles - d.id }) { Text("✕") }
- }
- }
- val addable = data.departments.filter { it.id != departmentId && !extraDepts.containsKey(it.id) }
- if (addable.isNotEmpty()) {
- SelectionField(label = "Abteilung hinzufügen", value = "— Abteilung auswählen —", expanded = addExpanded, onExpand = { addExpanded = true }, onDismiss = { addExpanded = false }) {
- addable.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { extraDepts = extraDepts + (d.id to true); addExpanded = false }) }
- }
- }
- }
- 2 -> {
- SelectionField(label = "Kontotyp", value = if (role == "admin") "Administrator (alle Rechte)" else "Mitarbeiter", expanded = roleExpanded, onExpand = { roleExpanded = true }, onDismiss = { roleExpanded = false }) {
- DropdownMenuItem(text = { Text("Mitarbeiter") }, onClick = { role = "agent"; roleExpanded = false })
- DropdownMenuItem(text = { Text("Administrator (alle Rechte)") }, onClick = { role = "admin"; roleExpanded = false })
- }
- if (role != "admin" && data.roles.isNotEmpty()) {
- SelectionField(label = "Rolle", value = data.roles.firstOrNull { it.id == roleId }?.name ?: "– Standard-Mitarbeiterrechte –", expanded = customRoleExpanded, onExpand = { customRoleExpanded = true }, onDismiss = { customRoleExpanded = false }) {
- DropdownMenuItem(text = { Text("– Standard-Mitarbeiterrechte –") }, onClick = { roleId = null; customRoleExpanded = false })
- data.roles.forEach { r -> DropdownMenuItem(text = { Text(r.name) }, onClick = { roleId = r.id; customRoleExpanded = false }) }
- }
- }
- Text("Rollen und ihre Rechte legst du unter „Rollen“ an.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- else -> {
- if (data.teams.isEmpty()) {
- Text("Es sind noch keine Teams angelegt.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
- } else {
- Text(
- if (data.canManageTeams) "Der Agent ist Mitglied in folgenden Teams." else "Zum Ändern fehlt das Recht „Teams verwalten“.",
- style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- data.teams.forEach { t ->
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(
- checked = t.id in teamIds, enabled = data.canManageTeams,
- onCheckedChange = { on -> teamIds = if (on) teamIds + t.id else teamIds - t.id },
- )
- Text(t.name)
- }
- }
- }
- }
- }
- state.staffAdminMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = {
- onSave(
- person?.id,
- StaffAdminInput(
- name = name.trim(), email = email.trim(), role = role, roleId = if (role == "admin") null else roleId, departmentId = departmentId,
- isActive = isActive, password = password, signature = signature.trim().ifBlank { null },
- extraDepartments = extraDepts.filterKeys { it != departmentId }.map { (deptId, notify) -> StaffExtraDept(deptId, notify, extraRoles[deptId]?.takeIf { it != 0 }) },
- teamIds = if (data.canManageTeams) teamIds else null,
- ),
- ) { editingId = null; isCreating = false; tab = 0 }
- },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.staffAdminMutationLoading && name.isNotBlank() && email.isNotBlank() && (person != null || password.isNotBlank()),
- ) { Text(if (state.staffAdminMutationLoading) "Wird gespeichert …" else "Änderungen speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- if (person != null) {
- HorizontalDivider(Modifier.padding(vertical = 8.dp))
- OutlinedButton(
- onClick = { onDelete(person.id); editingId = null },
- shape = MaterialTheme.shapes.medium,
- colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
- ) { Text("Mitarbeiter löschen") }
- }
- }
- }
- }
- data.staff.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Keine Mitarbeiter gefunden.") }
- else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- items(data.staff) { s -> StaffRowAdmin(s, onEdit = { editingId = s.id }) }
- }
- }
- }
-}
-
-@Composable
-private fun StaffRowAdmin(s: StaffAdmin, onEdit: () -> Unit) {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, 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))
- if (!s.isActive) {
- Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
- Text("Gesperrt", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- }
- }
- Text(s.email, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Text(
- "${if (s.role == "admin") "Administrator" else "Mitarbeiter"} · ${s.departmentName ?: "keine Abteilung"}",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
-}
-
-// ─── Admin: Geschäftszeiten ──────────────────────────────────────────────────
-
-private val BusinessWeekdayNames = linkedMapOf(
- 1 to "Montag", 2 to "Dienstag", 3 to "Mittwoch", 4 to "Donnerstag", 5 to "Freitag", 6 to "Samstag", 0 to "Sonntag",
-)
-
-@Composable
-private fun AdminBusinessHoursScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSaveHours: (Boolean, List, () -> Unit) -> Unit,
- onAddHoliday: (String, String) -> Unit,
- onDeleteHoliday: (Int) -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- val data = state.businessHours
-
- Scaffold(topBar = { AdminTopBar("Geschäftszeiten", onBack) }) { padding ->
- when {
- state.businessHoursLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Geschäftszeiten werden geladen …") }
- state.businessHoursError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.businessHoursError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- else -> {
- var enabled by rememberSaveable(data) { mutableStateOf(data.enabled) }
- var hours by rememberSaveable(data) { mutableStateOf(data.hours) }
- var newHolidayDate by rememberSaveable { mutableStateOf("") }
- var newHolidayName by rememberSaveable { mutableStateOf("") }
-
- LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
- item {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = enabled, onCheckedChange = { enabled = it })
- Text("Geschäftszeiten für SLA-Berechnung verwenden")
- }
- }
- items(hours) { h ->
- val idx = hours.indexOf(h)
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- 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) } })
- Text(BusinessWeekdayNames[h.weekday] ?: h.weekday.toString(), fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
- }
- if (h.isOpen) {
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedTextField(
- value = h.openTime, onValueChange = { v -> hours = hours.toMutableList().also { it[idx] = h.copy(openTime = v) } },
- modifier = Modifier.weight(1f), label = { Text("Von (HH:MM)") }, singleLine = true, shape = MaterialTheme.shapes.medium,
- )
- OutlinedTextField(
- value = h.closeTime, onValueChange = { v -> hours = hours.toMutableList().also { it[idx] = h.copy(closeTime = v) } },
- modifier = Modifier.weight(1f), label = { Text("Bis (HH:MM)") }, singleLine = true, shape = MaterialTheme.shapes.medium,
- )
- }
- }
- }
- }
- }
- item {
- state.businessHoursMutationError?.let { ErrorText(it) }
- Button(
- onClick = { onSaveHours(enabled, hours) {} },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- enabled = !state.businessHoursMutationLoading,
- ) { Text(if (state.businessHoursMutationLoading) "Wird gespeichert …" else "Öffnungszeiten speichern") }
- }
- item { HorizontalDivider(Modifier.padding(vertical = 8.dp)); Text("Feiertage", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) }
- if (data.holidays.isEmpty()) {
- item { EmptyState("Noch keine Feiertage eingetragen.") }
- }
- items(data.holidays) { h ->
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- 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") }
- }
- }
- }
- item {
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- OutlinedTextField(value = newHolidayDate, onValueChange = { newHolidayDate = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Datum (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = newHolidayName, onValueChange = { newHolidayName = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- Button(
- onClick = { onAddHoliday(newHolidayDate.trim(), newHolidayName.trim()); newHolidayDate = ""; newHolidayName = "" },
- shape = MaterialTheme.shapes.medium,
- enabled = newHolidayDate.isNotBlank() && newHolidayName.isNotBlank(),
- ) { Text("Feiertag hinzufügen") }
- }
- }
- }
- }
- }
- }
-}
-
-// ─── Admin: E-Mail-Adressen ──────────────────────────────────────────────────
-
-@Composable
-private fun AdminEmailAddressesScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (Int?, EmailAddressInput, () -> Unit) -> Unit,
- onDelete: (Int) -> Unit,
- onTest: (Int, String) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- var editingId by rememberSaveable { mutableStateOf(null) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val data = state.emailAddresses
- val showForm = isCreating || editingId != null
-
- Scaffold(
- topBar = { AdminTopBar("E-Mail-Adressen", onBack) },
- floatingActionButton = {
- if (data != null && !showForm) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Adresse") }
- }
- },
- ) { padding ->
- when {
- state.emailAddressesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("E-Mail-Adressen werden geladen …") }
- state.emailAddressesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.emailAddressesError, onLoad) }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- showForm -> {
- val addr = data.addresses.firstOrNull { it.id == editingId }
- var tab by rememberSaveable { mutableStateOf(0) }
- // Konto
- var email by rememberSaveable(addr?.id) { mutableStateOf(addr?.email ?: "") }
- var label by rememberSaveable(addr?.id) { mutableStateOf(addr?.label ?: "") }
- var departmentId by rememberSaveable(addr?.id) { mutableStateOf(addr?.departmentId ?: data.departments.firstOrNull()?.id ?: 0) }
- var priorityId by rememberSaveable(addr?.id) { mutableStateOf(addr?.priorityId) }
- var isDefault by rememberSaveable(addr?.id) { mutableStateOf(addr?.isDefault ?: false) }
- var deptExpanded by remember { mutableStateOf(false) }
- var prioExpanded by remember { mutableStateOf(false) }
- // Entferntes Postfach
- var imapEnabled by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapEnabled ?: false) }
- var imapHost by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapHost ?: "") }
- var imapPort by rememberSaveable(addr?.id) { mutableStateOf((addr?.imapPort ?: 993).toString()) }
- var imapEncryption by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapEncryption ?: "ssl") }
- var imapUsername by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapUsername ?: "") }
- var imapPassword by rememberSaveable(addr?.id) { mutableStateOf("") }
- var imapFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapFolder ?: "INBOX") }
- var imapAfterFetch by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapAfterFetch ?: "seen") }
- var imapArchiveFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapArchiveFolder ?: "Processed") }
- var imapSpamFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapSpamFolder ?: "Junk") }
- // Konto: Status, Hilfethema, Notizen
- var isActive by rememberSaveable(addr?.id) { mutableStateOf(addr?.isActive ?: true) }
- var helpTopicId by rememberSaveable(addr?.id) { mutableStateOf(addr?.helpTopicId) }
- var notes by rememberSaveable(addr?.id) { mutableStateOf(addr?.notes ?: "") }
- var topicExpanded by remember { mutableStateOf(false) }
- // Abgehend (eigener SMTP)
- var smtpEnabled by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpEnabled ?: false) }
- var smtpHost by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpHost ?: "") }
- var smtpPort by rememberSaveable(addr?.id) { mutableStateOf((addr?.smtpPort ?: 587).toString()) }
- var smtpEncryption by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpEncryption ?: "tls") }
- var smtpUsername by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpUsername ?: "") }
- var smtpPassword by rememberSaveable(addr?.id) { mutableStateOf("") }
- var smtpEncExpanded by remember { mutableStateOf(false) }
- val smtpEncLabels = listOf("tls" to "STARTTLS (587)", "ssl" to "SSL/TLS (465)", "none" to "Keine")
- var encExpanded by remember { mutableStateOf(false) }
- var afterExpanded by remember { mutableStateOf(false) }
- val encLabels = listOf("ssl" to "SSL/TLS (993)", "tls" to "STARTTLS (143)", "none" to "Keine")
- val afterLabels = listOf("seen" to "Als gelesen markieren", "archive" to "In Ordner verschieben", "delete" to "Löschen")
-
- Column(Modifier.padding(padding).fillMaxSize()) {
- ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
- Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Konto") })
- Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Entferntes Postfach") })
- Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Abgehend (SMTP)") })
- }
- Column(
- modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text(if (addr == null) "Neue E-Mail-Adresse" else "E-Mail-Adresse bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- if (tab == 0) {
- OutlinedTextField(value = email, onValueChange = { email = it }, modifier = Modifier.fillMaxWidth(), label = { Text("E-Mail-Adresse") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email))
- OutlinedTextField(value = label, onValueChange = { label = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Anzeigename (optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- Text("Status", style = MaterialTheme.typography.labelLarge)
- Row(verticalAlignment = Alignment.CenterVertically) {
- RadioButton(selected = isActive, onClick = { isActive = true })
- Text("Aktiv", Modifier.clickable { isActive = true })
- Spacer(Modifier.width(16.dp))
- RadioButton(selected = !isActive, onClick = { isActive = false })
- Text("Deaktiviert", Modifier.clickable { isActive = false })
- }
- Text("Einstellungen bei neuem Ticket", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
- SelectionField(label = "Abteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) {
- data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; deptExpanded = false }) }
- }
- SelectionField(label = "Priorität", value = data.priorities.firstOrNull { it.id == priorityId }?.name ?: "Systemstandard", expanded = prioExpanded, onExpand = { prioExpanded = true }, onDismiss = { prioExpanded = false }) {
- DropdownMenuItem(text = { Text("Systemstandard") }, onClick = { priorityId = null; prioExpanded = false })
- data.priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { priorityId = p.id; prioExpanded = false }) }
- }
- SelectionField(label = "Hilfethema", value = data.helpTopics.firstOrNull { it.id == helpTopicId }?.name ?: "Systemstandard", expanded = topicExpanded, onExpand = { topicExpanded = true }, onDismiss = { topicExpanded = false }) {
- DropdownMenuItem(text = { Text("Systemstandard") }, onClick = { helpTopicId = null; topicExpanded = false })
- data.helpTopics.forEach { t -> DropdownMenuItem(text = { Text(t.name) }, onClick = { helpTopicId = t.id; topicExpanded = false }) }
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = isDefault, onCheckedChange = { isDefault = it })
- Text("Standard-Absenderadresse dieser Abteilung")
- }
- OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Interne Notizen (vertraulich)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
- } else if (tab == 2) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = smtpEnabled, onCheckedChange = { smtpEnabled = it })
- Text("Eigenen SMTP-Server für diese Adresse verwenden")
- }
- Text("Ohne eigenen Server wird der globale SMTP aus den Einstellungen genutzt.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- if (smtpEnabled) {
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedTextField(value = smtpHost, onValueChange = { smtpHost = it }, modifier = Modifier.weight(1f), label = { Text("Server") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = smtpPort, onValueChange = { smtpPort = it.filter(Char::isDigit) }, modifier = Modifier.width(100.dp), label = { Text("Port") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- }
- OutlinedTextField(value = smtpUsername, onValueChange = { smtpUsername = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(
- value = smtpPassword, onValueChange = { smtpPassword = it }, modifier = Modifier.fillMaxWidth(),
- label = { Text(if (addr?.smtpHasPassword == true) "Passwort (leer = unverändert)" else "Passwort") },
- singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(),
- )
- SelectionField(label = "Verschlüsselung", value = smtpEncLabels.firstOrNull { it.first == smtpEncryption }?.second ?: smtpEncryption, expanded = smtpEncExpanded, onExpand = { smtpEncExpanded = true }, onDismiss = { smtpEncExpanded = false }) {
- smtpEncLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { smtpEncryption = v; smtpEncExpanded = false }) }
- }
- }
- } else {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = imapEnabled, onCheckedChange = { imapEnabled = it })
- Text("Abruf für diese Adresse aktivieren")
- }
- Text(
- "Abteilung und Priorität stammen aus dem Reiter „Konto“." + (addr?.imapLastRunAt?.let { " Letzter Abruf: $it." } ?: ""),
- style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- if (imapEnabled) {
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedTextField(value = imapHost, onValueChange = { imapHost = it }, modifier = Modifier.weight(1f), label = { Text("Server") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = imapPort, onValueChange = { imapPort = it.filter(Char::isDigit) }, modifier = Modifier.width(100.dp), label = { Text("Port") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- }
- OutlinedTextField(value = imapUsername, onValueChange = { imapUsername = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Benutzername (leer = E-Mail-Adresse)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(
- value = imapPassword, onValueChange = { imapPassword = it }, modifier = Modifier.fillMaxWidth(),
- label = { Text(if (addr?.imapHasPassword == true) "Passwort (leer = unverändert)" else "Passwort") },
- singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
- )
- SelectionField(label = "Verschlüsselung", value = encLabels.firstOrNull { it.first == imapEncryption }?.second ?: imapEncryption, expanded = encExpanded, onExpand = { encExpanded = true }, onDismiss = { encExpanded = false }) {
- encLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { imapEncryption = v; encExpanded = false }) }
- }
- OutlinedTextField(value = imapFolder, onValueChange = { imapFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Ordner") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- SelectionField(label = "Aktion nach Abruf", value = afterLabels.firstOrNull { it.first == imapAfterFetch }?.second ?: imapAfterFetch, expanded = afterExpanded, onExpand = { afterExpanded = true }, onDismiss = { afterExpanded = false }) {
- afterLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { imapAfterFetch = v; afterExpanded = false }) }
- }
- if (imapAfterFetch == "archive") {
- OutlinedTextField(value = imapArchiveFolder, onValueChange = { imapArchiveFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Zielordner") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- }
- OutlinedTextField(value = imapSpamFolder, onValueChange = { imapSpamFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Spam-/Junk-Ordner") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- }
- }
- state.emailAddressesNotice?.let { Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium) }
- state.emailAddressesMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = {
- onSave(
- addr?.id,
- EmailAddressInput(
- email = email.trim(), label = label.trim().ifBlank { null }, departmentId = departmentId, priorityId = priorityId, isDefault = isDefault,
- helpTopicId = helpTopicId, isActive = isActive, notes = notes.trim(),
- smtpEnabled = smtpEnabled, smtpHost = smtpHost.trim(), smtpPort = smtpPort.toIntOrNull() ?: 587, smtpEncryption = smtpEncryption,
- smtpUsername = smtpUsername.trim(), smtpPassword = smtpPassword,
- imapEnabled = imapEnabled, imapHost = imapHost.trim(), imapPort = imapPort.toIntOrNull() ?: 993, imapEncryption = imapEncryption,
- imapUsername = imapUsername.trim(), imapPassword = imapPassword, imapFolder = imapFolder.trim(), imapAfterFetch = imapAfterFetch,
- imapArchiveFolder = imapArchiveFolder.trim(), imapSpamFolder = imapSpamFolder.trim(),
- ),
- ) { editingId = null; isCreating = false }
- },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.emailAddressesMutationLoading && email.isNotBlank() && departmentId != 0,
- ) { Text(if (state.emailAddressesMutationLoading) "Bitte warten …" else "Speichern") }
- OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- if (addr != null) {
- Text("Tests verwenden die gespeicherten Werte.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- OutlinedButton(onClick = { onTest(addr.id, "sendTest") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("Test-Mail senden") }
- if (addr.imapEnabled) {
- OutlinedButton(onClick = { onTest(addr.id, "testImap") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("Postfach testen") }
- }
- if (addr.smtpEnabled) {
- OutlinedButton(onClick = { onTest(addr.id, "testSmtp") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("SMTP testen") }
- }
- }
- }
- }
- }
- }
- 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) {
- Column(Modifier.padding(14.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text(addr.email, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
- if (!addr.isActive) {
- Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
- Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- Spacer(Modifier.width(6.dp))
- }
- if (addr.imapEnabled) {
- Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.extraSmall) {
- Text("Abruf", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- Spacer(Modifier.width(6.dp))
- }
- if (addr.isDefault) {
- Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) {
- Text("Standard", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- }
- }
- Text("${addr.label?.let { "$it · " } ?: ""}${addr.departmentName}${addr.priorityName?.let { " · $it" } ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- Row(Modifier.padding(top = 6.dp)) {
- TextButton(onClick = { editingId = addr.id }) { Text("Bearbeiten") }
- TextButton(onClick = { onDelete(addr.id) }) { Text("Löschen") }
- }
- }
- }
- }
- }
- }
- }
-}
-
-// ─── Admin: E-Mail-Vorlagen ──────────────────────────────────────────────────
-
-@Composable
-private fun AdminEmailTemplatesScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: () -> Unit,
- onSave: (String, String, String) -> Unit,
- onReset: (String) -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad() }
- val items = state.emailTemplates
-
- Scaffold(topBar = { AdminTopBar("E-Mail-Vorlagen", onBack) }) { padding ->
- when {
- state.emailTemplatesLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Vorlagen werden geladen …") }
- state.emailTemplatesError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.emailTemplatesError, onLoad) }
- items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
- item {
- Text(
- "Der Branding-Rahmen (Kopf-/Fußzeile) wird beim Versand automatisch ergänzt – hier nur den Inhalt anpassen.",
- style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- 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) {
- 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))
- if (tpl.isCustomized) {
- Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) {
- Text("angepasst", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
- }
- }
- }
- Text(
- "Platzhalter: " + tpl.placeholders.joinToString(", ") { "{$it}" },
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- OutlinedTextField(value = subject, onValueChange = { subject = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Betreff") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = body, onValueChange = { body = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Text (HTML erlaubt)") }, minLines = 5, shape = MaterialTheme.shapes.medium)
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = { onSave(tpl.key, subject.trim(), body.trim()) },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.emailTemplatesMutationLoading && subject.isNotBlank() && body.isNotBlank(),
- ) { Text("Speichern") }
- if (tpl.isCustomized) {
- OutlinedButton(onClick = { onReset(tpl.key) }, shape = MaterialTheme.shapes.medium) { Text("Zurücksetzen") }
- }
- }
- }
- }
- }
- state.emailTemplatesMutationError?.let { err -> item { ErrorText(err) } }
- }
- }
- }
-}
-
-// ─── Admin: Admin-Protokoll ──────────────────────────────────────────────────
-
-@Composable
-private fun AdminAuditLogScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: (Boolean) -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad(true) }
-
- Scaffold(topBar = { AdminTopBar("Admin-Protokoll", onBack) }) { padding ->
- when {
- state.auditLogLoading && state.auditLog.isEmpty() -> Box(Modifier.padding(padding)) { LoadingState("Protokoll wird geladen …") }
- state.auditLogError != null && state.auditLog.isEmpty() -> Box(Modifier.padding(padding)) { ErrorState(state.auditLogError) { onLoad(true) } }
- 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) {
- Column(Modifier.padding(12.dp)) {
- Text(entry.action, style = MaterialTheme.typography.bodyMedium)
- Text(
- "${entry.staffName ?: "System"} · ${entry.createdAt}",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(top = 2.dp),
- )
- }
- }
- }
- if (state.auditLogHasMore) {
- item {
- OutlinedButton(
- onClick = { onLoad(false) },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- enabled = !state.auditLogLoading,
- ) { Text(if (state.auditLogLoading) "Lädt …" else "Mehr laden") }
- }
- }
- }
- }
- }
-}
-
-// ─── Aufgaben ────────────────────────────────────────────────────────────────
-
-private val TaskStatusLabels = linkedMapOf("open" to "Offen", "in_progress" to "In Arbeit", "done" to "Erledigt")
-
-@Composable
-private fun AdminTasksScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onLoad: (String) -> Unit,
- onCreate: (String, String?, Int?, String?, String?, () -> Unit) -> Unit,
- onUpdateStatus: (Int, String) -> Unit,
- onDelete: (Int) -> Unit,
- onClearError: () -> Unit,
-) {
- LaunchedEffect(Unit) { onLoad(state.tasksFilter) }
- var isCreating by rememberSaveable { mutableStateOf(false) }
- val data = state.tasks
-
- Scaffold(
- topBar = { AdminTopBar("Aufgaben", onBack) },
- floatingActionButton = {
- if (data != null && !isCreating) {
- FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Aufgabe") }
- }
- },
- ) { padding ->
- when {
- state.tasksLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Aufgaben werden geladen …") }
- state.tasksError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.tasksError) { onLoad(state.tasksFilter) } }
- data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
- isCreating -> {
- var title by rememberSaveable { mutableStateOf("") }
- var description by rememberSaveable { mutableStateOf("") }
- var assignedToId by rememberSaveable { mutableStateOf(null) }
- var dueAt by rememberSaveable { mutableStateOf("") }
- var ticketNumber by rememberSaveable { mutableStateOf("") }
- var assignExpanded by remember { mutableStateOf(false) }
- Column(
- modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- Text("Neue Aufgabe", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- OutlinedTextField(value = title, onValueChange = { title = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Titel") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = description, onValueChange = { description = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Beschreibung (optional)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
- SelectionField(label = "Zuweisen an", value = data.staffOptions.firstOrNull { it.id == assignedToId }?.name ?: "Nicht zugewiesen", expanded = assignExpanded, onExpand = { assignExpanded = true }, onDismiss = { assignExpanded = false }) {
- DropdownMenuItem(text = { Text("Nicht zugewiesen") }, onClick = { assignedToId = null; assignExpanded = false })
- data.staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { assignedToId = s.id; assignExpanded = false }) }
- }
- OutlinedTextField(value = dueAt, onValueChange = { dueAt = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Fällig bis (JJJJ-MM-TT HH:MM, optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- OutlinedTextField(value = ticketNumber, onValueChange = { ticketNumber = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Ticketnummer verknüpfen (optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
- state.tasksMutationError?.let { ErrorText(it) }
- Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
- Button(
- onClick = {
- onCreate(title.trim(), description.trim().ifBlank { null }, assignedToId, dueAt.trim().ifBlank { null }.let { it?.replace(" ", "T") }, ticketNumber.trim().ifBlank { null }) {
- isCreating = false
- }
- },
- shape = MaterialTheme.shapes.medium,
- enabled = !state.tasksMutationLoading && title.isNotBlank(),
- ) { Text(if (state.tasksMutationLoading) "Wird erstellt …" else "Erstellen") }
- OutlinedButton(onClick = { isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
- }
- }
- }
- else -> Column(Modifier.padding(padding).fillMaxSize()) {
- ScrollableTabRow(selectedTabIndex = listOf("my", "all", "open", "done").indexOf(state.tasksFilter).coerceAtLeast(0), edgePadding = 12.dp) {
- listOf("my" to "Meine", "all" to "Alle", "open" to "Offen", "done" to "Erledigt").forEach { (value, label) ->
- Tab(selected = state.tasksFilter == value, onClick = { onLoad(value) }, text = { Text(label) })
- }
- }
- if (data.tasks.isEmpty()) {
- EmptyState("Keine Aufgaben gefunden.")
- } else {
- LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- items(data.tasks, key = StaffTask::id) { task -> TaskRowAdmin(task, onUpdateStatus = { onUpdateStatus(task.id, it) }, onDelete = { onDelete(task.id) }) }
- }
- }
- }
- }
- }
-}
-
-@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) {
- Column(Modifier.padding(14.dp)) {
- Text(
- task.title,
- fontWeight = FontWeight.SemiBold,
- textDecoration = if (task.status == "done") androidx.compose.ui.text.style.TextDecoration.LineThrough else null,
- )
- task.description?.takeIf { it.isNotBlank() }?.let {
- Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
- }
- if (task.ticketNumber != null) {
- Text("Ticket #${task.ticketNumber}: ${task.ticketSubject ?: ""}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
- }
- Text(
- buildString {
- append(task.assignedName ?: "Nicht zugewiesen")
- task.dueAt?.let { append(" · fällig $it") }
- },
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
- SelectionField(
- label = "Status", value = TaskStatusLabels[task.status] ?: task.status,
- expanded = statusExpanded, onExpand = { statusExpanded = true }, onDismiss = { statusExpanded = false },
- ) {
- TaskStatusLabels.forEach { (value, label) -> DropdownMenuItem(text = { Text(label) }, onClick = { onUpdateStatus(value); statusExpanded = false }) }
- }
- Spacer(Modifier.width(10.dp))
- TextButton(onClick = onDelete) { Text("Löschen") }
- }
- }
- }
-}
-
-// ─── Customers & new ticket ─────────────────────────────────────────────────
-
-@Composable
-private fun CustomersScreen(
- state: DeskHubState,
- onSearch: (String) -> Unit,
- onCustomer: (de.deskhub.staff.domain.model.Customer) -> Unit,
-) {
- var query by rememberSaveable { mutableStateOf(state.customersQuery) }
- LaunchedEffect(Unit) { if (state.customers.isEmpty()) onSearch(query) }
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("Kunden", fontWeight = FontWeight.Bold) },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = LocalChrome.current,
- titleContentColor = Color.White,
- ),
- )
- },
- ) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize(),
- ) {
- OutlinedTextField(
- value = query,
- onValueChange = { query = it },
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- label = { Text("Name oder E-Mail") },
- leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
- keyboardActions = KeyboardActions(onSearch = { onSearch(query) }),
- )
- when {
- state.customersLoading && state.customers.isEmpty() -> LoadingState("Kunden werden geladen …")
- state.customersError != null && state.customers.isEmpty() ->
- ErrorState(state.customersError) { onSearch(query) }
- state.customers.isEmpty() -> EmptyState("Keine Kunden gefunden.")
- else -> LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- items(state.customers, key = { "${it.isGuest}-${it.id}-${it.email}" }) { customer ->
- CustomerRow(customer, onCustomer)
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun CustomerRow(customer: de.deskhub.staff.domain.model.Customer, onClick: (de.deskhub.staff.domain.model.Customer) -> Unit) {
- Card(onClick = { onClick(customer) }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Row(
- modifier = Modifier
- .padding(14.dp)
- .fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Column(Modifier.weight(1f)) {
- Text(customer.name, fontWeight = FontWeight.SemiBold)
- Text(
- customer.email,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- customer.organizationName?.let {
- Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
- }
- }
- Column(horizontalAlignment = Alignment.End) {
- Surface(
- color = if (customer.isGuest) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer,
- shape = MaterialTheme.shapes.extraSmall,
- ) {
- Text(
- if (customer.isGuest) "Gast" else "Konto",
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
- style = MaterialTheme.typography.labelSmall,
- color = if (customer.isGuest) {
- MaterialTheme.colorScheme.onSurfaceVariant
- } else {
- MaterialTheme.colorScheme.onPrimaryContainer
- },
- )
- }
- Spacer(Modifier.height(4.dp))
- Text(
- "${customer.ticketCount} Tickets",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- }
-}
-
-@Composable
-private fun CustomerProfileScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onTicket: (String) -> Unit,
- onNewTicket: (String, String) -> Unit,
-) {
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(state.selectedCustomer?.customer?.name ?: "Kunde", 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,
- ),
- )
- },
- ) { padding ->
- val profile = state.selectedCustomer
- when {
- state.customerLoading && profile == null ->
- Box(Modifier.padding(padding)) { LoadingState("Kundenprofil wird geladen …") }
- state.customerError != null && profile == null ->
- Box(Modifier.padding(padding)) { ErrorState(state.customerError, onBack) }
- profile != null -> LazyColumn(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- item {
- Card(
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.large,
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
- ) {
- Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
- Text(
- profile.customer.name,
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- Text(profile.customer.email, color = MaterialTheme.colorScheme.onPrimaryContainer)
- profile.customer.organizationName?.let {
- Text(
- it,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f),
- )
- }
- Text(
- if (profile.customer.isGuest) "Gast-Kunde" else "Registriertes Konto",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f),
- )
- }
- }
- }
- item {
- Button(
- onClick = { onNewTicket(profile.customer.name, profile.customer.email) },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- ) {
- Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
- Spacer(Modifier.width(8.dp))
- Text("Neues Ticket für diesen Kunden")
- }
- }
- item {
- Text(
- "Tickets (${profile.tickets.size})",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- )
- }
- if (profile.tickets.isEmpty()) {
- item { EmptyState("Keine Tickets vorhanden.") }
- } else {
- items(profile.tickets, key = TicketSummary::id) { ticket -> TicketCard(ticket, state.metadata, onTicket) }
- }
- }
- else -> Box(Modifier.padding(padding)) { EmptyState("Kein Kunde ausgewählt.") }
- }
- }
-}
-
-@Composable
-private fun NewTicketScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onCreate: (String, String, String, String, Int, Int, Int?, Boolean) -> Unit,
-) {
- var name by rememberSaveable { mutableStateOf(state.newTicketPrefillName) }
- var email by rememberSaveable { mutableStateOf(state.newTicketPrefillEmail) }
- var subject by rememberSaveable { mutableStateOf("") }
- var message by rememberSaveable { mutableStateOf("") }
- val departments = state.metadata?.departments.orEmpty()
- val priorities = state.metadata?.priorities.orEmpty()
- var departmentId by rememberSaveable { mutableStateOf(departments.firstOrNull()?.id ?: 0) }
- var priorityId by rememberSaveable { mutableStateOf(priorities.firstOrNull()?.id ?: 0) }
- var assignToMe by rememberSaveable { mutableStateOf(true) }
- var notifyCustomer by rememberSaveable { mutableStateOf(true) }
- var departmentExpanded by remember { mutableStateOf(false) }
- var priorityExpanded by remember { mutableStateOf(false) }
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("Neues Ticket", 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,
- ),
- )
- },
- ) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- OutlinedTextField(
- value = name,
- onValueChange = { name = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Name des Kunden") },
- leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- OutlinedTextField(
- value = email,
- onValueChange = { email = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("E-Mail (optional)") },
- leadingIcon = { Icon(Icons.Filled.Email, contentDescription = null) },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
- )
- OutlinedTextField(
- value = subject,
- onValueChange = { subject = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Betreff") },
- singleLine = true,
- shape = MaterialTheme.shapes.medium,
- )
- SelectionField(
- label = "Abteilung",
- value = departments.firstOrNull { it.id == departmentId }?.name ?: "–",
- expanded = departmentExpanded,
- onExpand = { departmentExpanded = true },
- onDismiss = { departmentExpanded = false },
- ) {
- departments.forEach { department ->
- DropdownMenuItem(
- text = { Text(department.name) },
- onClick = { departmentId = department.id; departmentExpanded = false },
- )
- }
- }
- SelectionField(
- label = "Priorität",
- value = priorities.firstOrNull { it.id == priorityId }?.name ?: "–",
- expanded = priorityExpanded,
- onExpand = { priorityExpanded = true },
- onDismiss = { priorityExpanded = false },
- ) {
- priorities.forEach { priority ->
- DropdownMenuItem(
- text = { Text(priority.name) },
- onClick = { priorityId = priority.id; priorityExpanded = false },
- )
- }
- }
- OutlinedTextField(
- value = message,
- onValueChange = { message = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Beschreibung") },
- shape = MaterialTheme.shapes.medium,
- minLines = 5,
- )
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = assignToMe, onCheckedChange = { assignToMe = it })
- Text("Mir zuweisen")
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- Checkbox(checked = notifyCustomer, onCheckedChange = { notifyCustomer = it }, enabled = email.isNotBlank())
- Text("Eingangsbestätigung per E-Mail senden")
- }
- state.newTicketError?.let { ErrorText(it) }
- Button(
- onClick = {
- onCreate(
- name.trim(),
- email.trim(),
- subject.trim(),
- message.trim(),
- departmentId,
- priorityId,
- if (assignToMe) state.staff?.id else null,
- notifyCustomer && email.isNotBlank(),
- )
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp),
- shape = MaterialTheme.shapes.medium,
- enabled = !state.newTicketLoading && name.isNotBlank() && subject.isNotBlank() &&
- message.isNotBlank() && departmentId != 0 && priorityId != 0,
- ) {
- Text(if (state.newTicketLoading) "Wird erstellt …" else "Ticket erstellen")
- }
- }
- }
-}
-
-// ─── Ticket detail ──────────────────────────────────────────────────────────
-
-@Composable
-private fun TicketDetailScreen(
- state: DeskHubState,
- onBack: () -> Unit,
- onRetry: () -> Unit,
- onUpdate: (String, Int?) -> Unit,
- onMessage: (String, String, String?, () -> Unit) -> Unit,
- onSpamAction: (Boolean) -> Unit,
-) {
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(state.selectedTicket?.summary?.let { "#${it.number}" } ?: "Ticket", 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,
- ),
- )
- },
- ) { padding ->
- when {
- state.detailLoading && state.selectedTicket == null ->
- Box(Modifier.padding(padding)) { LoadingState("Ticket wird geladen …") }
- state.detailError != null && state.selectedTicket == null ->
- Box(Modifier.padding(padding)) { ErrorState(state.detailError, onRetry) }
- state.selectedTicket != null -> TicketDetailContent(
- modifier = Modifier.padding(padding),
- ticket = state.selectedTicket,
- metadata = state.metadata,
- cannedResponses = state.cannedResponses,
- mutationLoading = state.mutationLoading,
- mutationError = state.mutationError,
- onUpdate = onUpdate,
- onMessage = onMessage,
- onSpamAction = onSpamAction,
- )
- else -> Box(Modifier.padding(padding)) { EmptyState("Kein Ticket ausgewählt.") }
- }
- }
-}
-
-@Composable
-private fun TicketDetailContent(
- modifier: Modifier,
- ticket: TicketDetail,
- metadata: Metadata?,
- cannedResponses: List,
- mutationLoading: Boolean,
- mutationError: String?,
- onUpdate: (String, Int?) -> Unit,
- onMessage: (String, String, String?, () -> Unit) -> Unit,
- onSpamAction: (Boolean) -> Unit,
-) {
- var selectedStatus by remember(ticket.summary.number) { mutableStateOf(ticket.summary.status) }
- var selectedAgent by remember(ticket.summary.number) { mutableStateOf(ticket.summary.assignedStaff?.id) }
- var statusExpanded by remember { mutableStateOf(false) }
- var agentExpanded by remember { mutableStateOf(false) }
- var messageType by rememberSaveable(ticket.summary.number) { mutableStateOf("reply") }
- var message by rememberSaveable(ticket.summary.number) { mutableStateOf("") }
- var replyStatus by rememberSaveable(ticket.summary.number) { mutableStateOf("answered") }
- // Rechte für dieses Ticket (gelten pro Abteilung); ältere Server liefern sie nicht -> globale Rechte
- fun ticketAllowed(permission: String) = ticketPermission(ticket.permissions, metadata?.staff?.permissions, permission)
- val canReply = ticketAllowed("reply_ticket")
- val canNote = ticketAllowed("post_note")
- val canAssign = ticketAllowed("assign_ticket")
- val canClose = ticketAllowed("close_ticket")
- val statusChoices = metadata?.statuses.orEmpty().filter { status ->
- if (status.value in setOf("resolved", "closed")) canClose else canReply
- }
- val canChangeStatus = statusChoices.isNotEmpty()
- val priorityColor = parseHexColor(ticket.summary.priority.color, MaterialTheme.colorScheme.outline)
- // Live-Chat-Sperre: ein anderer Mitarbeiter (Web oder App) bearbeitet diesen Chat
- // gerade aktiv. Lesen bleibt erlaubt, nur öffentliche Antworten werden blockiert –
- // interne Notizen bleiben immer möglich (siehe api_add_ticket_message() im Backend).
- val chatLockedByOther = ticket.source == "chat" && ticket.lockedByOtherName != null
-
- LazyColumn(
- modifier = modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- 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)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Text(
- "#${ticket.summary.number}",
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
- if (ticket.summary.isSpam) {
- Surface(color = Color(0xFFDC2626).copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) {
- Text(
- "🚫 SPAM",
- modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
- style = MaterialTheme.typography.labelMedium,
- color = Color(0xFFDC2626),
- fontWeight = FontWeight.SemiBold,
- )
- }
- }
- StatusPill(ticket.summary.status, metadata)
- }
- }
- Text(
- ticket.summary.subject,
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- Icons.Filled.Person,
- contentDescription = null,
- modifier = Modifier.size(16.dp),
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- Spacer(Modifier.width(6.dp))
- Text(
- "${ticket.summary.requesterName} · ${ticket.summary.requesterEmail}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- }
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(
- modifier = Modifier
- .size(8.dp)
- .clip(CircleShape)
- .background(priorityColor),
- )
- Spacer(Modifier.width(6.dp))
- Text(
- "${ticket.summary.department.name} · ${ticket.summary.priority.name} · ${ticketSourceLabel(ticket.source)}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f),
- )
- }
- ticket.resolutionDueAt?.let { due ->
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- Icons.Filled.Schedule,
- contentDescription = null,
- modifier = Modifier.size(16.dp),
- tint = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- Spacer(Modifier.width(6.dp))
- Text(
- "Lösung fällig: $due",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- }
- }
- }
- }
- }
- if (ticket.source == "email") {
- item {
- if (!ticket.summary.isSpam) {
- OutlinedButton(
- onClick = { onSpamAction(false) },
- enabled = !mutationLoading,
- modifier = Modifier.fillMaxWidth(),
- colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFDC2626)),
- ) { Text("🚫 Als Spam markieren") }
- } else {
- OutlinedButton(
- onClick = { onSpamAction(true) },
- enabled = !mutationLoading,
- modifier = Modifier.fillMaxWidth(),
- ) { Text("↩️ Spam-Markierung aufheben") }
- }
- }
- }
- item {
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- Text("Bearbeitung", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- SelectionField(
- label = "Status",
- value = metadata?.statuses?.firstOrNull { it.value == selectedStatus }?.label ?: selectedStatus,
- expanded = statusExpanded,
- enabled = canChangeStatus,
- onExpand = { statusExpanded = true },
- onDismiss = { statusExpanded = false },
- ) {
- statusChoices.forEach { status ->
- DropdownMenuItem(
- text = { Text(status.label) },
- onClick = {
- selectedStatus = status.value
- statusExpanded = false
- },
- )
- }
- }
- SelectionField(
- label = "Zuständig",
- value = metadata?.agents?.firstOrNull { it.id == selectedAgent }?.name ?: "Nicht zugewiesen",
- expanded = agentExpanded,
- enabled = canAssign,
- onExpand = { agentExpanded = true },
- onDismiss = { agentExpanded = false },
- ) {
- DropdownMenuItem(
- text = { Text("Nicht zugewiesen") },
- onClick = {
- selectedAgent = null
- agentExpanded = false
- },
- )
- metadata?.agents?.forEach { agent ->
- DropdownMenuItem(
- text = { Text(agent.name) },
- onClick = {
- selectedAgent = agent.id
- agentExpanded = false
- },
- )
- }
- }
- Button(
- onClick = { onUpdate(selectedStatus, selectedAgent) },
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- enabled = !mutationLoading &&
- (selectedStatus != ticket.summary.status || selectedAgent != ticket.summary.assignedStaff?.id),
- ) {
- Text(if (mutationLoading) "Speichert …" else "Änderungen speichern")
- }
- }
- }
- }
- item {
- Text("Verlauf", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
- }
- if (ticket.threads.isEmpty()) {
- item { EmptyState("Noch keine Nachrichten vorhanden.") }
- } else {
- items(ticket.threads, key = { it.id }) { thread -> ThreadBubble(thread) }
- }
- item {
- // Ist der Chat gerade woanders in Bearbeitung, auf "Interne Notiz" ausweichen statt
- // auf dem gesperrten "Antwort"-Tab hängen zu bleiben (falls erlaubt).
- LaunchedEffect(chatLockedByOther, canNote) {
- if (chatLockedByOther && messageType == "reply" && canNote) messageType = "note"
- }
- Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
- Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
- Text("Nachricht verfassen", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
- if (chatLockedByOther) {
- Surface(color = Color(0xFFFFFBEB), shape = MaterialTheme.shapes.small) {
- Row(
- modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text("🔒", style = MaterialTheme.typography.bodyMedium)
- Spacer(Modifier.width(8.dp))
- Text(
- "Wird gerade von ${ticket.lockedByOtherName} bearbeitet – Sie sehen live mit, können hier aber nicht antworten.",
- style = MaterialTheme.typography.bodySmall,
- color = Color(0xFF92400E),
- )
- }
- }
- }
- SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
- SegmentedButton(
- selected = messageType == "reply",
- onClick = { messageType = "reply" },
- enabled = canReply && !chatLockedByOther,
- shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
- ) { Text("Antwort") }
- SegmentedButton(
- selected = messageType == "note",
- onClick = { messageType = "note" },
- enabled = canNote,
- shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
- ) { Text("Interne Notiz") }
- }
- if (cannedResponses.isNotEmpty() && (canReply || canNote)) {
- var templatesExpanded by remember { mutableStateOf(false) }
- Box {
- TextButton(onClick = { templatesExpanded = true }) {
- Icon(Icons.Filled.Description, contentDescription = null, modifier = Modifier.size(18.dp))
- Spacer(Modifier.width(6.dp))
- Text("Vorlage einfügen")
- }
- DropdownMenu(expanded = templatesExpanded, onDismissRequest = { templatesExpanded = false }) {
- cannedResponses.forEach { canned ->
- DropdownMenuItem(
- text = { Text(canned.title) },
- onClick = {
- message = canned.body
- templatesExpanded = false
- },
- )
- }
- }
- }
- }
- OutlinedTextField(
- value = message,
- onValueChange = { message = it },
- modifier = Modifier.fillMaxWidth(),
- label = { Text(if (messageType == "note") "Interne Notiz" else "Antwort an Kunden") },
- shape = MaterialTheme.shapes.medium,
- minLines = 4,
- enabled = !(chatLockedByOther && messageType == "reply"),
- )
- if (messageType == "reply") {
- var replyStatusExpanded by remember { mutableStateOf(false) }
- SelectionField(
- label = "Status nach Antwort",
- value = metadata?.statuses?.firstOrNull { it.value == replyStatus }?.label ?: replyStatus,
- expanded = replyStatusExpanded,
- onExpand = { replyStatusExpanded = true },
- onDismiss = { replyStatusExpanded = false },
- ) {
- statusChoices.forEach { status ->
- DropdownMenuItem(
- text = { Text(status.label) },
- onClick = {
- replyStatus = status.value
- replyStatusExpanded = false
- },
- )
- }
- }
- }
- mutationError?.let { ErrorText(it) }
- Button(
- onClick = {
- onMessage(
- messageType,
- message,
- replyStatus.takeIf { messageType == "reply" },
- ) { message = "" }
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp),
- shape = MaterialTheme.shapes.medium,
- enabled = !mutationLoading && message.isNotBlank() &&
- ((messageType == "reply" && canReply && !chatLockedByOther) || (messageType == "note" && canNote)),
- ) {
- if (!mutationLoading) {
- Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(18.dp))
- Spacer(Modifier.width(8.dp))
- }
- Text(if (mutationLoading) "Sendet …" else if (messageType == "note") "Notiz speichern" else "Antwort senden")
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun ThreadBubble(thread: de.deskhub.staff.domain.model.TicketThread) {
- val isStaff = thread.authorType == "staff"
- val isSystem = thread.authorType == "system"
-
- if (isSystem) {
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
- Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
- Text(
- thread.message,
- modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- }
- return
- }
-
- val bubbleColor = when {
- thread.isInternalNote -> MaterialTheme.colorScheme.secondaryContainer
- isStaff -> MaterialTheme.colorScheme.primaryContainer
- else -> MaterialTheme.colorScheme.surfaceVariant
- }
- val onBubbleColor = when {
- thread.isInternalNote -> MaterialTheme.colorScheme.onSecondaryContainer
- isStaff -> MaterialTheme.colorScheme.onPrimaryContainer
- else -> MaterialTheme.colorScheme.onSurfaceVariant
- }
- val bubbleShape = if (isStaff) {
- RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 4.dp)
- } else {
- RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
- }
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start,
- ) {
- Column(modifier = Modifier.fillMaxWidth(0.86f)) {
- if (thread.isInternalNote) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start,
- ) {
- Icon(
- Icons.Filled.Lock,
- contentDescription = null,
- modifier = Modifier.size(12.dp),
- tint = MaterialTheme.colorScheme.secondary,
- )
- Spacer(Modifier.width(4.dp))
- Text(
- "Interne Notiz",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.secondary,
- fontWeight = FontWeight.Bold,
- )
- }
- Spacer(Modifier.height(2.dp))
- }
- Surface(color = bubbleColor, shape = bubbleShape) {
- 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)
- Text(thread.createdAt, style = MaterialTheme.typography.labelSmall, color = onBubbleColor.copy(alpha = 0.7f))
- }
- Text(thread.message, color = onBubbleColor, style = MaterialTheme.typography.bodyMedium)
- thread.attachments.forEach { attachment ->
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- Icons.Filled.AttachFile,
- contentDescription = null,
- modifier = Modifier.size(14.dp),
- tint = onBubbleColor,
- )
- Spacer(Modifier.width(4.dp))
- Text(
- "${attachment.name} (${attachment.size / 1024} KB)",
- style = MaterialTheme.typography.labelSmall,
- color = onBubbleColor,
- )
- }
- }
- thread.comments.forEach { comment ->
- HorizontalDivider(color = onBubbleColor.copy(alpha = 0.15f))
- Text(
- "${comment.staff.name}: ${comment.message}",
- style = MaterialTheme.typography.bodySmall,
- color = onBubbleColor,
- )
- }
- }
- }
- }
- }
-}
-
-/**
- * Mehrzeiliges Signatur-Feld mit "Bild einfügen"-Button: lädt ein per Bildauswahl gewähltes
- * Foto hoch und hängt bei Erfolg ein
-Tag an den (reinen) HTML-Signaturtext an – analog
- * zum 🖼️-Knopf im Web-Editor (assets/js/richtext.js).
- */
-@Composable
-private fun SignatureField(
- value: String,
- onValueChange: (String) -> Unit,
- uploading: Boolean,
- uploadError: String?,
- onUpload: (ByteArray, String, String, (String) -> Unit) -> Unit,
- minLines: Int = 3,
-) {
- val context = LocalContext.current
- val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
- if (uri == null) return@rememberLauncherForActivityResult
- val resolver = context.contentResolver
- val mimeType = resolver.getType(uri) ?: "image/jpeg"
- val bytes = runCatching { resolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
- if (bytes != null) {
- val ext = when {
- mimeType.contains("png") -> "png"
- mimeType.contains("gif") -> "gif"
- else -> "jpg"
- }
- onUpload(bytes, "signature.$ext", mimeType) { url ->
- val insertion = "
"
- onValueChange(if (value.isBlank()) insertion else "$value\n$insertion")
- }
- }
- }
-
- Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
- OutlinedTextField(
- value = value,
- onValueChange = onValueChange,
- modifier = Modifier.fillMaxWidth(),
- label = { Text("Signatur (optional)") },
- minLines = minLines,
- shape = MaterialTheme.shapes.medium,
- )
- TextButton(onClick = { pickImage.launch("image/*") }, enabled = !uploading) {
- Text(if (uploading) "Bild wird hochgeladen …" else "🖼️ Bild einfügen")
- }
- uploadError?.let { ErrorText(it) }
- }
-}
-
-@Composable
-private fun SelectionField(
- label: String,
- value: String,
- expanded: Boolean,
- enabled: Boolean = true,
- onExpand: () -> Unit,
- onDismiss: () -> Unit,
- content: @Composable () -> Unit,
-) {
- Box {
- OutlinedButton(
- onClick = onExpand,
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.medium,
- enabled = enabled,
- ) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
- Text("$label: $value")
- Text("▾")
- }
- }
- DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
- content()
- }
- }
-}
-
-@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,
- )
- }
-}
-
-private fun parseHexColor(hex: String?, fallback: Color): Color =
- runCatching { Color(parseColor(hex ?: return fallback)) }.getOrDefault(fallback)
-
-/** Spiegelt die Beschriftungen aus staff/ticket_view.php ($sourceLabels) für die App. */
-private fun ticketSourceLabel(source: String): String = when (source) {
- "web" -> "🌐 Web"
- "email" -> "📧 E-Mail"
- "phone" -> "📞 Telefon"
- "staff" -> "🖥 Intern"
- "chat" -> "💬 Live-Chat"
- else -> source
-}
-
-@Composable
-private fun LoadingState(message: String) {
- Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- CircularProgressIndicator()
- Spacer(Modifier.height(12.dp))
- Text(message)
- }
- }
-}
-
-@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") }
- }
- }
-}
-
-@Composable
-private fun ErrorText(message: String, modifier: Modifier = Modifier) {
- Text(message, modifier = modifier, color = MaterialTheme.colorScheme.error)
-}
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package de.deskhub.staff.ui
+
+import android.graphics.Color.parseColor
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+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.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+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.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.Assignment
+import androidx.compose.material.icons.automirrored.filled.Chat
+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.automirrored.filled.Send
+import androidx.compose.material.icons.filled.AccessTime
+import androidx.compose.material.icons.filled.AdminPanelSettings
+import androidx.compose.material.icons.filled.Apartment
+import androidx.compose.material.icons.filled.AttachFile
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Block
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.Assessment
+import androidx.compose.material.icons.filled.Business
+import androidx.compose.material.icons.filled.Close
+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.Description
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.ErrorOutline
+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.People
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SupportAgent
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Badge
+import androidx.compose.material3.BadgedBox
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.NavigationBar
+import androidx.compose.material3.NavigationBarItem
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.ScrollableTabRow
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.Shapes
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.material.icons.filled.DarkMode
+import androidx.compose.material.icons.filled.LightMode
+import androidx.compose.material.icons.filled.BrightnessMedium
+import androidx.compose.material3.Typography
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+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.platform.LocalContext
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import de.deskhub.staff.R
+import de.deskhub.staff.domain.model.AuditLogEntry
+import de.deskhub.staff.domain.model.BanEntry
+import de.deskhub.staff.domain.model.BusinessHour
+import de.deskhub.staff.domain.model.Department
+import de.deskhub.staff.domain.model.DepartmentInput
+import de.deskhub.staff.domain.model.EmailAddress
+import de.deskhub.staff.domain.model.EmailAddressInput
+import de.deskhub.staff.domain.model.EmailTemplate
+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.KeyLabel
+import de.deskhub.staff.domain.model.Organization
+import de.deskhub.staff.domain.model.PriorityFull
+import de.deskhub.staff.domain.model.Role
+import de.deskhub.staff.domain.model.Staff
+import de.deskhub.staff.domain.model.StaffAdmin
+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.Team
+import de.deskhub.staff.domain.model.KbArticle
+import de.deskhub.staff.domain.model.Metadata
+import de.deskhub.staff.domain.model.NamedOption
+import de.deskhub.staff.domain.model.TicketDetail
+import de.deskhub.staff.domain.model.TicketFilters
+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.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)
+ }
+ }
+ }
+ }
+}
+
+/** Kleines Menü zum Umschalten zwischen System/Hell/Dunkel (für Kopfleisten). */
+@Composable
+internal fun ThemeMenuButton() {
+ val controller = LocalThemeController.current
+ var open by remember { mutableStateOf(false) }
+ Box {
+ IconButton(onClick = { open = true }) {
+ Icon(
+ when (controller.mode) {
+ ThemeMode.Dark -> Icons.Filled.DarkMode
+ ThemeMode.Light -> Icons.Filled.LightMode
+ ThemeMode.System -> Icons.Filled.BrightnessMedium
+ },
+ contentDescription = "Design wechseln",
+ )
+ }
+ DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
+ ThemeMode.values().forEach { mode ->
+ DropdownMenuItem(
+ text = { Text(mode.label, fontWeight = if (mode == controller.mode) FontWeight.Bold else FontWeight.Normal) },
+ onClick = { controller.setMode(mode); open = false },
+ )
+ }
+ }
+ }
+}
+
+
+@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")
+
+@Composable
+private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
+ val navController = rememberNavController()
+ val backStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = backStackEntry?.destination?.route
+
+ Scaffold(
+ 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") },
+ )
+ }
+ }
+ }
+ },
+ ) { outerPadding ->
+ Column(Modifier.padding(outerPadding)) {
+ StatusBanners(state, onRetry = viewModel::retryConnection)
+ NavHost(
+ navController = navController,
+ startDestination = "dashboard",
+ modifier = Modifier.weight(1f),
+ ) {
+ composable("tickets") {
+ TicketListScreen(
+ state = state,
+ onFiltersChanged = viewModel::updateFilters,
+ onSearch = viewModel::refreshTickets,
+ onRefresh = viewModel::refreshTickets,
+ onLoadMore = viewModel::loadMoreTickets,
+ onApplyView = viewModel::applySavedView,
+ onSaveView = viewModel::saveCurrentView,
+ onDeleteView = viewModel::deleteSavedView,
+ onLoadViews = viewModel::loadSavedViews,
+ onTicket = { number ->
+ viewModel.openTicket(number)
+ navController.navigate("detail")
+ },
+ onLogout = viewModel::logout,
+ onNewTicket = {
+ viewModel.startNewTicket()
+ navController.navigate("newTicket")
+ },
+ )
+ }
+ composable("dashboard") {
+ DashboardScreen(
+ state = state,
+ onLoad = {
+ viewModel.loadDashboard()
+ viewModel.loadAvailability()
+ },
+ onSetStatus = viewModel::setMyAvailability,
+ onTicket = { number ->
+ viewModel.openTicket(number)
+ navController.navigate("detail")
+ },
+ )
+ }
+ composable("customers") {
+ CustomersScreen(
+ state = state,
+ onSearch = viewModel::searchCustomers,
+ onCustomer = { customer ->
+ viewModel.openCustomer(customer)
+ navController.navigate("customerProfile")
+ },
+ )
+ }
+ composable("customerProfile") {
+ CustomerProfileScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onTicket = { number ->
+ viewModel.openTicket(number)
+ navController.navigate("detail")
+ },
+ onNewTicket = { name, email ->
+ viewModel.startNewTicket(name, email)
+ navController.navigate("newTicket")
+ },
+ )
+ }
+ composable("chat") {
+ ChatHubScreen(
+ state = state,
+ onLoadContacts = viewModel::loadChatContacts,
+ onOpenContact = { contact ->
+ viewModel.openChatConversation(contact.id)
+ navController.navigate("chatConversation")
+ },
+ onLoadLiveChats = viewModel::loadLiveChats,
+ onStopLiveChatPolling = viewModel::stopLiveChatListPolling,
+ onOpenLiveChat = { number ->
+ viewModel.openLiveChat(number)
+ navController.navigate("liveChatConversation")
+ },
+ )
+ }
+ composable("chatConversation") {
+ ChatConversationScreen(
+ state = state,
+ onBack = { viewModel.closeChatConversation(); navController.popBackStack() },
+ onSend = viewModel::sendChatMessage,
+ onClearError = viewModel::clearChatSendError,
+ )
+ }
+ composable("liveChatConversation") {
+ LiveChatConversationScreen(
+ state = state,
+ onBack = { viewModel.closeLiveChat(); navController.popBackStack() },
+ onSend = viewModel::sendLiveChatMessage,
+ onTyping = viewModel::pingLiveChatTyping,
+ onClearError = viewModel::clearLiveChatSendError,
+ onEnd = { number -> viewModel.endLiveChat(number) { navController.popBackStack() } },
+ )
+ }
+ composable("newTicket") {
+ NewTicketScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onCreate = { requesterName, requesterEmail, subject, message, departmentId, priorityId, assignedStaffId, notify ->
+ viewModel.createTicket(
+ requesterName, requesterEmail, subject, message,
+ departmentId, priorityId, assignedStaffId, notify,
+ ) { number ->
+ viewModel.openTicket(number)
+ navController.navigate("detail") {
+ popUpTo("tickets")
+ }
+ }
+ },
+ )
+ }
+ composable("detail") {
+ TicketDetailScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onRetry = { state.selectedTicket?.summary?.number?.let(viewModel::openTicket) },
+ onUpdate = viewModel::updateTicket,
+ onMessage = viewModel::addMessage,
+ onSpamAction = viewModel::setTicketSpam,
+ )
+ }
+ composable("admin") {
+ AdminScreen(staff = state.staff, onNavigate = { route -> navController.navigate(route) })
+ }
+ composable("adminKb") {
+ AdminKbScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadKb,
+ onSaveArticle = viewModel::saveKbArticle,
+ onDeleteArticle = viewModel::deleteKbArticle,
+ onCreateCategory = viewModel::createKbCategory,
+ onDeleteCategory = viewModel::deleteKbCategory,
+ onClearError = viewModel::clearKbMutationError,
+ )
+ }
+ composable("adminTimeEntries") {
+ AdminTimeEntriesScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadTimeEntries,
+ )
+ }
+ composable("adminEscalation") {
+ AdminEscalationScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadEscalationRules,
+ onSave = viewModel::saveEscalationRule,
+ onDelete = viewModel::deleteEscalationRule,
+ onToggle = viewModel::toggleEscalationRule,
+ onClearError = viewModel::clearEscalationMutationError,
+ )
+ }
+ composable("adminTicketRules") {
+ AdminTicketRulesScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadTicketRules,
+ onSave = viewModel::saveTicketRule,
+ onDelete = viewModel::deleteTicketRule,
+ onToggle = viewModel::toggleTicketRule,
+ onClearError = viewModel::clearTicketRulesMutationError,
+ onSaveAutoClose = viewModel::saveAutoClose,
+ )
+ }
+ composable("adminAvailability") {
+ AdminAvailabilityScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadAvailability,
+ onSetStatus = viewModel::setMyAvailability,
+ isAdmin = state.staff?.role == "admin",
+ onSetMemberStatus = viewModel::setMemberAvailability,
+ )
+ }
+ composable("adminBanlist") {
+ AdminBanlistScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadBanlist,
+ onCreate = viewModel::createBanEntry,
+ onBulkAdd = viewModel::bulkAddBanEntries,
+ onDelete = viewModel::deleteBanEntry,
+ onClearError = viewModel::clearBanlistMutationError,
+ onClearBulkResult = viewModel::clearBanlistBulkResult,
+ )
+ }
+ composable("adminDepartments") {
+ AdminDepartmentsScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadDepartments,
+ onSave = viewModel::saveDepartment,
+ onDelete = viewModel::deleteDepartment,
+ onClearError = viewModel::clearDepartmentsError,
+ onUploadImage = viewModel::uploadSignatureImage,
+ )
+ }
+ composable("adminReports") {
+ AdminReportsScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onRun = viewModel::runReport,
+ onSave = viewModel::saveReport,
+ onDelete = viewModel::deleteReport,
+ )
+ }
+ composable("adminSlaPlans") {
+ AdminSlaPlansScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadSlaPlans,
+ onSave = viewModel::saveSlaPlan,
+ onDelete = viewModel::deleteSlaPlan,
+ onClearError = viewModel::clearSlaPlansError,
+ )
+ }
+ composable("adminPriorities") {
+ AdminPrioritiesScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadPrioritiesAdmin,
+ onSave = viewModel::savePriority,
+ onDelete = viewModel::deletePriority,
+ onClearError = viewModel::clearPrioritiesError,
+ )
+ }
+ composable("adminHelpTopics") {
+ AdminHelpTopicsScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadHelpTopics,
+ onSave = viewModel::saveHelpTopic,
+ onDelete = viewModel::deleteHelpTopic,
+ onClearError = viewModel::clearHelpTopicsError,
+ )
+ }
+ composable("adminOrganizations") {
+ AdminOrganizationsScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadOrganizations,
+ onSave = viewModel::saveOrganization,
+ onDelete = viewModel::deleteOrganization,
+ onClearError = viewModel::clearOrganizationsError,
+ )
+ }
+ composable("adminTeams") {
+ AdminTeamsScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadTeams,
+ onSave = viewModel::saveTeam,
+ onToggle = viewModel::toggleTeam,
+ onDelete = viewModel::deleteTeam,
+ onClearError = viewModel::clearTeamsError,
+ )
+ }
+ composable("adminRoles") {
+ AdminRolesScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadRoles,
+ onSave = viewModel::saveRole,
+ onDelete = viewModel::deleteRole,
+ onClearError = viewModel::clearRolesError,
+ )
+ }
+ composable("adminStaff") {
+ AdminStaffScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadStaffAdmin,
+ onSave = viewModel::saveStaffAdmin,
+ onDelete = viewModel::deleteStaffAdmin,
+ onClearError = viewModel::clearStaffAdminError,
+ onUploadImage = viewModel::uploadSignatureImage,
+ )
+ }
+ composable("adminBusinessHours") {
+ AdminBusinessHoursScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadBusinessHours,
+ onSaveHours = viewModel::saveBusinessHours,
+ onAddHoliday = viewModel::addBusinessHoliday,
+ onDeleteHoliday = viewModel::deleteBusinessHoliday,
+ )
+ }
+ composable("adminEmailAddresses") {
+ AdminEmailAddressesScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadEmailAddresses,
+ onSave = viewModel::saveEmailAddress,
+ onDelete = viewModel::deleteEmailAddress,
+ onTest = viewModel::testEmailAddress,
+ onClearError = viewModel::clearEmailAddressesError,
+ )
+ }
+ composable("adminEmailTemplates") {
+ AdminEmailTemplatesScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadEmailTemplates,
+ onSave = { key, subject, bodyText -> viewModel.saveEmailTemplate(key, subject, bodyText) {} },
+ onReset = viewModel::resetEmailTemplate,
+ )
+ }
+ composable("pushPrefs") {
+ PushPrefsScreen(onBack = { navController.popBackStack() })
+ }
+ composable("adminAuditLog") {
+ AdminAuditLogScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadAuditLog,
+ )
+ }
+ composable("adminTasks") {
+ AdminTasksScreen(
+ state = state,
+ onBack = { navController.popBackStack() },
+ onLoad = viewModel::loadTasks,
+ onCreate = viewModel::createTask,
+ onUpdateStatus = viewModel::updateTaskStatus,
+ onDelete = viewModel::deleteTask,
+ onClearError = viewModel::clearTasksError,
+ )
+ }
+ }
+ }
+ }
+}
+
+/** Banner unter der Kopfleiste: Verbindungsausfall und Systemhinweise des Servers (z. B. Mail-Abruf). */
+@Composable
+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) {
+ Text(
+ "Keine Verbindung zum Server. Es wird automatisch erneut versucht.",
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colorScheme.onError,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ TextButton(onClick = onRetry) { Text("Erneut", color = MaterialTheme.colorScheme.onError) }
+ }
+ }
+ }
+ state.healthIssues.forEach { issue ->
+ Surface(
+ color = if (issue.isError) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.tertiaryContainer,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ (if (issue.isError) "Störung: " else "Hinweis: ") + issue.message,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ color = if (issue.isError) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onTertiaryContainer,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
+
+private fun androidx.navigation.NavController.navigateToTab(route: String) {
+ navigate(route) {
+ popUpTo(graph.findStartDestination().id) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+}
+
+// ─── Login ──────────────────────────────────────────────────────────────────
+
+@Composable
+private fun LoginScreen(state: DeskHubState, viewModel: DeskHubViewModel) {
+ var serverUrl by rememberSaveable { mutableStateOf(state.savedServerUrl ?: "") }
+ var email by rememberSaveable { mutableStateOf("") }
+ var password by rememberSaveable { mutableStateOf("") }
+ var totpCode by rememberSaveable { mutableStateOf("") }
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(220.dp)
+ .background(
+ Brush.linearGradient(listOf(Color(0xFF14202E), BrandTealDark, Color(0xFF1F6FB5))),
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(
+ modifier = Modifier
+ .size(56.dp)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.14f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Filled.SupportAgent, contentDescription = null, tint = Color.White)
+ }
+ Spacer(Modifier.height(12.dp))
+ Text(
+ "DeskHub",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ )
+ Text(
+ "Mitarbeiter-Bereich",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White.copy(alpha = 0.75f),
+ )
+ }
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp),
+ shape = MaterialTheme.shapes.large,
+ elevation = CardDefaults.cardElevation(defaultElevation = 3.dp),
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Text(
+ if (state.totpRequired) "Zweiten Faktor bestätigen" else "Mit deinem Mitarbeiterkonto anmelden",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (!state.totpRequired) {
+ OutlinedTextField(
+ value = serverUrl,
+ onValueChange = { serverUrl = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.server_label)) },
+ placeholder = { Text("https://support.example.de") },
+ leadingIcon = { Icon(Icons.Filled.Business, contentDescription = null) },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("E-Mail-Adresse") },
+ leadingIcon = { Icon(Icons.Filled.Email, contentDescription = null) },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ imeAction = ImeAction.Next,
+ ),
+ )
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Passwort") },
+ leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
+ visualTransformation = PasswordVisualTransformation(),
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = { viewModel.login(serverUrl, email, password, "") },
+ ),
+ )
+ } else {
+ OutlinedTextField(
+ value = totpCode,
+ onValueChange = { value -> totpCode = value.filter(Char::isDigit).take(6) },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Authenticator-Code") },
+ leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.NumberPassword,
+ imeAction = ImeAction.Done,
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = { viewModel.login(serverUrl, email, password, totpCode) },
+ ),
+ )
+ }
+ state.authError?.let { ErrorText(it) }
+ Button(
+ onClick = {
+ viewModel.login(
+ serverUrl = serverUrl,
+ email = email,
+ password = password,
+ totpCode = if (state.totpRequired) totpCode else "",
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.authLoading &&
+ serverUrl.isNotBlank() && email.isNotBlank() && password.isNotBlank() &&
+ (!state.totpRequired || totpCode.length == 6),
+ ) {
+ if (state.authLoading) {
+ CircularProgressIndicator(modifier = Modifier.width(20.dp), strokeWidth = 2.dp)
+ } else {
+ Text(if (state.totpRequired) "Bestätigen" else "Anmelden")
+ }
+ }
+ if (state.totpRequired) {
+ TextButton(
+ onClick = {
+ totpCode = ""
+ password = ""
+ viewModel.cancelTotp()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Zurück")
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Ticket list ────────────────────────────────────────────────────────────
+
+@Composable
+private fun TicketListScreen(
+ state: DeskHubState,
+ onFiltersChanged: (TicketFilters) -> Unit,
+ onSearch: () -> Unit,
+ onRefresh: () -> Unit,
+ onLoadMore: () -> Unit,
+ onApplyView: (de.deskhub.staff.domain.model.SavedView) -> Unit,
+ onSaveView: (String, Boolean) -> Unit,
+ onDeleteView: (Int) -> Unit,
+ onLoadViews: () -> Unit,
+ onTicket: (String) -> Unit,
+ onLogout: () -> Unit,
+ onNewTicket: () -> Unit,
+) {
+ 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,
+ ),
+ )
+ },
+ floatingActionButton = {
+ if (canCreateTicket) {
+ FloatingActionButton(onClick = onNewTicket) {
+ Icon(Icons.Filled.Add, contentDescription = "Neues Ticket")
+ }
+ }
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ ) {
+ TicketFilterBar(
+ filters = state.filters,
+ metadata = state.metadata,
+ onFiltersChanged = onFiltersChanged,
+ onSearch = onSearch,
+ savedViews = state.savedViews,
+ onApplyView = onApplyView,
+ onSaveView = onSaveView,
+ onDeleteView = onDeleteView,
+ )
+ state.mutationError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) }
+ 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.")
+ else -> LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ items(state.tickets, key = TicketSummary::id) { ticket ->
+ TicketCard(ticket, state.metadata, onTicket)
+ }
+ item {
+ if (state.ticketsError != null) {
+ ErrorText(state.ticketsError)
+ }
+ if (state.ticketsHasMore) {
+ OutlinedButton(
+ onClick = onLoadMore,
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ 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")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TicketFilterBar(
+ filters: TicketFilters,
+ metadata: Metadata?,
+ onFiltersChanged: (TicketFilters) -> Unit,
+ onSearch: () -> Unit,
+ savedViews: List = emptyList(),
+ onApplyView: (de.deskhub.staff.domain.model.SavedView) -> Unit = {},
+ onSaveView: (String, Boolean) -> Unit = { _, _ -> },
+ onDeleteView: (Int) -> Unit = {},
+) {
+ var viewsExpanded by remember { mutableStateOf(false) }
+ var showSaveDialog by remember { mutableStateOf(false) }
+ var statusExpanded by remember { mutableStateOf(false) }
+ var assignedExpanded by remember { mutableStateOf(false) }
+ var departmentExpanded by remember { mutableStateOf(false) }
+ var priorityExpanded by remember { mutableStateOf(false) }
+ var tagExpanded by remember { mutableStateOf(false) }
+ var slaExpanded by remember { mutableStateOf(false) }
+ val activeStatuses = setOf("open", "pending", "answered", "on_hold")
+ val statusLabel = when (filters.statuses) {
+ activeStatuses -> "Aktiv"
+ emptySet() -> "Alle Status"
+ else -> metadata?.statuses?.firstOrNull { it.value in filters.statuses }?.label ?: "Status"
+ }
+ val assignedLabel = when (filters.assigned) {
+ "me" -> "Mir zugewiesen"
+ "unassigned" -> "Nicht zugewiesen"
+ else -> "Alle Zuständigkeiten"
+ }
+ val departmentLabel = metadata?.departments
+ ?.firstOrNull { it.id == filters.departmentId }?.name ?: "Alle Abteilungen"
+ val priorityLabel = metadata?.priorities
+ ?.firstOrNull { it.id == filters.priorityId }?.name ?: "Alle Prioritäten"
+ val tagLabel = metadata?.tags
+ ?.firstOrNull { it.id == filters.tagId }?.name ?: "Alle Tags"
+ val slaLabel = when (filters.sla) {
+ "overdue" -> "🔴 Überfällig"
+ "at_risk" -> "🟡 Bald fällig"
+ "on_time" -> "🟢 Im Zeitplan"
+ else -> "SLA: Alle"
+ }
+
+ Surface(color = MaterialTheme.colorScheme.surface, shadowElevation = 1.dp) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.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") },
+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { onSearch() }),
+ trailingIcon = {
+ TextButton(onClick = onSearch) { Text("Suchen") }
+ },
+ )
+ if (showSaveDialog) {
+ var viewName by remember { mutableStateOf("") }
+ var viewShared by remember { mutableStateOf(false) }
+ AlertDialog(
+ onDismissRequest = { showSaveDialog = false },
+ title = { Text("Ansicht speichern") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Speichert die aktuellen Filter.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ OutlinedTextField(value = viewName, onValueChange = { viewName = it }, label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = viewShared, onCheckedChange = { viewShared = it })
+ Text("Für alle Mitarbeiter sichtbar")
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(enabled = viewName.isNotBlank(), onClick = { onSaveView(viewName.trim(), viewShared); showSaveDialog = false }) { Text("Speichern") }
+ },
+ dismissButton = { TextButton(onClick = { showSaveDialog = false }) { Text("Abbrechen") } },
+ )
+ }
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ item {
+ Box {
+ FilterChip(
+ selected = false,
+ onClick = { viewsExpanded = true },
+ label = { Text("Ansichten") },
+ shape = MaterialTheme.shapes.small,
+ )
+ DropdownMenu(expanded = viewsExpanded, onDismissRequest = { viewsExpanded = false }) {
+ savedViews.forEach { view ->
+ DropdownMenuItem(
+ text = { Text(view.name + if (view.isShared) " ⇄" else "") },
+ onClick = { onApplyView(view); viewsExpanded = false },
+ trailingIcon = {
+ if (view.mine) {
+ IconButton(onClick = { onDeleteView(view.id); viewsExpanded = false }) {
+ Icon(Icons.Filled.Close, contentDescription = "Ansicht löschen")
+ }
+ }
+ },
+ )
+ }
+ if (savedViews.isNotEmpty()) HorizontalDivider()
+ DropdownMenuItem(text = { Text("Aktuelle Filter speichern …") }, onClick = { viewsExpanded = false; showSaveDialog = true })
+ }
+ }
+ }
+ item {
+ Box {
+ FilterChip(
+ selected = filters.statuses != activeStatuses,
+ onClick = { statusExpanded = true },
+ label = { Text(statusLabel) },
+ shape = MaterialTheme.shapes.small,
+ )
+ DropdownMenu(expanded = statusExpanded, onDismissRequest = { statusExpanded = false }) {
+ DropdownMenuItem(
+ text = { Text("Aktiv") },
+ onClick = {
+ onFiltersChanged(filters.copy(statuses = activeStatuses))
+ statusExpanded = false
+ onSearch()
+ },
+ )
+ DropdownMenuItem(
+ text = { Text("Alle Status") },
+ onClick = {
+ onFiltersChanged(filters.copy(statuses = emptySet()))
+ statusExpanded = false
+ onSearch()
+ },
+ )
+ metadata?.statuses?.forEach { status ->
+ DropdownMenuItem(
+ text = { Text(status.label) },
+ onClick = {
+ onFiltersChanged(filters.copy(statuses = setOf(status.value)))
+ statusExpanded = false
+ onSearch()
+ },
+ )
+ }
+ }
+ }
+ }
+ item {
+ Box {
+ FilterChip(
+ selected = filters.assigned.isNotEmpty(),
+ onClick = { assignedExpanded = true },
+ label = { Text(assignedLabel) },
+ shape = MaterialTheme.shapes.small,
+ )
+ DropdownMenu(expanded = assignedExpanded, onDismissRequest = { assignedExpanded = false }) {
+ listOf(
+ "" to "Alle Zuständigkeiten",
+ "me" to "Mir zugewiesen",
+ "unassigned" to "Nicht zugewiesen",
+ ).forEach { (value, label) ->
+ DropdownMenuItem(
+ text = { Text(label) },
+ onClick = {
+ onFiltersChanged(filters.copy(assigned = value))
+ assignedExpanded = false
+ onSearch()
+ },
+ )
+ }
+ }
+ }
+ }
+ item {
+ Box {
+ FilterChip(
+ selected = filters.departmentId != null,
+ onClick = { departmentExpanded = true },
+ label = { Text(departmentLabel) },
+ shape = MaterialTheme.shapes.small,
+ )
+ DropdownMenu(expanded = departmentExpanded, onDismissRequest = { departmentExpanded = false }) {
+ DropdownMenuItem(
+ text = { Text("Alle Abteilungen") },
+ onClick = {
+ onFiltersChanged(filters.copy(departmentId = null))
+ departmentExpanded = false
+ onSearch()
+ },
+ )
+ metadata?.departments?.forEach { department ->
+ DropdownMenuItem(
+ text = { Text(department.name) },
+ onClick = {
+ onFiltersChanged(filters.copy(departmentId = department.id))
+ departmentExpanded = false
+ onSearch()
+ },
+ )
+ }
+ }
+ }
+ }
+ item {
+ Box {
+ FilterChip(
+ selected = filters.priorityId != null,
+ onClick = { priorityExpanded = true },
+ label = { Text(priorityLabel) },
+ shape = MaterialTheme.shapes.small,
+ )
+ DropdownMenu(expanded = priorityExpanded, onDismissRequest = { priorityExpanded = false }) {
+ DropdownMenuItem(
+ text = { Text("Alle Prioritäten") },
+ onClick = {
+ onFiltersChanged(filters.copy(priorityId = null))
+ priorityExpanded = false
+ onSearch()
+ },
+ )
+ metadata?.priorities?.forEach { priority ->
+ DropdownMenuItem(
+ text = { Text(priority.name) },
+ onClick = {
+ onFiltersChanged(filters.copy(priorityId = priority.id))
+ priorityExpanded = false
+ onSearch()
+ },
+ )
+ }
+ }
+ }
+ }
+ if (metadata?.tags?.isNotEmpty() == true) {
+ item {
+ Box {
+ FilterChip(
+ selected = filters.tagId != null,
+ onClick = { tagExpanded = true },
+ label = { Text(tagLabel) },
+ shape = MaterialTheme.shapes.small,
+ )
+ DropdownMenu(expanded = tagExpanded, onDismissRequest = { tagExpanded = false }) {
+ DropdownMenuItem(
+ text = { Text("Alle Tags") },
+ onClick = {
+ onFiltersChanged(filters.copy(tagId = null))
+ tagExpanded = false
+ onSearch()
+ },
+ )
+ metadata.tags.forEach { tag ->
+ DropdownMenuItem(
+ text = { Text(tag.name) },
+ onClick = {
+ onFiltersChanged(filters.copy(tagId = tag.id))
+ tagExpanded = false
+ onSearch()
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ item {
+ Box {
+ FilterChip(
+ selected = filters.sla.isNotEmpty(),
+ onClick = { slaExpanded = true },
+ label = { Text(slaLabel) },
+ shape = MaterialTheme.shapes.small,
+ )
+ DropdownMenu(expanded = slaExpanded, onDismissRequest = { slaExpanded = false }) {
+ listOf(
+ "" to "SLA: Alle",
+ "overdue" to "🔴 Überfällig",
+ "at_risk" to "🟡 Bald fällig (< 4 Std.)",
+ "on_time" to "🟢 Im Zeitplan",
+ ).forEach { (value, label) ->
+ DropdownMenuItem(
+ text = { Text(label) },
+ onClick = {
+ onFiltersChanged(filters.copy(sla = value))
+ slaExpanded = false
+ onSearch()
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private 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,
+ )
+ StatusPill(ticket.status, metadata)
+ }
+ 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,
+ )
+ 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,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun InitialsAvatarChip(name: String) {
+ val initials = name.trim().split(" ").mapNotNull { it.firstOrNull()?.uppercaseChar() }.take(2).joinToString("")
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(18.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ initials,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ Spacer(Modifier.width(5.dp))
+ Text(name, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+}
+
+// ─── Dashboard ──────────────────────────────────────────────────────────────
+
+@Composable
+private fun DashboardScreen(
+ state: DeskHubState,
+ onLoad: () -> Unit,
+ onSetStatus: (String, String) -> Unit,
+ 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)
+ }
+ }
+ 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.") }
+ }
+ }
+}
+
+@Composable
+private fun DashboardStatusRow(myStatus: String, onSetStatus: (String, String) -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ "Mein Status",
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ AvailabilityStatusLabels.forEach { (value, meta) ->
+ val (label, emoji, color) = meta
+ FilterChip(
+ 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,
+ ),
+ )
+ }
+ }
+ }
+ }
+}
+
+@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.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,
+ )
+ }
+ }
+}
+
+@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,
+ )
+ }
+ }
+ }
+ }
+}
+
+// ─── 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(
+ label.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 10.dp, bottom = 2.dp, start = 4.dp),
+ )
+}
+
+@Composable
+private fun AdminMenuRow(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, subtitle: String, onClick: () -> Unit) {
+ Card(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(MaterialTheme.shapes.small)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer)
+ }
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.titleSmall)
+ Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
+
+@Composable
+private fun AdminTopBar(title: String, onBack: () -> Unit) {
+ TopAppBar(
+ title = { Text(title, 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,
+ ),
+ )
+}
+
+// ─── Interner Chat ───────────────────────────────────────────────────────────
+
+// ─── Chat-Hub: interner Chat + Live-Chat unter einem Tab ────────────────────
+// Vorher zwei eigene Bottom-Nav-Einträge – bei 6 Tabs wurde die Leiste auf normalen
+// Handy-Breiten zu voll (siehe Nutzer-Feedback). Beide Funktionen bleiben komplett
+// erhalten, teilen sich jetzt nur die Navigation über einen Umschalter oben.
+
+@Composable
+private fun ChatHubScreen(
+ state: DeskHubState,
+ onLoadContacts: () -> Unit,
+ onOpenContact: (de.deskhub.staff.domain.model.ChatContact) -> Unit,
+ onLoadLiveChats: () -> Unit,
+ onStopLiveChatPolling: () -> Unit,
+ onOpenLiveChat: (String) -> Unit,
+) {
+ val liveChatAvailable = state.metadata?.liveChatEnabled == true
+ var showLiveChat by rememberSaveable { mutableStateOf(false) }
+
+ DisposableEffect(Unit) {
+ onLoadContacts()
+ if (liveChatAvailable) onLoadLiveChats()
+ onDispose { if (liveChatAvailable) onStopLiveChatPolling() }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Chat", fontWeight = FontWeight.Bold) },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = LocalChrome.current,
+ titleContentColor = Color.White,
+ ),
+ )
+ },
+ ) { padding ->
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ if (liveChatAvailable) {
+ val needsReply = state.liveChats?.count { it.needsReply } ?: 0
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
+ SegmentedButton(
+ selected = !showLiveChat,
+ onClick = { showLiveChat = false },
+ shape = SegmentedButtonDefaults.itemShape(0, 2),
+ ) { Text("Team") }
+ SegmentedButton(
+ selected = showLiveChat,
+ onClick = { showLiveChat = true },
+ shape = SegmentedButtonDefaults.itemShape(1, 2),
+ ) {
+ BadgedBox(badge = { if (needsReply > 0) Badge { Text(needsReply.toString()) } }) {
+ Text("Kunden")
+ }
+ }
+ }
+ }
+ Box(Modifier.weight(1f).fillMaxWidth()) {
+ if (showLiveChat && liveChatAvailable) {
+ LiveChatListBody(state = state, onLoad = onLoadLiveChats, onOpenChat = onOpenLiveChat)
+ } else {
+ ChatContactsBody(state = state, onLoad = onLoadContacts, onOpenContact = onOpenContact)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChatContactsBody(
+ state: DeskHubState,
+ onLoad: () -> Unit,
+ onOpenContact: (de.deskhub.staff.domain.model.ChatContact) -> Unit,
+) {
+ val contacts = state.chatContacts
+ when {
+ state.chatContactsLoading && contacts == null -> LoadingState("Kontakte werden geladen …")
+ state.chatContactsError != null && contacts == null -> ErrorState(state.chatContactsError, onLoad)
+ contacts == null -> EmptyState("Keine Daten verfügbar.")
+ contacts.isEmpty() -> EmptyState("Keine weiteren Mitarbeiter vorhanden.")
+ else -> LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(contacts, key = { it.id }) { contact ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = { onOpenContact(contact) }) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column {
+ Text(contact.name, fontWeight = FontWeight.Medium)
+ contact.lastMessageAt?.let {
+ Text(it, style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.75f))
+ }
+ }
+ if (contact.unreadCount > 0) {
+ Badge { Text(contact.unreadCount.toString()) }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChatConversationScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onSend: (Int, String) -> Unit,
+ onClearError: () -> Unit,
+) {
+ val conversation = state.chatConversation
+ var draft by rememberSaveable { mutableStateOf("") }
+ val listState = rememberLazyListState()
+
+ LaunchedEffect(conversation?.messages?.size) {
+ val count = conversation?.messages?.size ?: 0
+ if (count > 0) listState.animateScrollToItem(count - 1)
+ }
+
+ Scaffold(
+ topBar = { AdminTopBar(conversation?.with?.name ?: "Chat", onBack) },
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding).fillMaxSize()) {
+ when {
+ state.chatConversationLoading && conversation == null -> Box(Modifier.weight(1f)) { LoadingState("Unterhaltung wird geladen …") }
+ state.chatConversationError != null && conversation == null -> Box(Modifier.weight(1f)) { ErrorState(state.chatConversationError) {} }
+ conversation == null -> Box(Modifier.weight(1f)) { EmptyState("Keine Daten verfügbar.") }
+ conversation.messages.isEmpty() -> Box(Modifier.weight(1f)) { EmptyState("Noch keine Nachrichten. Schreib die erste!") }
+ else -> LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(conversation.messages, key = { it.id }) { msg ->
+ val isOwn = msg.fromStaffId == state.staff?.id
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isOwn) Arrangement.End else Arrangement.Start) {
+ Surface(
+ color = if (isOwn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp).widthIn(max = 280.dp)) {
+ Text(
+ msg.message,
+ color = if (isOwn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ msg.createdAt,
+ style = MaterialTheme.typography.labelSmall,
+ color = (if (isOwn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant).copy(alpha = 0.7f),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ state.chatSendError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Nachricht …") },
+ shape = MaterialTheme.shapes.medium,
+ )
+ IconButton(
+ onClick = {
+ val withId = conversation?.with?.id
+ if (withId != null && draft.isNotBlank()) {
+ onClearError()
+ onSend(withId, draft.trim())
+ draft = ""
+ }
+ },
+ enabled = !state.chatSendLoading && draft.isNotBlank() && conversation != null,
+ ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden") }
+ }
+ }
+ }
+}
+
+// ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────────
+// Eigener Tab, getrennt vom internen Chat oben – Chats mit Kunden über das
+// Live-Chat-Widget im Kundenportal, gespiegelt vom Web-Postfach staff/live_chat.php
+// (gleiche Sperr-Logik: ein zweiter Mitarbeiter kann mitlesen, aber nicht schreiben).
+
+@Composable
+private fun LiveChatListBody(
+ state: DeskHubState,
+ onLoad: () -> Unit,
+ onOpenChat: (String) -> Unit,
+) {
+ val chats = state.liveChats
+ when {
+ state.liveChatsLoading && chats == null -> LoadingState("Chats werden geladen …")
+ state.liveChatsError != null && chats == null -> ErrorState(state.liveChatsError, onLoad)
+ chats == null -> EmptyState("Keine Daten verfügbar.")
+ chats.isEmpty() -> EmptyState("Aktuell keine offenen Live-Chats.")
+ else -> LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(chats, key = { it.number }) { chat ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, onClick = { onOpenChat(chat.number) }) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (chat.needsReply) {
+ Box(
+ Modifier
+ .size(8.dp)
+ .background(Color(0xFFDC2626), shape = androidx.compose.foundation.shape.CircleShape),
+ )
+ Spacer(Modifier.width(6.dp))
+ }
+ Text(chat.name, fontWeight = FontWeight.Medium)
+ }
+ Text(
+ chat.preview,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Text(
+ chat.updatedAt.takeLast(5),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LiveChatConversationScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onSend: (String, String) -> Unit,
+ onTyping: (String) -> Unit,
+ onClearError: () -> Unit,
+ onEnd: (String) -> Unit,
+) {
+ val detail = state.liveChatDetail
+ val number = detail?.number
+ var draft by rememberSaveable { mutableStateOf("") }
+ var showEndConfirm by rememberSaveable { mutableStateOf(false) }
+ val listState = rememberLazyListState()
+ val lockedByOther = detail?.lockedByOtherName
+ val isClosed = detail?.status == "closed"
+
+ LaunchedEffect(detail?.messages?.size) {
+ val count = detail?.messages?.size ?: 0
+ if (count > 0) listState.animateScrollToItem(count - 1)
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Column {
+ Text(detail?.requesterName ?: "Live-Chat", fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ detail?.requesterEmail?.let {
+ Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
+ },
+ actions = {
+ if (!isClosed && lockedByOther == null && number != null) {
+ TextButton(onClick = { showEndConfirm = true }) {
+ Text("Beenden", color = Color.White)
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = LocalChrome.current,
+ titleContentColor = Color.White,
+ actionIconContentColor = Color.White,
+ ),
+ )
+ },
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding).fillMaxSize()) {
+ if (lockedByOther != null) {
+ Surface(color = Color(0xFFFFFBEB)) {
+ Text(
+ "🔒 Wird gerade von $lockedByOther bearbeitet – Sie sehen live mit, können hier aber nicht schreiben.",
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = Color(0xFF92400E),
+ )
+ }
+ }
+ when {
+ state.liveChatDetailLoading && detail == null -> Box(Modifier.weight(1f)) { LoadingState("Chat wird geladen …") }
+ state.liveChatDetailError != null && detail == null -> Box(Modifier.weight(1f)) { ErrorState(state.liveChatDetailError) {} }
+ detail == null -> Box(Modifier.weight(1f)) { EmptyState("Keine Daten verfügbar.") }
+ detail.messages.isEmpty() -> Box(Modifier.weight(1f)) { EmptyState("Noch keine Nachrichten.") }
+ else -> LazyColumn(
+ state = listState,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(detail.messages, key = { it.id }) { msg ->
+ val isStaff = msg.authorType == "staff"
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start) {
+ Column(horizontalAlignment = if (isStaff) Alignment.End else Alignment.Start) {
+ if (isStaff && msg.authorName != null) {
+ Text(
+ msg.authorName,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(bottom = 2.dp, end = 4.dp),
+ )
+ }
+ if (msg.message.isNotBlank()) {
+ Surface(
+ color = if (isStaff) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Text(
+ msg.message,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp).widthIn(max = 280.dp),
+ color = if (isStaff) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ msg.attachments.forEach { att ->
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.small,
+ modifier = Modifier.padding(top = 4.dp),
+ ) {
+ Text(
+ "📎 ${att.name}",
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ Text(
+ msg.createdAt.takeLast(5),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+ )
+ }
+ }
+ }
+ if (detail.customerTyping) {
+ item {
+ Text(
+ "Kunde tippt …",
+ style = MaterialTheme.typography.labelSmall,
+ fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ state.liveChatSendError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) }
+ when {
+ isClosed -> Text(
+ "Dieser Chat wurde beendet.",
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ lockedByOther != null -> Text(
+ "Nur Lesezugriff, solange $lockedByOther diesen Chat bearbeitet.",
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ else -> Row(
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ OutlinedTextField(
+ value = draft,
+ onValueChange = { draft = it; number?.let(onTyping) },
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Nachricht …") },
+ shape = MaterialTheme.shapes.medium,
+ )
+ IconButton(
+ onClick = {
+ if (number != null && draft.isNotBlank()) {
+ onClearError()
+ onSend(number, draft.trim())
+ draft = ""
+ }
+ },
+ enabled = !state.liveChatSendLoading && draft.isNotBlank() && number != null,
+ ) { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Senden") }
+ }
+ }
+ }
+ }
+
+ if (showEndConfirm) {
+ AlertDialog(
+ onDismissRequest = { showEndConfirm = false },
+ title = { Text("Chat wirklich beenden?") },
+ text = { Text("Er bleibt als Ticket erhalten, verschwindet aber aus den aktiven Live-Chats.") },
+ confirmButton = {
+ TextButton(onClick = { showEndConfirm = false; number?.let(onEnd) }) { Text("Beenden") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showEndConfirm = false }) { Text("Abbrechen") }
+ },
+ )
+ }
+}
+
+// ─── Admin: Wissensdatenbank ────────────────────────────────────────────────
+
+@Composable
+private fun AdminKbScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSaveArticle: (Int?, String, Int, String, Boolean, () -> Unit) -> Unit,
+ onDeleteArticle: (Int) -> Unit,
+ onCreateCategory: (String) -> Unit,
+ onDeleteCategory: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingArticle by rememberSaveable { mutableStateOf(null) }
+ var isCreatingArticle by rememberSaveable { mutableStateOf(false) }
+ var isAddingCategory by rememberSaveable { mutableStateOf(false) }
+
+ val kb = state.kb
+ val showForm = isCreatingArticle || editingArticle != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Wissensdatenbank", onBack) },
+ floatingActionButton = {
+ if (kb != null && !showForm) {
+ FloatingActionButton(onClick = { isCreatingArticle = true }) {
+ Icon(Icons.Filled.Add, contentDescription = "Neuer Artikel")
+ }
+ }
+ },
+ ) { padding ->
+ when {
+ state.kbLoading && kb == null -> Box(Modifier.padding(padding)) { LoadingState("Wissensdatenbank wird geladen …") }
+ state.kbError != null && kb == null -> Box(Modifier.padding(padding)) { ErrorState(state.kbError, onLoad) }
+ kb == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val article = kb.articles.firstOrNull { it.id == editingArticle }
+ KbArticleForm(
+ modifier = Modifier.padding(padding),
+ article = article,
+ categories = kb.categories,
+ saving = state.kbMutationLoading,
+ error = state.kbMutationError,
+ onCancel = { editingArticle = null; isCreatingArticle = false; onClearError() },
+ onSave = { title, categoryId, body, published ->
+ onSaveArticle(article?.id, title, categoryId, body, published) {
+ editingArticle = null
+ isCreatingArticle = false
+ }
+ },
+ )
+ }
+ else -> LazyColumn(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ 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) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(category.name, fontWeight = FontWeight.Medium)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("$articleCount Artikel", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ if (articleCount == 0) {
+ TextButton(onClick = { onDeleteCategory(category.id) }) { Text("Löschen") }
+ }
+ }
+ }
+ }
+ }
+ item {
+ if (isAddingCategory) {
+ var newCategoryName by rememberSaveable { mutableStateOf("") }
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ OutlinedTextField(
+ value = newCategoryName,
+ onValueChange = { newCategoryName = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Neue Kategorie") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ TextButton(onClick = {
+ if (newCategoryName.isNotBlank()) {
+ onCreateCategory(newCategoryName.trim())
+ newCategoryName = ""
+ isAddingCategory = false
+ }
+ }) { Text("Hinzufügen") }
+ }
+ } else {
+ OutlinedButton(onClick = { isAddingCategory = true }, modifier = Modifier.fillMaxWidth()) {
+ Text("+ Kategorie hinzufügen")
+ }
+ }
+ }
+ item { Spacer(Modifier.height(8.dp)); Text("Artikel", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) }
+ if (kb.articles.isEmpty()) {
+ item { EmptyState("Noch keine Artikel.") }
+ }
+ items(kb.articles) { article ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(article.title, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
+ Surface(
+ color = if (article.isPublished) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.extraSmall,
+ ) {
+ Text(
+ if (article.isPublished) "Live" else "Entwurf",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+ Text(article.categoryName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(Modifier.padding(top = 8.dp)) {
+ TextButton(onClick = { editingArticle = article.id }) { Text("Bearbeiten") }
+ TextButton(onClick = { onDeleteArticle(article.id) }) { Text("Löschen") }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun KbArticleForm(
+ modifier: Modifier = Modifier,
+ article: KbArticle?,
+ categories: List,
+ saving: Boolean,
+ error: String?,
+ onCancel: () -> Unit,
+ onSave: (String, Int, String, Boolean) -> Unit,
+) {
+ var title by rememberSaveable(article?.id) { mutableStateOf(article?.title ?: "") }
+ var body by rememberSaveable(article?.id) { mutableStateOf(article?.body ?: "") }
+ var isPublished by rememberSaveable(article?.id) { mutableStateOf(article?.isPublished ?: true) }
+ var categoryId by rememberSaveable(article?.id) { mutableStateOf(article?.categoryId ?: categories.firstOrNull()?.id ?: 0) }
+ var categoryExpanded by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (article == null) "Neuer Artikel" else "Artikel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(
+ value = title,
+ onValueChange = { title = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Titel") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ SelectionField(
+ label = "Kategorie",
+ value = categories.firstOrNull { it.id == categoryId }?.name ?: "–",
+ expanded = categoryExpanded,
+ onExpand = { categoryExpanded = true },
+ onDismiss = { categoryExpanded = false },
+ ) {
+ categories.forEach { category ->
+ DropdownMenuItem(text = { Text(category.name) }, onClick = { categoryId = category.id; categoryExpanded = false })
+ }
+ }
+ OutlinedTextField(
+ value = body,
+ onValueChange = { body = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Inhalt") },
+ shape = MaterialTheme.shapes.medium,
+ minLines = 8,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = isPublished, onCheckedChange = { isPublished = it })
+ Text("Veröffentlicht")
+ }
+ error?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onSave(title.trim(), categoryId, body.trim(), isPublished) },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !saving && title.isNotBlank() && body.isNotBlank() && categoryId != 0,
+ ) {
+ Text(if (saving) "Wird gespeichert …" else "Speichern")
+ }
+ OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+}
+
+// ─── Admin: Zeiterfassung ────────────────────────────────────────────────────
+
+@Composable
+private fun AdminTimeEntriesScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: (Int?, String, String) -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad(state.timeEntriesStaffId, state.timeEntriesFrom, state.timeEntriesTo) }
+ var from by rememberSaveable { mutableStateOf(state.timeEntriesFrom) }
+ var to by rememberSaveable { mutableStateOf(state.timeEntriesTo) }
+ var staffId by rememberSaveable { mutableStateOf(state.timeEntriesStaffId) }
+ var staffExpanded by remember { mutableStateOf(false) }
+ val report = state.timeEntries
+
+ Scaffold(topBar = { AdminTopBar("Zeiterfassung", onBack) }) { padding ->
+ Column(modifier = Modifier.padding(padding).fillMaxSize()) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(
+ value = from,
+ onValueChange = { from = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Von (JJJJ-MM-TT)") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ OutlinedTextField(
+ value = to,
+ onValueChange = { to = it },
+ modifier = Modifier.weight(1f),
+ label = { Text("Bis (JJJJ-MM-TT)") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ }
+ SelectionField(
+ label = "Mitarbeiter",
+ value = report?.staffOptions?.firstOrNull { it.id == staffId }?.name ?: "Alle",
+ expanded = staffExpanded,
+ onExpand = { staffExpanded = true },
+ onDismiss = { staffExpanded = false },
+ ) {
+ DropdownMenuItem(text = { Text("Alle") }, onClick = { staffId = null; staffExpanded = false })
+ report?.staffOptions.orEmpty().forEach { option ->
+ DropdownMenuItem(text = { Text(option.name) }, onClick = { staffId = option.id; staffExpanded = false })
+ }
+ }
+ Button(
+ onClick = { onLoad(staffId, from, to) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ ) { Text("Filtern") }
+ }
+ when {
+ state.timeEntriesLoading && report == null -> LoadingState("Zeiten werden geladen …")
+ state.timeEntriesError != null && report == null -> ErrorState(state.timeEntriesError) { onLoad(staffId, from, to) }
+ report != null -> LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (report.totalsByStaff.isNotEmpty()) {
+ item {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ StatTile("Gesamt", formatMinutes(report.totalMinutes), Modifier.weight(1f))
+ StatTile(
+ report.totalsByStaff.first().staffName,
+ formatMinutes(report.totalsByStaff.first().minutes),
+ Modifier.weight(1f),
+ )
+ }
+ }
+ if (report.totalsByStaff.size > 1) {
+ items(report.totalsByStaff.drop(1)) { total ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(total.staffName)
+ Text(formatMinutes(total.minutes), fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ }
+ }
+ if (report.entries.isEmpty()) {
+ item { EmptyState("Keine Zeiteinträge im gewählten Zeitraum.") }
+ }
+ items(report.entries) { entry ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ 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)
+ Text(formatMinutes(entry.minutes), fontWeight = FontWeight.SemiBold)
+ }
+ Text(
+ "${entry.staffName} · ${entry.loggedAt}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ entry.note?.takeIf { it.isNotBlank() }?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun formatMinutes(minutes: Int): String = "${minutes / 60}h ${minutes % 60}min"
+
+// ─── Admin: Eskalationsregeln ────────────────────────────────────────────────
+
+private val EscalationTriggerLabels = linkedMapOf(
+ "no_reply" to "Keine Mitarbeiter-Antwort seit X Stunden",
+ "no_update" to "Kein Update seit X Stunden",
+ "overdue" to "Ticket ist überfällig (SLA überschritten)",
+ "open_too_long" to "Ticket offen seit X Stunden",
+)
+
+private val EscalationStatusLabels = linkedMapOf(
+ "open" to "Offen",
+ "pending" to "Wartet auf Kunde",
+ "on_hold" to "Pausiert",
+)
+
+@Composable
+private fun AdminEscalationScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, String, Boolean, String, Int, String?, Int?, Int?, Int?, Int?, String?, Boolean, Boolean, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onToggle: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingRuleId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val data = state.escalation
+ val showForm = isCreating || editingRuleId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Eskalationsregeln", onBack) },
+ floatingActionButton = {
+ if (data != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) {
+ Icon(Icons.Filled.Add, contentDescription = "Neue Regel")
+ }
+ }
+ },
+ ) { padding ->
+ when {
+ state.escalationLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Regeln werden geladen …") }
+ state.escalationError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.escalationError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val rule = data.rules.firstOrNull { it.id == editingRuleId }
+ EscalationRuleForm(
+ modifier = Modifier.padding(padding),
+ rule = rule,
+ priorities = data.priorities,
+ staffOptions = data.staffOptions,
+ departments = data.departments,
+ saving = state.escalationMutationLoading,
+ error = state.escalationMutationError,
+ onCancel = { editingRuleId = null; isCreating = false; onClearError() },
+ onSave = { name, isActive, triggerType, triggerHours, condStatus, condPrio, condDept, actPrio, actStaff, actStatus, notAdmin, notStaff ->
+ onSave(rule?.id, name, isActive, triggerType, triggerHours, condStatus, condPrio, condDept, actPrio, actStaff, actStatus, notAdmin, notStaff) {
+ editingRuleId = null
+ isCreating = false
+ }
+ },
+ )
+ }
+ data.rules.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Eskalationsregeln. Mit + eine neue Regel anlegen.") }
+ else -> LazyColumn(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ items(data.rules) { rule -> EscalationRuleRow(rule, onEdit = { editingRuleId = rule.id }, onDelete = { onDelete(rule.id) }, onToggle = { onToggle(rule.id) }) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EscalationRuleRow(rule: EscalationRule, onEdit: () -> Unit, onDelete: () -> Unit, onToggle: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(rule.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
+ Surface(
+ color = if (rule.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.extraSmall,
+ ) {
+ Text(
+ if (rule.isActive) "Aktiv" else "Inaktiv",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+ Text(
+ EscalationTriggerLabels[rule.triggerType] ?: rule.triggerType,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ val actions = buildList {
+ rule.actionSetPriorityName?.let { add("↑ Priorität: $it") }
+ rule.actionAssignStaffName?.let { add("→ $it") }
+ rule.actionSetStatus?.let { add("⚑ ${EscalationStatusLabels[it] ?: it}") }
+ if (rule.actionNotifyAdmin) add("📧 Admin")
+ if (rule.actionNotifyStaff) add("📧 Mitarbeiter")
+ }
+ Text(
+ if (actions.isEmpty()) "Nur benachrichtigen" else actions.joinToString(", "),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Text(
+ "${rule.appliedCount}× angewendet",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = onEdit) { Text("Bearbeiten") }
+ TextButton(onClick = onToggle) { Text(if (rule.isActive) "Deaktivieren" else "Aktivieren") }
+ TextButton(onClick = onDelete) { Text("Löschen") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EscalationRuleForm(
+ modifier: Modifier = Modifier,
+ rule: EscalationRule?,
+ priorities: List,
+ staffOptions: List,
+ departments: List,
+ saving: Boolean,
+ error: String?,
+ onCancel: () -> Unit,
+ onSave: (String, Boolean, String, Int, String?, Int?, Int?, Int?, Int?, String?, Boolean, Boolean) -> Unit,
+) {
+ var name by rememberSaveable(rule?.id) { mutableStateOf(rule?.name ?: "") }
+ var isActive by rememberSaveable(rule?.id) { mutableStateOf(rule?.isActive ?: true) }
+ var triggerType by rememberSaveable(rule?.id) { mutableStateOf(rule?.triggerType ?: "no_reply") }
+ var triggerHours by rememberSaveable(rule?.id) { mutableStateOf((rule?.triggerHours ?: 24).toString()) }
+ var conditionStatus by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionStatus ?: "") }
+ var conditionPriorityId by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionPriorityId) }
+ var conditionDeptId by rememberSaveable(rule?.id) { mutableStateOf(rule?.conditionDeptId) }
+ var actionSetPriorityId by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionSetPriorityId) }
+ var actionAssignStaffId by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionAssignStaffId) }
+ var actionSetStatus by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionSetStatus) }
+ var notifyAdmin by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionNotifyAdmin ?: true) }
+ var notifyStaff by rememberSaveable(rule?.id) { mutableStateOf(rule?.actionNotifyStaff ?: false) }
+
+ var triggerExpanded by remember { mutableStateOf(false) }
+ var condPrioExpanded by remember { mutableStateOf(false) }
+ var condDeptExpanded by remember { mutableStateOf(false) }
+ var actPrioExpanded by remember { mutableStateOf(false) }
+ var actStaffExpanded by remember { mutableStateOf(false) }
+ var actStatusExpanded by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (rule == null) "Neue Eskalationsregel" else "Regel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Name") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = isActive, onCheckedChange = { isActive = it })
+ Text("Aktiv")
+ }
+
+ Text("Auslöser", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
+ SelectionField(
+ label = "Trigger",
+ value = EscalationTriggerLabels[triggerType] ?: triggerType,
+ expanded = triggerExpanded,
+ onExpand = { triggerExpanded = true },
+ onDismiss = { triggerExpanded = false },
+ ) {
+ EscalationTriggerLabels.forEach { (value, label) ->
+ DropdownMenuItem(text = { Text(label) }, onClick = { triggerType = value; triggerExpanded = false })
+ }
+ }
+ if (triggerType != "overdue") {
+ OutlinedTextField(
+ value = triggerHours,
+ onValueChange = { triggerHours = it.filter(Char::isDigit) },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Stunden") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ }
+
+ Text("Bedingungen (optional – leer = alle Tickets)", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(
+ value = conditionStatus,
+ onValueChange = { conditionStatus = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Nur Status (kommagetrennt, z.B. open,pending)") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ SelectionField(
+ label = "Nur Priorität",
+ value = priorities.firstOrNull { it.id == conditionPriorityId }?.name ?: "Alle",
+ expanded = condPrioExpanded,
+ onExpand = { condPrioExpanded = true },
+ onDismiss = { condPrioExpanded = false },
+ ) {
+ DropdownMenuItem(text = { Text("Alle") }, onClick = { conditionPriorityId = null; condPrioExpanded = false })
+ priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { conditionPriorityId = p.id; condPrioExpanded = false }) }
+ }
+ SelectionField(
+ label = "Nur Abteilung",
+ value = departments.firstOrNull { it.id == conditionDeptId }?.name ?: "Alle",
+ expanded = condDeptExpanded,
+ onExpand = { condDeptExpanded = true },
+ onDismiss = { condDeptExpanded = false },
+ ) {
+ DropdownMenuItem(text = { Text("Alle") }, onClick = { conditionDeptId = null; condDeptExpanded = false })
+ departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { conditionDeptId = d.id; condDeptExpanded = false }) }
+ }
+
+ Text("Aktionen", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
+ SelectionField(
+ label = "Priorität setzen",
+ value = priorities.firstOrNull { it.id == actionSetPriorityId }?.name ?: "– keine Änderung –",
+ expanded = actPrioExpanded,
+ onExpand = { actPrioExpanded = true },
+ onDismiss = { actPrioExpanded = false },
+ ) {
+ DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionSetPriorityId = null; actPrioExpanded = false })
+ priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { actionSetPriorityId = p.id; actPrioExpanded = false }) }
+ }
+ SelectionField(
+ label = "Mitarbeiter zuweisen",
+ value = staffOptions.firstOrNull { it.id == actionAssignStaffId }?.name ?: "– keine Änderung –",
+ expanded = actStaffExpanded,
+ onExpand = { actStaffExpanded = true },
+ onDismiss = { actStaffExpanded = false },
+ ) {
+ DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionAssignStaffId = null; actStaffExpanded = false })
+ staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { actionAssignStaffId = s.id; actStaffExpanded = false }) }
+ }
+ SelectionField(
+ label = "Status setzen",
+ value = EscalationStatusLabels[actionSetStatus] ?: "– keine Änderung –",
+ expanded = actStatusExpanded,
+ onExpand = { actStatusExpanded = true },
+ onDismiss = { actStatusExpanded = false },
+ ) {
+ DropdownMenuItem(text = { Text("– keine Änderung –") }, onClick = { actionSetStatus = null; actStatusExpanded = false })
+ EscalationStatusLabels.forEach { (value, label) ->
+ DropdownMenuItem(text = { Text(label) }, onClick = { actionSetStatus = value; actStatusExpanded = false })
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = notifyAdmin, onCheckedChange = { notifyAdmin = it })
+ Text("Admin benachrichtigen")
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = notifyStaff, onCheckedChange = { notifyStaff = it })
+ Text("Zugewiesenen Mitarbeiter benachrichtigen")
+ }
+
+ error?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = {
+ onSave(
+ name.trim(), isActive, triggerType, triggerHours.toIntOrNull() ?: 24,
+ conditionStatus.trim().ifBlank { null }, conditionPriorityId, conditionDeptId,
+ actionSetPriorityId, actionAssignStaffId, actionSetStatus, notifyAdmin, notifyStaff,
+ )
+ },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !saving && name.isNotBlank(),
+ ) { Text(if (saving) "Wird gespeichert …" else "Speichern") }
+ OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+}
+
+// ─── Mein Status (Verfügbarkeit) ────────────────────────────────────────────
+// Bewusst NICHT admin-gated (siehe AdminScreen oben) – jeder Mitarbeiter darf seinen
+// eigenen Status setzen, genau wie unter staff/availability.php im Web.
+
+private val AvailabilityStatusLabels = mapOf(
+ "available" to Triple("Verfügbar", "🟢", Color(0xFF22C55E)),
+ "busy" to Triple("Beschäftigt", "🟡", Color(0xFFF59E0B)),
+ "break" to Triple("Pause", "🟣", Color(0xFF6366F1)),
+ "offline" to Triple("Offline", "⚫", Color(0xFF94A3B8)),
+)
+
+@Composable
+private fun AdminAvailabilityScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSetStatus: (String, String) -> Unit,
+ isAdmin: Boolean = false,
+ onSetMemberStatus: (Int, String, String) -> Unit = { _, _, _ -> },
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ val data = state.availability
+ // Administrator: Status eines Teammitglieds ändern (Dialog)
+ var editMember by remember { mutableStateOf(null) }
+ var editStatus by remember { mutableStateOf("available") }
+ var editNote by remember { mutableStateOf("") }
+ editMember?.let { member ->
+ AlertDialog(
+ onDismissRequest = { editMember = null },
+ title = { Text("Status von ${member.name}") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ AvailabilityStatusLabels.forEach { (value, meta) ->
+ val (label, emoji, _) = meta
+ FilterChip(selected = editStatus == value, onClick = { editStatus = value }, label = { Text("$emoji $label") })
+ }
+ OutlinedTextField(value = editNote, onValueChange = { editNote = it }, label = { Text("Notiz (optional)") }, singleLine = true)
+ }
+ },
+ confirmButton = { TextButton(onClick = { onSetMemberStatus(member.id, editStatus, editNote.trim()); editMember = null }) { Text("Speichern") } },
+ dismissButton = { TextButton(onClick = { editMember = null }) { Text("Abbrechen") } },
+ )
+ }
+ var selectedStatus by rememberSaveable(data?.myStatus) { mutableStateOf(data?.myStatus ?: "available") }
+ var note by rememberSaveable(data?.myNote) { mutableStateOf(data?.myNote ?: "") }
+
+ Scaffold(
+ topBar = { AdminTopBar("Mein Status", onBack) },
+ ) { padding ->
+ when {
+ state.availabilityLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Status wird geladen …") }
+ state.availabilityError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.availabilityError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ else -> LazyColumn(
+ modifier = Modifier.padding(padding).fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ item {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text("Mein Status", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ AvailabilityStatusLabels.forEach { (value, meta) ->
+ val (label, emoji, color) = meta
+ FilterChip(
+ selected = selectedStatus == value,
+ onClick = { selectedStatus = value },
+ label = { Text("$emoji $label") },
+ shape = MaterialTheme.shapes.small,
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = color.copy(alpha = 0.18f),
+ selectedLabelColor = color,
+ ),
+ )
+ }
+ }
+ OutlinedTextField(
+ value = note,
+ onValueChange = { note = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Notiz (optional)") },
+ placeholder = { Text("z.B. „In Besprechung bis 14:00“") },
+ shape = MaterialTheme.shapes.medium,
+ singleLine = true,
+ )
+ state.availabilityError?.let { ErrorText(it) }
+ Button(
+ onClick = { onSetStatus(selectedStatus, note.trim()) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.availabilitySaving,
+ ) { Text(if (state.availabilitySaving) "Wird gespeichert …" else "Speichern") }
+ }
+ }
+ }
+ item { AdminSectionLabel(if (isAdmin) "Team-Übersicht (zum Ändern antippen)" else "Team-Übersicht") }
+ items(data.team, key = { it.id }) { member ->
+ val meta = AvailabilityStatusLabels[member.status] ?: Triple("Unbekannt", "⚫", Color(0xFF94A3B8))
+ val (label, emoji, color) = meta
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ onClick = { if (isAdmin) { editStatus = member.status.ifBlank { "offline" }; editNote = member.note; editMember = member } },
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column {
+ Text(member.name, fontWeight = FontWeight.Medium)
+ if (member.autoOffline) {
+ Text("automatisch offline (inaktiv)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (member.note.isNotBlank()) {
+ Text(member.note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ Surface(color = color.copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) {
+ Text(
+ "$emoji $label",
+ modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = color,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Automatisierung (Ticket-Regeln + Auto-Close) ───────────────────────────
+
+@Composable
+private fun AdminTicketRulesScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, String, String, Boolean, Int, List, List, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onToggle: (Int) -> Unit,
+ onClearError: () -> Unit,
+ onSaveAutoClose: (Boolean, Int) -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingRuleId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val data = state.ticketRules
+ val showForm = isCreating || editingRuleId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Automatisierung", onBack) },
+ floatingActionButton = {
+ if (data != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) {
+ Icon(Icons.Filled.Add, contentDescription = "Neue Regel")
+ }
+ }
+ },
+ ) { padding ->
+ when {
+ state.ticketRulesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Regeln werden geladen …") }
+ state.ticketRulesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.ticketRulesError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val rule = data.rules.firstOrNull { it.id == editingRuleId }
+ TicketRuleForm(
+ modifier = Modifier.padding(padding),
+ rule = rule,
+ data = data,
+ saving = state.ticketRulesMutationLoading,
+ error = state.ticketRulesMutationError,
+ onCancel = { editingRuleId = null; isCreating = false; onClearError() },
+ onSave = { name, matchType, isActive, sortOrder, conditions, actions ->
+ onSave(rule?.id, name, matchType, isActive, sortOrder, conditions, actions) {
+ editingRuleId = null
+ isCreating = false
+ }
+ },
+ )
+ }
+ else -> LazyColumn(
+ modifier = Modifier.padding(padding).fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ item {
+ AutoCloseCard(
+ enabled = data.autoCloseEnabled,
+ days = data.autoCloseDays,
+ onSave = onSaveAutoClose,
+ )
+ }
+ item { AdminSectionLabel("Ticket-Regeln") }
+ if (data.rules.isEmpty()) {
+ item { EmptyState("Noch keine Ticket-Regeln. Mit + eine neue Regel anlegen.") }
+ } else {
+ items(data.rules, key = { it.id }) { rule ->
+ TicketRuleRow(rule, onEdit = { editingRuleId = rule.id }, onDelete = { onDelete(rule.id) }, onToggle = { onToggle(rule.id) })
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AutoCloseCard(enabled: Boolean, days: Int, onSave: (Boolean, Int) -> Unit) {
+ var localEnabled by rememberSaveable(enabled) { mutableStateOf(enabled) }
+ var localDays by rememberSaveable(days) { mutableStateOf(days.toString()) }
+ val changed = localEnabled != enabled || localDays != days.toString()
+
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(
+ Modifier.fillMaxWidth().clickable { localEnabled = !localEnabled },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text("Automatisches Schließen", fontWeight = FontWeight.SemiBold)
+ Text(
+ "Schließt Tickets, auf die der Kunde nach einer Antwort nicht mehr reagiert.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Switch(checked = localEnabled, onCheckedChange = { localEnabled = it })
+ }
+ if (localEnabled) {
+ OutlinedTextField(
+ value = localDays,
+ onValueChange = { v -> if (v.all(Char::isDigit)) localDays = v },
+ label = { Text("Tage ohne Reaktion") },
+ modifier = Modifier.width(160.dp),
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ )
+ }
+ if (changed) {
+ Button(
+ onClick = { onSave(localEnabled, localDays.toIntOrNull()?.coerceIn(1, 90) ?: 7) },
+ shape = MaterialTheme.shapes.medium,
+ ) { Text("Speichern") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TicketRuleRow(rule: TicketRule, onEdit: () -> Unit, onDelete: () -> Unit, onToggle: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(rule.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
+ Surface(
+ color = if (rule.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.extraSmall,
+ ) {
+ Text(
+ if (rule.isActive) "Aktiv" else "Inaktiv",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+ Text(
+ "${rule.conditions.size} Bedingung(en) (${if (rule.matchType == "any") "eine trifft" else "alle treffen"})",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ rule.actions.joinToString(", ") { it.valueLabel ?: it.type },
+ style = MaterialTheme.typography.bodySmall,
+ )
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = onEdit) { Text("Bearbeiten") }
+ TextButton(onClick = onToggle) { Text(if (rule.isActive) "Deaktivieren" else "Aktivieren") }
+ TextButton(onClick = onDelete) { Text("Löschen") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TicketRuleForm(
+ modifier: Modifier = Modifier,
+ rule: TicketRule?,
+ data: TicketRulesData,
+ saving: Boolean,
+ error: String?,
+ onCancel: () -> Unit,
+ onSave: (String, String, Boolean, Int, List, List) -> Unit,
+) {
+ var name by rememberSaveable(rule?.id) { mutableStateOf(rule?.name ?: "") }
+ var matchType by rememberSaveable(rule?.id) { mutableStateOf(rule?.matchType ?: "all") }
+ var isActive by rememberSaveable(rule?.id) { mutableStateOf(rule?.isActive ?: true) }
+ var sortOrder by rememberSaveable(rule?.id) { mutableStateOf((rule?.sortOrder ?: 0).toString()) }
+ val conditions = remember(rule?.id) {
+ mutableStateListOf(*(rule?.conditions?.toTypedArray() ?: arrayOf(TicketRuleCondition("subject", "contains", ""))))
+ }
+ val actions = remember(rule?.id) {
+ mutableStateListOf(*(rule?.actions?.toTypedArray() ?: arrayOf(TicketRuleAction("set_priority", "", null))))
+ }
+
+ Column(
+ modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (rule == null) "Neue Ticket-Regel" else "Ticket-Regel bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("Name") },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ )
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ SegmentedButton(selected = matchType == "all", onClick = { matchType = "all" }, shape = SegmentedButtonDefaults.itemShape(0, 2)) { Text("Alle Bedingungen") }
+ SegmentedButton(selected = matchType == "any", onClick = { matchType = "any" }, shape = SegmentedButtonDefaults.itemShape(1, 2)) { Text("Eine Bedingung") }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Switch(checked = isActive, onCheckedChange = { isActive = it })
+ Spacer(Modifier.width(8.dp))
+ Text("Aktiv")
+ }
+
+ Text("Bedingungen", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+ conditions.forEachIndexed { index, cond ->
+ RuleConditionRow(
+ condition = cond,
+ fieldOptions = data.fieldOptions,
+ operatorOptions = data.operatorOptions,
+ onChange = { conditions[index] = it },
+ onRemove = { if (conditions.size > 1) conditions.removeAt(index) },
+ )
+ }
+ OutlinedButton(onClick = { conditions.add(TicketRuleCondition("subject", "contains", "")) }, shape = MaterialTheme.shapes.medium) {
+ Text("+ Bedingung hinzufügen")
+ }
+
+ Text("Aktionen", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+ actions.forEachIndexed { index, act ->
+ RuleActionRow(
+ action = act,
+ data = data,
+ onChange = { actions[index] = it },
+ onRemove = { if (actions.size > 1) actions.removeAt(index) },
+ )
+ }
+ OutlinedButton(onClick = { actions.add(TicketRuleAction("set_priority", "", null)) }, shape = MaterialTheme.shapes.medium) {
+ Text("+ Aktion hinzufügen")
+ }
+
+ error?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onSave(name.trim(), matchType, isActive, sortOrder.toIntOrNull() ?: 0, conditions.toList(), actions.toList()) },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !saving && name.isNotBlank(),
+ ) { Text(if (saving) "Wird gespeichert …" else "Speichern") }
+ OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+}
+
+@Composable
+private fun RuleConditionRow(
+ condition: TicketRuleCondition,
+ fieldOptions: List,
+ operatorOptions: List,
+ onChange: (TicketRuleCondition) -> Unit,
+ onRemove: () -> Unit,
+) {
+ var fieldExpanded by remember { mutableStateOf(false) }
+ var operatorExpanded by remember { mutableStateOf(false) }
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
+ Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ SelectionField(
+ label = "Feld",
+ value = fieldOptions.firstOrNull { it.value == condition.field }?.label ?: condition.field,
+ expanded = fieldExpanded,
+ onExpand = { fieldExpanded = true },
+ onDismiss = { fieldExpanded = false },
+ ) {
+ fieldOptions.forEach { opt ->
+ DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(condition.copy(field = opt.value)); fieldExpanded = false })
+ }
+ }
+ SelectionField(
+ label = "Operator",
+ value = operatorOptions.firstOrNull { it.value == condition.operator }?.label ?: condition.operator,
+ expanded = operatorExpanded,
+ onExpand = { operatorExpanded = true },
+ onDismiss = { operatorExpanded = false },
+ ) {
+ operatorOptions.forEach { opt ->
+ DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(condition.copy(operator = opt.value)); operatorExpanded = false })
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ OutlinedTextField(
+ value = condition.value,
+ onValueChange = { onChange(condition.copy(value = it)) },
+ label = { Text("Wert") },
+ modifier = Modifier.weight(1f),
+ shape = MaterialTheme.shapes.medium,
+ singleLine = true,
+ )
+ IconButton(onClick = onRemove) { Icon(Icons.Filled.Block, contentDescription = "Entfernen") }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RuleActionRow(
+ action: TicketRuleAction,
+ data: TicketRulesData,
+ onChange: (TicketRuleAction) -> Unit,
+ onRemove: () -> Unit,
+) {
+ var typeExpanded by remember { mutableStateOf(false) }
+ var valueExpanded by remember { mutableStateOf(false) }
+ val valueOptions: List = when (action.type) {
+ "set_department" -> data.departments
+ "set_priority" -> data.priorities
+ "set_help_topic" -> data.helpTopics
+ "assign_staff" -> data.staffOptions
+ "auto_reply" -> data.cannedResponses
+ else -> emptyList()
+ }
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) {
+ Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ Box(Modifier.weight(1f)) {
+ SelectionField(
+ label = "Aktion",
+ value = data.actionOptions.firstOrNull { it.value == action.type }?.label ?: action.type,
+ expanded = typeExpanded,
+ onExpand = { typeExpanded = true },
+ onDismiss = { typeExpanded = false },
+ ) {
+ data.actionOptions.forEach { opt ->
+ DropdownMenuItem(text = { Text(opt.label) }, onClick = { onChange(TicketRuleAction(opt.value, "", null)); typeExpanded = false })
+ }
+ }
+ }
+ IconButton(onClick = onRemove) { Icon(Icons.Filled.Block, contentDescription = "Entfernen") }
+ }
+ when (action.type) {
+ "reject" -> Text("Keine weitere Einstellung nötig.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ "add_tag" -> OutlinedTextField(
+ value = action.value,
+ onValueChange = { onChange(action.copy(value = it)) },
+ label = { Text("Tag-Name") },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ singleLine = true,
+ )
+ else -> SelectionField(
+ label = "Wert",
+ value = valueOptions.firstOrNull { it.id.toString() == action.value }?.name ?: "– wählen –",
+ expanded = valueExpanded,
+ onExpand = { valueExpanded = true },
+ onDismiss = { valueExpanded = false },
+ ) {
+ valueOptions.forEach { opt ->
+ DropdownMenuItem(text = { Text(opt.name) }, onClick = { onChange(action.copy(value = opt.id.toString(), valueLabel = opt.name)); valueExpanded = false })
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Admin: Sperrliste ───────────────────────────────────────────────────────
+
+@Composable
+private fun AdminBanlistScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onCreate: (String, String, String?, () -> Unit) -> Unit,
+ onBulkAdd: (String, String?, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+ onClearBulkResult: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var showForm by rememberSaveable { mutableStateOf(false) }
+ val entries = state.banlist
+
+ Scaffold(
+ topBar = { AdminTopBar("Sperrliste", onBack) },
+ floatingActionButton = {
+ if (entries != null && !showForm) {
+ FloatingActionButton(onClick = { showForm = true }) {
+ Icon(Icons.Filled.Add, contentDescription = "Sperren")
+ }
+ }
+ },
+ ) { padding ->
+ when {
+ state.banlistLoading && entries == null -> Box(Modifier.padding(padding)) { LoadingState("Sperrliste wird geladen …") }
+ state.banlistError != null && entries == null -> Box(Modifier.padding(padding)) { ErrorState(state.banlistError, onLoad) }
+ entries == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> BanlistForm(
+ modifier = Modifier.padding(padding),
+ saving = state.banlistMutationLoading,
+ error = state.banlistMutationError,
+ bulkResult = state.banlistLastBulkResult,
+ onCancel = { showForm = false; onClearError(); onClearBulkResult() },
+ onCreate = { matchType, value, reason -> onCreate(matchType, value, reason) { showForm = false } },
+ onBulkAdd = { values, reason -> onBulkAdd(values, reason) { } },
+ )
+ entries.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Sperrliste ist leer. Mit + eine E-Mail-Adresse oder Domain sperren.") }
+ else -> LazyColumn(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ items(entries) { entry -> BanEntryRow(entry, onDelete = { onDelete(entry.id) }) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BanEntryRow(entry: BanEntry, onDelete: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Surface(
+ color = if (entry.matchType == "domain") Color(0xFFF3E8FF) else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.extraSmall,
+ ) {
+ Text(
+ if (entry.matchType == "domain") "Domain" else "E-Mail",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = if (entry.matchType == "domain") Color(0xFF7E22CE) else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Spacer(Modifier.width(8.dp))
+ Text(entry.value, fontWeight = FontWeight.SemiBold)
+ }
+ TextButton(onClick = onDelete) { Text("Entsperren") }
+ }
+ entry.reason?.takeIf { it.isNotBlank() }?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
+ }
+ Text(
+ buildString {
+ append(entry.createdAt)
+ entry.addedByName?.let { append(" · $it") }
+ },
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun BanlistForm(
+ modifier: Modifier = Modifier,
+ saving: Boolean,
+ error: String?,
+ bulkResult: Pair?,
+ onCancel: () -> Unit,
+ onCreate: (String, String, String?) -> Unit,
+ onBulkAdd: (String, String?) -> Unit,
+) {
+ var bulkMode by rememberSaveable { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ SegmentedButton(
+ selected = !bulkMode,
+ onClick = { bulkMode = false },
+ shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
+ ) { Text("Einzeln") }
+ SegmentedButton(
+ selected = bulkMode,
+ onClick = { bulkMode = true },
+ shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
+ ) { Text("Mehrere") }
+ }
+
+ if (!bulkMode) {
+ var matchType by rememberSaveable { mutableStateOf("email") }
+ var value by rememberSaveable { mutableStateOf("") }
+ var reason by rememberSaveable { mutableStateOf("") }
+ var typeExpanded by remember { mutableStateOf(false) }
+
+ SelectionField(
+ label = "Typ",
+ value = if (matchType == "domain") "Domain (ganzer Mailanbieter)" else "E-Mail-Adresse",
+ expanded = typeExpanded,
+ onExpand = { typeExpanded = true },
+ onDismiss = { typeExpanded = false },
+ ) {
+ DropdownMenuItem(text = { Text("E-Mail-Adresse") }, onClick = { matchType = "email"; typeExpanded = false })
+ DropdownMenuItem(text = { Text("Domain (ganzer Mailanbieter)") }, onClick = { matchType = "domain"; typeExpanded = false })
+ }
+ OutlinedTextField(
+ value = value,
+ onValueChange = { value = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(if (matchType == "domain") "Domain, z.B. spam.de" else "E-Mail, z.B. spam@beispiel.de") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ OutlinedTextField(
+ value = reason,
+ onValueChange = { reason = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Grund (optional)") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ error?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onCreate(matchType, value.trim(), reason.trim().ifBlank { null }) },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !saving && value.isNotBlank(),
+ ) { Text(if (saving) "Wird gesperrt …" else "Sperren") }
+ OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ } else {
+ var values by rememberSaveable { mutableStateOf("") }
+ var reason by rememberSaveable { mutableStateOf("") }
+
+ Text(
+ "Eine E-Mail oder Domain pro Zeile. Typ wird automatisch erkannt.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = values,
+ onValueChange = { values = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("E-Mails / Domains") },
+ shape = MaterialTheme.shapes.medium,
+ minLines = 6,
+ )
+ OutlinedTextField(
+ value = reason,
+ onValueChange = { reason = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Grund für alle (optional)") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ bulkResult?.let { (added, skipped) ->
+ Text(
+ "$added gesperrt, $skipped übersprungen.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ error?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onBulkAdd(values, reason.trim().ifBlank { null }) },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !saving && values.isNotBlank(),
+ ) { Text(if (saving) "Wird gesperrt …" else "Alle sperren") }
+ OutlinedButton(onClick = onCancel, shape = MaterialTheme.shapes.medium) { Text("Fertig") }
+ }
+ }
+ }
+}
+
+// ─── Admin: Abteilungen ──────────────────────────────────────────────────────
+
+@Composable
+private fun AdminDepartmentsScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, DepartmentInput, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+ onUploadImage: (ByteArray, String, String, (String) -> Unit) -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val items = state.departments2
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Abteilungen", onBack) },
+ floatingActionButton = {
+ if (items != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Abteilung") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.departments2Loading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Abteilungen werden geladen …") }
+ state.departments2Error != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.departments2Error, onLoad) }
+ items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val dept = items.firstOrNull { it.id == editingId }
+ var tab by rememberSaveable { mutableStateOf(0) }
+ var name by rememberSaveable(dept?.id) { mutableStateOf(dept?.name ?: "") }
+ var email by rememberSaveable(dept?.id) { mutableStateOf(dept?.email ?: "") }
+ var isDefault by rememberSaveable(dept?.id) { mutableStateOf(dept?.isDefault ?: false) }
+ var isActive by rememberSaveable(dept?.id) { mutableStateOf(dept?.isActive ?: true) }
+ var managerId by rememberSaveable(dept?.id) { mutableStateOf(dept?.managerId) }
+ var notes by rememberSaveable(dept?.id) { mutableStateOf(dept?.notes ?: "") }
+ var signature by rememberSaveable(dept?.id) { mutableStateOf(dept?.signature ?: "") }
+ var emailExpanded by remember { mutableStateOf(false) }
+ var managerExpanded by remember { mutableStateOf(false) }
+ var slaPlanId by rememberSaveable(dept?.id) { mutableStateOf(dept?.slaPlanId) }
+ var slaExpanded by remember { mutableStateOf(false) }
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
+ Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Abteilung") })
+ Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Mitglieder" + (dept?.let { " (${it.members.size})" } ?: "")) })
+ }
+ Column(
+ modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (dept == null) "Neue Abteilung" else "Abteilung aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ if (tab == 0) {
+ OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ Text("Status", style = MaterialTheme.typography.labelLarge)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(selected = isActive, onClick = { isActive = true })
+ Text("Aktiv", Modifier.clickable { isActive = true })
+ Spacer(Modifier.width(16.dp))
+ RadioButton(selected = !isActive, onClick = { isActive = false })
+ Text("Deaktiviert", Modifier.clickable { isActive = false })
+ }
+ SelectionField(label = "Abteilungsleiter", value = state.departmentStaffOptions.firstOrNull { it.id == managerId }?.name ?: "— Keine(r) —", expanded = managerExpanded, onExpand = { managerExpanded = true }, onDismiss = { managerExpanded = false }) {
+ DropdownMenuItem(text = { Text("— Keine(r) —") }, onClick = { managerId = null; managerExpanded = false })
+ state.departmentStaffOptions.forEach { m -> DropdownMenuItem(text = { Text(m.name) }, onClick = { managerId = m.id; managerExpanded = false }) }
+ }
+ SelectionField(label = "SLA-Plan", value = state.departmentSlaPlans.firstOrNull { it.id == slaPlanId }?.name ?: "Zeiten der Priorität verwenden", expanded = slaExpanded, onExpand = { slaExpanded = true }, onDismiss = { slaExpanded = false }) {
+ DropdownMenuItem(text = { Text("Zeiten der Priorität verwenden") }, onClick = { slaPlanId = null; slaExpanded = false })
+ state.departmentSlaPlans.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { slaPlanId = p.id; slaExpanded = false }) }
+ }
+ SelectionField(label = "Abteilungs-E-Mail (optional)", value = email.ifBlank { "Automatisch (Standardadresse der Abteilung)" }, expanded = emailExpanded, onExpand = { emailExpanded = true }, onDismiss = { emailExpanded = false }) {
+ DropdownMenuItem(text = { Text("Automatisch (Standardadresse der Abteilung)") }, onClick = { email = ""; emailExpanded = false })
+ state.departmentEmailOptions.forEach { o ->
+ DropdownMenuItem(text = { Text("${o.email} (${o.departmentName})") }, onClick = { email = o.email; emailExpanded = false })
+ }
+ if (email.isNotBlank() && state.departmentEmailOptions.none { it.email.equals(email, ignoreCase = true) }) {
+ DropdownMenuItem(text = { Text(email) }, onClick = { emailExpanded = false })
+ }
+ }
+ Text("Adressen verwaltest du unter „E-Mail-Adressen“.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = isDefault, onCheckedChange = { isDefault = it })
+ Text("Standardabteilung")
+ }
+ SignatureField(
+ value = signature,
+ onValueChange = { signature = it },
+ uploading = state.imageUploadLoading,
+ uploadError = state.imageUploadError,
+ onUpload = onUploadImage,
+ )
+ OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Administratornotizen (intern)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
+ } else {
+ val members = dept?.members.orEmpty()
+ if (members.isEmpty()) {
+ Text(
+ if (dept == null) "Mitglieder lassen sich nach dem Erstellen unter „Mitarbeiter“ zuweisen." else "Dieser Abteilung sind noch keine Mitarbeiter zugewiesen. Die Zuweisung erfolgt unter „Mitarbeiter“.",
+ style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ members.forEach { m ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ 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)
+ }
+ }
+ }
+ Text("Zuweisungen änderst du unter „Mitarbeiter“.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ state.departments2MutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = {
+ onSave(dept?.id, DepartmentInput(name.trim(), email.trim(), isDefault, isActive, managerId, slaPlanId, notes.trim(), signature.trim().ifBlank { null })) { editingId = null; isCreating = false; tab = 0 }
+ },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.departments2MutationLoading && name.isNotBlank(),
+ ) { Text(if (state.departments2MutationLoading) "Wird gespeichert …" else "Änderungen speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+ }
+ }
+ items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Abteilungen.") }
+ else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(items) { dept -> DepartmentRowAdmin(dept, onEdit = { editingId = dept.id }, onDelete = { onDelete(dept.id) }) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DepartmentRowAdmin(dept: Department, onEdit: () -> Unit, onDelete: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(dept.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
+ if (!dept.isActive) {
+ Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ Spacer(Modifier.width(6.dp))
+ }
+ if (dept.isDefault) {
+ Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Standard", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ Text(
+ "${dept.members.size} Mitglied(er)" + (dept.managerName?.let { " · Leiter: $it" } ?: "") + " · " + dept.email.ifBlank { "keine Abteilungs-E-Mail" },
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = onEdit) { Text("Bearbeiten") }
+ TextButton(onClick = onDelete) { Text("Löschen") }
+ }
+ }
+ }
+}
+
+// ─── Berichte ────────────────────────────────────────────────────────────────
+
+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
+@Composable
+private fun AdminReportsScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onRun: (de.deskhub.staff.data.remote.dto.ReportConfigDto?, Int?) -> Unit,
+ onSave: (String, Boolean) -> Unit,
+ onDelete: (Int) -> Unit,
+) {
+ LaunchedEffect(Unit) { if (state.report == null) onRun(null, null) }
+ val report = state.report
+ var dimension by remember(report?.config?.dimension) { mutableStateOf(state.reportConfig.dimension) }
+ var from by remember(report?.config?.from) { mutableStateOf(state.reportConfig.from) }
+ var to by remember(report?.config?.to) { mutableStateOf(state.reportConfig.to) }
+ var metrics by remember(report?.config?.metrics) { mutableStateOf(state.reportConfig.metrics.toSet()) }
+ var dimExpanded by remember { mutableStateOf(false) }
+ var showSave by remember { mutableStateOf(false) }
+
+ fun format(value: Any?, unit: String): String {
+ val n = (value as? Number)?.toDouble() ?: return "–"
+ return when (unit) {
+ "%" -> String.format(java.util.Locale.GERMANY, "%.1f %%", n)
+ "Std" -> String.format(java.util.Locale.GERMANY, "%.1f Std", n)
+ else -> n.toLong().toString()
+ }
+ }
+
+ if (showSave) {
+ var name by remember { mutableStateOf("") }
+ var shared by remember { mutableStateOf(false) }
+ AlertDialog(
+ onDismissRequest = { showSave = false },
+ title = { Text("Bericht speichern") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = shared, onCheckedChange = { shared = it })
+ Text("Für alle Mitarbeiter sichtbar")
+ }
+ }
+ },
+ confirmButton = { TextButton(enabled = name.isNotBlank(), onClick = { onSave(name.trim(), shared); showSave = false }) { Text("Speichern") } },
+ dismissButton = { TextButton(onClick = { showSave = false }) { Text("Abbrechen") } },
+ )
+ }
+
+ Scaffold(topBar = { AdminTopBar("Berichte", onBack) }) { padding ->
+ Column(
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (report?.saved?.isNotEmpty() == true) {
+ Text("Gespeicherte Berichte", style = MaterialTheme.typography.labelLarge)
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(report.saved) { sr ->
+ androidx.compose.material3.InputChip(
+ selected = false,
+ onClick = { onRun(null, sr.id) },
+ label = { Text(sr.name + if (sr.isShared) " ⇄" else "") },
+ trailingIcon = if (sr.mine) ({ Icon(Icons.Filled.Close, contentDescription = "Löschen", modifier = Modifier.size(16.dp).clickable { onDelete(sr.id) }) }) else null,
+ )
+ }
+ }
+ }
+ SelectionField(label = "Gruppieren nach", value = report?.dimensions?.firstOrNull { it.key == dimension }?.label ?: dimension, expanded = dimExpanded, onExpand = { dimExpanded = true }, onDismiss = { dimExpanded = false }) {
+ report?.dimensions?.forEach { d -> DropdownMenuItem(text = { Text(d.label) }, onClick = { dimension = d.key; dimExpanded = false }) }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(value = from, onValueChange = { from = it }, modifier = Modifier.weight(1f), label = { Text("Von (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = to, onValueChange = { to = it }, modifier = Modifier.weight(1f), label = { Text("Bis (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ }
+ Text("Kennzahlen", style = MaterialTheme.typography.labelLarge)
+ androidx.compose.foundation.layout.FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ report?.metrics?.forEach { m ->
+ FilterChip(
+ 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,
+ )
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onRun(state.reportConfig.copy(dimension = dimension, from = from, to = to, metrics = metrics.toList().ifEmpty { listOf("total") }), null) },
+ enabled = !state.reportLoading,
+ shape = MaterialTheme.shapes.medium,
+ ) { Text(if (state.reportLoading) "Wird berechnet …" else "Bericht erstellen") }
+ OutlinedButton(onClick = { showSave = true }, enabled = report != null, shape = MaterialTheme.shapes.medium) { Text("Speichern") }
+ }
+ state.reportError?.let { ErrorText(it) }
+ if (report != null) {
+ if (report.rows.isEmpty()) {
+ Text("Keine Tickets im gewählten Zeitraum.", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ report.rows.forEach { row ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(row["label"]?.toString() ?: "–", style = MaterialTheme.typography.titleSmall)
+ report.columns.drop(1).forEach { c ->
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(c.label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(format(row[c.key], c.unit), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+ }
+ }
+ if (report.rows.isNotEmpty()) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
+ Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text("Gesamt", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onPrimaryContainer)
+ report.columns.drop(1).forEach { c ->
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(c.label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer)
+ Text(format(report.totals[c.key], c.unit), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Admin: SLA-Pläne ────────────────────────────────────────────────────────
+
+@Composable
+private fun AdminSlaPlansScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, String, Int, Int, Boolean, String, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val items = state.slaPlans
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("SLA-Pläne", onBack) },
+ floatingActionButton = {
+ if (items != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neuer SLA-Plan") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.slaPlansLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("SLA-Pläne werden geladen …") }
+ state.slaPlansError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.slaPlansError, onLoad) }
+ items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val plan = items.firstOrNull { it.id == editingId }
+ var name by rememberSaveable(plan?.id) { mutableStateOf(plan?.name ?: "") }
+ var isActive by rememberSaveable(plan?.id) { mutableStateOf(plan?.isActive ?: true) }
+ var responseHours by rememberSaveable(plan?.id) { mutableStateOf((plan?.responseHours ?: 4).toString()) }
+ var resolutionHours by rememberSaveable(plan?.id) { mutableStateOf((plan?.resolutionHours ?: 24).toString()) }
+ var notes by rememberSaveable(plan?.id) { mutableStateOf(plan?.notes ?: "") }
+ Column(
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (plan == null) "Neuer SLA-Plan" else "SLA-Plan aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(selected = isActive, onClick = { isActive = true })
+ Text("Aktiv", Modifier.clickable { isActive = true })
+ Spacer(Modifier.width(16.dp))
+ RadioButton(selected = !isActive, onClick = { isActive = false })
+ Text("Deaktiviert", Modifier.clickable { isActive = false })
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(value = responseHours, onValueChange = { responseHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Reaktion (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ OutlinedTextField(value = resolutionHours, onValueChange = { resolutionHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Lösung (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ }
+ Text("Ersetzt die Zeiten der Priorität für Tickets, deren Hilfethema oder Abteilung diesen Plan nutzt (Hilfethema vor Abteilung).", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Administratornotizen (intern)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
+ state.slaPlansMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onSave(plan?.id, name.trim(), responseHours.toIntOrNull() ?: 0, resolutionHours.toIntOrNull() ?: 0, isActive, notes.trim()) { editingId = null; isCreating = false } },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.slaPlansMutationLoading && name.isNotBlank(),
+ ) { Text(if (state.slaPlansMutationLoading) "Wird gespeichert …" else "Änderungen speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+ }
+ items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine SLA-Pläne angelegt.") }
+ else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(items) { plan ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(plan.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
+ if (!plan.isActive) {
+ Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ Text("Reaktion ${plan.responseHours} Std · Lösung ${plan.resolutionHours} Std", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text("${plan.departmentCount} Abteilung(en) · ${plan.helpTopicCount} Hilfethema/-themen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = { editingId = plan.id }) { Text("Bearbeiten") }
+ TextButton(onClick = { onDelete(plan.id) }) { Text("Löschen") }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Admin: Prioritäten ──────────────────────────────────────────────────────
+
+@Composable
+private fun AdminPrioritiesScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, String, String, Int, Int, Int, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val items = state.prioritiesAdmin
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Prioritäten / SLA", onBack) },
+ floatingActionButton = {
+ if (items != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Priorität") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.prioritiesAdminLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Prioritäten werden geladen …") }
+ state.prioritiesAdminError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.prioritiesAdminError, onLoad) }
+ items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val prio = items.firstOrNull { it.id == editingId }
+ var name by rememberSaveable(prio?.id) { mutableStateOf(prio?.name ?: "") }
+ var color by rememberSaveable(prio?.id) { mutableStateOf(prio?.color ?: "#2563eb") }
+ var responseHours by rememberSaveable(prio?.id) { mutableStateOf((prio?.responseHours ?: 24).toString()) }
+ var resolutionHours by rememberSaveable(prio?.id) { mutableStateOf((prio?.resolutionHours ?: 72).toString()) }
+ var sortOrder by rememberSaveable(prio?.id) { mutableStateOf((prio?.sortOrder ?: 0).toString()) }
+ Column(
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (prio == null) "Neue Priorität" else "Priorität bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = color, onValueChange = { color = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Farbe (#RRGGBB)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(value = responseHours, onValueChange = { responseHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Reaktion (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ OutlinedTextField(value = resolutionHours, onValueChange = { resolutionHours = it.filter(Char::isDigit) }, modifier = Modifier.weight(1f), label = { Text("Lösung (Std)") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ }
+ OutlinedTextField(value = sortOrder, onValueChange = { sortOrder = it.filter(Char::isDigit) }, modifier = Modifier.fillMaxWidth(), label = { Text("Sortierung") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ state.prioritiesAdminMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = {
+ onSave(prio?.id, name.trim(), color.trim(), responseHours.toIntOrNull() ?: 24, resolutionHours.toIntOrNull() ?: 72, sortOrder.toIntOrNull() ?: 0) {
+ editingId = null; isCreating = false
+ }
+ },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.prioritiesAdminMutationLoading && name.isNotBlank(),
+ ) { Text(if (state.prioritiesAdminMutationLoading) "Wird gespeichert …" else "Speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+ }
+ items.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Prioritäten.") }
+ else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(items) { prio -> PriorityRowAdmin(prio, onEdit = { editingId = prio.id }, onDelete = { onDelete(prio.id) }) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PriorityRowAdmin(prio: PriorityFull, onEdit: () -> Unit, onDelete: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ 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) {
+ Text(prio.name, modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelMedium, color = parseHexColor(prio.color, MaterialTheme.colorScheme.primary), fontWeight = FontWeight.SemiBold)
+ }
+ }
+ Text("Reaktion: ${prio.responseHours}h · Lösung: ${prio.resolutionHours}h", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp))
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = onEdit) { Text("Bearbeiten") }
+ TextButton(onClick = onDelete) { Text("Löschen") }
+ }
+ }
+ }
+}
+
+// ─── Admin: Hilfe-Themen ─────────────────────────────────────────────────────
+
+@Composable
+private fun AdminHelpTopicsScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, HelpTopicInput, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val data = state.helpTopics
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Hilfethemen", onBack) },
+ floatingActionButton = {
+ if (data != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neues Hilfethema") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.helpTopicsLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Hilfethemen werden geladen …") }
+ state.helpTopicsError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.helpTopicsError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val topic = data.topics.firstOrNull { it.id == editingId }
+ var tab by rememberSaveable { mutableStateOf(0) }
+ var name by rememberSaveable(topic?.id) { mutableStateOf(topic?.name ?: "") }
+ var isActive by rememberSaveable(topic?.id) { mutableStateOf(topic?.isActive ?: true) }
+ var isPublic by rememberSaveable(topic?.id) { mutableStateOf(topic?.isPublic ?: true) }
+ var parentId by rememberSaveable(topic?.id) { mutableStateOf(topic?.parentId) }
+ var notes by rememberSaveable(topic?.id) { mutableStateOf(topic?.notes ?: "") }
+ var departmentId by rememberSaveable(topic?.id) { mutableStateOf(topic?.departmentId ?: data.departments.firstOrNull()?.id ?: 0) }
+ var priorityId by rememberSaveable(topic?.id) { mutableStateOf(topic?.priorityId ?: data.priorities.firstOrNull()?.id ?: 0) }
+ var sortOrder by rememberSaveable(topic?.id) { mutableStateOf((topic?.sortOrder ?: 0).toString()) }
+ var deptExpanded by remember { mutableStateOf(false) }
+ var prioExpanded by remember { mutableStateOf(false) }
+ var parentExpanded by remember { mutableStateOf(false) }
+ var statusExpanded by remember { mutableStateOf(false) }
+ var topicSlaPlanId by rememberSaveable(topic?.id) { mutableStateOf(topic?.slaPlanId) }
+ var topicSlaExpanded by remember { mutableStateOf(false) }
+ // Eigenes Thema und dessen Unterthemen kommen als Elternthema nicht in Frage
+ val descendants = mutableSetOf().also { set ->
+ if (topic != null) {
+ set.add(topic.id)
+ var grew = true
+ while (grew) { grew = false; data.topics.forEach { t -> if (t.parentId in set && set.add(t.id)) grew = true } }
+ }
+ }
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
+ Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Informationen") })
+ Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Optionen bei neuem Ticket") })
+ }
+ Column(
+ modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (topic == null) "Neues Hilfethema" else "Hilfethema aktualisieren", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ if (tab == 0) {
+ OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Thema") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ SelectionField(label = "Status", value = if (isActive) "Aktiv" else "Deaktiviert", expanded = statusExpanded, onExpand = { statusExpanded = true }, onDismiss = { statusExpanded = false }) {
+ DropdownMenuItem(text = { Text("Aktiv") }, onClick = { isActive = true; statusExpanded = false })
+ DropdownMenuItem(text = { Text("Deaktiviert") }, onClick = { isActive = false; statusExpanded = false })
+ }
+ Text("Typ", style = MaterialTheme.typography.labelLarge)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(selected = isPublic, onClick = { isPublic = true })
+ Text("Öffentlich", Modifier.clickable { isPublic = true })
+ Spacer(Modifier.width(16.dp))
+ RadioButton(selected = !isPublic, onClick = { isPublic = false })
+ Text("Vertraulich/Intern", Modifier.clickable { isPublic = false })
+ }
+ SelectionField(label = "Übergeordnetes Thema", value = data.topics.firstOrNull { it.id == parentId }?.path ?: "— Oberste Hilfethemenebene —", expanded = parentExpanded, onExpand = { parentExpanded = true }, onDismiss = { parentExpanded = false }) {
+ DropdownMenuItem(text = { Text("— Oberste Hilfethemenebene —") }, onClick = { parentId = null; parentExpanded = false })
+ data.topics.filter { it.id !in descendants }.forEach { t -> DropdownMenuItem(text = { Text(t.path) }, onClick = { parentId = t.id; parentExpanded = false }) }
+ }
+ OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Interne Notizen (vertraulich)") }, minLines = 4, shape = MaterialTheme.shapes.medium)
+ } else {
+ SelectionField(label = "Abteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) {
+ data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; deptExpanded = false }) }
+ }
+ SelectionField(label = "Priorität", value = data.priorities.firstOrNull { it.id == priorityId }?.name ?: "–", expanded = prioExpanded, onExpand = { prioExpanded = true }, onDismiss = { prioExpanded = false }) {
+ data.priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { priorityId = p.id; prioExpanded = false }) }
+ }
+ SelectionField(label = "SLA-Plan", value = data.slaPlans.firstOrNull { it.id == topicSlaPlanId }?.name ?: "Abteilung/Priorität bestimmt die Zeiten", expanded = topicSlaExpanded, onExpand = { topicSlaExpanded = true }, onDismiss = { topicSlaExpanded = false }) {
+ DropdownMenuItem(text = { Text("Abteilung/Priorität bestimmt die Zeiten") }, onClick = { topicSlaPlanId = null; topicSlaExpanded = false })
+ data.slaPlans.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { topicSlaPlanId = p.id; topicSlaExpanded = false }) }
+ }
+ OutlinedTextField(value = sortOrder, onValueChange = { sortOrder = it.filter(Char::isDigit) }, modifier = Modifier.fillMaxWidth(), label = { Text("Sortierung") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ }
+ state.helpTopicsMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = {
+ onSave(topic?.id, HelpTopicInput(name.trim(), isActive, isPublic, parentId, topicSlaPlanId, notes.trim(), departmentId, priorityId, sortOrder.toIntOrNull() ?: 0)) { editingId = null; isCreating = false; tab = 0 }
+ },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.helpTopicsMutationLoading && name.isNotBlank() && departmentId != 0 && priorityId != 0,
+ ) { Text(if (state.helpTopicsMutationLoading) "Wird gespeichert …" else "Änderungen speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+ }
+ }
+ data.topics.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Hilfethemen.") }
+ else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(data.topics.sortedBy { it.path.lowercase() }) { topic -> HelpTopicRowAdmin(topic, onEdit = { editingId = topic.id }, onDelete = { onDelete(topic.id) }) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HelpTopicRowAdmin(topic: HelpTopic, onEdit: () -> Unit, onDelete: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(topic.path, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
+ if (!topic.isPublic) {
+ Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Intern", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ Spacer(Modifier.width(6.dp))
+ }
+ if (!topic.isActive) {
+ Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ Text("${topic.departmentName} · ${topic.priorityName}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = onEdit) { Text("Bearbeiten") }
+ TextButton(onClick = onDelete) { Text("Löschen") }
+ }
+ }
+ }
+}
+
+// ─── Admin: Organisationen ───────────────────────────────────────────────────
+
+@Composable
+private fun AdminOrganizationsScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, String, String?, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val items = state.organizations
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Organisationen", onBack) },
+ floatingActionButton = {
+ if (items != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Organisation") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.organizationsLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Organisationen werden geladen …") }
+ state.organizationsError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.organizationsError, onLoad) }
+ items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val org = items.firstOrNull { it.id == editingId }
+ var name by rememberSaveable(org?.id) { mutableStateOf(org?.name ?: "") }
+ var notes by rememberSaveable(org?.id) { mutableStateOf(org?.notes ?: "") }
+ Column(
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (org == null) "Neue Organisation" else "Organisation bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Notizen (optional)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
+ state.organizationsMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onSave(org?.id, name.trim(), notes.trim().ifBlank { null }) { editingId = null; isCreating = false } },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.organizationsMutationLoading && name.isNotBlank(),
+ ) { Text(if (state.organizationsMutationLoading) "Wird gespeichert …" else "Speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+ }
+ 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) {
+ Column(Modifier.padding(14.dp)) {
+ Text(org.name, fontWeight = FontWeight.SemiBold)
+ Text("${org.memberCount} Kontakte", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = { editingId = org.id }) { Text("Bearbeiten") }
+ TextButton(onClick = { onDelete(org.id) }) { Text("Löschen") }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Admin: Teams ────────────────────────────────────────────────────────────
+
+@Composable
+private fun AdminTeamsScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, String, Int?, String?, List, () -> Unit) -> Unit,
+ onToggle: (Int) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val data = state.teams
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Teams", onBack) },
+ floatingActionButton = {
+ if (data != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neues Team") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.teamsLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Teams werden geladen …") }
+ state.teamsError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.teamsError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val team = data.teams.firstOrNull { it.id == editingId }
+ var name by rememberSaveable(team?.id) { mutableStateOf(team?.name ?: "") }
+ var leadId by rememberSaveable(team?.id) { mutableStateOf(team?.leadId) }
+ var notes by rememberSaveable(team?.id) { mutableStateOf(team?.notes ?: "") }
+ var memberIds by rememberSaveable(team?.id) { mutableStateOf(team?.memberIds?.toSet() ?: emptySet()) }
+ var leadExpanded by remember { mutableStateOf(false) }
+ Column(
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (team == null) "Neues Team" else "Team bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ SelectionField(label = "Teamleiter", value = data.staffOptions.firstOrNull { it.id == leadId }?.name ?: "– kein Leiter –", expanded = leadExpanded, onExpand = { leadExpanded = true }, onDismiss = { leadExpanded = false }) {
+ DropdownMenuItem(text = { Text("– kein Leiter –") }, onClick = { leadId = null; leadExpanded = false })
+ data.staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { leadId = s.id; leadExpanded = false }) }
+ }
+ Text("Mitglieder", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
+ Column(
+ Modifier.fillMaxWidth().border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.medium).padding(10.dp).heightIn(max = 220.dp).verticalScroll(rememberScrollState()),
+ ) {
+ data.staffOptions.forEach { s ->
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
+ Checkbox(
+ checked = memberIds.contains(s.id),
+ onCheckedChange = { checked -> memberIds = if (checked) memberIds + s.id else memberIds - s.id },
+ )
+ Text(s.name)
+ }
+ }
+ }
+ OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Notizen (optional)") }, minLines = 2, shape = MaterialTheme.shapes.medium)
+ state.teamsMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onSave(team?.id, name.trim(), leadId, notes.trim().ifBlank { null }, memberIds.toList()) { editingId = null; isCreating = false } },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.teamsMutationLoading && name.isNotBlank(),
+ ) { Text(if (state.teamsMutationLoading) "Wird gespeichert …" else "Speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+ }
+ data.teams.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine Teams.") }
+ else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(data.teams) { team -> TeamRowAdmin(team, onEdit = { editingId = team.id }, onToggle = { onToggle(team.id) }, onDelete = { onDelete(team.id) }) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TeamRowAdmin(team: Team, onEdit: () -> Unit, onToggle: () -> Unit, onDelete: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(team.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
+ Surface(color = if (team.isActive) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
+ Text(if (team.isActive) "Aktiv" else "Inaktiv", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ Text("Leiter: ${team.leadName ?: "–"} · ${team.memberCount} Mitglieder", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = onEdit) { Text("Bearbeiten") }
+ TextButton(onClick = onToggle) { Text(if (team.isActive) "Deaktivieren" else "Aktivieren") }
+ TextButton(onClick = onDelete) { Text("Löschen") }
+ }
+ }
+ }
+}
+
+// ─── Admin: Rollen ───────────────────────────────────────────────────────────
+
+private val TicketPermissionLabels = linkedMapOf(
+ "reply_ticket" to "Tickets beantworten",
+ "close_ticket" to "Tickets schließen/lösen",
+ "delete_ticket" to "Tickets löschen",
+ "transfer_ticket" to "Tickets übertragen",
+ "post_note" to "Interne Notizen schreiben",
+ "assign_ticket" to "Tickets zuweisen",
+ "manage_canned" to "Textbausteine verwalten",
+ "view_reports" to "Berichte einsehen",
+ "manage_kb" to "Wissensdatenbank verwalten",
+ "create_ticket" to "Tickets im Namen von Kunden erstellen",
+)
+
+/** Feinkörnige Adminportal-Bereichsrechte – muss zu admin_section_permissions() in includes/functions.php passen. */
+private val AdminSectionPermissionLabels = linkedMapOf(
+ "manage_departments" to "Abteilungen verwalten",
+ "manage_priorities" to "Prioritäten / SLA verwalten",
+ "manage_help_topics" to "Hilfe-Themen verwalten",
+ "manage_email_addresses" to "E-Mail-Adressen verwalten",
+ "manage_email_templates" to "E-Mail-Vorlagen verwalten",
+ "manage_organizations" to "Organisationen verwalten",
+ "manage_teams" to "Teams verwalten",
+ "manage_roles" to "Rollen verwalten",
+ "manage_staff" to "Mitarbeiter verwalten",
+ "view_audit_log" to "Admin-Protokoll einsehen",
+ "manage_escalations" to "Eskalationsregeln verwalten",
+ "view_time_entries" to "Zeiterfassung einsehen",
+ "manage_business_hours" to "Geschäftszeiten verwalten",
+)
+
+private val RolePermissionLabels = TicketPermissionLabels + AdminSectionPermissionLabels
+
+/** Ist true für echte Admins UND für Agenten, denen eine der obigen Berechtigungen zugewiesen wurde. */
+private fun Staff?.hasAnyAdminAccess(): Boolean {
+ if (this == null) return false
+ if (role == "admin") return true
+ return AdminSectionPermissionLabels.keys.any { permissions[it] == true } || permissions["view_reports"] == true
+}
+
+@Composable
+private fun AdminRolesScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, String, Map, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val data = state.roles
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Rollen", onBack) },
+ floatingActionButton = {
+ if (data != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Rolle") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.rolesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Rollen werden geladen …") }
+ state.rolesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.rolesError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val role = data.roles.firstOrNull { it.id == editingId }
+ var name by rememberSaveable(role?.id) { mutableStateOf(role?.name ?: "") }
+ var perms by rememberSaveable(role?.id) { mutableStateOf(role?.permissions ?: emptyMap()) }
+ Column(
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (role == null) "Neue Rolle" else "Rolle bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ Text("Ticket-Berechtigungen", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
+ TicketPermissionLabels.forEach { (key, label) ->
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Checkbox(
+ checked = perms[key] == true,
+ onCheckedChange = { checked -> perms = perms + (key to checked) },
+ )
+ Text(label, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ Text("Adminportal-Bereiche (\"Co-Admin\")", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
+ AdminSectionPermissionLabels.forEach { (key, label) ->
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Checkbox(
+ checked = perms[key] == true,
+ onCheckedChange = { checked -> perms = perms + (key to checked) },
+ )
+ Text(label, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ state.rolesMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onSave(role?.id, name.trim(), perms) { editingId = null; isCreating = false } },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.rolesMutationLoading && name.isNotBlank(),
+ ) { Text(if (state.rolesMutationLoading) "Wird gespeichert …" else "Speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+ }
+ data.roles.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Noch keine benutzerdefinierten Rollen. Ohne eigene Rolle gelten die Standard-Agentenrechte.") }
+ else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(data.roles) { role -> RoleRowAdmin(role, onEdit = { editingId = role.id }, onDelete = { onDelete(role.id) }) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RoleRowAdmin(role: Role, onEdit: () -> Unit, onDelete: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(14.dp)) {
+ Text(role.name, fontWeight = FontWeight.SemiBold)
+ Text("${role.staffCount} Mitarbeiter", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ val granted = role.permissions.filterValues { it }.keys.mapNotNull { RolePermissionLabels[it] }
+ Text(
+ if (granted.isEmpty()) "Keine Berechtigungen" else granted.joinToString(", "),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = onEdit) { Text("Bearbeiten") }
+ TextButton(onClick = onDelete) { Text("Löschen") }
+ }
+ }
+ }
+}
+
+// ─── Admin: Mitarbeiter ──────────────────────────────────────────────────────
+
+@Composable
+private fun AdminStaffScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, StaffAdminInput, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+ onUploadImage: (ByteArray, String, String, (String) -> Unit) -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val data = state.staffAdmin
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("Mitarbeiter", onBack) },
+ floatingActionButton = {
+ if (data != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neuer Mitarbeiter") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.staffAdminLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Mitarbeiter werden geladen …") }
+ state.staffAdminError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.staffAdminError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val person = data.staff.firstOrNull { it.id == editingId }
+ var tab by rememberSaveable { mutableStateOf(0) }
+ var name by rememberSaveable(person?.id) { mutableStateOf(person?.name ?: "") }
+ var email by rememberSaveable(person?.id) { mutableStateOf(person?.email ?: "") }
+ var role by rememberSaveable(person?.id) { mutableStateOf(person?.role ?: "agent") }
+ var roleId by rememberSaveable(person?.id) { mutableStateOf(person?.roleId) }
+ var departmentId by rememberSaveable(person?.id) { mutableStateOf(person?.departmentId) }
+ var isActive by rememberSaveable(person?.id) { mutableStateOf(person?.isActive ?: true) }
+ var password by rememberSaveable(person?.id) { mutableStateOf("") }
+ var signature by rememberSaveable(person?.id) { mutableStateOf(person?.signature ?: "") }
+ var roleExpanded by remember { mutableStateOf(false) }
+ var customRoleExpanded by remember { mutableStateOf(false) }
+ var deptExpanded by remember { mutableStateOf(false) }
+ var addExpanded by remember { mutableStateOf(false) }
+ var extraDepts by rememberSaveable(person?.id) {
+ mutableStateOf(person?.extraDepartments?.associate { it.departmentId to it.notify } ?: emptyMap())
+ }
+ // Rolle pro erweiterter Abteilung (0 = Hauptrolle)
+ var extraRoles by rememberSaveable(person?.id) {
+ mutableStateOf(person?.extraDepartments?.associate { it.departmentId to (it.roleId ?: 0) } ?: emptyMap())
+ }
+ var roleMenuFor by remember { mutableStateOf(null) }
+ var teamIds by rememberSaveable(person?.id) { mutableStateOf(person?.teamIds ?: emptyList()) }
+
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
+ Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Konto") })
+ Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Zugang") })
+ Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Berechtigungen") })
+ Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("Teams") })
+ }
+ Column(
+ modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (person == null) "Neuer Agent" else "Agenten verwalten — ${person.name}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ when (tab) {
+ 0 -> {
+ OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = email, onValueChange = { email = it }, modifier = Modifier.fillMaxWidth(), label = { Text("E-Mail-Adresse") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email))
+ Text("Status", style = MaterialTheme.typography.labelLarge)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(selected = isActive, onClick = { isActive = true })
+ Text("Aktiv", Modifier.clickable { isActive = true })
+ Spacer(Modifier.width(16.dp))
+ RadioButton(selected = !isActive, onClick = { isActive = false })
+ Text("Gesperrt", Modifier.clickable { isActive = false })
+ }
+ OutlinedTextField(
+ value = password, onValueChange = { password = it }, modifier = Modifier.fillMaxWidth(),
+ label = { Text(if (person == null) "Passwort" else "Neues Passwort (leer = unverändert)") },
+ singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(),
+ )
+ SignatureField(
+ value = signature,
+ onValueChange = { signature = it },
+ uploading = state.imageUploadLoading,
+ uploadError = state.imageUploadError,
+ onUpload = onUploadImage,
+ minLines = 2,
+ )
+ }
+ 1 -> {
+ Text("Wähle die Abteilungen, zu denen der Agent Zugang haben soll.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ SelectionField(label = "Hauptabteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) {
+ DropdownMenuItem(text = { Text("–") }, onClick = { departmentId = null; deptExpanded = false })
+ data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; extraDepts = extraDepts - d.id; deptExpanded = false }) }
+ }
+ Text("Erweiterter Zugriff", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+ data.departments.filter { extraDepts.containsKey(it.id) && it.id != departmentId }.forEach { d ->
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Text(d.name, modifier = Modifier.weight(1f))
+ Box {
+ TextButton(onClick = { roleMenuFor = d.id }) { Text(data.roles.firstOrNull { it.id == extraRoles[d.id] }?.name ?: "Hauptrolle") }
+ DropdownMenu(expanded = roleMenuFor == d.id, onDismissRequest = { roleMenuFor = null }) {
+ DropdownMenuItem(text = { Text("Hauptrolle") }, onClick = { extraRoles = extraRoles + (d.id to 0); roleMenuFor = null })
+ data.roles.forEach { r -> DropdownMenuItem(text = { Text(r.name) }, onClick = { extraRoles = extraRoles + (d.id to r.id); roleMenuFor = null }) }
+ }
+ }
+ Checkbox(checked = extraDepts[d.id] == true, onCheckedChange = { notify -> extraDepts = extraDepts + (d.id to notify) })
+ Text("Benachr.", style = MaterialTheme.typography.labelSmall)
+ TextButton(onClick = { extraDepts = extraDepts - d.id; extraRoles = extraRoles - d.id }) { Text("✕") }
+ }
+ }
+ val addable = data.departments.filter { it.id != departmentId && !extraDepts.containsKey(it.id) }
+ if (addable.isNotEmpty()) {
+ SelectionField(label = "Abteilung hinzufügen", value = "— Abteilung auswählen —", expanded = addExpanded, onExpand = { addExpanded = true }, onDismiss = { addExpanded = false }) {
+ addable.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { extraDepts = extraDepts + (d.id to true); addExpanded = false }) }
+ }
+ }
+ }
+ 2 -> {
+ SelectionField(label = "Kontotyp", value = if (role == "admin") "Administrator (alle Rechte)" else "Mitarbeiter", expanded = roleExpanded, onExpand = { roleExpanded = true }, onDismiss = { roleExpanded = false }) {
+ DropdownMenuItem(text = { Text("Mitarbeiter") }, onClick = { role = "agent"; roleExpanded = false })
+ DropdownMenuItem(text = { Text("Administrator (alle Rechte)") }, onClick = { role = "admin"; roleExpanded = false })
+ }
+ if (role != "admin" && data.roles.isNotEmpty()) {
+ SelectionField(label = "Rolle", value = data.roles.firstOrNull { it.id == roleId }?.name ?: "– Standard-Mitarbeiterrechte –", expanded = customRoleExpanded, onExpand = { customRoleExpanded = true }, onDismiss = { customRoleExpanded = false }) {
+ DropdownMenuItem(text = { Text("– Standard-Mitarbeiterrechte –") }, onClick = { roleId = null; customRoleExpanded = false })
+ data.roles.forEach { r -> DropdownMenuItem(text = { Text(r.name) }, onClick = { roleId = r.id; customRoleExpanded = false }) }
+ }
+ }
+ Text("Rollen und ihre Rechte legst du unter „Rollen“ an.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ else -> {
+ if (data.teams.isEmpty()) {
+ Text("Es sind noch keine Teams angelegt.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ Text(
+ if (data.canManageTeams) "Der Agent ist Mitglied in folgenden Teams." else "Zum Ändern fehlt das Recht „Teams verwalten“.",
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ data.teams.forEach { t ->
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(
+ checked = t.id in teamIds, enabled = data.canManageTeams,
+ onCheckedChange = { on -> teamIds = if (on) teamIds + t.id else teamIds - t.id },
+ )
+ Text(t.name)
+ }
+ }
+ }
+ }
+ }
+ state.staffAdminMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = {
+ onSave(
+ person?.id,
+ StaffAdminInput(
+ name = name.trim(), email = email.trim(), role = role, roleId = if (role == "admin") null else roleId, departmentId = departmentId,
+ isActive = isActive, password = password, signature = signature.trim().ifBlank { null },
+ extraDepartments = extraDepts.filterKeys { it != departmentId }.map { (deptId, notify) -> StaffExtraDept(deptId, notify, extraRoles[deptId]?.takeIf { it != 0 }) },
+ teamIds = if (data.canManageTeams) teamIds else null,
+ ),
+ ) { editingId = null; isCreating = false; tab = 0 }
+ },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.staffAdminMutationLoading && name.isNotBlank() && email.isNotBlank() && (person != null || password.isNotBlank()),
+ ) { Text(if (state.staffAdminMutationLoading) "Wird gespeichert …" else "Änderungen speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ if (person != null) {
+ HorizontalDivider(Modifier.padding(vertical = 8.dp))
+ OutlinedButton(
+ onClick = { onDelete(person.id); editingId = null },
+ shape = MaterialTheme.shapes.medium,
+ colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) { Text("Mitarbeiter löschen") }
+ }
+ }
+ }
+ }
+ data.staff.isEmpty() -> Box(Modifier.padding(padding)) { EmptyState("Keine Mitarbeiter gefunden.") }
+ else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(data.staff) { s -> StaffRowAdmin(s, onEdit = { editingId = s.id }) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StaffRowAdmin(s: StaffAdmin, onEdit: () -> Unit) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium, 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))
+ if (!s.isActive) {
+ Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Gesperrt", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ Text(s.email, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(
+ "${if (s.role == "admin") "Administrator" else "Mitarbeiter"} · ${s.departmentName ?: "keine Abteilung"}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+// ─── Admin: Geschäftszeiten ──────────────────────────────────────────────────
+
+private val BusinessWeekdayNames = linkedMapOf(
+ 1 to "Montag", 2 to "Dienstag", 3 to "Mittwoch", 4 to "Donnerstag", 5 to "Freitag", 6 to "Samstag", 0 to "Sonntag",
+)
+
+@Composable
+private fun AdminBusinessHoursScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSaveHours: (Boolean, List, () -> Unit) -> Unit,
+ onAddHoliday: (String, String) -> Unit,
+ onDeleteHoliday: (Int) -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ val data = state.businessHours
+
+ Scaffold(topBar = { AdminTopBar("Geschäftszeiten", onBack) }) { padding ->
+ when {
+ state.businessHoursLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Geschäftszeiten werden geladen …") }
+ state.businessHoursError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.businessHoursError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ else -> {
+ var enabled by rememberSaveable(data) { mutableStateOf(data.enabled) }
+ var hours by rememberSaveable(data) { mutableStateOf(data.hours) }
+ var newHolidayDate by rememberSaveable { mutableStateOf("") }
+ var newHolidayName by rememberSaveable { mutableStateOf("") }
+
+ LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ item {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = enabled, onCheckedChange = { enabled = it })
+ Text("Geschäftszeiten für SLA-Berechnung verwenden")
+ }
+ }
+ items(hours) { h ->
+ val idx = hours.indexOf(h)
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ 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) } })
+ Text(BusinessWeekdayNames[h.weekday] ?: h.weekday.toString(), fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
+ }
+ if (h.isOpen) {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(
+ value = h.openTime, onValueChange = { v -> hours = hours.toMutableList().also { it[idx] = h.copy(openTime = v) } },
+ modifier = Modifier.weight(1f), label = { Text("Von (HH:MM)") }, singleLine = true, shape = MaterialTheme.shapes.medium,
+ )
+ OutlinedTextField(
+ value = h.closeTime, onValueChange = { v -> hours = hours.toMutableList().also { it[idx] = h.copy(closeTime = v) } },
+ modifier = Modifier.weight(1f), label = { Text("Bis (HH:MM)") }, singleLine = true, shape = MaterialTheme.shapes.medium,
+ )
+ }
+ }
+ }
+ }
+ }
+ item {
+ state.businessHoursMutationError?.let { ErrorText(it) }
+ Button(
+ onClick = { onSaveHours(enabled, hours) {} },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.businessHoursMutationLoading,
+ ) { Text(if (state.businessHoursMutationLoading) "Wird gespeichert …" else "Öffnungszeiten speichern") }
+ }
+ item { HorizontalDivider(Modifier.padding(vertical = 8.dp)); Text("Feiertage", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) }
+ if (data.holidays.isEmpty()) {
+ item { EmptyState("Noch keine Feiertage eingetragen.") }
+ }
+ items(data.holidays) { h ->
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ 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") }
+ }
+ }
+ }
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(value = newHolidayDate, onValueChange = { newHolidayDate = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Datum (JJJJ-MM-TT)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = newHolidayName, onValueChange = { newHolidayName = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ Button(
+ onClick = { onAddHoliday(newHolidayDate.trim(), newHolidayName.trim()); newHolidayDate = ""; newHolidayName = "" },
+ shape = MaterialTheme.shapes.medium,
+ enabled = newHolidayDate.isNotBlank() && newHolidayName.isNotBlank(),
+ ) { Text("Feiertag hinzufügen") }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Admin: E-Mail-Adressen ──────────────────────────────────────────────────
+
+@Composable
+private fun AdminEmailAddressesScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (Int?, EmailAddressInput, () -> Unit) -> Unit,
+ onDelete: (Int) -> Unit,
+ onTest: (Int, String) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ var editingId by rememberSaveable { mutableStateOf(null) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val data = state.emailAddresses
+ val showForm = isCreating || editingId != null
+
+ Scaffold(
+ topBar = { AdminTopBar("E-Mail-Adressen", onBack) },
+ floatingActionButton = {
+ if (data != null && !showForm) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Adresse") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.emailAddressesLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("E-Mail-Adressen werden geladen …") }
+ state.emailAddressesError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.emailAddressesError, onLoad) }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ showForm -> {
+ val addr = data.addresses.firstOrNull { it.id == editingId }
+ var tab by rememberSaveable { mutableStateOf(0) }
+ // Konto
+ var email by rememberSaveable(addr?.id) { mutableStateOf(addr?.email ?: "") }
+ var label by rememberSaveable(addr?.id) { mutableStateOf(addr?.label ?: "") }
+ var departmentId by rememberSaveable(addr?.id) { mutableStateOf(addr?.departmentId ?: data.departments.firstOrNull()?.id ?: 0) }
+ var priorityId by rememberSaveable(addr?.id) { mutableStateOf(addr?.priorityId) }
+ var isDefault by rememberSaveable(addr?.id) { mutableStateOf(addr?.isDefault ?: false) }
+ var deptExpanded by remember { mutableStateOf(false) }
+ var prioExpanded by remember { mutableStateOf(false) }
+ // Entferntes Postfach
+ var imapEnabled by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapEnabled ?: false) }
+ var imapHost by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapHost ?: "") }
+ var imapPort by rememberSaveable(addr?.id) { mutableStateOf((addr?.imapPort ?: 993).toString()) }
+ var imapEncryption by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapEncryption ?: "ssl") }
+ var imapUsername by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapUsername ?: "") }
+ var imapPassword by rememberSaveable(addr?.id) { mutableStateOf("") }
+ var imapFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapFolder ?: "INBOX") }
+ var imapAfterFetch by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapAfterFetch ?: "seen") }
+ var imapArchiveFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapArchiveFolder ?: "Processed") }
+ var imapSpamFolder by rememberSaveable(addr?.id) { mutableStateOf(addr?.imapSpamFolder ?: "Junk") }
+ // Konto: Status, Hilfethema, Notizen
+ var isActive by rememberSaveable(addr?.id) { mutableStateOf(addr?.isActive ?: true) }
+ var helpTopicId by rememberSaveable(addr?.id) { mutableStateOf(addr?.helpTopicId) }
+ var notes by rememberSaveable(addr?.id) { mutableStateOf(addr?.notes ?: "") }
+ var topicExpanded by remember { mutableStateOf(false) }
+ // Abgehend (eigener SMTP)
+ var smtpEnabled by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpEnabled ?: false) }
+ var smtpHost by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpHost ?: "") }
+ var smtpPort by rememberSaveable(addr?.id) { mutableStateOf((addr?.smtpPort ?: 587).toString()) }
+ var smtpEncryption by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpEncryption ?: "tls") }
+ var smtpUsername by rememberSaveable(addr?.id) { mutableStateOf(addr?.smtpUsername ?: "") }
+ var smtpPassword by rememberSaveable(addr?.id) { mutableStateOf("") }
+ var smtpEncExpanded by remember { mutableStateOf(false) }
+ val smtpEncLabels = listOf("tls" to "STARTTLS (587)", "ssl" to "SSL/TLS (465)", "none" to "Keine")
+ var encExpanded by remember { mutableStateOf(false) }
+ var afterExpanded by remember { mutableStateOf(false) }
+ val encLabels = listOf("ssl" to "SSL/TLS (993)", "tls" to "STARTTLS (143)", "none" to "Keine")
+ val afterLabels = listOf("seen" to "Als gelesen markieren", "archive" to "In Ordner verschieben", "delete" to "Löschen")
+
+ Column(Modifier.padding(padding).fillMaxSize()) {
+ ScrollableTabRow(selectedTabIndex = tab, edgePadding = 12.dp) {
+ Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Konto") })
+ Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Entferntes Postfach") })
+ Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Abgehend (SMTP)") })
+ }
+ Column(
+ modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(if (addr == null) "Neue E-Mail-Adresse" else "E-Mail-Adresse bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ if (tab == 0) {
+ OutlinedTextField(value = email, onValueChange = { email = it }, modifier = Modifier.fillMaxWidth(), label = { Text("E-Mail-Adresse") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email))
+ OutlinedTextField(value = label, onValueChange = { label = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Anzeigename (optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ Text("Status", style = MaterialTheme.typography.labelLarge)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(selected = isActive, onClick = { isActive = true })
+ Text("Aktiv", Modifier.clickable { isActive = true })
+ Spacer(Modifier.width(16.dp))
+ RadioButton(selected = !isActive, onClick = { isActive = false })
+ Text("Deaktiviert", Modifier.clickable { isActive = false })
+ }
+ Text("Einstellungen bei neuem Ticket", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
+ SelectionField(label = "Abteilung", value = data.departments.firstOrNull { it.id == departmentId }?.name ?: "–", expanded = deptExpanded, onExpand = { deptExpanded = true }, onDismiss = { deptExpanded = false }) {
+ data.departments.forEach { d -> DropdownMenuItem(text = { Text(d.name) }, onClick = { departmentId = d.id; deptExpanded = false }) }
+ }
+ SelectionField(label = "Priorität", value = data.priorities.firstOrNull { it.id == priorityId }?.name ?: "Systemstandard", expanded = prioExpanded, onExpand = { prioExpanded = true }, onDismiss = { prioExpanded = false }) {
+ DropdownMenuItem(text = { Text("Systemstandard") }, onClick = { priorityId = null; prioExpanded = false })
+ data.priorities.forEach { p -> DropdownMenuItem(text = { Text(p.name) }, onClick = { priorityId = p.id; prioExpanded = false }) }
+ }
+ SelectionField(label = "Hilfethema", value = data.helpTopics.firstOrNull { it.id == helpTopicId }?.name ?: "Systemstandard", expanded = topicExpanded, onExpand = { topicExpanded = true }, onDismiss = { topicExpanded = false }) {
+ DropdownMenuItem(text = { Text("Systemstandard") }, onClick = { helpTopicId = null; topicExpanded = false })
+ data.helpTopics.forEach { t -> DropdownMenuItem(text = { Text(t.name) }, onClick = { helpTopicId = t.id; topicExpanded = false }) }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = isDefault, onCheckedChange = { isDefault = it })
+ Text("Standard-Absenderadresse dieser Abteilung")
+ }
+ OutlinedTextField(value = notes, onValueChange = { notes = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Interne Notizen (vertraulich)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
+ } else if (tab == 2) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = smtpEnabled, onCheckedChange = { smtpEnabled = it })
+ Text("Eigenen SMTP-Server für diese Adresse verwenden")
+ }
+ Text("Ohne eigenen Server wird der globale SMTP aus den Einstellungen genutzt.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ if (smtpEnabled) {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(value = smtpHost, onValueChange = { smtpHost = it }, modifier = Modifier.weight(1f), label = { Text("Server") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = smtpPort, onValueChange = { smtpPort = it.filter(Char::isDigit) }, modifier = Modifier.width(100.dp), label = { Text("Port") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ }
+ OutlinedTextField(value = smtpUsername, onValueChange = { smtpUsername = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(
+ value = smtpPassword, onValueChange = { smtpPassword = it }, modifier = Modifier.fillMaxWidth(),
+ label = { Text(if (addr?.smtpHasPassword == true) "Passwort (leer = unverändert)" else "Passwort") },
+ singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(),
+ )
+ SelectionField(label = "Verschlüsselung", value = smtpEncLabels.firstOrNull { it.first == smtpEncryption }?.second ?: smtpEncryption, expanded = smtpEncExpanded, onExpand = { smtpEncExpanded = true }, onDismiss = { smtpEncExpanded = false }) {
+ smtpEncLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { smtpEncryption = v; smtpEncExpanded = false }) }
+ }
+ }
+ } else {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = imapEnabled, onCheckedChange = { imapEnabled = it })
+ Text("Abruf für diese Adresse aktivieren")
+ }
+ Text(
+ "Abteilung und Priorität stammen aus dem Reiter „Konto“." + (addr?.imapLastRunAt?.let { " Letzter Abruf: $it." } ?: ""),
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (imapEnabled) {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedTextField(value = imapHost, onValueChange = { imapHost = it }, modifier = Modifier.weight(1f), label = { Text("Server") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = imapPort, onValueChange = { imapPort = it.filter(Char::isDigit) }, modifier = Modifier.width(100.dp), label = { Text("Port") }, singleLine = true, shape = MaterialTheme.shapes.medium, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ }
+ OutlinedTextField(value = imapUsername, onValueChange = { imapUsername = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Benutzername (leer = E-Mail-Adresse)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(
+ value = imapPassword, onValueChange = { imapPassword = it }, modifier = Modifier.fillMaxWidth(),
+ label = { Text(if (addr?.imapHasPassword == true) "Passwort (leer = unverändert)" else "Passwort") },
+ singleLine = true, shape = MaterialTheme.shapes.medium, visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ )
+ SelectionField(label = "Verschlüsselung", value = encLabels.firstOrNull { it.first == imapEncryption }?.second ?: imapEncryption, expanded = encExpanded, onExpand = { encExpanded = true }, onDismiss = { encExpanded = false }) {
+ encLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { imapEncryption = v; encExpanded = false }) }
+ }
+ OutlinedTextField(value = imapFolder, onValueChange = { imapFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Ordner") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ SelectionField(label = "Aktion nach Abruf", value = afterLabels.firstOrNull { it.first == imapAfterFetch }?.second ?: imapAfterFetch, expanded = afterExpanded, onExpand = { afterExpanded = true }, onDismiss = { afterExpanded = false }) {
+ afterLabels.forEach { (v, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { imapAfterFetch = v; afterExpanded = false }) }
+ }
+ if (imapAfterFetch == "archive") {
+ OutlinedTextField(value = imapArchiveFolder, onValueChange = { imapArchiveFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Zielordner") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ }
+ OutlinedTextField(value = imapSpamFolder, onValueChange = { imapSpamFolder = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Spam-/Junk-Ordner") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ }
+ }
+ state.emailAddressesNotice?.let { Text(it, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodyMedium) }
+ state.emailAddressesMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = {
+ onSave(
+ addr?.id,
+ EmailAddressInput(
+ email = email.trim(), label = label.trim().ifBlank { null }, departmentId = departmentId, priorityId = priorityId, isDefault = isDefault,
+ helpTopicId = helpTopicId, isActive = isActive, notes = notes.trim(),
+ smtpEnabled = smtpEnabled, smtpHost = smtpHost.trim(), smtpPort = smtpPort.toIntOrNull() ?: 587, smtpEncryption = smtpEncryption,
+ smtpUsername = smtpUsername.trim(), smtpPassword = smtpPassword,
+ imapEnabled = imapEnabled, imapHost = imapHost.trim(), imapPort = imapPort.toIntOrNull() ?: 993, imapEncryption = imapEncryption,
+ imapUsername = imapUsername.trim(), imapPassword = imapPassword, imapFolder = imapFolder.trim(), imapAfterFetch = imapAfterFetch,
+ imapArchiveFolder = imapArchiveFolder.trim(), imapSpamFolder = imapSpamFolder.trim(),
+ ),
+ ) { editingId = null; isCreating = false }
+ },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.emailAddressesMutationLoading && email.isNotBlank() && departmentId != 0,
+ ) { Text(if (state.emailAddressesMutationLoading) "Bitte warten …" else "Speichern") }
+ OutlinedButton(onClick = { editingId = null; isCreating = false; tab = 0; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ if (addr != null) {
+ Text("Tests verwenden die gespeicherten Werte.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ OutlinedButton(onClick = { onTest(addr.id, "sendTest") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("Test-Mail senden") }
+ if (addr.imapEnabled) {
+ OutlinedButton(onClick = { onTest(addr.id, "testImap") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("Postfach testen") }
+ }
+ if (addr.smtpEnabled) {
+ OutlinedButton(onClick = { onTest(addr.id, "testSmtp") }, enabled = !state.emailAddressesMutationLoading, shape = MaterialTheme.shapes.medium) { Text("SMTP testen") }
+ }
+ }
+ }
+ }
+ }
+ }
+ 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) {
+ Column(Modifier.padding(14.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(addr.email, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
+ if (!addr.isActive) {
+ Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Deaktiviert", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ Spacer(Modifier.width(6.dp))
+ }
+ if (addr.imapEnabled) {
+ Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Abruf", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ Spacer(Modifier.width(6.dp))
+ }
+ if (addr.isDefault) {
+ Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) {
+ Text("Standard", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ Text("${addr.label?.let { "$it · " } ?: ""}${addr.departmentName}${addr.priorityName?.let { " · $it" } ?: ""}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Row(Modifier.padding(top = 6.dp)) {
+ TextButton(onClick = { editingId = addr.id }) { Text("Bearbeiten") }
+ TextButton(onClick = { onDelete(addr.id) }) { Text("Löschen") }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Admin: E-Mail-Vorlagen ──────────────────────────────────────────────────
+
+@Composable
+private fun AdminEmailTemplatesScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: () -> Unit,
+ onSave: (String, String, String) -> Unit,
+ onReset: (String) -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad() }
+ val items = state.emailTemplates
+
+ Scaffold(topBar = { AdminTopBar("E-Mail-Vorlagen", onBack) }) { padding ->
+ when {
+ state.emailTemplatesLoading && items == null -> Box(Modifier.padding(padding)) { LoadingState("Vorlagen werden geladen …") }
+ state.emailTemplatesError != null && items == null -> Box(Modifier.padding(padding)) { ErrorState(state.emailTemplatesError, onLoad) }
+ items == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ else -> LazyColumn(modifier = Modifier.padding(padding).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ item {
+ Text(
+ "Der Branding-Rahmen (Kopf-/Fußzeile) wird beim Versand automatisch ergänzt – hier nur den Inhalt anpassen.",
+ style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ 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) {
+ 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))
+ if (tpl.isCustomized) {
+ Surface(color = MaterialTheme.colorScheme.primaryContainer, shape = MaterialTheme.shapes.extraSmall) {
+ Text("angepasst", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), style = MaterialTheme.typography.labelSmall)
+ }
+ }
+ }
+ Text(
+ "Platzhalter: " + tpl.placeholders.joinToString(", ") { "{$it}" },
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(value = subject, onValueChange = { subject = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Betreff") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = body, onValueChange = { body = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Text (HTML erlaubt)") }, minLines = 5, shape = MaterialTheme.shapes.medium)
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { onSave(tpl.key, subject.trim(), body.trim()) },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.emailTemplatesMutationLoading && subject.isNotBlank() && body.isNotBlank(),
+ ) { Text("Speichern") }
+ if (tpl.isCustomized) {
+ OutlinedButton(onClick = { onReset(tpl.key) }, shape = MaterialTheme.shapes.medium) { Text("Zurücksetzen") }
+ }
+ }
+ }
+ }
+ }
+ state.emailTemplatesMutationError?.let { err -> item { ErrorText(err) } }
+ }
+ }
+ }
+}
+
+// ─── Admin: Admin-Protokoll ──────────────────────────────────────────────────
+
+@Composable
+private fun AdminAuditLogScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: (Boolean) -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad(true) }
+
+ Scaffold(topBar = { AdminTopBar("Admin-Protokoll", onBack) }) { padding ->
+ when {
+ state.auditLogLoading && state.auditLog.isEmpty() -> Box(Modifier.padding(padding)) { LoadingState("Protokoll wird geladen …") }
+ state.auditLogError != null && state.auditLog.isEmpty() -> Box(Modifier.padding(padding)) { ErrorState(state.auditLogError) { onLoad(true) } }
+ 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) {
+ Column(Modifier.padding(12.dp)) {
+ Text(entry.action, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ "${entry.staffName ?: "System"} · ${entry.createdAt}",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ }
+ }
+ }
+ if (state.auditLogHasMore) {
+ item {
+ OutlinedButton(
+ onClick = { onLoad(false) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.auditLogLoading,
+ ) { Text(if (state.auditLogLoading) "Lädt …" else "Mehr laden") }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ─── Aufgaben ────────────────────────────────────────────────────────────────
+
+private val TaskStatusLabels = linkedMapOf("open" to "Offen", "in_progress" to "In Arbeit", "done" to "Erledigt")
+
+@Composable
+private fun AdminTasksScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onLoad: (String) -> Unit,
+ onCreate: (String, String?, Int?, String?, String?, () -> Unit) -> Unit,
+ onUpdateStatus: (Int, String) -> Unit,
+ onDelete: (Int) -> Unit,
+ onClearError: () -> Unit,
+) {
+ LaunchedEffect(Unit) { onLoad(state.tasksFilter) }
+ var isCreating by rememberSaveable { mutableStateOf(false) }
+ val data = state.tasks
+
+ Scaffold(
+ topBar = { AdminTopBar("Aufgaben", onBack) },
+ floatingActionButton = {
+ if (data != null && !isCreating) {
+ FloatingActionButton(onClick = { isCreating = true }) { Icon(Icons.Filled.Add, contentDescription = "Neue Aufgabe") }
+ }
+ },
+ ) { padding ->
+ when {
+ state.tasksLoading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Aufgaben werden geladen …") }
+ state.tasksError != null && data == null -> Box(Modifier.padding(padding)) { ErrorState(state.tasksError) { onLoad(state.tasksFilter) } }
+ data == null -> Box(Modifier.padding(padding)) { EmptyState("Keine Daten verfügbar.") }
+ isCreating -> {
+ var title by rememberSaveable { mutableStateOf("") }
+ var description by rememberSaveable { mutableStateOf("") }
+ var assignedToId by rememberSaveable { mutableStateOf(null) }
+ var dueAt by rememberSaveable { mutableStateOf("") }
+ var ticketNumber by rememberSaveable { mutableStateOf("") }
+ var assignExpanded by remember { mutableStateOf(false) }
+ Column(
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text("Neue Aufgabe", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ OutlinedTextField(value = title, onValueChange = { title = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Titel") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = description, onValueChange = { description = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Beschreibung (optional)") }, minLines = 3, shape = MaterialTheme.shapes.medium)
+ SelectionField(label = "Zuweisen an", value = data.staffOptions.firstOrNull { it.id == assignedToId }?.name ?: "Nicht zugewiesen", expanded = assignExpanded, onExpand = { assignExpanded = true }, onDismiss = { assignExpanded = false }) {
+ DropdownMenuItem(text = { Text("Nicht zugewiesen") }, onClick = { assignedToId = null; assignExpanded = false })
+ data.staffOptions.forEach { s -> DropdownMenuItem(text = { Text(s.name) }, onClick = { assignedToId = s.id; assignExpanded = false }) }
+ }
+ OutlinedTextField(value = dueAt, onValueChange = { dueAt = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Fällig bis (JJJJ-MM-TT HH:MM, optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ OutlinedTextField(value = ticketNumber, onValueChange = { ticketNumber = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Ticketnummer verknüpfen (optional)") }, singleLine = true, shape = MaterialTheme.shapes.medium)
+ state.tasksMutationError?.let { ErrorText(it) }
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = {
+ onCreate(title.trim(), description.trim().ifBlank { null }, assignedToId, dueAt.trim().ifBlank { null }.let { it?.replace(" ", "T") }, ticketNumber.trim().ifBlank { null }) {
+ isCreating = false
+ }
+ },
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.tasksMutationLoading && title.isNotBlank(),
+ ) { Text(if (state.tasksMutationLoading) "Wird erstellt …" else "Erstellen") }
+ OutlinedButton(onClick = { isCreating = false; onClearError() }, shape = MaterialTheme.shapes.medium) { Text("Abbrechen") }
+ }
+ }
+ }
+ else -> Column(Modifier.padding(padding).fillMaxSize()) {
+ ScrollableTabRow(selectedTabIndex = listOf("my", "all", "open", "done").indexOf(state.tasksFilter).coerceAtLeast(0), edgePadding = 12.dp) {
+ listOf("my" to "Meine", "all" to "Alle", "open" to "Offen", "done" to "Erledigt").forEach { (value, label) ->
+ Tab(selected = state.tasksFilter == value, onClick = { onLoad(value) }, text = { Text(label) })
+ }
+ }
+ if (data.tasks.isEmpty()) {
+ EmptyState("Keine Aufgaben gefunden.")
+ } else {
+ LazyColumn(contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ items(data.tasks, key = StaffTask::id) { task -> TaskRowAdmin(task, onUpdateStatus = { onUpdateStatus(task.id, it) }, onDelete = { onDelete(task.id) }) }
+ }
+ }
+ }
+ }
+ }
+}
+
+@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) {
+ Column(Modifier.padding(14.dp)) {
+ Text(
+ task.title,
+ fontWeight = FontWeight.SemiBold,
+ textDecoration = if (task.status == "done") androidx.compose.ui.text.style.TextDecoration.LineThrough else null,
+ )
+ task.description?.takeIf { it.isNotBlank() }?.let {
+ Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ if (task.ticketNumber != null) {
+ Text("Ticket #${task.ticketNumber}: ${task.ticketSubject ?: ""}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
+ }
+ Text(
+ buildString {
+ append(task.assignedName ?: "Nicht zugewiesen")
+ task.dueAt?.let { append(" · fällig $it") }
+ },
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
+ SelectionField(
+ label = "Status", value = TaskStatusLabels[task.status] ?: task.status,
+ expanded = statusExpanded, onExpand = { statusExpanded = true }, onDismiss = { statusExpanded = false },
+ ) {
+ TaskStatusLabels.forEach { (value, label) -> DropdownMenuItem(text = { Text(label) }, onClick = { onUpdateStatus(value); statusExpanded = false }) }
+ }
+ Spacer(Modifier.width(10.dp))
+ TextButton(onClick = onDelete) { Text("Löschen") }
+ }
+ }
+ }
+}
+
+// ─── Customers & new ticket ─────────────────────────────────────────────────
+
+@Composable
+private fun CustomersScreen(
+ state: DeskHubState,
+ onSearch: (String) -> Unit,
+ onCustomer: (de.deskhub.staff.domain.model.Customer) -> Unit,
+) {
+ var query by rememberSaveable { mutableStateOf(state.customersQuery) }
+ LaunchedEffect(Unit) { if (state.customers.isEmpty()) onSearch(query) }
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Kunden", fontWeight = FontWeight.Bold) },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = LocalChrome.current,
+ titleContentColor = Color.White,
+ ),
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ ) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ label = { Text("Name oder E-Mail") },
+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { onSearch(query) }),
+ )
+ when {
+ state.customersLoading && state.customers.isEmpty() -> LoadingState("Kunden werden geladen …")
+ state.customersError != null && state.customers.isEmpty() ->
+ ErrorState(state.customersError) { onSearch(query) }
+ state.customers.isEmpty() -> EmptyState("Keine Kunden gefunden.")
+ else -> LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(state.customers, key = { "${it.isGuest}-${it.id}-${it.email}" }) { customer ->
+ CustomerRow(customer, onCustomer)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CustomerRow(customer: de.deskhub.staff.domain.model.Customer, onClick: (de.deskhub.staff.domain.model.Customer) -> Unit) {
+ Card(onClick = { onClick(customer) }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Row(
+ modifier = Modifier
+ .padding(14.dp)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(customer.name, fontWeight = FontWeight.SemiBold)
+ Text(
+ customer.email,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ customer.organizationName?.let {
+ Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ Column(horizontalAlignment = Alignment.End) {
+ Surface(
+ color = if (customer.isGuest) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer,
+ shape = MaterialTheme.shapes.extraSmall,
+ ) {
+ Text(
+ if (customer.isGuest) "Gast" else "Konto",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = if (customer.isGuest) {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ } else {
+ MaterialTheme.colorScheme.onPrimaryContainer
+ },
+ )
+ }
+ Spacer(Modifier.height(4.dp))
+ Text(
+ "${customer.ticketCount} Tickets",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun CustomerProfileScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onTicket: (String) -> Unit,
+ onNewTicket: (String, String) -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(state.selectedCustomer?.customer?.name ?: "Kunde", 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,
+ ),
+ )
+ },
+ ) { padding ->
+ val profile = state.selectedCustomer
+ when {
+ state.customerLoading && profile == null ->
+ Box(Modifier.padding(padding)) { LoadingState("Kundenprofil wird geladen …") }
+ state.customerError != null && profile == null ->
+ Box(Modifier.padding(padding)) { ErrorState(state.customerError, onBack) }
+ profile != null -> LazyColumn(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
+ ) {
+ Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(
+ profile.customer.name,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Text(profile.customer.email, color = MaterialTheme.colorScheme.onPrimaryContainer)
+ profile.customer.organizationName?.let {
+ Text(
+ it,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f),
+ )
+ }
+ Text(
+ if (profile.customer.isGuest) "Gast-Kunde" else "Registriertes Konto",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f),
+ )
+ }
+ }
+ }
+ item {
+ Button(
+ onClick = { onNewTicket(profile.customer.name, profile.customer.email) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ ) {
+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text("Neues Ticket für diesen Kunden")
+ }
+ }
+ item {
+ Text(
+ "Tickets (${profile.tickets.size})",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ if (profile.tickets.isEmpty()) {
+ item { EmptyState("Keine Tickets vorhanden.") }
+ } else {
+ items(profile.tickets, key = TicketSummary::id) { ticket -> TicketCard(ticket, state.metadata, onTicket) }
+ }
+ }
+ else -> Box(Modifier.padding(padding)) { EmptyState("Kein Kunde ausgewählt.") }
+ }
+ }
+}
+
+@Composable
+private fun NewTicketScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onCreate: (String, String, String, String, Int, Int, Int?, Boolean) -> Unit,
+) {
+ var name by rememberSaveable { mutableStateOf(state.newTicketPrefillName) }
+ var email by rememberSaveable { mutableStateOf(state.newTicketPrefillEmail) }
+ var subject by rememberSaveable { mutableStateOf("") }
+ var message by rememberSaveable { mutableStateOf("") }
+ val departments = state.metadata?.departments.orEmpty()
+ val priorities = state.metadata?.priorities.orEmpty()
+ var departmentId by rememberSaveable { mutableStateOf(departments.firstOrNull()?.id ?: 0) }
+ var priorityId by rememberSaveable { mutableStateOf(priorities.firstOrNull()?.id ?: 0) }
+ var assignToMe by rememberSaveable { mutableStateOf(true) }
+ var notifyCustomer by rememberSaveable { mutableStateOf(true) }
+ var departmentExpanded by remember { mutableStateOf(false) }
+ var priorityExpanded by remember { mutableStateOf(false) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Neues Ticket", 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,
+ ),
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Name des Kunden") },
+ leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ OutlinedTextField(
+ value = email,
+ onValueChange = { email = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("E-Mail (optional)") },
+ leadingIcon = { Icon(Icons.Filled.Email, contentDescription = null) },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
+ )
+ OutlinedTextField(
+ value = subject,
+ onValueChange = { subject = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Betreff") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium,
+ )
+ SelectionField(
+ label = "Abteilung",
+ value = departments.firstOrNull { it.id == departmentId }?.name ?: "–",
+ expanded = departmentExpanded,
+ onExpand = { departmentExpanded = true },
+ onDismiss = { departmentExpanded = false },
+ ) {
+ departments.forEach { department ->
+ DropdownMenuItem(
+ text = { Text(department.name) },
+ onClick = { departmentId = department.id; departmentExpanded = false },
+ )
+ }
+ }
+ SelectionField(
+ label = "Priorität",
+ value = priorities.firstOrNull { it.id == priorityId }?.name ?: "–",
+ expanded = priorityExpanded,
+ onExpand = { priorityExpanded = true },
+ onDismiss = { priorityExpanded = false },
+ ) {
+ priorities.forEach { priority ->
+ DropdownMenuItem(
+ text = { Text(priority.name) },
+ onClick = { priorityId = priority.id; priorityExpanded = false },
+ )
+ }
+ }
+ OutlinedTextField(
+ value = message,
+ onValueChange = { message = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Beschreibung") },
+ shape = MaterialTheme.shapes.medium,
+ minLines = 5,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = assignToMe, onCheckedChange = { assignToMe = it })
+ Text("Mir zuweisen")
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = notifyCustomer, onCheckedChange = { notifyCustomer = it }, enabled = email.isNotBlank())
+ Text("Eingangsbestätigung per E-Mail senden")
+ }
+ state.newTicketError?.let { ErrorText(it) }
+ Button(
+ onClick = {
+ onCreate(
+ name.trim(),
+ email.trim(),
+ subject.trim(),
+ message.trim(),
+ departmentId,
+ priorityId,
+ if (assignToMe) state.staff?.id else null,
+ notifyCustomer && email.isNotBlank(),
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ shape = MaterialTheme.shapes.medium,
+ enabled = !state.newTicketLoading && name.isNotBlank() && subject.isNotBlank() &&
+ message.isNotBlank() && departmentId != 0 && priorityId != 0,
+ ) {
+ Text(if (state.newTicketLoading) "Wird erstellt …" else "Ticket erstellen")
+ }
+ }
+ }
+}
+
+// ─── Ticket detail ──────────────────────────────────────────────────────────
+
+@Composable
+private fun TicketDetailScreen(
+ state: DeskHubState,
+ onBack: () -> Unit,
+ onRetry: () -> Unit,
+ onUpdate: (String, Int?) -> Unit,
+ onMessage: (String, String, String?, String?, () -> Unit) -> Unit,
+ onSpamAction: (Boolean) -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(state.selectedTicket?.summary?.let { "#${it.number}" } ?: "Ticket", 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,
+ ),
+ )
+ },
+ ) { padding ->
+ when {
+ state.detailLoading && state.selectedTicket == null ->
+ Box(Modifier.padding(padding)) { LoadingState("Ticket wird geladen …") }
+ state.detailError != null && state.selectedTicket == null ->
+ Box(Modifier.padding(padding)) { ErrorState(state.detailError, onRetry) }
+ state.selectedTicket != null -> TicketDetailContent(
+ modifier = Modifier.padding(padding),
+ ticket = state.selectedTicket,
+ metadata = state.metadata,
+ cannedResponses = state.cannedResponses,
+ mutationLoading = state.mutationLoading,
+ mutationError = state.mutationError,
+ onUpdate = onUpdate,
+ onMessage = onMessage,
+ onSpamAction = onSpamAction,
+ )
+ else -> Box(Modifier.padding(padding)) { EmptyState("Kein Ticket ausgewählt.") }
+ }
+ }
+}
+
+@Composable
+private fun TicketDetailContent(
+ modifier: Modifier,
+ ticket: TicketDetail,
+ metadata: Metadata?,
+ cannedResponses: List,
+ mutationLoading: Boolean,
+ mutationError: String?,
+ onUpdate: (String, Int?) -> Unit,
+ onMessage: (String, String, String?, String?, () -> Unit) -> Unit,
+ onSpamAction: (Boolean) -> Unit,
+) {
+ var selectedStatus by remember(ticket.summary.number) { mutableStateOf(ticket.summary.status) }
+ var selectedAgent by remember(ticket.summary.number) { mutableStateOf(ticket.summary.assignedStaff?.id) }
+ var statusExpanded by remember { mutableStateOf(false) }
+ var agentExpanded by remember { mutableStateOf(false) }
+ var messageType by rememberSaveable(ticket.summary.number) { mutableStateOf("reply") }
+ var message by rememberSaveable(ticket.summary.number) { mutableStateOf("") }
+ var replyStatus by rememberSaveable(ticket.summary.number) { mutableStateOf("answered") }
+ // Signatur beim Antworten: "own", "dept" oder "none" (Voreinstellung vom Server)
+ var signatureChoice by rememberSaveable(ticket.summary.number) { mutableStateOf(ticket.replySignatures?.default ?: "none") }
+ // Textbausteine: nur die der Abteilung dieses Tickets und die für alle Abteilungen (ältere Server liefern alle)
+ val visibleCanned = cannedResponses.filter { it.departmentId == null || it.departmentId == ticket.summary.department.id }
+ // Rechte für dieses Ticket (gelten pro Abteilung); ältere Server liefern sie nicht -> globale Rechte
+ fun ticketAllowed(permission: String) = ticketPermission(ticket.permissions, metadata?.staff?.permissions, permission)
+ val canReply = ticketAllowed("reply_ticket")
+ val canNote = ticketAllowed("post_note")
+ val canAssign = ticketAllowed("assign_ticket")
+ val canClose = ticketAllowed("close_ticket")
+ val statusChoices = metadata?.statuses.orEmpty().filter { status ->
+ if (status.value in setOf("resolved", "closed")) canClose else canReply
+ }
+ val canChangeStatus = statusChoices.isNotEmpty()
+ val priorityColor = parseHexColor(ticket.summary.priority.color, MaterialTheme.colorScheme.outline)
+ // Live-Chat-Sperre: ein anderer Mitarbeiter (Web oder App) bearbeitet diesen Chat
+ // gerade aktiv. Lesen bleibt erlaubt, nur öffentliche Antworten werden blockiert –
+ // interne Notizen bleiben immer möglich (siehe api_add_ticket_message() im Backend).
+ val chatLockedByOther = ticket.source == "chat" && ticket.lockedByOtherName != null
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ 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)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ "#${ticket.summary.number}",
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (ticket.summary.isSpam) {
+ Surface(color = Color(0xFFDC2626).copy(alpha = 0.16f), shape = MaterialTheme.shapes.extraSmall) {
+ Text(
+ "🚫 SPAM",
+ modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelMedium,
+ color = Color(0xFFDC2626),
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+ StatusPill(ticket.summary.status, metadata)
+ }
+ }
+ Text(
+ ticket.summary.subject,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Filled.Person,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(Modifier.width(6.dp))
+ Text(
+ "${ticket.summary.requesterName} · ${ticket.summary.requesterEmail}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .clip(CircleShape)
+ .background(priorityColor),
+ )
+ Spacer(Modifier.width(6.dp))
+ Text(
+ "${ticket.summary.department.name} · ${ticket.summary.priority.name} · ${ticketSourceLabel(ticket.source)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.85f),
+ )
+ }
+ ticket.resolutionDueAt?.let { due ->
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Filled.Schedule,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ Spacer(Modifier.width(6.dp))
+ Text(
+ "Lösung fällig: $due",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+ }
+ }
+ }
+ }
+ if (ticket.customFields.isNotEmpty()) {
+ item {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ 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 ->
+ Column {
+ Text(field.label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(field.value.ifBlank { "–" }, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+ }
+ }
+ }
+ if (ticket.source == "email") {
+ item {
+ if (!ticket.summary.isSpam) {
+ OutlinedButton(
+ onClick = { onSpamAction(false) },
+ enabled = !mutationLoading,
+ modifier = Modifier.fillMaxWidth(),
+ colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFDC2626)),
+ ) { Text("🚫 Als Spam markieren") }
+ } else {
+ OutlinedButton(
+ onClick = { onSpamAction(true) },
+ enabled = !mutationLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("↩️ Spam-Markierung aufheben") }
+ }
+ }
+ }
+ item {
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text("Bearbeitung", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ SelectionField(
+ label = "Status",
+ value = metadata?.statuses?.firstOrNull { it.value == selectedStatus }?.label ?: selectedStatus,
+ expanded = statusExpanded,
+ enabled = canChangeStatus,
+ onExpand = { statusExpanded = true },
+ onDismiss = { statusExpanded = false },
+ ) {
+ statusChoices.forEach { status ->
+ DropdownMenuItem(
+ text = { Text(status.label) },
+ onClick = {
+ selectedStatus = status.value
+ statusExpanded = false
+ },
+ )
+ }
+ }
+ SelectionField(
+ label = "Zuständig",
+ value = metadata?.agents?.firstOrNull { it.id == selectedAgent }?.name ?: "Nicht zugewiesen",
+ expanded = agentExpanded,
+ enabled = canAssign,
+ onExpand = { agentExpanded = true },
+ onDismiss = { agentExpanded = false },
+ ) {
+ DropdownMenuItem(
+ text = { Text("Nicht zugewiesen") },
+ onClick = {
+ selectedAgent = null
+ agentExpanded = false
+ },
+ )
+ metadata?.agents?.forEach { agent ->
+ DropdownMenuItem(
+ text = { Text(agent.name) },
+ onClick = {
+ selectedAgent = agent.id
+ agentExpanded = false
+ },
+ )
+ }
+ }
+ Button(
+ onClick = { onUpdate(selectedStatus, selectedAgent) },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ enabled = !mutationLoading &&
+ (selectedStatus != ticket.summary.status || selectedAgent != ticket.summary.assignedStaff?.id),
+ ) {
+ Text(if (mutationLoading) "Speichert …" else "Änderungen speichern")
+ }
+ }
+ }
+ }
+ item {
+ Text("Verlauf", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
+ }
+ if (ticket.threads.isEmpty()) {
+ item { EmptyState("Noch keine Nachrichten vorhanden.") }
+ } else {
+ items(ticket.threads, key = { it.id }) { thread -> ThreadBubble(thread) }
+ }
+ item {
+ // Ist der Chat gerade woanders in Bearbeitung, auf "Interne Notiz" ausweichen statt
+ // auf dem gesperrten "Antwort"-Tab hängen zu bleiben (falls erlaubt).
+ LaunchedEffect(chatLockedByOther, canNote) {
+ if (chatLockedByOther && messageType == "reply" && canNote) messageType = "note"
+ }
+ Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text("Nachricht verfassen", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
+ if (chatLockedByOther) {
+ Surface(color = Color(0xFFFFFBEB), shape = MaterialTheme.shapes.small) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("🔒", style = MaterialTheme.typography.bodyMedium)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ "Wird gerade von ${ticket.lockedByOtherName} bearbeitet – Sie sehen live mit, können hier aber nicht antworten.",
+ style = MaterialTheme.typography.bodySmall,
+ color = Color(0xFF92400E),
+ )
+ }
+ }
+ }
+ SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
+ SegmentedButton(
+ selected = messageType == "reply",
+ onClick = { messageType = "reply" },
+ enabled = canReply && !chatLockedByOther,
+ shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2),
+ ) { Text("Antwort") }
+ SegmentedButton(
+ selected = messageType == "note",
+ onClick = { messageType = "note" },
+ enabled = canNote,
+ shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2),
+ ) { Text("Interne Notiz") }
+ }
+ if (visibleCanned.isNotEmpty() && (canReply || canNote)) {
+ var templatesExpanded by remember { mutableStateOf(false) }
+ Box {
+ TextButton(onClick = { templatesExpanded = true }) {
+ Icon(Icons.Filled.Description, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text("Vorlage einfügen")
+ }
+ DropdownMenu(expanded = templatesExpanded, onDismissRequest = { templatesExpanded = false }) {
+ visibleCanned.sortedBy { it.departmentId == null }.forEach { canned ->
+ DropdownMenuItem(
+ text = { Text(if (canned.departmentId == null) canned.title + " · für alle" else canned.title) },
+ onClick = {
+ message = canned.body
+ templatesExpanded = false
+ },
+ )
+ }
+ }
+ }
+ }
+ OutlinedTextField(
+ value = message,
+ onValueChange = { message = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(if (messageType == "note") "Interne Notiz" else "Antwort an Kunden") },
+ shape = MaterialTheme.shapes.medium,
+ minLines = 4,
+ enabled = !(chatLockedByOther && messageType == "reply"),
+ )
+ val replySigs = ticket.replySignatures
+ if (messageType == "reply" && replySigs != null) {
+ var signatureExpanded by remember { mutableStateOf(false) }
+ val signatureLabel = when (signatureChoice) {
+ "own" -> "Meine Signatur"
+ "dept" -> "Abteilung „" + replySigs.department.name + "“"
+ else -> "Keine Signatur"
+ }
+ SelectionField(
+ label = "Signatur",
+ value = signatureLabel,
+ expanded = signatureExpanded,
+ onExpand = { signatureExpanded = true },
+ onDismiss = { signatureExpanded = false },
+ ) {
+ DropdownMenuItem(
+ text = { Text("Meine Signatur" + if (replySigs.own.available) "" else " (nicht hinterlegt)") },
+ enabled = replySigs.own.available,
+ onClick = { signatureChoice = "own"; signatureExpanded = false },
+ )
+ DropdownMenuItem(
+ text = { Text("Abteilung „" + replySigs.department.name + "“" + if (replySigs.department.available) "" else " (nicht hinterlegt)") },
+ enabled = replySigs.department.available,
+ onClick = { signatureChoice = "dept"; signatureExpanded = false },
+ )
+ DropdownMenuItem(
+ text = { Text("Keine Signatur") },
+ onClick = { signatureChoice = "none"; signatureExpanded = false },
+ )
+ }
+ val chosen = when (signatureChoice) {
+ "own" -> replySigs.own
+ "dept" -> replySigs.department
+ else -> null
+ }
+ Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.small, modifier = Modifier.fillMaxWidth()) {
+ Column {
+ Text(
+ text = when {
+ chosen != null && chosen.available -> chosen.text
+ signatureChoice == "none" -> "Es wird keine Signatur angehängt."
+ else -> "Keine Signatur hinterlegt."
+ },
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (chosen != null && chosen.available && chosen.imageUrl.isNotBlank()) {
+ SignatureImage(chosen.imageUrl, Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp))
+ }
+ }
+ }
+ }
+ if (messageType == "reply") {
+ var replyStatusExpanded by remember { mutableStateOf(false) }
+ SelectionField(
+ label = "Status nach Antwort",
+ value = metadata?.statuses?.firstOrNull { it.value == replyStatus }?.label ?: replyStatus,
+ expanded = replyStatusExpanded,
+ onExpand = { replyStatusExpanded = true },
+ onDismiss = { replyStatusExpanded = false },
+ ) {
+ statusChoices.forEach { status ->
+ DropdownMenuItem(
+ text = { Text(status.label) },
+ onClick = {
+ replyStatus = status.value
+ replyStatusExpanded = false
+ },
+ )
+ }
+ }
+ }
+ mutationError?.let { ErrorText(it) }
+ Button(
+ onClick = {
+ onMessage(
+ messageType,
+ message,
+ replyStatus.takeIf { messageType == "reply" },
+ signatureChoice.takeIf { messageType == "reply" && ticket.replySignatures != null },
+ ) { message = "" }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ shape = MaterialTheme.shapes.medium,
+ enabled = !mutationLoading && message.isNotBlank() &&
+ ((messageType == "reply" && canReply && !chatLockedByOther) || (messageType == "note" && canNote)),
+ ) {
+ if (!mutationLoading) {
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ }
+ Text(if (mutationLoading) "Sendet …" else if (messageType == "note") "Notiz speichern" else "Antwort senden")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ThreadBubble(thread: de.deskhub.staff.domain.model.TicketThread) {
+ val isStaff = thread.authorType == "staff"
+ val isSystem = thread.authorType == "system"
+
+ if (isSystem) {
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
+ Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.extraSmall) {
+ Text(
+ thread.message,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ return
+ }
+
+ val bubbleColor = when {
+ thread.isInternalNote -> MaterialTheme.colorScheme.secondaryContainer
+ isStaff -> MaterialTheme.colorScheme.primaryContainer
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ }
+ val onBubbleColor = when {
+ thread.isInternalNote -> MaterialTheme.colorScheme.onSecondaryContainer
+ isStaff -> MaterialTheme.colorScheme.onPrimaryContainer
+ else -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+ val bubbleShape = if (isStaff) {
+ RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 16.dp, bottomEnd = 4.dp)
+ } else {
+ RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start,
+ ) {
+ Column(modifier = Modifier.fillMaxWidth(0.86f)) {
+ if (thread.isInternalNote) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = if (isStaff) Arrangement.End else Arrangement.Start,
+ ) {
+ Icon(
+ Icons.Filled.Lock,
+ contentDescription = null,
+ modifier = Modifier.size(12.dp),
+ tint = MaterialTheme.colorScheme.secondary,
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ "Interne Notiz",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.secondary,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ Spacer(Modifier.height(2.dp))
+ }
+ Surface(color = bubbleColor, shape = bubbleShape) {
+ 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)
+ Text(thread.createdAt, style = MaterialTheme.typography.labelSmall, color = onBubbleColor.copy(alpha = 0.7f))
+ }
+ Text(thread.message, color = onBubbleColor, style = MaterialTheme.typography.bodyMedium)
+ thread.attachments.forEach { attachment ->
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Filled.AttachFile,
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = onBubbleColor,
+ )
+ Spacer(Modifier.width(4.dp))
+ Text(
+ "${attachment.name} (${attachment.size / 1024} KB)",
+ style = MaterialTheme.typography.labelSmall,
+ color = onBubbleColor,
+ )
+ }
+ }
+ thread.comments.forEach { comment ->
+ HorizontalDivider(color = onBubbleColor.copy(alpha = 0.15f))
+ Text(
+ "${comment.staff.name}: ${comment.message}",
+ style = MaterialTheme.typography.bodySmall,
+ color = onBubbleColor,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Mehrzeiliges Signatur-Feld mit "Bild einfügen"-Button: lädt ein per Bildauswahl gewähltes
+ * Foto hoch und hängt bei Erfolg ein
-Tag an den (reinen) HTML-Signaturtext an – analog
+ * zum 🖼️-Knopf im Web-Editor (assets/js/richtext.js).
+ */
+@Composable
+private fun SignatureField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ uploading: Boolean,
+ uploadError: String?,
+ onUpload: (ByteArray, String, String, (String) -> Unit) -> Unit,
+ minLines: Int = 3,
+) {
+ val context = LocalContext.current
+ val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ val resolver = context.contentResolver
+ val mimeType = resolver.getType(uri) ?: "image/jpeg"
+ val bytes = runCatching { resolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
+ if (bytes != null) {
+ val ext = when {
+ mimeType.contains("png") -> "png"
+ mimeType.contains("gif") -> "gif"
+ else -> "jpg"
+ }
+ onUpload(bytes, "signature.$ext", mimeType) { url ->
+ val insertion = "
"
+ onValueChange(if (value.isBlank()) insertion else "$value\n$insertion")
+ }
+ }
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Signatur (optional)") },
+ minLines = minLines,
+ shape = MaterialTheme.shapes.medium,
+ )
+ TextButton(onClick = { pickImage.launch("image/*") }, enabled = !uploading) {
+ Text(if (uploading) "Bild wird hochgeladen …" else "🖼️ Bild einfügen")
+ }
+ uploadError?.let { ErrorText(it) }
+ }
+}
+
+@Composable
+private fun SelectionField(
+ label: String,
+ value: String,
+ expanded: Boolean,
+ enabled: Boolean = true,
+ onExpand: () -> Unit,
+ onDismiss: () -> Unit,
+ content: @Composable () -> Unit,
+) {
+ Box {
+ OutlinedButton(
+ onClick = onExpand,
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium,
+ enabled = enabled,
+ ) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text("$label: $value")
+ Text("▾")
+ }
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
+ content()
+ }
+ }
+}
+
+@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,
+ )
+ }
+}
+
+private fun parseHexColor(hex: String?, fallback: Color): Color =
+ runCatching { Color(parseColor(hex ?: return fallback)) }.getOrDefault(fallback)
+
+/** Spiegelt die Beschriftungen aus staff/ticket_view.php ($sourceLabels) für die App. */
+private fun ticketSourceLabel(source: String): String = when (source) {
+ "web" -> "🌐 Web"
+ "email" -> "📧 E-Mail"
+ "phone" -> "📞 Telefon"
+ "staff" -> "🖥 Intern"
+ "chat" -> "💬 Live-Chat"
+ else -> source
+}
+
+@Composable
+private fun LoadingState(message: String) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ CircularProgressIndicator()
+ Spacer(Modifier.height(12.dp))
+ Text(message)
+ }
+ }
+}
+
+@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") }
+ }
+ }
+}
+
+@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 bd2ee40f..a0b2c196 100644
--- a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt
+++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt
@@ -261,6 +261,8 @@ class DeskHubViewModel(
private var liveChatListPollingJob: Job? = null
private var liveChatDetailPollingJob: Job? = null
private var lastLiveChatTypingPingAt = 0L
+ // Ticket, das über eine Benachrichtigung geöffnet werden soll, bevor die Anmeldung fertig ist
+ private var pendingTicket: String? = null
init {
restoreSession()
@@ -313,6 +315,7 @@ class DeskHubViewModel(
loadChatContacts()
startActivityPolling()
startTicketsPolling()
+ applyPendingTicket()
}
}
}.onFailure { error ->
@@ -411,6 +414,16 @@ class DeskHubViewModel(
loadTickets(reset = false)
}
+ /** Tipp auf eine Push-Benachrichtigung: das Ticket öffnen (sobald die Anmeldung steht). */
+ fun openTicketFromNotification(number: String) {
+ if (number.isBlank()) return
+ if (state.value.staff != null) openTicket(number) else pendingTicket = number
+ }
+
+ private fun applyPendingTicket() {
+ pendingTicket?.let { pendingTicket = null; openTicket(it) }
+ }
+
fun openTicket(number: String) {
viewModelScope.launch {
mutableState.update {
@@ -479,11 +492,11 @@ class DeskHubViewModel(
}
}
- fun addMessage(type: String, message: String, setStatus: String?, onSuccess: () -> Unit) {
+ fun addMessage(type: String, message: String, setStatus: String?, signature: String?, onSuccess: () -> Unit) {
val number = state.value.selectedTicket?.summary?.number ?: return
viewModelScope.launch {
mutableState.update { it.copy(mutationLoading = true, mutationError = null) }
- runCatching { repository.addMessage(number, type, message, setStatus) }
+ runCatching { repository.addMessage(number, type, message, setStatus, signature) }
.onSuccess {
mutableState.update { it.copy(mutationLoading = false) }
onSuccess()
@@ -1419,6 +1432,19 @@ class DeskHubViewModel(
}
}
+ /** Administrator: Status eines anderen Mitarbeiters ändern. */
+ fun setMemberAvailability(staffId: Int, status: String, note: String) {
+ viewModelScope.launch {
+ mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) }
+ runCatching { repository.setAvailability(status, note, staffId) }
+ .onSuccess {
+ mutableState.update { it.copy(availabilitySaving = false) }
+ loadAvailability()
+ }
+ .onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } }
+ }
+ }
+
fun setMyAvailability(status: String, note: String) {
viewModelScope.launch {
mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) }
@@ -1520,6 +1546,7 @@ class DeskHubViewModel(
loadChatContacts()
startActivityPolling()
startTicketsPolling()
+ applyPendingTicket()
}
.onFailure { error ->
if (error is ApiException && error.status == 401) repository.clearSession()
diff --git a/mobile/src/main/java/de/deskhub/staff/ui/PushPrefsScreen.kt b/mobile/src/main/java/de/deskhub/staff/ui/PushPrefsScreen.kt
new file mode 100644
index 00000000..6fa996f3
--- /dev/null
+++ b/mobile/src/main/java/de/deskhub/staff/ui/PushPrefsScreen.kt
@@ -0,0 +1,124 @@
+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.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.Button
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+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.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import de.deskhub.staff.data.remote.DeskHubApi
+import de.deskhub.staff.data.remote.dto.PushPrefsRequest
+import de.deskhub.staff.data.security.SecureSessionStore
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/** Wählen, welche Popups die App zeigt, und stille Zeiten festlegen (die Einstellungen liegen auf dem Server). */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun PushPrefsScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val types = remember { mutableStateListOf>() }
+ val selected = remember { mutableStateListOf() }
+ var quietFrom by remember { mutableStateOf("") }
+ var quietTo by remember { mutableStateOf("") }
+ var loading by remember { mutableStateOf(true) }
+ var message by remember { mutableStateOf(null) }
+
+ fun api(): DeskHubApi? {
+ val session = SecureSessionStore(context.applicationContext).load() ?: return null
+ return DeskHubApi(session.serverUrl) { session.token }
+ }
+
+ LaunchedEffect(Unit) {
+ runCatching {
+ val prefs = withContext(Dispatchers.IO) { api()?.pushPrefs() }
+ if (prefs != null) {
+ types.clear(); types.addAll(prefs.eventTypes.map { it.key to it.label })
+ selected.clear(); selected.addAll(prefs.events)
+ quietFrom = prefs.quietFrom; quietTo = prefs.quietTo
+ } else {
+ message = "Nicht angemeldet."
+ }
+ }.onFailure { message = "Einstellungen konnten nicht geladen werden (hat der Server die neueste Version?)." }
+ loading = false
+ }
+
+ Scaffold(
+ topBar = {
+ 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,
+ ),
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text("Wofür soll die App ein Popup zeigen?", fontWeight = FontWeight.Bold)
+ if (loading) Text("Lade …")
+ types.forEach { (key, label) ->
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = key in selected, onCheckedChange = { on -> if (on) selected.add(key) else selected.remove(key) })
+ Text(label)
+ }
+ }
+ if (types.isNotEmpty()) {
+ Text("Stille Zeiten (keine Popups)", fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 12.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(value = quietFrom, onValueChange = { quietFrom = it.take(5) }, label = { Text("von (HH:MM)") }, singleLine = true, modifier = Modifier.weight(1f))
+ OutlinedTextField(value = quietTo, onValueChange = { quietTo = it.take(5) }, label = { Text("bis (HH:MM)") }, singleLine = true, modifier = Modifier.weight(1f))
+ }
+ Text("Beide leer = immer Popups. Es gilt die Zeit des Servers, auch über Mitternacht (z. B. 22:00 bis 06:00).", style = MaterialTheme.typography.bodySmall)
+ Button(
+ onClick = {
+ scope.launch {
+ message = runCatching {
+ withContext(Dispatchers.IO) { api()?.savePushPrefs(PushPrefsRequest(selected.toList(), quietFrom.trim(), quietTo.trim())) }
+ "Gespeichert."
+ }.getOrElse { "Nicht gespeichert: bitte Uhrzeiten prüfen (z. B. 22:00) oder später erneut versuchen." }
+ }
+ },
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Speichern") }
+ }
+ message?.let { Text(it) }
+ }
+ }
+}
diff --git a/mobile/src/main/res/drawable/ic_stat_notification.xml b/mobile/src/main/res/drawable/ic_stat_notification.xml
new file mode 100644
index 00000000..813c94b7
--- /dev/null
+++ b/mobile/src/main/res/drawable/ic_stat_notification.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
\ No newline at end of file