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