diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 00000000..d27a9b69 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +*.jks +*.keystore +keystore.properties diff --git a/mobile/build/tmp/kotlin-classes/release/META-INF/DeskHubMobile_release.kotlin_module b/mobile/build/tmp/kotlin-classes/release/META-INF/DeskHubMobile_release.kotlin_module new file mode 100644 index 00000000..6454b814 Binary files /dev/null and b/mobile/build/tmp/kotlin-classes/release/META-INF/DeskHubMobile_release.kotlin_module differ diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModel$uploadSignatureImage$1.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModel$uploadSignatureImage$1.class new file mode 100644 index 00000000..d681e39e Binary files /dev/null and b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModel$uploadSignatureImage$1.class differ diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModel.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModel.class new file mode 100644 index 00000000..167519de Binary files /dev/null and b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModel.class differ diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelFactory.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelFactory.class new file mode 100644 index 00000000..b286b9b0 Binary files /dev/null and b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelFactory.class differ diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelKt.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelKt.class new file mode 100644 index 00000000..89a33359 Binary files /dev/null and b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/DeskHubViewModelKt.class differ diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeController.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeController.class new file mode 100644 index 00000000..74bfe9bb Binary files /dev/null and b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeController.class differ diff --git a/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeMode.class b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeMode.class new file mode 100644 index 00000000..86f6b538 Binary files /dev/null and b/mobile/build/tmp/kotlin-classes/release/de/deskhub/staff/ui/ThemeMode.class differ diff --git a/mobile/gradle/wrapper/gradle-wrapper.jar b/mobile/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e6441136 Binary files /dev/null and b/mobile/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mobile/gradle/wrapper/gradle-wrapper.properties b/mobile/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b82aa23a --- /dev/null +++ b/mobile/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1fbf4964 --- /dev/null +++ b/mobile/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/mobile/src/main/java/de/deskhub/staff/MainActivity.kt b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt new file mode 100644 index 00000000..ef234487 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/MainActivity.kt @@ -0,0 +1,23 @@ +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) + } + } +} diff --git a/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt b/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt new file mode 100644 index 00000000..b5b27305 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/TicketRepository.kt @@ -0,0 +1,901 @@ +package de.deskhub.staff.data + +import android.os.Build +import de.deskhub.staff.data.remote.DeskHubApi +import de.deskhub.staff.data.remote.DeskHubUrl +import de.deskhub.staff.data.remote.dto.AuditLogEntryDto +import de.deskhub.staff.data.remote.dto.BanEntryDto +import de.deskhub.staff.data.remote.dto.BusinessHolidayDto +import de.deskhub.staff.data.remote.dto.BusinessHourDto +import de.deskhub.staff.data.remote.dto.ChatContactDto +import de.deskhub.staff.data.remote.dto.ChatMessageDto +import de.deskhub.staff.data.remote.dto.KeyLabelDto +import de.deskhub.staff.data.remote.dto.LiveChatAttachmentDto +import de.deskhub.staff.data.remote.dto.LiveChatListItemDto +import de.deskhub.staff.data.remote.dto.LiveChatMessageDto +import de.deskhub.staff.data.remote.dto.TeamMemberDto +import de.deskhub.staff.data.remote.dto.TicketRuleActionDto +import de.deskhub.staff.data.remote.dto.TicketRuleConditionDto +import de.deskhub.staff.data.remote.dto.TicketRuleDto +import de.deskhub.staff.data.remote.dto.TicketRuleSaveRequest +import de.deskhub.staff.data.remote.dto.CannedResponseDto +import de.deskhub.staff.data.remote.dto.CreateTicketRequest +import de.deskhub.staff.data.remote.dto.DepartmentDto +import de.deskhub.staff.data.remote.dto.DepartmentSaveRequest +import de.deskhub.staff.data.remote.dto.EmailAddressDto +import de.deskhub.staff.data.remote.dto.EmailAddressSaveRequest +import de.deskhub.staff.data.remote.dto.EmailTemplateDto +import de.deskhub.staff.data.remote.dto.HelpTopicDto +import de.deskhub.staff.data.remote.dto.HelpTopicSaveRequest +import de.deskhub.staff.data.remote.dto.OrganizationDto +import de.deskhub.staff.data.remote.dto.OrganizationSaveRequest +import de.deskhub.staff.data.remote.dto.PriorityFullDto +import de.deskhub.staff.data.remote.dto.PrioritySaveRequest +import de.deskhub.staff.data.remote.dto.RoleDto +import de.deskhub.staff.data.remote.dto.RoleSaveRequest +import de.deskhub.staff.data.remote.dto.StaffAdminDto +import de.deskhub.staff.data.remote.dto.StaffAdminSaveRequest +import de.deskhub.staff.data.remote.dto.StaffExtraDeptDto +import de.deskhub.staff.data.remote.dto.TaskCreateRequest +import de.deskhub.staff.data.remote.dto.TaskDto +import de.deskhub.staff.data.remote.dto.TeamDto +import de.deskhub.staff.data.remote.dto.TeamSaveRequest +import de.deskhub.staff.data.remote.dto.CustomerDetailDto +import de.deskhub.staff.data.remote.dto.CustomerDto +import de.deskhub.staff.data.remote.dto.DepartmentStatDto +import de.deskhub.staff.data.remote.dto.EscalationRuleDto +import de.deskhub.staff.data.remote.dto.EscalationSaveRequest +import de.deskhub.staff.data.remote.dto.KbArticleDto +import de.deskhub.staff.data.remote.dto.KbCategoryDto +import de.deskhub.staff.data.remote.dto.KbSaveArticleRequest +import de.deskhub.staff.data.remote.dto.LoginRequest +import de.deskhub.staff.data.remote.dto.MessageRequest +import de.deskhub.staff.data.remote.dto.NamedOptionDto +import de.deskhub.staff.data.remote.dto.PriorityDto +import de.deskhub.staff.data.remote.dto.StaffDto +import de.deskhub.staff.data.remote.dto.StatusOptionDto +import de.deskhub.staff.data.remote.dto.TicketDetailDto +import de.deskhub.staff.data.remote.dto.TicketSummaryDto +import de.deskhub.staff.data.remote.dto.TimeEntryDto +import de.deskhub.staff.data.remote.dto.TimeEntryTotalDto +import de.deskhub.staff.data.security.SecureSessionStore +import de.deskhub.staff.data.security.StoredSession +import de.deskhub.staff.domain.model.ActivityUpdate +import de.deskhub.staff.domain.model.AuditLogEntry +import de.deskhub.staff.domain.model.AuditLogPage +import de.deskhub.staff.domain.model.BanEntry +import de.deskhub.staff.domain.model.BusinessHoliday +import de.deskhub.staff.domain.model.BusinessHour +import de.deskhub.staff.domain.model.BusinessHoursData +import de.deskhub.staff.domain.model.AvailabilityData +import de.deskhub.staff.domain.model.ChatContact +import de.deskhub.staff.domain.model.ChatConversation +import de.deskhub.staff.domain.model.ChatMessage +import de.deskhub.staff.domain.model.KeyLabel +import de.deskhub.staff.domain.model.LiveChatAttachment +import de.deskhub.staff.domain.model.LiveChatDetail +import de.deskhub.staff.domain.model.LiveChatMessage +import de.deskhub.staff.domain.model.LiveChatSummary +import de.deskhub.staff.domain.model.TeamMember +import de.deskhub.staff.domain.model.TicketRule +import de.deskhub.staff.domain.model.TicketRuleAction +import de.deskhub.staff.domain.model.TicketRuleCondition +import de.deskhub.staff.domain.model.TicketRulesData +import de.deskhub.staff.domain.model.CannedResponse +import de.deskhub.staff.domain.model.Customer +import de.deskhub.staff.domain.model.CustomerDetail +import de.deskhub.staff.domain.model.CustomerProfile +import de.deskhub.staff.domain.model.DashboardStats +import de.deskhub.staff.domain.model.Department +import de.deskhub.staff.domain.model.DepartmentStat +import de.deskhub.staff.domain.model.DepartmentData +import de.deskhub.staff.domain.model.DepartmentEmailOption +import de.deskhub.staff.domain.model.HealthIssue +import de.deskhub.staff.domain.model.DepartmentInput +import de.deskhub.staff.domain.model.DepartmentMember +import de.deskhub.staff.domain.model.EmailAddress +import de.deskhub.staff.domain.model.EmailAddressInput +import de.deskhub.staff.domain.model.EmailAddressData +import de.deskhub.staff.domain.model.EmailTemplate +import de.deskhub.staff.domain.model.EscalationData +import de.deskhub.staff.domain.model.EscalationRule +import de.deskhub.staff.domain.model.HelpTopic +import de.deskhub.staff.domain.model.HelpTopicInput +import de.deskhub.staff.domain.model.HelpTopicData +import de.deskhub.staff.domain.model.KbArticle +import de.deskhub.staff.domain.model.KbCategory +import de.deskhub.staff.domain.model.KbData +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.NamedOption +import de.deskhub.staff.domain.model.Organization +import de.deskhub.staff.domain.model.Priority +import de.deskhub.staff.domain.model.PriorityFull +import de.deskhub.staff.domain.model.Role +import de.deskhub.staff.domain.model.RoleData +import de.deskhub.staff.domain.model.SpamActionResult +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StaffAdmin +import de.deskhub.staff.domain.model.StaffAdminData +import de.deskhub.staff.domain.model.StaffAdminInput +import de.deskhub.staff.domain.model.StaffExtraDept +import de.deskhub.staff.domain.model.StaffTask +import de.deskhub.staff.domain.model.StatusOption +import de.deskhub.staff.domain.model.Team +import de.deskhub.staff.domain.model.TeamData +import de.deskhub.staff.domain.model.ThreadAttachment +import de.deskhub.staff.domain.model.ThreadComment +import de.deskhub.staff.domain.model.TicketDetail +import de.deskhub.staff.domain.model.TicketFilters +import de.deskhub.staff.domain.model.TicketPage +import de.deskhub.staff.domain.model.TicketSummary +import de.deskhub.staff.domain.model.TicketThread +import de.deskhub.staff.domain.model.TimeEntriesReport +import de.deskhub.staff.domain.model.TimeEntry +import de.deskhub.staff.domain.model.TimeEntryTotal + +sealed interface LoginResult { + data object TotpRequired : LoginResult + data class Success(val staff: Staff) : LoginResult +} + +class TicketRepository( + private val sessionStore: SecureSessionStore, +) { + private var session: StoredSession? = null + private var api: DeskHubApi? = null + + fun restoreSession(): StoredSession? { + val stored = sessionStore.load() ?: return null + configure(stored) + return stored + } + + /** Zuletzt verwendete Server-Adresse, falls (noch) keine aktive Sitzung besteht – erspart erneutes Eintippen. */ + fun lastServerUrl(): String? = sessionStore.loadLastServerUrl() + + suspend fun login( + serverUrl: String, + email: String, + password: String, + totpCode: String?, + ): LoginResult { + val normalizedUrl = DeskHubUrl.normalize(serverUrl) + sessionStore.saveLastServerUrl(normalizedUrl) + val loginApi = DeskHubApi(normalizedUrl) { null } + val response = loginApi.login( + LoginRequest( + email = email.trim(), + password = password, + totpCode = totpCode?.takeIf { it.isNotBlank() }, + deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(), + ), + ) + if (response.requiresTotp) return LoginResult.TotpRequired + val token = requireNotNull(response.token) { "Die Anmeldung lieferte kein Token." } + val expiresAt = requireNotNull(response.expiresAt) { "Das Token hat kein Ablaufdatum." } + val staffDto = requireNotNull(response.staff) { "Die Anmeldung lieferte kein Mitarbeiterprofil." } + val stored = StoredSession(normalizedUrl, token, expiresAt, staffDto.name) + sessionStore.save(stored) + configure(stored) + return LoginResult.Success(staffDto.toDomain()) + } + + suspend fun validateSession(): Staff = requireApi().me().staff.toDomain() + + suspend fun metadata(): Metadata { + val response = requireApi().metadata() + return Metadata( + staff = response.staff.toDomain(), + departments = response.departments.map(NamedOptionDto::toDomain), + priorities = response.priorities.map(PriorityDto::toDomain), + agents = response.agents.map(NamedOptionDto::toDomain), + statuses = response.statuses.map(StatusOptionDto::toDomain), + tags = response.tags.map(NamedOptionDto::toDomain), + liveChatEnabled = response.liveChatEnabled, + ) + } + + suspend fun tickets(filters: TicketFilters, page: Int = 1): TicketPage { + val query = buildMap { + put("page", page.toString()) + put("perPage", "30") + put("status", filters.statuses.joinToString(",")) + if (filters.query.isNotBlank()) put("q", filters.query.trim()) + if (filters.assigned.isNotBlank()) put("assigned", filters.assigned) + filters.departmentId?.let { put("departmentId", it.toString()) } + filters.priorityId?.let { put("priorityId", it.toString()) } + filters.tagId?.let { put("tagId", it.toString()) } + if (filters.sla.isNotBlank()) put("sla", filters.sla) + } + val response = requireApi().tickets(query) + return TicketPage( + items = response.items.map(TicketSummaryDto::toDomain), + page = response.page, + total = response.total, + hasMore = response.hasMore, + ) + } + + suspend fun ticket(number: String): TicketDetail = + requireApi().ticket(number).ticket.toDomain() + + suspend fun updateTicket( + number: String, + status: String?, + assignedStaffId: Int?, + updateAssignment: Boolean, + ): TicketSummary { + val request = linkedMapOf("number" to number) + status?.let { request["status"] = it } + if (updateAssignment) request["assignedStaffId"] = assignedStaffId + return requireApi().updateTicket(request).ticket.toDomain() + } + + suspend fun setTicketSpam(number: String, restore: Boolean): SpamActionResult { + val response = requireApi().ticketSpam(number, if (restore) "restore" else "mark") + return SpamActionResult( + ticket = response.ticket.toDomain(), + moved = response.moved, + failed = response.failed, + ) + } + + suspend fun addMessage(number: String, type: String, message: String, setStatus: String?) { + requireApi().addMessage(MessageRequest(number, type, message, setStatus)) + } + + suspend fun cannedResponses(): List = + requireApi().cannedResponses().items.map(CannedResponseDto::toDomain) + + suspend fun dashboard(): DashboardStats { + val response = requireApi().dashboard() + return DashboardStats( + myOpen = response.myOpen, + overdue = response.overdue, + unassigned = response.unassigned, + unanswered = response.unanswered, + resolvedToday = response.resolvedToday, + slaPercent = response.slaPercent, + recent = response.recent.map(TicketSummaryDto::toDomain), + departments = response.departments?.map(DepartmentStatDto::toDomain), + ) + } + + suspend fun customers(query: String): List = + requireApi().customers(query).items.map(CustomerDto::toDomain) + + suspend fun customerProfile(userId: Int?, email: String?): CustomerProfile { + val response = requireApi().customer(userId, email) + return CustomerProfile( + customer = response.customer.toDomain(), + tickets = response.tickets.map(TicketSummaryDto::toDomain), + ) + } + + suspend fun createTicket( + requesterName: String, + requesterEmail: String, + subject: String, + message: String, + departmentId: Int, + priorityId: Int, + assignedStaffId: Int?, + notifyCustomer: Boolean, + ): TicketSummary { + val request = CreateTicketRequest( + requesterName = requesterName, + requesterEmail = requesterEmail, + subject = subject, + message = message, + departmentId = departmentId, + priorityId = priorityId, + assignedStaffId = assignedStaffId, + notifyCustomer = notifyCustomer, + ) + return requireApi().createTicket(request).ticket.toDomain() + } + + suspend fun activitySince(since: String): ActivityUpdate { + val response = requireApi().activity(since) + return ActivityUpdate(response.newReplies, response.newUnassigned, response.serverTime, response.health.map { HealthIssue(it.level, it.message) }) + } + + suspend fun logout() { + requireApi().logout() + clearSession() + } + + // ─── Admin: Wissensdatenbank ───────────────────────────────────────────── + + suspend fun kbData(): KbData { + val response = requireApi().kb() + return KbData( + categories = response.categories.map(KbCategoryDto::toDomain), + articles = response.articles.map(KbArticleDto::toDomain), + ) + } + + suspend fun kbSaveArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean): Int = + requireApi().kbSaveArticle(KbSaveArticleRequest(id = id, title = title, categoryId = categoryId, body = body, isPublished = isPublished)).id + + suspend fun kbDeleteArticle(id: Int) = requireApi().kbDeleteArticle(id) + + suspend fun kbCreateCategory(name: String): Int = requireApi().kbCreateCategory(name).id + + suspend fun kbDeleteCategory(id: Int) = requireApi().kbDeleteCategory(id) + + // ─── Admin: Zeiterfassung ──────────────────────────────────────────────── + + suspend fun timeEntries(staffId: Int?, from: String, to: String): TimeEntriesReport { + val query = buildMap { + put("from", from) + put("to", to) + staffId?.let { put("staffId", it.toString()) } + } + val response = requireApi().timeEntries(query) + return TimeEntriesReport( + staffOptions = response.staffOptions.map(NamedOptionDto::toDomain), + entries = response.entries.map(TimeEntryDto::toDomain), + totalsByStaff = response.totalsByStaff.map(TimeEntryTotalDto::toDomain), + totalMinutes = response.totalMinutes, + ) + } + + // ─── Admin: Eskalationsregeln ──────────────────────────────────────────── + + suspend fun escalationData(): EscalationData { + val response = requireApi().escalationRules() + return EscalationData( + priorities = response.priorities.map(NamedOptionDto::toDomain), + staffOptions = response.staffOptions.map(NamedOptionDto::toDomain), + departments = response.departments.map(NamedOptionDto::toDomain), + rules = response.rules.map(EscalationRuleDto::toDomain), + ) + } + + suspend fun escalationSave( + id: Int?, + name: String, + isActive: Boolean, + triggerType: String, + triggerHours: Int, + conditionStatus: String?, + conditionPriorityId: Int?, + conditionDeptId: Int?, + actionSetPriorityId: Int?, + actionAssignStaffId: Int?, + actionSetStatus: String?, + actionNotifyAdmin: Boolean, + actionNotifyStaff: Boolean, + sortOrder: Int, + ): Int = requireApi().escalationSave( + EscalationSaveRequest( + id = id, + name = name, + isActive = isActive, + triggerType = triggerType, + triggerHours = triggerHours, + conditionStatus = conditionStatus, + conditionPriorityId = conditionPriorityId, + conditionDeptId = conditionDeptId, + actionSetPriorityId = actionSetPriorityId, + actionAssignStaffId = actionAssignStaffId, + actionSetStatus = actionSetStatus, + actionNotifyAdmin = actionNotifyAdmin, + actionNotifyStaff = actionNotifyStaff, + sortOrder = sortOrder, + ), + ).id + + suspend fun escalationDelete(id: Int) = requireApi().escalationDelete(id) + + suspend fun escalationToggle(id: Int) = requireApi().escalationToggle(id) + + // ─── Admin: Sperrliste ──────────────────────────────────────────────────── + + suspend fun banlist(): List = requireApi().banlist().items.map(BanEntryDto::toDomain) + + suspend fun banCreate(matchType: String, value: String, reason: String?) = + requireApi().banCreate(matchType, value, reason?.ifBlank { null }) + + suspend fun banBulkAdd(values: String, reason: String?): Pair { + val response = requireApi().banBulkAdd(values, reason?.ifBlank { null }) + return response.added to response.skipped + } + + suspend fun banDelete(id: Int) = requireApi().banDelete(id) + + // ─── Admin: Abteilungen ─────────────────────────────────────────────────── + + suspend fun departmentData(): DepartmentData { + val r = requireApi().departments() + return DepartmentData( + departments = r.items.map(DepartmentDto::toDomain), + emailOptions = r.emailOptions.map { DepartmentEmailOption(it.email, it.departmentName) }, + staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), + slaPlans = r.slaPlans.map(NamedOptionDto::toDomain), + ) + } + + suspend fun departmentSave(id: Int?, input: DepartmentInput): Int = + requireApi().departmentSave( + DepartmentSaveRequest( + id = id, name = input.name, email = input.email, isDefault = input.isDefault, signature = input.signature, + isActive = input.isActive, managerId = input.managerId, slaPlanId = input.slaPlanId, notes = input.notes, + ), + ).id + + suspend fun departmentDelete(id: Int) = requireApi().departmentDelete(id) + + // ─── Admin: Prioritäten ─────────────────────────────────────────────────── + + suspend fun report(config: de.deskhub.staff.data.remote.dto.ReportConfigDto?, savedId: Int?): de.deskhub.staff.data.remote.dto.ReportResponseDto { + val query = buildMap { + if (savedId != null) { + put("saved", savedId.toString()) + } else if (config != null) { + put("dimension", config.dimension) + put("metrics", config.metrics.joinToString(",")) + if (config.from.isNotBlank()) put("from", config.from) + if (config.to.isNotBlank()) put("to", config.to) + put("sort", config.sort) + if (config.desc) put("desc", "1") + } + } + return requireApi().report(query) + } + + suspend fun reportSave(name: String, shared: Boolean, config: de.deskhub.staff.data.remote.dto.ReportConfigDto): Int = + requireApi().reportSave(de.deskhub.staff.data.remote.dto.ReportSaveRequest(name = name, isShared = shared, config = config)).id + + suspend fun reportDelete(id: Int) = requireApi().reportDelete(id) + + suspend fun savedViews(): List = requireApi().savedViews().items.map { dto -> + val f = dto.filters + de.deskhub.staff.domain.model.SavedView( + id = dto.id, name = dto.name, isShared = dto.isShared, mine = dto.mine, ownerName = dto.ownerName, + filters = TicketFilters( + query = f["q"].orEmpty(), + statuses = f["status"].orEmpty().split(',').filter { it.isNotBlank() }.toSet().let { s -> + if (s.containsAll(setOf("open", "pending", "answered", "on_hold", "resolved", "closed"))) emptySet() else s + }, + assigned = f["assigned"].orEmpty(), + departmentId = f["departmentId"]?.toIntOrNull(), + priorityId = f["priorityId"]?.toIntOrNull(), + sla = f["sla"].orEmpty(), + ), + ) + } + + suspend fun savedViewSave(name: String, shared: Boolean, filters: TicketFilters): Int { + val map = buildMap { + if (filters.query.isNotBlank()) put("q", filters.query) + put("status", (if (filters.statuses.isEmpty()) setOf("open", "pending", "answered", "on_hold", "resolved", "closed") else filters.statuses).joinToString(",")) + if (filters.assigned.isNotBlank()) put("assigned", filters.assigned) + filters.departmentId?.let { put("departmentId", it.toString()) } + filters.priorityId?.let { put("priorityId", it.toString()) } + if (filters.sla.isNotBlank()) put("sla", filters.sla) + } + return requireApi().savedViewSave(de.deskhub.staff.data.remote.dto.SavedViewSaveRequest(name = name, isShared = shared, filters = map)).id + } + + suspend fun savedViewDelete(id: Int) = requireApi().savedViewDelete(id) + + suspend fun slaPlans(): List = + requireApi().slaPlans().items.map { de.deskhub.staff.domain.model.SlaPlan(it.id, it.name, it.responseHours, it.resolutionHours, it.isActive, it.notes, it.departmentCount, it.helpTopicCount) } + + suspend fun slaPlanSave(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String): Int = + requireApi().slaPlanSave(de.deskhub.staff.data.remote.dto.SlaPlanSaveRequest(id = id, name = name, responseHours = responseHours, resolutionHours = resolutionHours, isActive = isActive, notes = notes)).id + + suspend fun slaPlanDelete(id: Int) = requireApi().slaPlanDelete(id) + + suspend fun prioritiesAdmin(): List = requireApi().prioritiesAdmin().items.map(PriorityFullDto::toDomain) + + suspend fun prioritySave(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int): Int = + requireApi().prioritySave( + PrioritySaveRequest(id = id, name = name, color = color, responseHours = responseHours, resolutionHours = resolutionHours, sortOrder = sortOrder), + ).id + + suspend fun priorityDelete(id: Int) = requireApi().priorityDelete(id) + + // ─── Admin: Hilfe-Themen ────────────────────────────────────────────────── + + suspend fun helpTopics(): HelpTopicData { + val r = requireApi().helpTopics() + return HelpTopicData( + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + slaPlans = r.slaPlans.map(NamedOptionDto::toDomain), + topics = r.items.map(HelpTopicDto::toDomain), + ) + } + + suspend fun helpTopicSave(id: Int?, input: HelpTopicInput): Int = + requireApi().helpTopicSave( + HelpTopicSaveRequest( + id = id, name = input.name, departmentId = input.departmentId, priorityId = input.priorityId, isActive = input.isActive, + isPublic = input.isPublic, parentId = input.parentId, slaPlanId = input.slaPlanId, notes = input.notes, sortOrder = input.sortOrder, + ), + ).id + + suspend fun helpTopicDelete(id: Int) = requireApi().helpTopicDelete(id) + + // ─── Admin: Organisationen ──────────────────────────────────────────────── + + suspend fun organizations(): List = requireApi().organizations().items.map(OrganizationDto::toDomain) + + suspend fun organizationSave(id: Int?, name: String, notes: String?): Int = + requireApi().organizationSave(OrganizationSaveRequest(id = id, name = name, notes = notes)).id + + suspend fun organizationDelete(id: Int) = requireApi().organizationDelete(id) + + // ─── Admin: Teams ───────────────────────────────────────────────────────── + + suspend fun teams(): TeamData { + val r = requireApi().teams() + return TeamData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), teams = r.items.map(TeamDto::toDomain)) + } + + suspend fun teamSave(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List): Int = + requireApi().teamSave(TeamSaveRequest(id = id, name = name, leadId = leadId, notes = notes, memberIds = memberIds)).id + + suspend fun teamToggle(id: Int) = requireApi().teamToggle(id) + + suspend fun teamDelete(id: Int) = requireApi().teamDelete(id) + + // ─── Admin: Rollen ──────────────────────────────────────────────────────── + + suspend fun roles(): RoleData { + val r = requireApi().roles() + return RoleData( + permissionKeys = r.permissionKeys, + defaultAgentPermissions = r.defaultAgentPermissions, + roles = r.items.map(RoleDto::toDomain), + ) + } + + suspend fun roleSave(id: Int?, name: String, permissions: Map): Int = + requireApi().roleSave(RoleSaveRequest(id = id, name = name, permissions = permissions)).id + + suspend fun roleDelete(id: Int) = requireApi().roleDelete(id) + + // ─── Admin: Mitarbeiter ─────────────────────────────────────────────────── + + suspend fun staffAdminList(): StaffAdminData { + val r = requireApi().staffAdminList() + return StaffAdminData( + departments = r.departments.map(NamedOptionDto::toDomain), + roles = r.roles.map(NamedOptionDto::toDomain), + staff = r.items.map(StaffAdminDto::toDomain), + teams = r.teams.map(NamedOptionDto::toDomain), + canManageTeams = r.canManageTeams, + ) + } + + suspend fun staffAdminSave(id: Int?, input: StaffAdminInput): Int = requireApi().staffAdminSave( + StaffAdminSaveRequest( + id = id, name = input.name, email = input.email, role = input.role, roleId = input.roleId, + departmentId = input.departmentId, isActive = input.isActive, password = input.password, signature = input.signature, + extraDepartments = input.extraDepartments.map { StaffExtraDeptDto(it.departmentId, it.notify, it.roleId) }, + teamIds = input.teamIds, + ), + ).id + + suspend fun staffAdminDelete(id: Int) = requireApi().staffAdminDelete(id) + + // ─── Admin: Geschäftszeiten ─────────────────────────────────────────────── + + suspend fun businessHours(): BusinessHoursData { + val r = requireApi().businessHours() + return BusinessHoursData( + enabled = r.enabled, + hours = r.hours.map(BusinessHourDto::toDomain), + holidays = r.holidays.map(BusinessHolidayDto::toDomain), + ) + } + + suspend fun businessHoursSave(enabled: Boolean, hours: List) = + requireApi().businessHoursSave(enabled, hours.map { BusinessHourDto(it.weekday, it.isOpen, it.openTime, it.closeTime) }) + + suspend fun businessHolidayAdd(date: String, name: String): Int = requireApi().businessHolidayAdd(date, name).id + + suspend fun businessHolidayDelete(id: Int) = requireApi().businessHolidayDelete(id) + + // ─── Admin: E-Mail-Adressen ─────────────────────────────────────────────── + + suspend fun emailAddresses(): EmailAddressData { + val r = requireApi().emailAddresses() + return EmailAddressData( + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + helpTopics = r.helpTopics.map(NamedOptionDto::toDomain), + addresses = r.items.map(EmailAddressDto::toDomain), + ) + } + + suspend fun emailAddressSave(id: Int?, input: EmailAddressInput): Int = + requireApi().emailAddressSave( + EmailAddressSaveRequest( + id = id, email = input.email, label = input.label, departmentId = input.departmentId, priorityId = input.priorityId, isDefault = input.isDefault, + helpTopicId = input.helpTopicId, isActive = input.isActive, notes = input.notes, + smtpEnabled = input.smtpEnabled, smtpHost = input.smtpHost, smtpPort = input.smtpPort, smtpEncryption = input.smtpEncryption, + smtpUsername = input.smtpUsername, smtpPassword = input.smtpPassword, + imapEnabled = input.imapEnabled, imapHost = input.imapHost, imapPort = input.imapPort, imapEncryption = input.imapEncryption, + imapUsername = input.imapUsername, imapPassword = input.imapPassword, imapFolder = input.imapFolder, imapAfterFetch = input.imapAfterFetch, + imapArchiveFolder = input.imapArchiveFolder, imapSpamFolder = input.imapSpamFolder, + ), + ).id + + /** action: "sendTest" (Test-Mail an die eigene Adresse) oder "testImap" (Postfach-Verbindung). Liefert die Servermeldung. */ + suspend fun emailAddressAction(action: String, id: Int): String = requireApi().emailAddressAction(action, id).message + + suspend fun emailAddressDelete(id: Int) = requireApi().emailAddressDelete(id) + + // ─── Admin: E-Mail-Vorlagen ─────────────────────────────────────────────── + + suspend fun emailTemplates(): List = requireApi().emailTemplates().items.map(EmailTemplateDto::toDomain) + + suspend fun emailTemplateSave(key: String, subject: String, bodyText: String) = requireApi().emailTemplateSave(key, subject, bodyText) + + suspend fun emailTemplateReset(key: String) = requireApi().emailTemplateReset(key) + + // ─── Admin: Admin-Protokoll ─────────────────────────────────────────────── + + suspend fun auditLog(page: Int): AuditLogPage { + val r = requireApi().auditLog(page) + return AuditLogPage(items = r.items.map(AuditLogEntryDto::toDomain), page = r.page, hasMore = r.hasMore) + } + + // ─── Aufgaben ───────────────────────────────────────────────────────────── + + suspend fun tasks(filter: String): de.deskhub.staff.domain.model.TaskData { + val r = requireApi().tasks(filter) + return de.deskhub.staff.domain.model.TaskData(staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), tasks = r.items.map(TaskDto::toDomain)) + } + + suspend fun taskCreate(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?): Int = + requireApi().taskCreate( + TaskCreateRequest(title = title, description = description, assignedToId = assignedToId, dueAt = dueAt, ticketNumber = ticketNumber), + ).id + + suspend fun taskUpdateStatus(id: Int, status: String) = requireApi().taskUpdateStatus(id, status) + + suspend fun taskDelete(id: Int) = requireApi().taskDelete(id) + + // ─── Interner Chat ──────────────────────────────────────────────────────── + + suspend fun chatContacts(): List = requireApi().chatContacts().contacts.map(ChatContactDto::toDomain) + + suspend fun chatConversation(withId: Int): ChatConversation { + val r = requireApi().chatConversation(withId) + return ChatConversation(with = r.with.toDomain(), messages = r.messages.map(ChatMessageDto::toDomain)) + } + + suspend fun chatSend(toId: Int, message: String): Int = requireApi().chatSend(toId, message).id + + // ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────── + + suspend fun liveChatList(): List = + requireApi().liveChatList().items.map(LiveChatListItemDto::toDomain) + + suspend fun liveChatMessages(number: String): LiveChatDetail { + val r = requireApi().liveChatMessages(number, 0) + return LiveChatDetail( + number = number, + status = r.status, + requesterName = r.requesterName, + requesterEmail = r.requesterEmail, + departmentName = r.departmentName, + lockedByOtherName = r.lockedByOtherName, + customerTyping = r.customerTyping, + messages = r.messages.map(LiveChatMessageDto::toDomain), + ) + } + + suspend fun liveChatSend(number: String, message: String) { requireApi().liveChatSend(number, message) } + + suspend fun liveChatTyping(number: String) { requireApi().liveChatTyping(number) } + + suspend fun liveChatEnd(number: String) { requireApi().liveChatEnd(number) } + + // ─── Verfügbarkeit (Status) ──────────────────────────────────────────────── + + suspend fun availability(): AvailabilityData { + val r = requireApi().availability() + return AvailabilityData(myStatus = r.myStatus, myNote = r.myNote, team = r.team.map(TeamMemberDto::toDomain)) + } + + suspend fun setAvailability(status: String, note: String) { requireApi().setAvailability(status, note) } + + // ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────── + + suspend fun ticketRules(): TicketRulesData { + val r = requireApi().ticketRules() + return TicketRulesData( + fieldOptions = r.fieldOptions.map(KeyLabelDto::toDomain), + operatorOptions = r.operatorOptions.map(KeyLabelDto::toDomain), + actionOptions = r.actionOptions.map(KeyLabelDto::toDomain), + departments = r.departments.map(NamedOptionDto::toDomain), + priorities = r.priorities.map(NamedOptionDto::toDomain), + helpTopics = r.helpTopics.map(NamedOptionDto::toDomain), + staffOptions = r.staffOptions.map(NamedOptionDto::toDomain), + cannedResponses = r.cannedResponses.map(NamedOptionDto::toDomain), + rules = r.rules.map(TicketRuleDto::toDomain), + autoCloseEnabled = r.autoCloseEnabled, + autoCloseDays = r.autoCloseDays, + ) + } + + suspend fun saveTicketRule( + id: Int?, + name: String, + matchType: String, + isActive: Boolean, + sortOrder: Int, + conditions: List, + actions: List, + ): Int { + val request = TicketRuleSaveRequest( + id = id, + name = name, + matchType = matchType, + isActive = isActive, + sortOrder = sortOrder, + conditions = conditions.map { TicketRuleConditionDto(it.field, it.operator, it.value) }, + actions = actions.map { TicketRuleActionDto(it.type, it.value, it.valueLabel) }, + ) + return requireApi().saveTicketRule(request).id + } + + suspend fun toggleTicketRule(id: Int) { requireApi().toggleTicketRule(id) } + + suspend fun deleteTicketRule(id: Int) { requireApi().deleteTicketRule(id) } + + suspend fun saveAutoClose(enabled: Boolean, days: Int) { requireApi().saveAutoClose(enabled, days) } + + // ─── Bild-Upload ────────────────────────────────────────────────────────── + + suspend fun uploadImage(bytes: ByteArray, filename: String, mimeType: String): String = + requireApi().uploadImage(bytes, filename, mimeType).url + + fun clearSession() { + sessionStore.clear() + session = null + api = null + } + + private fun configure(stored: StoredSession) { + session = stored + api = DeskHubApi(stored.serverUrl) { session?.token } + } + + private fun requireApi(): DeskHubApi = + checkNotNull(api) { "Keine aktive deskhub-Sitzung." } +} + +private fun StaffDto.toDomain() = Staff(id, name, email, role, permissions) +private fun NamedOptionDto.toDomain() = NamedOption(id, name) +private fun PriorityDto.toDomain() = Priority(id, name, color) +private fun StatusOptionDto.toDomain() = StatusOption(value, label, color) +private fun CannedResponseDto.toDomain() = CannedResponse(id, title, body, departmentId) +private fun DepartmentStatDto.toDomain() = DepartmentStat(name, open, overdue) +private fun CustomerDto.toDomain() = Customer(id, name, email, organizationName, ticketCount, isGuest) +private fun CustomerDetailDto.toDomain() = CustomerDetail(id, name, email, organizationName, isGuest, createdAt) +private fun BanEntryDto.toDomain() = BanEntry(id, matchType, value, reason, addedByName, createdAt) +private fun DepartmentDto.toDomain() = Department( + id, name, email, isDefault, signature, isActive, managerId, managerName, slaPlanId, notes, + members.map { DepartmentMember(it.id, it.name, it.isPrimary) }, updatedAt, +) +private fun PriorityFullDto.toDomain() = PriorityFull(id, name, color, responseHours, resolutionHours, sortOrder) +private fun HelpTopicDto.toDomain() = HelpTopic(id, name, departmentId, departmentName, priorityId, priorityName, priorityColor, isActive, sortOrder, isPublic, parentId, slaPlanId, path.ifBlank { name }, notes, updatedAt) +private fun OrganizationDto.toDomain() = Organization(id, name, notes, memberCount) +private fun TeamDto.toDomain() = Team(id, name, leadId, leadName, notes, isActive, memberCount, memberIds) +private fun RoleDto.toDomain() = Role(id, name, permissions, staffCount) +private fun StaffAdminDto.toDomain() = StaffAdmin( + id, name, email, role, roleId, departmentId, departmentName, isActive, signature, + extraDepartments.map { StaffExtraDept(it.departmentId, it.notify, it.roleId) }, + teamIds, createdAt, +) +private fun BusinessHourDto.toDomain() = BusinessHour(weekday, isOpen, openTime, closeTime) +private fun BusinessHolidayDto.toDomain() = BusinessHoliday(id, date, name) +private fun EmailAddressDto.toDomain() = EmailAddress( + id, email, label, departmentId, departmentName, priorityId, priorityName, isDefault, + helpTopicId, isActive, notes, smtpEnabled, smtpHost, smtpPort, smtpEncryption, smtpUsername, smtpHasPassword, + imapEnabled, imapHost, imapPort, imapEncryption, imapUsername, imapFolder, imapAfterFetch, imapArchiveFolder, imapSpamFolder, imapHasPassword, imapLastRunAt, +) +private fun EmailTemplateDto.toDomain() = EmailTemplate(key, label, placeholders, subject, body, isCustomized) +private fun AuditLogEntryDto.toDomain() = AuditLogEntry(id, staffName, action, createdAt) +private fun TaskDto.toDomain() = StaffTask(id, title, description, status, assignedToId, assignedName, creatorName, dueAt, ticketNumber, ticketSubject) +private fun ChatContactDto.toDomain() = ChatContact(id, name, unreadCount, lastMessageAt) +private fun ChatMessageDto.toDomain() = ChatMessage(id, fromStaffId, fromName, message, createdAt) +private fun LiveChatListItemDto.toDomain() = LiveChatSummary(number, name, preview, needsReply, updatedAt) +private fun LiveChatAttachmentDto.toDomain() = LiveChatAttachment(id, name, size) +private fun LiveChatMessageDto.toDomain() = LiveChatMessage(id, authorType, authorName, message, createdAt, attachments.map(LiveChatAttachmentDto::toDomain)) +private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt) +private fun KeyLabelDto.toDomain() = KeyLabel(value, label) +private fun TicketRuleConditionDto.toDomain() = TicketRuleCondition(field, operator, value) +private fun TicketRuleActionDto.toDomain() = TicketRuleAction(type, value, valueLabel) +private fun TicketRuleDto.toDomain() = TicketRule( + id, name, matchType, isActive, sortOrder, + conditions.map(TicketRuleConditionDto::toDomain), + actions.map(TicketRuleActionDto::toDomain), +) +private fun KbCategoryDto.toDomain() = KbCategory(id, name) +private fun KbArticleDto.toDomain() = KbArticle(id, categoryId, categoryName, title, body, isPublished, views) +private fun TimeEntryDto.toDomain() = TimeEntry(id, staffName, ticketNumber, subject, minutes, note, loggedAt) +private fun TimeEntryTotalDto.toDomain() = TimeEntryTotal(staffName, minutes) +private fun EscalationRuleDto.toDomain() = EscalationRule( + id = id, + name = name, + isActive = isActive, + triggerType = triggerType, + triggerHours = triggerHours, + conditionStatus = conditionStatus, + conditionPriorityId = conditionPriorityId, + conditionPriorityName = conditionPriorityName, + conditionDeptId = conditionDeptId, + conditionDeptName = conditionDeptName, + actionSetPriorityId = actionSetPriorityId, + actionSetPriorityName = actionSetPriorityName, + actionAssignStaffId = actionAssignStaffId, + actionAssignStaffName = actionAssignStaffName, + actionSetStatus = actionSetStatus, + actionNotifyAdmin = actionNotifyAdmin, + actionNotifyStaff = actionNotifyStaff, + sortOrder = sortOrder, + appliedCount = appliedCount, +) + +private fun TicketSummaryDto.toDomain() = TicketSummary( + id = id, + number = number, + subject = subject, + requesterName = requesterName, + requesterEmail = requesterEmail, + status = status, + priority = priority.toDomain(), + department = department.toDomain(), + assignedStaff = assignedStaff?.toDomain(), + isOverdue = isOverdue, + dueLabel = dueLabel, + isSpam = isSpam, + createdAt = createdAt, + updatedAt = updatedAt, +) + +private fun TicketDetailDto.toDomain() = TicketDetail( + summary = TicketSummary( + id = id, + number = number, + subject = subject, + requesterName = requesterName, + requesterEmail = requesterEmail, + status = status, + priority = priority.toDomain(), + department = department.toDomain(), + assignedStaff = assignedStaff?.toDomain(), + isOverdue = isOverdue, + isSpam = isSpam, + createdAt = createdAt, + updatedAt = updatedAt, + ), + source = source, + firstResponseDueAt = firstResponseDueAt, + resolutionDueAt = resolutionDueAt, + lockedByOtherName = lockedByOtherName, + threads = threads.map { thread -> + TicketThread( + id = thread.id, + authorType = thread.authorType, + authorName = thread.authorName, + message = thread.message, + isInternalNote = thread.isInternalNote, + createdAt = thread.createdAt, + attachments = thread.attachments.map { ThreadAttachment(it.id, it.name, it.size) }, + comments = thread.comments.map { + ThreadComment(it.id, it.message, it.staff.toDomain(), it.createdAt) + }, + ) + }, + permissions = permissions, +) diff --git a/mobile/src/main/java/de/deskhub/staff/data/remote/ConnectionMonitor.kt b/mobile/src/main/java/de/deskhub/staff/data/remote/ConnectionMonitor.kt new file mode 100644 index 00000000..35e991f6 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/remote/ConnectionMonitor.kt @@ -0,0 +1,14 @@ +package de.deskhub.staff.data.remote + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Merkt sich, ob der Server gerade erreichbar ist (wird von jedem API-Aufruf aktualisiert). */ +object ConnectionMonitor { + private val mutableOffline = MutableStateFlow(false) + val offline: StateFlow = mutableOffline + + fun set(isOffline: Boolean) { + mutableOffline.value = isOffline + } +} 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 new file mode 100644 index 00000000..8f0756ba --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/remote/DeskHubApi.kt @@ -0,0 +1,594 @@ +package de.deskhub.staff.data.remote + +import com.google.gson.GsonBuilder +import de.deskhub.staff.data.remote.dto.ActivityResponseDto +import de.deskhub.staff.data.remote.dto.CannedResponseListResponse +import de.deskhub.staff.data.remote.dto.CreateTicketRequest +import de.deskhub.staff.data.remote.dto.CreateTicketResponse +import de.deskhub.staff.data.remote.dto.CustomerListResponse +import de.deskhub.staff.data.remote.dto.CustomerProfileResponse +import de.deskhub.staff.data.remote.dto.AuditLogResponse +import de.deskhub.staff.data.remote.dto.BanBulkAddRequest +import de.deskhub.staff.data.remote.dto.BanBulkAddResponse +import de.deskhub.staff.data.remote.dto.BanCreateRequest +import de.deskhub.staff.data.remote.dto.BanDeleteRequest +import de.deskhub.staff.data.remote.dto.BanlistResponse +import de.deskhub.staff.data.remote.dto.BusinessHolidayAddRequest +import de.deskhub.staff.data.remote.dto.ChatContactsResponse +import de.deskhub.staff.data.remote.dto.ChatConversationResponse +import de.deskhub.staff.data.remote.dto.ChatSendRequest +import de.deskhub.staff.data.remote.dto.ChatSendResponse +import de.deskhub.staff.data.remote.dto.AutoCloseSaveRequest +import de.deskhub.staff.data.remote.dto.AvailabilityResponse +import de.deskhub.staff.data.remote.dto.AvailabilitySetRequest +import de.deskhub.staff.data.remote.dto.AvailabilitySetResponse +import de.deskhub.staff.data.remote.dto.LiveChatActionRequest +import de.deskhub.staff.data.remote.dto.LiveChatListResponse +import de.deskhub.staff.data.remote.dto.LiveChatMessagesResponse +import de.deskhub.staff.data.remote.dto.LiveChatSendRequest +import de.deskhub.staff.data.remote.dto.LiveChatSendResponse +import de.deskhub.staff.data.remote.dto.TicketRuleIdRequest +import de.deskhub.staff.data.remote.dto.TicketRuleSaveRequest +import de.deskhub.staff.data.remote.dto.TicketRuleSaveResponse +import de.deskhub.staff.data.remote.dto.TicketRulesResponse +import de.deskhub.staff.data.remote.dto.BusinessHolidayDeleteRequest +import de.deskhub.staff.data.remote.dto.BusinessHourDto +import de.deskhub.staff.data.remote.dto.BusinessHoursResponse +import de.deskhub.staff.data.remote.dto.BusinessHoursSaveRequest +import de.deskhub.staff.data.remote.dto.DashboardResponseDto +import de.deskhub.staff.data.remote.dto.DepartmentDeleteRequest +import de.deskhub.staff.data.remote.dto.DepartmentListResponse +import de.deskhub.staff.data.remote.dto.DepartmentSaveRequest +import de.deskhub.staff.data.remote.dto.EmailAddressActionRequest +import de.deskhub.staff.data.remote.dto.EmailAddressActionResponse +import de.deskhub.staff.data.remote.dto.EmailAddressDeleteRequest +import de.deskhub.staff.data.remote.dto.EmailAddressListResponse +import de.deskhub.staff.data.remote.dto.EmailAddressSaveRequest +import de.deskhub.staff.data.remote.dto.EmailTemplateListResponse +import de.deskhub.staff.data.remote.dto.EmailTemplateResetRequest +import de.deskhub.staff.data.remote.dto.EmailTemplateSaveRequest +import de.deskhub.staff.data.remote.dto.ErrorEnvelope +import de.deskhub.staff.data.remote.dto.EscalationDeleteRequest +import de.deskhub.staff.data.remote.dto.EscalationRulesResponse +import de.deskhub.staff.data.remote.dto.EscalationSaveRequest +import de.deskhub.staff.data.remote.dto.EscalationToggleRequest +import de.deskhub.staff.data.remote.dto.HelpTopicDeleteRequest +import de.deskhub.staff.data.remote.dto.HelpTopicListResponse +import de.deskhub.staff.data.remote.dto.HelpTopicSaveRequest +import de.deskhub.staff.data.remote.dto.IdResponse +import de.deskhub.staff.data.remote.dto.KbCreateCategoryRequest +import de.deskhub.staff.data.remote.dto.KbDeleteArticleRequest +import de.deskhub.staff.data.remote.dto.KbDeleteCategoryRequest +import de.deskhub.staff.data.remote.dto.KbIdResponse +import de.deskhub.staff.data.remote.dto.KbResponse +import de.deskhub.staff.data.remote.dto.KbSaveArticleRequest +import de.deskhub.staff.data.remote.dto.LoginRequest +import de.deskhub.staff.data.remote.dto.LoginResponse +import de.deskhub.staff.data.remote.dto.MessageRequest +import de.deskhub.staff.data.remote.dto.MessageResponse +import de.deskhub.staff.data.remote.dto.MetadataResponse +import de.deskhub.staff.data.remote.dto.OrganizationDeleteRequest +import de.deskhub.staff.data.remote.dto.OrganizationListResponse +import de.deskhub.staff.data.remote.dto.OrganizationSaveRequest +import de.deskhub.staff.data.remote.dto.PriorityDeleteRequest +import de.deskhub.staff.data.remote.dto.PriorityListResponse +import de.deskhub.staff.data.remote.dto.PrioritySaveRequest +import de.deskhub.staff.data.remote.dto.RoleDeleteRequest +import de.deskhub.staff.data.remote.dto.RoleListResponse +import de.deskhub.staff.data.remote.dto.RoleSaveRequest +import de.deskhub.staff.data.remote.dto.StaffAdminDeleteRequest +import de.deskhub.staff.data.remote.dto.StaffAdminListResponse +import de.deskhub.staff.data.remote.dto.StaffAdminSaveRequest +import de.deskhub.staff.data.remote.dto.StaffResponse +import de.deskhub.staff.data.remote.dto.TaskCreateRequest +import de.deskhub.staff.data.remote.dto.TaskDeleteRequest +import de.deskhub.staff.data.remote.dto.TaskListResponse +import de.deskhub.staff.data.remote.dto.TaskUpdateStatusRequest +import de.deskhub.staff.data.remote.dto.TeamDeleteRequest +import de.deskhub.staff.data.remote.dto.TeamListResponse +import de.deskhub.staff.data.remote.dto.TeamSaveRequest +import de.deskhub.staff.data.remote.dto.TeamToggleRequest +import de.deskhub.staff.data.remote.dto.TicketPageResponse +import de.deskhub.staff.data.remote.dto.UploadImageResponse +import de.deskhub.staff.data.remote.dto.TicketResponse +import de.deskhub.staff.data.remote.dto.TicketSpamRequest +import de.deskhub.staff.data.remote.dto.TicketSpamResponse +import de.deskhub.staff.data.remote.dto.TicketSummaryResponse +import de.deskhub.staff.data.remote.dto.TimeEntriesResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.net.URI +import java.util.concurrent.TimeUnit + +class ApiException( + val status: Int, + val code: String, + override val message: String, +) : Exception(message) + +object DeskHubUrl { + fun normalize(input: String): String { + val candidate = input.trim().let { + if (it.contains("://")) it else "https://$it" + } + val uri = runCatching { URI(candidate) } + .getOrElse { throw IllegalArgumentException("Die Server-Adresse ist ungültig.") } + require(uri.scheme.equals("https", ignoreCase = true)) { + "Die Server-Adresse muss HTTPS verwenden." + } + require(uri.host != null && uri.userInfo == null && uri.query == null && uri.fragment == null) { + "Die Server-Adresse ist ungültig." + } + val path = uri.path.orEmpty().trimEnd('/') + return URI("https", null, uri.host, uri.port, path, null, null).toASCIIString() + } +} + +class DeskHubApi( + serverUrl: String, + private val tokenProvider: () -> String?, +) { + private val baseUrl = DeskHubUrl.normalize(serverUrl) + private val gson = GsonBuilder().serializeNulls().create() + private val jsonType = "application/json; charset=utf-8".toMediaType() + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + suspend fun login(request: LoginRequest): LoginResponse = + execute("login.php", "POST", request, LoginResponse::class.java, authenticated = false) + + suspend fun me(): StaffResponse = + execute("me.php", "GET", null, StaffResponse::class.java) + + suspend fun metadata(): MetadataResponse = + execute("metadata.php", "GET", null, MetadataResponse::class.java) + + suspend fun tickets(query: Map): TicketPageResponse = + execute("tickets.php", "GET", null, TicketPageResponse::class.java, query = query) + + suspend fun ticket(number: String): TicketResponse = + execute( + "ticket.php", + "GET", + null, + TicketResponse::class.java, + query = mapOf("number" to number), + ) + + suspend fun updateTicket(request: Map): TicketSummaryResponse = + execute("ticket_update.php", "PATCH", request, TicketSummaryResponse::class.java) + + suspend fun addMessage(request: MessageRequest): MessageResponse = + execute("ticket_message.php", "POST", request, MessageResponse::class.java) + + suspend fun ticketSpam(number: String, action: String): TicketSpamResponse = + execute("ticket_spam.php", "POST", TicketSpamRequest(number = number, action = action), TicketSpamResponse::class.java) + + suspend fun cannedResponses(): CannedResponseListResponse = + execute("canned_responses.php", "GET", null, CannedResponseListResponse::class.java) + + suspend fun dashboard(): DashboardResponseDto = + execute("dashboard.php", "GET", null, DashboardResponseDto::class.java) + + suspend fun customers(query: String): CustomerListResponse = + execute( + "customers.php", + "GET", + null, + CustomerListResponse::class.java, + query = if (query.isBlank()) emptyMap() else mapOf("q" to query), + ) + + suspend fun customer(userId: Int?, email: String?): CustomerProfileResponse { + val query = buildMap { + if (userId != null) put("userId", userId.toString()) else email?.let { put("email", it) } + } + return execute("customer.php", "GET", null, CustomerProfileResponse::class.java, query = query) + } + + suspend fun createTicket(request: CreateTicketRequest): CreateTicketResponse = + execute("ticket_create.php", "POST", request, CreateTicketResponse::class.java) + + suspend fun activity(since: String): ActivityResponseDto = + execute("activity.php", "GET", null, ActivityResponseDto::class.java, query = mapOf("since" to since)) + + suspend fun logout() { + execute("logout.php", "POST", emptyMap(), Map::class.java) + } + + // ─── Admin: Wissensdatenbank ───────────────────────────────────────────── + + suspend fun kb(): KbResponse = + execute("kb.php", "GET", null, KbResponse::class.java) + + suspend fun kbSaveArticle(request: KbSaveArticleRequest): KbIdResponse = + execute("kb.php", "POST", request, KbIdResponse::class.java) + + suspend fun kbDeleteArticle(id: Int) { + execute("kb.php", "POST", KbDeleteArticleRequest(id = id), Map::class.java) + } + + suspend fun kbCreateCategory(name: String): KbIdResponse = + execute("kb.php", "POST", KbCreateCategoryRequest(name = name), KbIdResponse::class.java) + + suspend fun kbDeleteCategory(id: Int) { + execute("kb.php", "POST", KbDeleteCategoryRequest(id = id), Map::class.java) + } + + // ─── Admin: Zeiterfassung ──────────────────────────────────────────────── + + suspend fun timeEntries(query: Map): TimeEntriesResponse = + execute("time_entries.php", "GET", null, TimeEntriesResponse::class.java, query = query) + + // ─── Admin: Eskalationsregeln ──────────────────────────────────────────── + + suspend fun escalationRules(): EscalationRulesResponse = + execute("escalation_rules.php", "GET", null, EscalationRulesResponse::class.java) + + suspend fun escalationSave(request: EscalationSaveRequest): KbIdResponse = + execute("escalation_rules.php", "POST", request, KbIdResponse::class.java) + + suspend fun escalationDelete(id: Int) { + execute("escalation_rules.php", "POST", EscalationDeleteRequest(id = id), Map::class.java) + } + + suspend fun escalationToggle(id: Int) { + execute("escalation_rules.php", "POST", EscalationToggleRequest(id = id), Map::class.java) + } + + // ─── Admin: Sperrliste ─────────────────────────────────────────────────── + + suspend fun banlist(): BanlistResponse = + execute("banlist.php", "GET", null, BanlistResponse::class.java) + + suspend fun banCreate(matchType: String, value: String, reason: String?) { + execute("banlist.php", "POST", BanCreateRequest(matchType = matchType, value = value, reason = reason), Map::class.java) + } + + suspend fun banBulkAdd(values: String, reason: String?): BanBulkAddResponse = + execute("banlist.php", "POST", BanBulkAddRequest(values = values, reason = reason), BanBulkAddResponse::class.java) + + suspend fun banDelete(id: Int) { + execute("banlist.php", "POST", BanDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Abteilungen ────────────────────────────────────────────────── + + suspend fun departments(): DepartmentListResponse = + execute("departments.php", "GET", null, DepartmentListResponse::class.java) + + suspend fun departmentSave(request: DepartmentSaveRequest): IdResponse = + execute("departments.php", "POST", request, IdResponse::class.java) + + suspend fun departmentDelete(id: Int) { + execute("departments.php", "POST", DepartmentDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Prioritäten ────────────────────────────────────────────────── + + suspend fun report(query: Map): de.deskhub.staff.data.remote.dto.ReportResponseDto = + execute("report_builder.php", "GET", null, de.deskhub.staff.data.remote.dto.ReportResponseDto::class.java, query = query) + + suspend fun reportSave(request: de.deskhub.staff.data.remote.dto.ReportSaveRequest): IdResponse = + execute("report_builder.php", "POST", request, IdResponse::class.java) + + suspend fun reportDelete(id: Int) { + execute("report_builder.php", "POST", de.deskhub.staff.data.remote.dto.ReportDeleteRequest(id = id), Map::class.java) + } + + suspend fun savedViews(): de.deskhub.staff.data.remote.dto.SavedViewListResponse = + execute("queues.php", "GET", null, de.deskhub.staff.data.remote.dto.SavedViewListResponse::class.java) + + suspend fun savedViewSave(request: de.deskhub.staff.data.remote.dto.SavedViewSaveRequest): IdResponse = + execute("queues.php", "POST", request, IdResponse::class.java) + + suspend fun savedViewDelete(id: Int) { + execute("queues.php", "POST", de.deskhub.staff.data.remote.dto.SavedViewDeleteRequest(id = id), Map::class.java) + } + + suspend fun slaPlans(): de.deskhub.staff.data.remote.dto.SlaPlanListResponse = + execute("sla_plans.php", "GET", null, de.deskhub.staff.data.remote.dto.SlaPlanListResponse::class.java) + + suspend fun slaPlanSave(request: de.deskhub.staff.data.remote.dto.SlaPlanSaveRequest): IdResponse = + execute("sla_plans.php", "POST", request, IdResponse::class.java) + + suspend fun slaPlanDelete(id: Int) { + execute("sla_plans.php", "POST", de.deskhub.staff.data.remote.dto.SlaPlanDeleteRequest(id = id), Map::class.java) + } + + suspend fun prioritiesAdmin(): PriorityListResponse = + execute("priorities.php", "GET", null, PriorityListResponse::class.java) + + suspend fun prioritySave(request: PrioritySaveRequest): IdResponse = + execute("priorities.php", "POST", request, IdResponse::class.java) + + suspend fun priorityDelete(id: Int) { + execute("priorities.php", "POST", PriorityDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Hilfe-Themen ───────────────────────────────────────────────── + + suspend fun helpTopics(): HelpTopicListResponse = + execute("help_topics.php", "GET", null, HelpTopicListResponse::class.java) + + suspend fun helpTopicSave(request: HelpTopicSaveRequest): IdResponse = + execute("help_topics.php", "POST", request, IdResponse::class.java) + + suspend fun helpTopicDelete(id: Int) { + execute("help_topics.php", "POST", HelpTopicDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Organisationen ─────────────────────────────────────────────── + + suspend fun organizations(): OrganizationListResponse = + execute("organizations.php", "GET", null, OrganizationListResponse::class.java) + + suspend fun organizationSave(request: OrganizationSaveRequest): IdResponse = + execute("organizations.php", "POST", request, IdResponse::class.java) + + suspend fun organizationDelete(id: Int) { + execute("organizations.php", "POST", OrganizationDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Teams ──────────────────────────────────────────────────────── + + suspend fun teams(): TeamListResponse = + execute("teams.php", "GET", null, TeamListResponse::class.java) + + suspend fun teamSave(request: TeamSaveRequest): IdResponse = + execute("teams.php", "POST", request, IdResponse::class.java) + + suspend fun teamToggle(id: Int) { + execute("teams.php", "POST", TeamToggleRequest(id = id), Map::class.java) + } + + suspend fun teamDelete(id: Int) { + execute("teams.php", "POST", TeamDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Rollen ─────────────────────────────────────────────────────── + + suspend fun roles(): RoleListResponse = + execute("roles.php", "GET", null, RoleListResponse::class.java) + + suspend fun roleSave(request: RoleSaveRequest): IdResponse = + execute("roles.php", "POST", request, IdResponse::class.java) + + suspend fun roleDelete(id: Int) { + execute("roles.php", "POST", RoleDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Mitarbeiter ────────────────────────────────────────────────── + + suspend fun staffAdminList(): StaffAdminListResponse = + execute("staff_manage.php", "GET", null, StaffAdminListResponse::class.java) + + suspend fun staffAdminSave(request: StaffAdminSaveRequest): IdResponse = + execute("staff_manage.php", "POST", request, IdResponse::class.java) + + suspend fun staffAdminDelete(id: Int) { + execute("staff_manage.php", "POST", StaffAdminDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: Geschäftszeiten ────────────────────────────────────────────── + + suspend fun businessHours(): BusinessHoursResponse = + execute("business_hours.php", "GET", null, BusinessHoursResponse::class.java) + + suspend fun businessHoursSave(enabled: Boolean, hours: List) { + execute("business_hours.php", "POST", BusinessHoursSaveRequest(enabled = enabled, hours = hours), Map::class.java) + } + + suspend fun businessHolidayAdd(date: String, name: String): IdResponse = + execute("business_hours.php", "POST", BusinessHolidayAddRequest(date = date, name = name), IdResponse::class.java) + + suspend fun businessHolidayDelete(id: Int) { + execute("business_hours.php", "POST", BusinessHolidayDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: E-Mail-Adressen ────────────────────────────────────────────── + + suspend fun emailAddresses(): EmailAddressListResponse = + execute("email_addresses.php", "GET", null, EmailAddressListResponse::class.java) + + suspend fun emailAddressSave(request: EmailAddressSaveRequest): IdResponse = + execute("email_addresses.php", "POST", request, IdResponse::class.java) + + suspend fun emailAddressAction(action: String, id: Int): EmailAddressActionResponse = + execute("email_addresses.php", "POST", EmailAddressActionRequest(action = action, id = id), EmailAddressActionResponse::class.java) + + suspend fun emailAddressDelete(id: Int) { + execute("email_addresses.php", "POST", EmailAddressDeleteRequest(id = id), Map::class.java) + } + + // ─── Admin: E-Mail-Vorlagen ────────────────────────────────────────────── + + suspend fun emailTemplates(): EmailTemplateListResponse = + execute("email_templates.php", "GET", null, EmailTemplateListResponse::class.java) + + suspend fun emailTemplateSave(key: String, subject: String, bodyText: String) { + execute("email_templates.php", "POST", EmailTemplateSaveRequest(key = key, subject = subject, body = bodyText), Map::class.java) + } + + suspend fun emailTemplateReset(key: String) { + execute("email_templates.php", "POST", EmailTemplateResetRequest(key = key), Map::class.java) + } + + // ─── Admin: Admin-Protokoll ────────────────────────────────────────────── + + suspend fun auditLog(page: Int): AuditLogResponse = + execute("audit_log.php", "GET", null, AuditLogResponse::class.java, query = mapOf("page" to page.toString())) + + // ─── Aufgaben ──────────────────────────────────────────────────────────── + + suspend fun tasks(filter: String): TaskListResponse = + execute("tasks.php", "GET", null, TaskListResponse::class.java, query = mapOf("filter" to filter)) + + suspend fun taskCreate(request: TaskCreateRequest): IdResponse = + execute("tasks.php", "POST", request, IdResponse::class.java) + + suspend fun taskUpdateStatus(id: Int, status: String) { + execute("tasks.php", "POST", TaskUpdateStatusRequest(id = id, status = status), Map::class.java) + } + + suspend fun taskDelete(id: Int) { + execute("tasks.php", "POST", TaskDeleteRequest(id = id), Map::class.java) + } + + // ─── Interner Chat ─────────────────────────────────────────────────────── + + suspend fun chatContacts(): ChatContactsResponse = + execute("chat.php", "GET", null, ChatContactsResponse::class.java) + + suspend fun chatConversation(withId: Int): ChatConversationResponse = + execute("chat.php", "GET", null, ChatConversationResponse::class.java, query = mapOf("with" to withId.toString())) + + suspend fun chatSend(toId: Int, message: String): ChatSendResponse = + execute("chat.php", "POST", ChatSendRequest(toId = toId, message = message), ChatSendResponse::class.java) + + // ─── Live-Chat (Kunden-Widget) ─────────────────────────────────────────── + + suspend fun liveChatList(): LiveChatListResponse = + execute("live_chat.php", "GET", null, LiveChatListResponse::class.java, query = mapOf("action" to "list")) + + suspend fun liveChatMessages(number: String, since: Int): LiveChatMessagesResponse = + execute( + "live_chat.php", "GET", null, LiveChatMessagesResponse::class.java, + query = mapOf("action" to "messages", "number" to number, "since" to since.toString()), + ) + + suspend fun liveChatSend(number: String, message: String): LiveChatSendResponse = + execute("live_chat.php", "POST", LiveChatSendRequest(number = number, message = message), LiveChatSendResponse::class.java) + + suspend fun liveChatTyping(number: String) { + execute("live_chat.php", "POST", LiveChatActionRequest(action = "typing", number = number), Map::class.java) + } + + suspend fun liveChatEnd(number: String) { + execute("live_chat.php", "POST", LiveChatActionRequest(action = "end", number = number), Map::class.java) + } + + // ─── Verfügbarkeit (Status) ────────────────────────────────────────────── + + 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) + + // ─── Ticket-Regeln (Automatisierung) ───────────────────────────────────── + + suspend fun ticketRules(): TicketRulesResponse = + execute("ticket_rules.php", "GET", null, TicketRulesResponse::class.java) + + suspend fun saveTicketRule(request: TicketRuleSaveRequest): TicketRuleSaveResponse = + execute("ticket_rules.php", "POST", request, TicketRuleSaveResponse::class.java) + + suspend fun toggleTicketRule(id: Int) { + execute("ticket_rules.php", "POST", TicketRuleIdRequest("toggle", id), Map::class.java) + } + + suspend fun deleteTicketRule(id: Int) { + execute("ticket_rules.php", "POST", TicketRuleIdRequest("delete", id), Map::class.java) + } + + suspend fun saveAutoClose(enabled: Boolean, days: Int) { + execute("ticket_rules.php", "POST", AutoCloseSaveRequest(enabled = enabled, days = days), Map::class.java) + } + + private suspend fun execute( + path: String, + method: String, + payload: Any?, + responseType: Class, + authenticated: Boolean = true, + query: Map = emptyMap(), + ): T = withContext(Dispatchers.IO) { + val urlBuilder = "$baseUrl/api/staff/v1/$path".toHttpUrl().newBuilder() + query.forEach { (key, value) -> urlBuilder.addQueryParameter(key, value) } + val requestBuilder = Request.Builder() + .url(urlBuilder.build()) + .header("Accept", "application/json") + .header("Cache-Control", "no-cache, no-store") + .header("Pragma", "no-cache") + if (authenticated) { + val token = tokenProvider() + ?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.") + requestBuilder.header("Authorization", "Bearer $token") + } + val body = payload?.let { gson.toJson(it).toRequestBody(jsonType) } + when (method) { + "GET" -> requestBuilder.get() + "POST" -> requestBuilder.post(body ?: ByteArray(0).toRequestBody(jsonType)) + "PATCH" -> requestBuilder.patch(body ?: ByteArray(0).toRequestBody(jsonType)) + else -> error("Nicht unterstützte HTTP-Methode: $method") + } + + val call = try { + client.newCall(requestBuilder.build()).execute() + } catch (e: java.io.IOException) { + // Kein Netz, Server nicht erreichbar oder Zeitüberschreitung + ConnectionMonitor.set(true) + throw ApiException(0, "NETWORK", "Keine Verbindung zum Server.") + } + ConnectionMonitor.set(call.code in 502..504) + call.use { response -> + val responseBody = response.body?.string().orEmpty() + if (!response.isSuccessful) { + val envelope = runCatching { gson.fromJson(responseBody, ErrorEnvelope::class.java) }.getOrNull() + throw ApiException( + response.code, + envelope?.error?.code ?: "HTTP_${response.code}", + envelope?.error?.message ?: "Serverfehler (${response.code}).", + ) + } + if (responseBody.isBlank()) { + throw ApiException(response.code, "EMPTY_RESPONSE", "Der Server hat keine Antwort geliefert.") + } + gson.fromJson(responseBody, responseType) + ?: throw ApiException(response.code, "INVALID_RESPONSE", "Die Serverantwort ist ungültig.") + } + } + + /** Lädt ein Bild hoch (z.B. für eine Signatur) und liefert dessen öffentliche URL zurück. */ + suspend fun uploadImage(bytes: ByteArray, filename: String, mimeType: String): UploadImageResponse = + withContext(Dispatchers.IO) { + val token = tokenProvider() + ?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.") + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("image", filename, bytes.toRequestBody(mimeType.toMediaType())) + .build() + val request = Request.Builder() + .url("$baseUrl/api/staff/v1/upload_image.php".toHttpUrl()) + .header("Accept", "application/json") + .header("Authorization", "Bearer $token") + .post(body) + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string().orEmpty() + if (!response.isSuccessful) { + val envelope = runCatching { gson.fromJson(responseBody, ErrorEnvelope::class.java) }.getOrNull() + throw ApiException( + response.code, + envelope?.error?.code ?: "HTTP_${response.code}", + envelope?.error?.message ?: "Serverfehler (${response.code}).", + ) + } + gson.fromJson(responseBody, UploadImageResponse::class.java) + ?: throw ApiException(response.code, "INVALID_RESPONSE", "Die Serverantwort ist ungültig.") + } + } +} 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 new file mode 100644 index 00000000..5a2a813d --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/remote/dto/ApiDtos.kt @@ -0,0 +1,1046 @@ +package de.deskhub.staff.data.remote.dto + +data class StaffDto( + val id: Int = 0, + val name: String = "", + val email: String = "", + val role: String = "", + val permissions: Map = emptyMap(), +) + +data class NamedOptionDto( + val id: Int = 0, + val name: String = "", +) + +data class StatusOptionDto( + val value: String = "", + val label: String = "", + val color: String = "", +) + +data class PriorityDto( + val id: Int = 0, + val name: String = "", + val color: String = "", +) + +data class TicketSummaryDto( + val id: Int = 0, + val number: String = "", + val subject: String = "", + val requesterName: String = "", + val requesterEmail: String = "", + val status: String = "", + val priority: PriorityDto = PriorityDto(), + val department: NamedOptionDto = NamedOptionDto(), + val assignedStaff: NamedOptionDto? = null, + val isOverdue: Boolean = false, + val dueLabel: String? = null, + val isSpam: Boolean = false, + val createdAt: String = "", + val updatedAt: String = "", +) + +data class AttachmentDto( + val id: Int = 0, + val name: String = "", + val size: Long = 0, +) + +data class ThreadCommentDto( + val id: Int = 0, + val message: String = "", + val staff: NamedOptionDto = NamedOptionDto(), + val createdAt: String = "", +) + +data class TicketThreadDto( + val id: Int = 0, + val authorType: String = "", + val authorName: String = "", + val message: String = "", + val isInternalNote: Boolean = false, + val createdAt: String = "", + val attachments: List = emptyList(), + val comments: List = emptyList(), +) + +data class TicketDetailDto( + val id: Int = 0, + val number: String = "", + val subject: String = "", + val requesterName: String = "", + val requesterEmail: String = "", + val status: String = "", + val priority: PriorityDto = PriorityDto(), + val department: NamedOptionDto = NamedOptionDto(), + val assignedStaff: NamedOptionDto? = null, + val isOverdue: Boolean = false, + val isSpam: Boolean = false, + val createdAt: String = "", + val updatedAt: String = "", + val source: String = "", + val firstResponseDueAt: String? = null, + val resolutionDueAt: String? = null, + val lockedByOtherName: String? = null, + val threads: List = emptyList(), + val permissions: Map? = null, +) + +data class TicketSpamRequest( + val number: String, + val action: String, +) + +data class TicketSpamResponse( + val ticket: TicketSummaryDto = TicketSummaryDto(), + val moved: Int = 0, + val failed: List = emptyList(), +) + +data class LoginRequest( + val email: String, + val password: String, + val totpCode: String? = null, + val deviceName: String, +) + +data class LoginResponse( + val token: String? = null, + val expiresAt: String? = null, + val staff: StaffDto? = null, + val requiresTotp: Boolean = false, +) + +data class StaffResponse(val staff: StaffDto = StaffDto()) + +data class MetadataResponse( + val staff: StaffDto = StaffDto(), + val departments: List = emptyList(), + val priorities: List = emptyList(), + val agents: List = emptyList(), + val statuses: List = emptyList(), + val tags: List = emptyList(), + val liveChatEnabled: Boolean = false, + val health: List = emptyList(), +) + +data class TicketPageResponse( + val items: List = emptyList(), + val page: Int = 1, + val perPage: Int = 30, + val total: Int = 0, + val hasMore: Boolean = false, +) + +data class TicketResponse(val ticket: TicketDetailDto = TicketDetailDto()) + +data class TicketSummaryResponse(val ticket: TicketSummaryDto = TicketSummaryDto()) + +data class UpdateTicketRequest( + val number: String, + val status: String? = null, + val assignedStaffId: Int? = null, +) + +data class MessageRequest( + val number: String, + val type: String, + val message: String, + val setStatus: String? = null, +) + +data class MessageResponse(val threadId: Int = 0) + +data class CannedResponseDto( + val id: Int = 0, + val title: String = "", + val body: String = "", + val departmentId: Int? = null, +) + +data class CannedResponseListResponse(val items: List = emptyList()) + +data class DepartmentStatDto( + val name: String = "", + val open: Int = 0, + val overdue: Int = 0, +) + +data class DashboardResponseDto( + val myOpen: Int = 0, + val overdue: Int = 0, + val unassigned: Int = 0, + val unanswered: Int = 0, + val resolvedToday: Int = 0, + val slaPercent: Double? = null, + val recent: List = emptyList(), + val departments: List? = null, +) + +data class CustomerDto( + val id: Int? = null, + val name: String = "", + val email: String = "", + val organizationName: String? = null, + val ticketCount: Int = 0, + val isGuest: Boolean = false, +) + +data class CustomerListResponse(val items: List = emptyList()) + +data class CustomerDetailDto( + val id: Int? = null, + val name: String = "", + val email: String = "", + val organizationName: String? = null, + val isGuest: Boolean = false, + val createdAt: String = "", +) + +data class CustomerProfileResponse( + val customer: CustomerDetailDto = CustomerDetailDto(), + val tickets: List = emptyList(), +) + +data class CreateTicketRequest( + val requesterName: String, + val requesterEmail: String, + val subject: String, + val message: String, + val departmentId: Int, + val priorityId: Int, + val assignedStaffId: Int? = null, + val notifyCustomer: Boolean = true, +) + +data class CreateTicketResponse(val ticket: TicketSummaryDto = TicketSummaryDto()) + +data class HealthIssueDto( + val level: String = "warning", + val code: String = "", + val message: String = "", +) + +data class ActivityResponseDto( + val newReplies: Int = 0, + val newUnassigned: Int = 0, + val serverTime: String = "", + val health: List = emptyList(), +) + +data class ErrorEnvelope(val error: ApiErrorDto = ApiErrorDto()) + +data class ApiErrorDto( + val code: String = "UNKNOWN", + val message: String = "Unbekannter Serverfehler.", +) + +// ─── Admin: Wissensdatenbank ─────────────────────────────────────────────── + +data class KbCategoryDto( + val id: Int = 0, + val name: String = "", +) + +data class KbArticleDto( + val id: Int = 0, + val categoryId: Int = 0, + val categoryName: String = "", + val title: String = "", + val body: String = "", + val isPublished: Boolean = false, + val views: Int = 0, +) + +data class KbResponse( + val categories: List = emptyList(), + val articles: List = emptyList(), +) + +data class KbIdResponse(val id: Int = 0) + +data class KbSaveArticleRequest( + val action: String = "save_article", + val id: Int? = null, + val title: String, + val categoryId: Int, + val body: String, + val isPublished: Boolean, +) + +data class KbDeleteArticleRequest(val action: String = "delete_article", val id: Int) +data class KbCreateCategoryRequest(val action: String = "create_category", val name: String) +data class KbDeleteCategoryRequest(val action: String = "delete_category", val id: Int) + +// ─── Admin: Zeiterfassung ─────────────────────────────────────────────────── + +data class TimeEntryDto( + val id: Int = 0, + val staffName: String = "", + val ticketNumber: String = "", + val subject: String = "", + val minutes: Int = 0, + val note: String? = null, + val loggedAt: String = "", +) + +data class TimeEntryTotalDto( + val staffName: String = "", + val minutes: Int = 0, +) + +data class TimeEntriesResponse( + val staffOptions: List = emptyList(), + val entries: List = emptyList(), + val totalsByStaff: List = emptyList(), + val totalMinutes: Int = 0, +) + +// ─── Admin: Eskalationsregeln ─────────────────────────────────────────────── + +data class EscalationRuleDto( + val id: Int = 0, + val name: String = "", + val isActive: Boolean = true, + val triggerType: String = "no_reply", + val triggerHours: Int = 24, + val conditionStatus: String? = null, + val conditionPriorityId: Int? = null, + val conditionPriorityName: String? = null, + val conditionDeptId: Int? = null, + val conditionDeptName: String? = null, + val actionSetPriorityId: Int? = null, + val actionSetPriorityName: String? = null, + val actionAssignStaffId: Int? = null, + val actionAssignStaffName: String? = null, + val actionSetStatus: String? = null, + val actionNotifyAdmin: Boolean = true, + val actionNotifyStaff: Boolean = false, + val sortOrder: Int = 0, + val appliedCount: Int = 0, +) + +data class EscalationRulesResponse( + val priorities: List = emptyList(), + val staffOptions: List = emptyList(), + val departments: List = emptyList(), + val rules: List = emptyList(), +) + +data class EscalationSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val isActive: Boolean, + val triggerType: String, + val triggerHours: Int, + val conditionStatus: String? = null, + val conditionPriorityId: Int? = null, + val conditionDeptId: Int? = null, + val actionSetPriorityId: Int? = null, + val actionAssignStaffId: Int? = null, + val actionSetStatus: String? = null, + val actionNotifyAdmin: Boolean, + val actionNotifyStaff: Boolean, + val sortOrder: Int = 0, +) + +data class EscalationDeleteRequest(val action: String = "delete", val id: Int) +data class EscalationToggleRequest(val action: String = "toggle", val id: Int) + +// ─── Admin: Sperrliste ─────────────────────────────────────────────────────── + +data class BanEntryDto( + val id: Int = 0, + val matchType: String = "email", + val value: String = "", + val reason: String? = null, + val addedByName: String? = null, + val createdAt: String = "", +) + +data class BanlistResponse(val items: List = emptyList()) + +data class BanCreateRequest( + val action: String = "create", + val matchType: String, + val value: String, + val reason: String? = null, +) + +data class BanBulkAddRequest( + val action: String = "bulk_add", + val values: String, + val reason: String? = null, +) + +data class BanBulkAddResponse(val added: Int = 0, val skipped: Int = 0) + +data class BanDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: generische kleine Antworten ───────────────────────────────────── + +data class IdResponse(val id: Int = 0) + +// ─── Admin: Abteilungen ────────────────────────────────────────────────────── + +data class DepartmentMemberDto( + val id: Int = 0, + val name: String = "", + val isPrimary: Boolean = true, +) + +data class DepartmentDto( + val id: Int = 0, + val name: String = "", + val email: String = "", + val isDefault: Boolean = false, + val signature: String? = null, + val isActive: Boolean = true, + val managerId: Int? = null, + val managerName: String? = null, + val slaPlanId: Int? = null, + val notes: String? = null, + val members: List = emptyList(), + val createdAt: String? = null, + val updatedAt: String? = null, +) + +data class DepartmentEmailOptionDto(val email: String = "", val departmentName: String = "") + +data class DepartmentListResponse( + val items: List = emptyList(), + val emailOptions: List = emptyList(), + val staffOptions: List = emptyList(), + val slaPlans: List = emptyList(), +) + +data class DepartmentSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val email: String, + val isDefault: Boolean, + val signature: String? = null, + val isActive: Boolean = true, + val managerId: Int? = null, + val slaPlanId: Int? = null, + val notes: String = "", +) + +data class DepartmentDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Berichte (Bericht-Baukasten) ───────────────────────────────────────────── + +data class ReportColumnDto(val key: String = "", val label: String = "", val unit: String = "") + +data class ReportOptionDto(val key: String = "", val label: String = "", val unit: String = "") + +data class ReportSavedDto( + val id: Int = 0, + val name: String = "", + val isShared: Boolean = false, + val mine: Boolean = true, + val ownerName: String? = null, +) + +data class ReportConfigDto( + val dimension: String = "department", + val metrics: List = listOf("total", "open", "resolved"), + val from: String = "", + val to: String = "", + val sort: String = "label", + val desc: Boolean = false, +) + +data class ReportResponseDto( + val config: ReportConfigDto = ReportConfigDto(), + val columns: List = emptyList(), + val rows: List> = emptyList(), + val totals: Map = emptyMap(), + val dimensions: List = emptyList(), + val metrics: List = emptyList(), + val saved: List = emptyList(), +) + +data class ReportSaveRequest( + val action: String = "save", + val name: String, + val isShared: Boolean, + val config: ReportConfigDto, +) + +data class ReportDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Gespeicherte Ansichten ─────────────────────────────────────────────────── + +data class SavedViewDto( + val id: Int = 0, + val name: String = "", + val isShared: Boolean = false, + val mine: Boolean = true, + val ownerName: String? = null, + val filters: Map = emptyMap(), +) + +data class SavedViewListResponse(val items: List = emptyList()) + +data class SavedViewSaveRequest( + val action: String = "save", + val name: String, + val isShared: Boolean, + val filters: Map, +) + +data class SavedViewDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: SLA-Pläne ──────────────────────────────────────────────────────── + +data class SlaPlanDto( + val id: Int = 0, + val name: String = "", + val responseHours: Int = 24, + val resolutionHours: Int = 72, + val isActive: Boolean = true, + val notes: String? = null, + val departmentCount: Int = 0, + val helpTopicCount: Int = 0, + val updatedAt: String? = null, +) + +data class SlaPlanListResponse(val items: List = emptyList()) + +data class SlaPlanSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val responseHours: Int, + val resolutionHours: Int, + val isActive: Boolean = true, + val notes: String = "", +) + +data class SlaPlanDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Prioritäten ────────────────────────────────────────────────────── + +data class PriorityFullDto( + val id: Int = 0, + val name: String = "", + val color: String = "", + val responseHours: Int = 0, + val resolutionHours: Int = 0, + val sortOrder: Int = 0, +) + +data class PriorityListResponse(val items: List = emptyList()) + +data class PrioritySaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val color: String, + val responseHours: Int, + val resolutionHours: Int, + val sortOrder: Int = 0, +) + +data class PriorityDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Hilfe-Themen ───────────────────────────────────────────────────── + +data class HelpTopicDto( + val id: Int = 0, + val name: String = "", + val departmentId: Int = 0, + val departmentName: String = "", + val priorityId: Int = 0, + val priorityName: String = "", + val priorityColor: String = "", + val isActive: Boolean = true, + val isPublic: Boolean = true, + val parentId: Int? = null, + val slaPlanId: Int? = null, + val path: String = "", + val notes: String? = null, + val sortOrder: Int = 0, + val createdAt: String? = null, + val updatedAt: String? = null, +) + +data class HelpTopicListResponse( + val departments: List = emptyList(), + val priorities: List = emptyList(), + val slaPlans: List = emptyList(), + val items: List = emptyList(), +) + +data class HelpTopicSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val departmentId: Int, + val priorityId: Int, + val isActive: Boolean, + val isPublic: Boolean = true, + val parentId: Int? = null, + val slaPlanId: Int? = null, + val notes: String = "", + val sortOrder: Int = 0, +) + +data class HelpTopicDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Organisationen ─────────────────────────────────────────────────── + +data class OrganizationDto( + val id: Int = 0, + val name: String = "", + val notes: String? = null, + val memberCount: Int = 0, +) + +data class OrganizationListResponse(val items: List = emptyList()) + +data class OrganizationSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val notes: String? = null, +) + +data class OrganizationDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Teams ──────────────────────────────────────────────────────────── + +data class TeamDto( + val id: Int = 0, + val name: String = "", + val leadId: Int? = null, + val leadName: String? = null, + val notes: String? = null, + val isActive: Boolean = true, + val memberCount: Int = 0, + val memberIds: List = emptyList(), +) + +data class TeamListResponse( + val staffOptions: List = emptyList(), + val items: List = emptyList(), +) + +data class TeamSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val leadId: Int? = null, + val notes: String? = null, + val memberIds: List, +) + +data class TeamDeleteRequest(val action: String = "delete", val id: Int) +data class TeamToggleRequest(val action: String = "toggle", val id: Int) + +// ─── Admin: Rollen ─────────────────────────────────────────────────────────── + +data class RoleDto( + val id: Int = 0, + val name: String = "", + val permissions: Map = emptyMap(), + val staffCount: Int = 0, +) + +data class RoleListResponse( + val permissionKeys: List = emptyList(), + val defaultAgentPermissions: Map = emptyMap(), + val items: List = emptyList(), +) + +data class RoleSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val permissions: Map, +) + +data class RoleDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Mitarbeiter ────────────────────────────────────────────────────── + +data class StaffExtraDeptDto( + val departmentId: Int = 0, + val notify: Boolean = false, + val roleId: Int? = null, +) + +data class StaffAdminDto( + val id: Int = 0, + val name: String = "", + val email: String = "", + val role: String = "", + val roleId: Int? = null, + val departmentId: Int? = null, + val departmentName: String? = null, + val isActive: Boolean = true, + val signature: String? = null, + val extraDepartments: List = emptyList(), + val teamIds: List = emptyList(), + val createdAt: String? = null, +) + +data class StaffAdminListResponse( + val departments: List = emptyList(), + val roles: List = emptyList(), + val teams: List = emptyList(), + val canManageTeams: Boolean = false, + val items: List = emptyList(), +) + +data class StaffAdminSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val email: String, + val role: String, + val roleId: Int? = null, + val departmentId: Int? = null, + val isActive: Boolean, + val password: String = "", + val signature: String? = null, + val extraDepartments: List? = null, + val teamIds: List? = null, +) + +data class StaffAdminDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: Geschäftszeiten ────────────────────────────────────────────────── + +data class BusinessHourDto( + val weekday: Int = 0, + val isOpen: Boolean = false, + val openTime: String = "09:00", + val closeTime: String = "17:00", +) + +data class BusinessHolidayDto( + val id: Int = 0, + val date: String = "", + val name: String = "", +) + +data class BusinessHoursResponse( + val enabled: Boolean = false, + val hours: List = emptyList(), + val holidays: List = emptyList(), +) + +data class BusinessHoursSaveRequest( + val action: String = "save_hours", + val enabled: Boolean, + val hours: List, +) + +data class BusinessHolidayAddRequest( + val action: String = "add_holiday", + val date: String, + val name: String, +) + +data class BusinessHolidayDeleteRequest(val action: String = "delete_holiday", val id: Int) + +// ─── Admin: E-Mail-Adressen ────────────────────────────────────────────────── + +data class EmailAddressDto( + val id: Int = 0, + val email: String = "", + val label: String? = null, + val departmentId: Int = 0, + val departmentName: String = "", + val priorityId: Int? = null, + val priorityName: String? = null, + val helpTopicId: Int? = null, + val helpTopicName: String? = null, + val isDefault: Boolean = false, + val isActive: Boolean = true, + val notes: String? = null, + val updatedAt: String? = null, + val imapEnabled: Boolean = false, + val imapHost: String = "", + val imapPort: Int = 993, + val imapEncryption: String = "ssl", + val imapUsername: String = "", + val imapFolder: String = "INBOX", + val imapAfterFetch: String = "seen", + val imapArchiveFolder: String = "Processed", + val imapSpamFolder: String = "Junk", + val imapHasPassword: Boolean = false, + val imapLastRunAt: String? = null, + val smtpEnabled: Boolean = false, + val smtpHost: String = "", + val smtpPort: Int = 587, + val smtpEncryption: String = "tls", + val smtpUsername: String = "", + val smtpHasPassword: Boolean = false, +) + +data class EmailAddressListResponse( + val departments: List = emptyList(), + val priorities: List = emptyList(), + val helpTopics: List = emptyList(), + val items: List = emptyList(), +) + +data class EmailAddressSaveRequest( + val action: String = "save", + val id: Int? = null, + val email: String, + val label: String? = null, + val departmentId: Int, + val priorityId: Int? = null, + val helpTopicId: Int? = null, + val isDefault: Boolean, + val isActive: Boolean = true, + val notes: String = "", + val imapEnabled: Boolean = false, + val imapHost: String = "", + val imapPort: Int = 993, + val imapEncryption: String = "ssl", + val imapUsername: String = "", + val imapPassword: String = "", + val imapFolder: String = "INBOX", + val imapAfterFetch: String = "seen", + val imapArchiveFolder: String = "Processed", + val imapSpamFolder: String = "Junk", + val smtpEnabled: Boolean = false, + val smtpHost: String = "", + val smtpPort: Int = 587, + val smtpEncryption: String = "tls", + val smtpUsername: String = "", + val smtpPassword: String = "", +) + +data class EmailAddressActionRequest(val action: String, val id: Int) + +data class EmailAddressActionResponse(val ok: Boolean = true, val message: String = "") + +data class EmailAddressDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Admin: E-Mail-Vorlagen ────────────────────────────────────────────────── + +data class EmailTemplateDto( + val key: String = "", + val label: String = "", + val placeholders: List = emptyList(), + val subject: String = "", + val body: String = "", + val isCustomized: Boolean = false, +) + +data class EmailTemplateListResponse(val items: List = emptyList()) + +data class EmailTemplateSaveRequest( + val action: String = "save", + val key: String, + val subject: String, + val body: String, +) + +data class EmailTemplateResetRequest(val action: String = "reset", val key: String) + +// ─── Admin: Admin-Protokoll ────────────────────────────────────────────────── + +data class AuditLogEntryDto( + val id: Int = 0, + val staffName: String? = null, + val action: String = "", + val createdAt: String = "", +) + +data class AuditLogResponse( + val items: List = emptyList(), + val page: Int = 1, + val perPage: Int = 50, + val total: Int = 0, + val hasMore: Boolean = false, +) + +// ─── Aufgaben (alle Mitarbeiter) ───────────────────────────────────────────── + +data class TaskDto( + val id: Int = 0, + val title: String = "", + val description: String? = null, + val status: String = "open", + val assignedToId: Int? = null, + val assignedName: String? = null, + val creatorName: String? = null, + val dueAt: String? = null, + val ticketNumber: String? = null, + val ticketSubject: String? = null, +) + +data class TaskListResponse( + val staffOptions: List = emptyList(), + val items: List = emptyList(), +) + +data class TaskCreateRequest( + val action: String = "create", + val title: String, + val description: String? = null, + val assignedToId: Int? = null, + val dueAt: String? = null, + val ticketNumber: String? = null, +) + +data class TaskUpdateStatusRequest(val action: String = "update_status", val id: Int, val status: String) +data class TaskDeleteRequest(val action: String = "delete", val id: Int) + +// ─── Interner Chat (alle Mitarbeiter) ──────────────────────────────────────── + +data class ChatContactDto( + val id: Int = 0, + val name: String = "", + val unreadCount: Int = 0, + val lastMessageAt: String? = null, +) + +data class ChatContactsResponse(val contacts: List = emptyList()) + +data class ChatMessageDto( + val id: Int = 0, + val fromStaffId: Int = 0, + val fromName: String = "", + val message: String = "", + val createdAt: String = "", +) + +data class ChatConversationResponse( + val with: NamedOptionDto = NamedOptionDto(), + val messages: List = emptyList(), +) + +data class ChatSendRequest(val action: String = "send", val toId: Int, val message: String) +data class ChatSendResponse(val id: Int = 0) + +// ─── Live-Chat (Kunden-Widget, source='chat') ──────────────────────────────── + +data class LiveChatListItemDto( + val number: String = "", + val name: String = "", + val preview: String = "", + val needsReply: Boolean = false, + val updatedAt: String = "", +) + +data class LiveChatListResponse(val items: List = emptyList()) + +data class LiveChatAttachmentDto( + val id: Int = 0, + val name: String = "", + val size: Long = 0, +) + +data class LiveChatMessageDto( + val id: Int = 0, + val authorType: String = "", + val authorName: String? = null, + val message: String = "", + val createdAt: String = "", + val attachments: List = emptyList(), +) + +data class LiveChatMessagesResponse( + val status: String = "", + val requesterName: String = "", + val requesterEmail: String = "", + val departmentName: String = "", + val lockedByOtherName: String? = null, + val customerTyping: Boolean = false, + val messages: List = emptyList(), +) + +data class LiveChatSendRequest(val action: String = "send", val number: String, val message: String) +data class LiveChatSendResponse(val id: Int = 0, val createdAt: String = "", val status: String = "") +data class LiveChatActionRequest(val action: String, val number: String) + +// ─── Verfügbarkeit (Status) ─────────────────────────────────────────────────── + +data class TeamMemberDto( + val id: Int = 0, + val name: String = "", + val status: String = "", + val note: String = "", + val isOnVacation: Boolean = false, + val updatedAt: String? = null, +) + +data class AvailabilityResponse( + val myStatus: String = "", + val myNote: String = "", + val team: List = emptyList(), +) + +data class AvailabilitySetRequest(val status: String, val note: String) +data class AvailabilitySetResponse(val ok: Boolean = false, val status: String = "") + +// ─── Ticket-Regeln (Automatisierung) ───────────────────────────────────────── + +data class KeyLabelDto(val value: String = "", val label: String = "") + +data class TicketRuleConditionDto( + val field: String = "", + val operator: String = "", + val value: String = "", +) + +data class TicketRuleActionDto( + val type: String = "", + val value: String = "", + val valueLabel: String? = null, +) + +data class TicketRuleDto( + val id: Int = 0, + val name: String = "", + val matchType: String = "", + val isActive: Boolean = false, + val sortOrder: Int = 0, + val conditions: List = emptyList(), + val actions: List = emptyList(), +) + +data class TicketRulesResponse( + val fieldOptions: List = emptyList(), + val operatorOptions: List = emptyList(), + val actionOptions: List = emptyList(), + val departments: List = emptyList(), + val priorities: List = emptyList(), + val helpTopics: List = emptyList(), + val staffOptions: List = emptyList(), + val cannedResponses: List = emptyList(), + val rules: List = emptyList(), + val autoCloseEnabled: Boolean = false, + val autoCloseDays: Int = 7, +) + +data class TicketRuleSaveRequest( + val action: String = "save", + val id: Int? = null, + val name: String, + val matchType: String, + val isActive: Boolean, + val sortOrder: Int, + val conditions: List, + val actions: List, +) + +data class TicketRuleSaveResponse(val id: Int = 0) +data class TicketRuleIdRequest(val action: String, val id: Int) +data class AutoCloseSaveRequest(val action: String = "save_auto_close", val enabled: Boolean, val days: Int) + +// ─── Bild-Upload (z.B. für Signaturen) ─────────────────────────────────────── + +data class UploadImageResponse(val url: String = "") 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 new file mode 100644 index 00000000..1a96a333 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/data/security/SecureSessionStore.kt @@ -0,0 +1,117 @@ +package de.deskhub.staff.data.security + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import com.google.gson.Gson +import com.google.gson.JsonParseException +import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +data class StoredSession( + val serverUrl: String = "", + val token: String = "", + val expiresAt: String = "", + val staffName: String = "", +) + +class SessionStorageException(message: String, cause: Throwable) : Exception(message, cause) + +class SecureSessionStore(context: Context) { + private val preferences = context.getSharedPreferences("deskhub_secure_session", Context.MODE_PRIVATE) + private val gson = Gson() + + fun save(session: StoredSession) { + check(preferences.edit().putString(PAYLOAD_KEY, encrypt(gson.toJson(session))).commit()) { + "Die geschützte Anmeldung konnte nicht gespeichert werden." + } + } + + fun load(): StoredSession? { + val encrypted = preferences.getString(PAYLOAD_KEY, null) ?: return null + return try { + gson.fromJson(decrypt(encrypted), StoredSession::class.java) + } catch (error: GeneralSecurityException) { + invalidate(error) + } catch (error: IllegalArgumentException) { + invalidate(error) + } catch (error: JsonParseException) { + invalidate(error) + } + } + + /** + * Löscht nur die geschützte Sitzung (Token), NICHT die zuletzt verwendete Server-Adresse – + * die bleibt bewusst erhalten, damit man sie nach einem Logout nicht erneut eintippen muss. + */ + fun clear() { + check(preferences.edit().remove(PAYLOAD_KEY).commit()) { + "Die lokale Anmeldung konnte nicht entfernt werden." + } + } + + /** Server-Adresse ist nicht geheim – wird unverschlüsselt gespeichert und überlebt clear(). */ + fun saveLastServerUrl(url: String) { + preferences.edit().putString(LAST_SERVER_URL_KEY, url).apply() + } + + fun loadLastServerUrl(): String? = preferences.getString(LAST_SERVER_URL_KEY, null) + + private fun encrypt(value: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, secretKey()) + val ciphertext = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + val combined = cipher.iv + ciphertext + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + private fun decrypt(value: String): String { + val combined = Base64.decode(value, Base64.NO_WRAP) + require(combined.size > IV_LENGTH) { "Ungültige verschlüsselte Sitzungsdaten." } + val iv = combined.copyOfRange(0, IV_LENGTH) + val ciphertext = combined.copyOfRange(IV_LENGTH, combined.size) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv)) + return cipher.doFinal(ciphertext).toString(Charsets.UTF_8) + } + + private fun secretKey(): SecretKey { + val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + generateKey() + } + } + + private fun invalidate(error: Throwable): Nothing { + check(preferences.edit().clear().commit()) { + "Beschädigte Sitzungsdaten konnten nicht entfernt werden." + } + throw SessionStorageException( + "Die geschützte Anmeldung konnte nicht gelesen werden und wurde entfernt.", + error, + ) + } + + private companion object { + const val KEY_ALIAS = "deskhub_staff_session_v1" + const val PAYLOAD_KEY = "payload" + const val LAST_SERVER_URL_KEY = "last_server_url" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val IV_LENGTH = 12 + } +} 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 new file mode 100644 index 00000000..016b29f7 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/domain/model/Models.kt @@ -0,0 +1,708 @@ +package de.deskhub.staff.domain.model + +data class Staff( + val id: Int, + val name: String, + val email: String, + val role: String, + val permissions: Map, +) + +data class NamedOption( + val id: Int, + val name: String, +) + +data class StatusOption( + val value: String, + val label: String, + val color: String, +) + +data class Priority( + val id: Int, + val name: String, + val color: String, +) + +data class TicketSummary( + val id: Int, + val number: String, + val subject: String, + val requesterName: String, + val requesterEmail: String, + val status: String, + val priority: Priority, + val department: NamedOption, + val assignedStaff: NamedOption?, + val isOverdue: Boolean, + /** Fertig formatierter Fälligkeitstext vom Server ("in 3 Std 12 Min" / "vor 2 Std 5 + * Min (überfällig)"), null wenn keine Fälligkeit gesetzt ist – siehe time_until() in + * includes/functions.php, dieselbe Quelle wie im Web, kein eigenes Datumsformat hier. */ + val dueLabel: String? = null, + val isSpam: Boolean, + val createdAt: String, + val updatedAt: String, +) + +data class ThreadAttachment( + val id: Int, + val name: String, + val size: Long, +) + +data class ThreadComment( + val id: Int, + val message: String, + val staff: NamedOption, + val createdAt: String, +) + +data class TicketThread( + val id: Int, + val authorType: String, + val authorName: String, + val message: String, + val isInternalNote: Boolean, + val createdAt: String, + val attachments: List, + val comments: List, +) + +data class TicketDetail( + val summary: TicketSummary, + val source: String, + val firstResponseDueAt: String?, + val resolutionDueAt: String?, + // Name des Mitarbeiters, der diesen Live-Chat gerade aktiv bearbeitet (Web oder + // App) – null wenn frei oder man selbst der Besitzer ist. Nur bei source == "chat" + // relevant; siehe live_chat_locked_by_other() in includes/functions.php. + val lockedByOtherName: String?, + val threads: List, + // Rechte für dieses Ticket (gelten pro Abteilung); null = ältere Serverversion -> globale Rechte + val permissions: Map? = null, +) + +data class SpamActionResult( + val ticket: TicketSummary, + val moved: Int, + val failed: List, +) + +data class Metadata( + val staff: Staff, + val departments: List, + val priorities: List, + val agents: List, + val statuses: List, + val tags: List = emptyList(), + val liveChatEnabled: Boolean = false, +) + +data class TicketPage( + val items: List, + val page: Int, + val total: Int, + val hasMore: Boolean, +) + +data class CannedResponse( + val id: Int, + val title: String, + val body: String, + val departmentId: Int?, +) + +data class DepartmentStat( + val name: String, + val open: Int, + val overdue: Int, +) + +data class DashboardStats( + val myOpen: Int, + val overdue: Int, + val unassigned: Int = 0, + val unanswered: Int = 0, + val resolvedToday: Int, + val slaPercent: Double?, + /** "Priorisiert": mir zugewiesen oder unbearbeitet, dieselbe Auswahl wie im Web-Dashboard. */ + val recent: List = emptyList(), + val departments: List?, +) + +data class Customer( + val id: Int?, + val name: String, + val email: String, + val organizationName: String?, + val ticketCount: Int, + val isGuest: Boolean, +) + +data class CustomerDetail( + val id: Int?, + val name: String, + val email: String, + val organizationName: String?, + val isGuest: Boolean, + val createdAt: String, +) + +data class CustomerProfile( + val customer: CustomerDetail, + val tickets: List, +) + +/** Systemhinweis des Servers (z. B. Mail-Abruf fällt aus); nur für Verwalter von E-Mail-Adressen sichtbar. */ +data class HealthIssue(val level: String, val message: String) { + val isError: Boolean get() = level == "error" +} + +data class ActivityUpdate( + val newReplies: Int, + val newUnassigned: Int, + val serverTime: String, + val health: List = emptyList(), +) + +data class TicketFilters( + val query: String = "", + val statuses: Set = setOf("open", "pending", "answered", "on_hold"), + val assigned: String = "", + val departmentId: Int? = null, + val priorityId: Int? = null, + val tagId: Int? = null, + // "" (alle) | "overdue" | "at_risk" | "on_time" – siehe staff/tickets.php für dieselbe Logik. + val sla: String = "", +) + +/** Gespeicherte Ticket-Ansicht (Filterkombination), eigene oder von Kollegen geteilte. */ +data class SavedView( + val id: Int, + val name: String, + val isShared: Boolean, + val mine: Boolean, + val ownerName: String?, + val filters: TicketFilters, +) + +// ─── Admin: Wissensdatenbank ──────────────────────────────────────────────── + +data class KbCategory( + val id: Int, + val name: String, +) + +data class KbArticle( + val id: Int, + val categoryId: Int, + val categoryName: String, + val title: String, + val body: String, + val isPublished: Boolean, + val views: Int, +) + +data class KbData( + val categories: List, + val articles: List, +) + +// ─── Admin: Zeiterfassung ──────────────────────────────────────────────────── + +data class TimeEntry( + val id: Int, + val staffName: String, + val ticketNumber: String, + val subject: String, + val minutes: Int, + val note: String?, + val loggedAt: String, +) + +data class TimeEntryTotal( + val staffName: String, + val minutes: Int, +) + +data class TimeEntriesReport( + val staffOptions: List, + val entries: List, + val totalsByStaff: List, + val totalMinutes: Int, +) + +// ─── Admin: Eskalationsregeln ──────────────────────────────────────────────── + +data class EscalationRule( + val id: Int, + val name: String, + val isActive: Boolean, + val triggerType: String, + val triggerHours: Int, + val conditionStatus: String?, + val conditionPriorityId: Int?, + val conditionPriorityName: String?, + val conditionDeptId: Int?, + val conditionDeptName: String?, + val actionSetPriorityId: Int?, + val actionSetPriorityName: String?, + val actionAssignStaffId: Int?, + val actionAssignStaffName: String?, + val actionSetStatus: String?, + val actionNotifyAdmin: Boolean, + val actionNotifyStaff: Boolean, + val sortOrder: Int, + val appliedCount: Int, +) + +data class EscalationData( + val priorities: List, + val staffOptions: List, + val departments: List, + val rules: List, +) + +// ─── Admin: Sperrliste ─────────────────────────────────────────────────────── + +data class BanEntry( + val id: Int, + val matchType: String, + val value: String, + val reason: String?, + val addedByName: String?, + val createdAt: String, +) + +// ─── Admin: Abteilungen ────────────────────────────────────────────────────── + +data class Department( + val id: Int, + val name: String, + val email: String, + val isDefault: Boolean, + val signature: String?, + val isActive: Boolean = true, + val managerId: Int? = null, + val managerName: String? = null, + val slaPlanId: Int? = null, + val notes: String? = null, + val members: List = emptyList(), + val updatedAt: String? = null, +) + +data class DepartmentMember(val id: Int, val name: String, val isPrimary: Boolean) + +data class DepartmentEmailOption(val email: String, val departmentName: String) + +/** Eingaben des Formulars „Abteilung“. */ +data class DepartmentInput( + val name: String, + val email: String, + val isDefault: Boolean, + val isActive: Boolean, + val managerId: Int?, + val slaPlanId: Int?, + val notes: String, + val signature: String?, +) + +data class DepartmentData( + val departments: List, + val emailOptions: List, + val staffOptions: List, + val slaPlans: List = emptyList(), +) + +data class SlaPlan( + val id: Int, + val name: String, + val responseHours: Int, + val resolutionHours: Int, + val isActive: Boolean, + val notes: String?, + val departmentCount: Int, + val helpTopicCount: Int, +) + +// ─── Admin: Prioritäten ────────────────────────────────────────────────────── + +data class PriorityFull( + val id: Int, + val name: String, + val color: String, + val responseHours: Int, + val resolutionHours: Int, + val sortOrder: Int, +) + +// ─── Admin: Hilfe-Themen ───────────────────────────────────────────────────── + +data class HelpTopic( + val id: Int, + val name: String, + val departmentId: Int, + val departmentName: String, + val priorityId: Int, + val priorityName: String, + val priorityColor: String, + val isActive: Boolean, + val sortOrder: Int, + val isPublic: Boolean = true, + val parentId: Int? = null, + val slaPlanId: Int? = null, + val path: String = "", + val notes: String? = null, + val updatedAt: String? = null, +) + +/** Eingaben des Formulars „Hilfethema“ (Informationen + Optionen bei neuem Ticket). */ +data class HelpTopicInput( + val name: String, + val isActive: Boolean, + val isPublic: Boolean, + val parentId: Int?, + val slaPlanId: Int?, + val notes: String, + val departmentId: Int, + val priorityId: Int, + val sortOrder: Int, +) + +data class HelpTopicData( + val departments: List, + val priorities: List, + val slaPlans: List = emptyList(), + val topics: List, +) + +// ─── Admin: Organisationen ─────────────────────────────────────────────────── + +data class Organization( + val id: Int, + val name: String, + val notes: String?, + val memberCount: Int, +) + +// ─── Admin: Teams ──────────────────────────────────────────────────────────── + +data class Team( + val id: Int, + val name: String, + val leadId: Int?, + val leadName: String?, + val notes: String?, + val isActive: Boolean, + val memberCount: Int, + val memberIds: List, +) + +data class TeamData( + val staffOptions: List, + val teams: List, +) + +// ─── Admin: Rollen ─────────────────────────────────────────────────────────── + +data class Role( + val id: Int, + val name: String, + val permissions: Map, + val staffCount: Int, +) + +data class RoleData( + val permissionKeys: List, + val defaultAgentPermissions: Map, + val roles: List, +) + +// ─── Admin: Mitarbeiter ────────────────────────────────────────────────────── + +data class StaffExtraDept( + val departmentId: Int, + val notify: Boolean, + /** Rolle in dieser Abteilung; null = Hauptrolle des Mitarbeiters. */ + val roleId: Int? = null, +) + +data class StaffAdmin( + val id: Int, + val name: String, + val email: String, + val role: String, + val roleId: Int?, + val departmentId: Int?, + val departmentName: String?, + val isActive: Boolean, + val signature: String?, + val extraDepartments: List, + val teamIds: List = emptyList(), + val createdAt: String? = null, +) + +data class StaffAdminData( + val departments: List, + val roles: List, + val staff: List, + val teams: List = emptyList(), + val canManageTeams: Boolean = false, +) + +/** Eingaben des Formulars „Agent“ (Konto, Zugang, Berechtigungen, Teams). */ +data class StaffAdminInput( + val name: String, + val email: String, + val role: String, + val roleId: Int?, + val departmentId: Int?, + val isActive: Boolean, + val password: String, + val signature: String?, + val extraDepartments: List, + val teamIds: List?, +) + +// ─── Admin: Geschäftszeiten ────────────────────────────────────────────────── + +data class BusinessHour( + val weekday: Int, + val isOpen: Boolean, + val openTime: String, + val closeTime: String, +) + +data class BusinessHoliday( + val id: Int, + val date: String, + val name: String, +) + +data class BusinessHoursData( + val enabled: Boolean, + val hours: List, + val holidays: List, +) + +// ─── Admin: E-Mail-Adressen ────────────────────────────────────────────────── + +data class EmailAddress( + val id: Int, + val email: String, + val label: String?, + val departmentId: Int, + val departmentName: String, + val priorityId: Int?, + val priorityName: String?, + val isDefault: Boolean, + val helpTopicId: Int? = null, + val isActive: Boolean = true, + val notes: String? = null, + val smtpEnabled: Boolean = false, + val smtpHost: String = "", + val smtpPort: Int = 587, + val smtpEncryption: String = "tls", + val smtpUsername: String = "", + val smtpHasPassword: Boolean = false, + val imapEnabled: Boolean = false, + val imapHost: String = "", + val imapPort: Int = 993, + val imapEncryption: String = "ssl", + val imapUsername: String = "", + val imapFolder: String = "INBOX", + val imapAfterFetch: String = "seen", + val imapArchiveFolder: String = "Processed", + val imapSpamFolder: String = "Junk", + val imapHasPassword: Boolean = false, + val imapLastRunAt: String? = null, +) + +/** Eingaben des Formulars „E-Mail-Adresse“ (Konto + entferntes Postfach). */ +data class EmailAddressInput( + val email: String, + val label: String?, + val departmentId: Int, + val priorityId: Int?, + val isDefault: Boolean, + val helpTopicId: Int?, + val isActive: Boolean, + val notes: String, + val smtpEnabled: Boolean, + val smtpHost: String, + val smtpPort: Int, + val smtpEncryption: String, + val smtpUsername: String, + val smtpPassword: String, + val imapEnabled: Boolean, + val imapHost: String, + val imapPort: Int, + val imapEncryption: String, + val imapUsername: String, + val imapPassword: String, + val imapFolder: String, + val imapAfterFetch: String, + val imapArchiveFolder: String, + val imapSpamFolder: String, +) + +data class EmailAddressData( + val departments: List, + val priorities: List, + val helpTopics: List = emptyList(), + val addresses: List, +) + +// ─── Admin: E-Mail-Vorlagen ────────────────────────────────────────────────── + +data class EmailTemplate( + val key: String, + val label: String, + val placeholders: List, + val subject: String, + val body: String, + val isCustomized: Boolean, +) + +// ─── Admin: Admin-Protokoll ────────────────────────────────────────────────── + +data class AuditLogEntry( + val id: Int, + val staffName: String?, + val action: String, + val createdAt: String, +) + +data class AuditLogPage( + val items: List, + val page: Int, + val hasMore: Boolean, +) + +// ─── Aufgaben ──────────────────────────────────────────────────────────────── + +data class StaffTask( + val id: Int, + val title: String, + val description: String?, + val status: String, + val assignedToId: Int?, + val assignedName: String?, + val creatorName: String?, + val dueAt: String?, + val ticketNumber: String?, + val ticketSubject: String?, +) + +data class TaskData( + val staffOptions: List, + val tasks: List, +) + +// ─── Interner Chat ──────────────────────────────────────────────────────────── + +data class ChatContact( + val id: Int, + val name: String, + val unreadCount: Int, + val lastMessageAt: String?, +) + +data class ChatMessage( + val id: Int, + val fromStaffId: Int, + val fromName: String, + val message: String, + val createdAt: String, +) + +data class ChatConversation( + val with: NamedOption, + val messages: List, +) + +// ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────────── + +data class LiveChatSummary( + val number: String, + val name: String, + val preview: String, + val needsReply: Boolean, + val updatedAt: String, +) + +data class LiveChatAttachment( + val id: Int, + val name: String, + val size: Long, +) + +data class LiveChatMessage( + val id: Int, + val authorType: String, + val authorName: String?, + val message: String, + val createdAt: String, + val attachments: List, +) + +data class LiveChatDetail( + val number: String, + val status: String, + val requesterName: String, + val requesterEmail: String, + val departmentName: String, + val lockedByOtherName: String?, + val customerTyping: Boolean, + val messages: List, +) + +// ─── Verfügbarkeit (Status) ──────────────────────────────────────────────────── + +data class TeamMember( + val id: Int, + val name: String, + val status: String, + val note: String, + val isOnVacation: Boolean, + val updatedAt: String?, +) + +data class AvailabilityData( + val myStatus: String, + val myNote: String, + val team: List, +) + +// ─── Ticket-Regeln (Automatisierung) ────────────────────────────────────────── + +data class KeyLabel(val value: String, val label: String) + +data class TicketRuleCondition(val field: String, val operator: String, val value: String) + +data class TicketRuleAction(val type: String, val value: String, val valueLabel: String?) + +data class TicketRule( + val id: Int, + val name: String, + val matchType: String, + val isActive: Boolean, + val sortOrder: Int, + val conditions: List, + val actions: List, +) + +data class TicketRulesData( + val fieldOptions: List, + val operatorOptions: List, + val actionOptions: List, + val departments: List, + val priorities: List, + val helpTopics: List, + val staffOptions: List, + val cannedResponses: List, + val rules: List, + val autoCloseEnabled: Boolean, + val autoCloseDays: Int, +) diff --git a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt new file mode 100644 index 00000000..c438f5c1 --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubApp.kt @@ -0,0 +1,6206 @@ +@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) +} diff --git a/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt new file mode 100644 index 00000000..bd2ee40f --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/DeskHubViewModel.kt @@ -0,0 +1,1601 @@ +package de.deskhub.staff.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import de.deskhub.staff.data.LoginResult +import de.deskhub.staff.data.TicketRepository +import de.deskhub.staff.data.remote.ApiException +import de.deskhub.staff.domain.model.ActivityUpdate +import de.deskhub.staff.domain.model.AuditLogEntry +import de.deskhub.staff.domain.model.BanEntry +import de.deskhub.staff.domain.model.BusinessHour +import de.deskhub.staff.domain.model.BusinessHoursData +import de.deskhub.staff.domain.model.CannedResponse +import de.deskhub.staff.domain.model.ChatContact +import de.deskhub.staff.domain.model.ChatConversation +import de.deskhub.staff.domain.model.Customer +import de.deskhub.staff.domain.model.CustomerProfile +import de.deskhub.staff.domain.model.DashboardStats +import de.deskhub.staff.domain.model.Department +import de.deskhub.staff.domain.model.DepartmentEmailOption +import de.deskhub.staff.domain.model.DepartmentInput +import de.deskhub.staff.domain.model.NamedOption +import de.deskhub.staff.domain.model.EmailAddressData +import de.deskhub.staff.domain.model.EmailAddressInput +import de.deskhub.staff.domain.model.EmailTemplate +import de.deskhub.staff.domain.model.EscalationData +import de.deskhub.staff.domain.model.HelpTopicData +import de.deskhub.staff.domain.model.HelpTopicInput +import de.deskhub.staff.domain.model.AvailabilityData +import de.deskhub.staff.domain.model.KbData +import de.deskhub.staff.domain.model.LiveChatDetail +import de.deskhub.staff.domain.model.LiveChatSummary +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.TicketRuleAction +import de.deskhub.staff.domain.model.TicketRuleCondition +import de.deskhub.staff.domain.model.TicketRulesData +import de.deskhub.staff.domain.model.Organization +import de.deskhub.staff.domain.model.PriorityFull +import de.deskhub.staff.domain.model.RoleData +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StaffAdminData +import de.deskhub.staff.domain.model.StaffAdminInput +import de.deskhub.staff.domain.model.StaffTask +import de.deskhub.staff.domain.model.TaskData +import de.deskhub.staff.domain.model.TeamData +import de.deskhub.staff.domain.model.TicketDetail +import de.deskhub.staff.domain.model.TicketFilters +import de.deskhub.staff.domain.model.TicketSummary +import de.deskhub.staff.domain.model.TimeEntriesReport +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +data class DeskHubState( + val initializing: Boolean = true, + val staff: Staff? = null, + val authLoading: Boolean = false, + val authError: String? = null, + val totpRequired: Boolean = false, + /** Zuletzt verwendete Server-Adresse – bleibt auch nach dem Ausloggen erhalten, um erneutes Eintippen zu ersparen. */ + val savedServerUrl: String? = null, + val metadata: Metadata? = null, + val cannedResponses: List = emptyList(), + val filters: TicketFilters = TicketFilters(), + val tickets: List = emptyList(), + val ticketsPage: Int = 1, + val ticketsTotal: Int = 0, + val ticketsHasMore: Boolean = false, + val ticketsLoading: Boolean = false, + val ticketsError: String? = null, + val selectedTicket: TicketDetail? = null, + val detailLoading: Boolean = false, + val detailError: String? = null, + val mutationLoading: Boolean = false, + val mutationError: String? = null, + val dashboard: DashboardStats? = null, + val dashboardLoading: Boolean = false, + val dashboardError: String? = null, + val customers: List = emptyList(), + val customersQuery: String = "", + val customersLoading: Boolean = false, + val customersError: String? = null, + val selectedCustomer: CustomerProfile? = null, + val customerLoading: Boolean = false, + val customerError: String? = null, + val newTicketPrefillName: String = "", + val newTicketPrefillEmail: String = "", + val newTicketLoading: Boolean = false, + val newTicketError: String? = null, + val activityBadgeCount: Int = 0, + val offline: Boolean = false, + val healthIssues: List = emptyList(), + val kb: KbData? = null, + val kbLoading: Boolean = false, + val kbError: String? = null, + val kbMutationLoading: Boolean = false, + val kbMutationError: String? = null, + val timeEntries: TimeEntriesReport? = null, + val timeEntriesLoading: Boolean = false, + val timeEntriesError: String? = null, + val timeEntriesStaffId: Int? = null, + val timeEntriesFrom: String = currentMonthStart(), + val timeEntriesTo: String = today(), + val escalation: EscalationData? = null, + val escalationLoading: Boolean = false, + val escalationError: String? = null, + val escalationMutationLoading: Boolean = false, + val escalationMutationError: String? = null, + val banlist: List? = null, + val banlistLoading: Boolean = false, + val banlistError: String? = null, + val banlistMutationLoading: Boolean = false, + val banlistMutationError: String? = null, + val banlistLastBulkResult: Pair? = null, + // Abteilungen + val departments2: List? = null, + val departmentEmailOptions: List = emptyList(), + val departmentStaffOptions: List = emptyList(), + val departments2Loading: Boolean = false, + val departments2Error: String? = null, + val departments2MutationLoading: Boolean = false, + val departments2MutationError: String? = null, + // Prioritäten + val report: de.deskhub.staff.data.remote.dto.ReportResponseDto? = null, + val reportConfig: de.deskhub.staff.data.remote.dto.ReportConfigDto = de.deskhub.staff.data.remote.dto.ReportConfigDto(), + val reportLoading: Boolean = false, + val reportError: String? = null, + val savedViews: List = emptyList(), + val savedViewError: String? = null, + val slaPlans: List? = null, + val slaPlansLoading: Boolean = false, + val slaPlansError: String? = null, + val slaPlansMutationLoading: Boolean = false, + val slaPlansMutationError: String? = null, + val departmentSlaPlans: List = emptyList(), + val prioritiesAdmin: List? = null, + val prioritiesAdminLoading: Boolean = false, + val prioritiesAdminError: String? = null, + val prioritiesAdminMutationLoading: Boolean = false, + val prioritiesAdminMutationError: String? = null, + // Hilfe-Themen + val helpTopics: HelpTopicData? = null, + val helpTopicsLoading: Boolean = false, + val helpTopicsError: String? = null, + val helpTopicsMutationLoading: Boolean = false, + val helpTopicsMutationError: String? = null, + // Organisationen + val organizations: List? = null, + val organizationsLoading: Boolean = false, + val organizationsError: String? = null, + val organizationsMutationLoading: Boolean = false, + val organizationsMutationError: String? = null, + // Teams + val teams: TeamData? = null, + val teamsLoading: Boolean = false, + val teamsError: String? = null, + val teamsMutationLoading: Boolean = false, + val teamsMutationError: String? = null, + // Rollen + val roles: RoleData? = null, + val rolesLoading: Boolean = false, + val rolesError: String? = null, + val rolesMutationLoading: Boolean = false, + val rolesMutationError: String? = null, + // Mitarbeiter + val staffAdmin: StaffAdminData? = null, + val staffAdminLoading: Boolean = false, + val staffAdminError: String? = null, + val staffAdminMutationLoading: Boolean = false, + val staffAdminMutationError: String? = null, + // Geschäftszeiten + val businessHours: BusinessHoursData? = null, + val businessHoursLoading: Boolean = false, + val businessHoursError: String? = null, + val businessHoursMutationLoading: Boolean = false, + val businessHoursMutationError: String? = null, + // E-Mail-Adressen + val emailAddresses: EmailAddressData? = null, + val emailAddressesLoading: Boolean = false, + val emailAddressesError: String? = null, + val emailAddressesMutationLoading: Boolean = false, + val emailAddressesMutationError: String? = null, + val emailAddressesNotice: String? = null, + // E-Mail-Vorlagen + val emailTemplates: List? = null, + val emailTemplatesLoading: Boolean = false, + val emailTemplatesError: String? = null, + val emailTemplatesMutationLoading: Boolean = false, + val emailTemplatesMutationError: String? = null, + // Admin-Protokoll + val auditLog: List = emptyList(), + val auditLogPage: Int = 1, + val auditLogHasMore: Boolean = false, + val auditLogLoading: Boolean = false, + val auditLogError: String? = null, + // Aufgaben + val tasks: TaskData? = null, + val tasksFilter: String = "my", + val tasksLoading: Boolean = false, + val tasksError: String? = null, + val tasksMutationLoading: Boolean = false, + val tasksMutationError: String? = null, + // Interner Chat + val chatContacts: List? = null, + val chatContactsLoading: Boolean = false, + val chatContactsError: String? = null, + val chatConversation: ChatConversation? = null, + val chatConversationLoading: Boolean = false, + val chatConversationError: String? = null, + val chatSendLoading: Boolean = false, + val chatSendError: String? = null, + // Live-Chat (Kunden-Widget, source='chat') + val liveChats: List? = null, + val liveChatsLoading: Boolean = false, + val liveChatsError: String? = null, + val liveChatDetail: LiveChatDetail? = null, + val liveChatDetailLoading: Boolean = false, + val liveChatDetailError: String? = null, + val liveChatSendLoading: Boolean = false, + val liveChatSendError: String? = null, + // Verfügbarkeit (Status) + val availability: AvailabilityData? = null, + val availabilityLoading: Boolean = false, + val availabilityError: String? = null, + val availabilitySaving: Boolean = false, + // Ticket-Regeln (Automatisierung) + val ticketRules: TicketRulesData? = null, + val ticketRulesLoading: Boolean = false, + val ticketRulesError: String? = null, + val ticketRulesMutationLoading: Boolean = false, + val ticketRulesMutationError: String? = null, + // Bild-Upload (Signaturen) + val imageUploadLoading: Boolean = false, + val imageUploadError: String? = null, +) + +private fun currentMonthStart(): String { + val now = java.util.Calendar.getInstance() + now.set(java.util.Calendar.DAY_OF_MONTH, 1) + return java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(now.time) +} + +private fun today(): String = + java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US).format(java.util.Date()) + +class DeskHubViewModel( + private val repository: TicketRepository, +) : ViewModel() { + private val mutableState = MutableStateFlow(DeskHubState()) + val state: StateFlow = mutableState.asStateFlow() + private var activityPollingJob: Job? = null + private var ticketsPollingJob: Job? = null + private var chatPollingJob: Job? = null + private var liveChatListPollingJob: Job? = null + private var liveChatDetailPollingJob: Job? = null + private var lastLiveChatTypingPingAt = 0L + + init { + restoreSession() + // Verbindungsstatus: bei Ausfall alle 10 s erneut versuchen und nach Rückkehr die Daten auffrischen + viewModelScope.launch { + de.deskhub.staff.data.remote.ConnectionMonitor.offline.collect { off -> + mutableState.update { it.copy(offline = off) } + if (off) { + launch { + while (isActive && de.deskhub.staff.data.remote.ConnectionMonitor.offline.value) { + delay(10_000) + if (state.value.staff != null) runCatching { repository.activitySince("") } + } + if (state.value.staff != null) { + refreshTickets() + loadDashboard() + } + } + } + } + } + } + + /** Sofort erneut versuchen (Knopf im Verbindungs-Banner). */ + fun retryConnection() { + viewModelScope.launch { runCatching { repository.activitySince("") } } + } + + fun login(serverUrl: String, email: String, password: String, totpCode: String) { + viewModelScope.launch { + mutableState.update { it.copy(authLoading = true, authError = null) } + runCatching { + repository.login(serverUrl, email, password, totpCode.ifBlank { null }) + }.onSuccess { result -> + when (result) { + LoginResult.TotpRequired -> mutableState.update { + it.copy(authLoading = false, totpRequired = true) + } + is LoginResult.Success -> { + mutableState.update { + it.copy( + initializing = false, + staff = result.staff, + authLoading = false, + authError = null, + totpRequired = false, + ) + } + loadWorkspace() + loadChatContacts() + startActivityPolling() + startTicketsPolling() + } + } + }.onFailure { error -> + mutableState.update { + it.copy(authLoading = false, authError = error.userMessage()) + } + } + } + } + + fun cancelTotp() { + mutableState.update { it.copy(totpRequired = false, authError = null) } + } + + fun logout() { + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.logout() } + .onSuccess { + activityPollingJob?.cancel() + ticketsPollingJob?.cancel() + chatPollingJob?.cancel() + mutableState.value = DeskHubState(initializing = false, savedServerUrl = repository.lastServerUrl()) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun clearActivityBadge() { + mutableState.update { it.copy(activityBadgeCount = 0) } + } + + private fun startActivityPolling() { + activityPollingJob?.cancel() + activityPollingJob = viewModelScope.launch { + var since = "" + while (isActive) { + runCatching { repository.activitySince(since) }.onSuccess { update: ActivityUpdate -> + since = update.serverTime + mutableState.update { it.copy(healthIssues = update.health) } + val newCount = update.newReplies + update.newUnassigned + if (newCount > 0) { + mutableState.update { it.copy(activityBadgeCount = it.activityBadgeCount + newCount) } + } + } + delay(60_000) + } + } + } + + // Läuft dauerhaft im Hintergrund (auch außerhalb des Tickets-Tabs, analog zu + // startActivityPolling()), damit neue/geänderte Tickets von selbst auftauchen, ohne + // dass manuell auf "Aktualisieren" getippt werden muss – Pendant zum Web-Live-Refresh + // in staff/tickets.php. Aktualisiert bewusst nur leise Seite 1 (kein ticketsLoading = + // kein Flackern) und nur, solange der Nutzer nicht gerade auf einer Folgeseite ist + // ("Weitere laden") – sonst würde die Liste unter den Fingern auf Seite 1 zurückspringen. + private fun startTicketsPolling() { + ticketsPollingJob?.cancel() + ticketsPollingJob = viewModelScope.launch { + while (isActive) { + delay(15_000) + if (state.value.staff != null && state.value.ticketsPage <= 1 && !state.value.ticketsLoading) { + runCatching { repository.tickets(state.value.filters, 1) } + .onSuccess { page -> + mutableState.update { + it.copy( + tickets = page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ) + } + } + // Fehler werden bewusst verschluckt: ein einzelner Netzwerk-Hänger im + // Hintergrund soll weder die sichtbare Liste löschen noch eine + // Fehlermeldung aufploppen lassen – beim nächsten Tick wird es erneut versucht. + } + } + } + } + + fun updateFilters(filters: TicketFilters) { + mutableState.update { it.copy(filters = filters) } + } + + fun refreshTickets() { + loadTickets(reset = true) + } + + fun loadMoreTickets() { + if (!state.value.ticketsHasMore || state.value.ticketsLoading) return + loadTickets(reset = false) + } + + fun openTicket(number: String) { + viewModelScope.launch { + mutableState.update { + it.copy(selectedTicket = null, detailLoading = true, detailError = null) + } + runCatching { repository.ticket(number) } + .onSuccess { ticket -> + mutableState.update { + it.copy(selectedTicket = ticket, detailLoading = false) + } + } + .onFailure { error -> + mutableState.update { + it.copy(detailLoading = false, detailError = error.userMessage()) + } + } + } + } + + fun updateTicket(status: String, assignedStaffId: Int?) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + val updateAssignment = assignedStaffId != ticket.summary.assignedStaff?.id + runCatching { + repository.updateTicket( + number = ticket.summary.number, + status = status.takeIf { it != ticket.summary.status }, + assignedStaffId = assignedStaffId, + updateAssignment = updateAssignment, + ) + }.onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + openTicket(ticket.summary.number) + loadTickets(reset = true) + }.onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + /** Markiert das aktuell geöffnete Ticket als Spam, oder macht das (restore = true) rückgängig. */ + fun setTicketSpam(restore: Boolean) { + val ticket = state.value.selectedTicket ?: return + viewModelScope.launch { + mutableState.update { it.copy(mutationLoading = true, mutationError = null) } + runCatching { repository.setTicketSpam(ticket.summary.number, restore) } + .onSuccess { result -> + mutableState.update { it.copy(mutationLoading = false) } + if (result.failed.isNotEmpty()) { + val verb = if (restore) "zurückgeholt" else "als Spam markiert" + mutableState.update { + it.copy(mutationError = "Ticket wurde $verb, aber nicht alle Original-Mails konnten verschoben werden: " + result.failed.joinToString(" | ")) + } + } + openTicket(ticket.summary.number) + loadTickets(reset = true) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun addMessage(type: String, message: String, setStatus: String?, 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) } + .onSuccess { + mutableState.update { it.copy(mutationLoading = false) } + onSuccess() + openTicket(number) + loadTickets(reset = true) + } + .onFailure { error -> + mutableState.update { + it.copy(mutationLoading = false, mutationError = error.userMessage()) + } + } + } + } + + fun clearMutationError() { + mutableState.update { it.copy(mutationError = null) } + } + + fun searchCustomers(query: String) { + mutableState.update { it.copy(customersQuery = query) } + viewModelScope.launch { + mutableState.update { it.copy(customersLoading = true, customersError = null) } + runCatching { repository.customers(query) } + .onSuccess { list -> mutableState.update { it.copy(customers = list, customersLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(customersLoading = false, customersError = error.userMessage()) } + } + } + } + + fun openCustomer(customer: Customer) { + viewModelScope.launch { + mutableState.update { it.copy(selectedCustomer = null, customerLoading = true, customerError = null) } + runCatching { + if (customer.id != null) repository.customerProfile(customer.id, null) + else repository.customerProfile(null, customer.email) + } + .onSuccess { profile -> mutableState.update { it.copy(selectedCustomer = profile, customerLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(customerLoading = false, customerError = error.userMessage()) } + } + } + } + + fun startNewTicket(name: String = "", email: String = "") { + mutableState.update { + it.copy(newTicketPrefillName = name, newTicketPrefillEmail = email, newTicketError = null) + } + } + + fun createTicket( + requesterName: String, + requesterEmail: String, + subject: String, + message: String, + departmentId: Int, + priorityId: Int, + assignedStaffId: Int?, + notifyCustomer: Boolean, + onSuccess: (String) -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(newTicketLoading = true, newTicketError = null) } + runCatching { + repository.createTicket( + requesterName, requesterEmail, subject, message, + departmentId, priorityId, assignedStaffId, notifyCustomer, + ) + } + .onSuccess { ticket -> + mutableState.update { it.copy(newTicketLoading = false) } + onSuccess(ticket.number) + refreshTickets() + } + .onFailure { error -> + mutableState.update { it.copy(newTicketLoading = false, newTicketError = error.userMessage()) } + } + } + } + + fun loadDashboard() { + viewModelScope.launch { + mutableState.update { it.copy(dashboardLoading = true, dashboardError = null) } + runCatching { repository.dashboard() } + .onSuccess { stats -> + mutableState.update { it.copy(dashboard = stats, dashboardLoading = false) } + } + .onFailure { error -> + mutableState.update { + it.copy(dashboardLoading = false, dashboardError = error.userMessage()) + } + } + } + } + + // ─── Admin: Wissensdatenbank ────────────────────────────────────────────── + + fun loadKb() { + viewModelScope.launch { + mutableState.update { it.copy(kbLoading = true, kbError = null) } + runCatching { repository.kbData() } + .onSuccess { data -> mutableState.update { it.copy(kb = data, kbLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(kbLoading = false, kbError = error.userMessage()) } } + } + } + + fun saveKbArticle(id: Int?, title: String, categoryId: Int, body: String, isPublished: Boolean, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbSaveArticle(id, title, categoryId, body, isPublished) } + .onSuccess { + mutableState.update { it.copy(kbMutationLoading = false) } + onSuccess() + loadKb() + } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun deleteKbArticle(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbDeleteArticle(id) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun createKbCategory(name: String) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbCreateCategory(name) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun deleteKbCategory(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(kbMutationLoading = true, kbMutationError = null) } + runCatching { repository.kbDeleteCategory(id) } + .onSuccess { mutableState.update { it.copy(kbMutationLoading = false) }; loadKb() } + .onFailure { error -> + mutableState.update { it.copy(kbMutationLoading = false, kbMutationError = error.userMessage()) } + } + } + } + + fun clearKbMutationError() { + mutableState.update { it.copy(kbMutationError = null) } + } + + // ─── Admin: Zeiterfassung ───────────────────────────────────────────────── + + fun loadTimeEntries(staffId: Int? = state.value.timeEntriesStaffId, from: String = state.value.timeEntriesFrom, to: String = state.value.timeEntriesTo) { + viewModelScope.launch { + mutableState.update { + it.copy( + timeEntriesLoading = true, + timeEntriesError = null, + timeEntriesStaffId = staffId, + timeEntriesFrom = from, + timeEntriesTo = to, + ) + } + runCatching { repository.timeEntries(staffId, from, to) } + .onSuccess { report -> mutableState.update { it.copy(timeEntries = report, timeEntriesLoading = false) } } + .onFailure { error -> + mutableState.update { it.copy(timeEntriesLoading = false, timeEntriesError = error.userMessage()) } + } + } + } + + // ─── Admin: Eskalationsregeln ───────────────────────────────────────────── + + fun loadEscalationRules() { + viewModelScope.launch { + mutableState.update { it.copy(escalationLoading = true, escalationError = null) } + runCatching { repository.escalationData() } + .onSuccess { data -> mutableState.update { it.copy(escalation = data, escalationLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(escalationLoading = false, escalationError = error.userMessage()) } } + } + } + + fun saveEscalationRule( + id: Int?, + name: String, + isActive: Boolean, + triggerType: String, + triggerHours: Int, + conditionStatus: String?, + conditionPriorityId: Int?, + conditionDeptId: Int?, + actionSetPriorityId: Int?, + actionAssignStaffId: Int?, + actionSetStatus: String?, + actionNotifyAdmin: Boolean, + actionNotifyStaff: Boolean, + onSuccess: () -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(escalationMutationLoading = true, escalationMutationError = null) } + runCatching { + repository.escalationSave( + id = id, name = name, isActive = isActive, triggerType = triggerType, + triggerHours = triggerHours, conditionStatus = conditionStatus, + conditionPriorityId = conditionPriorityId, conditionDeptId = conditionDeptId, + actionSetPriorityId = actionSetPriorityId, actionAssignStaffId = actionAssignStaffId, + actionSetStatus = actionSetStatus, actionNotifyAdmin = actionNotifyAdmin, + actionNotifyStaff = actionNotifyStaff, sortOrder = 0, + ) + }.onSuccess { + mutableState.update { it.copy(escalationMutationLoading = false) } + onSuccess() + loadEscalationRules() + }.onFailure { error -> + mutableState.update { it.copy(escalationMutationLoading = false, escalationMutationError = error.userMessage()) } + } + } + } + + fun deleteEscalationRule(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(escalationMutationLoading = true, escalationMutationError = null) } + runCatching { repository.escalationDelete(id) } + .onSuccess { mutableState.update { it.copy(escalationMutationLoading = false) }; loadEscalationRules() } + .onFailure { error -> + mutableState.update { it.copy(escalationMutationLoading = false, escalationMutationError = error.userMessage()) } + } + } + } + + fun toggleEscalationRule(id: Int) { + viewModelScope.launch { + runCatching { repository.escalationToggle(id) } + .onSuccess { loadEscalationRules() } + .onFailure { error -> mutableState.update { it.copy(escalationMutationError = error.userMessage()) } } + } + } + + fun clearEscalationMutationError() { + mutableState.update { it.copy(escalationMutationError = null) } + } + + // ─── Admin: Sperrliste ──────────────────────────────────────────────────── + + fun loadBanlist() { + viewModelScope.launch { + mutableState.update { it.copy(banlistLoading = true, banlistError = null) } + runCatching { repository.banlist() } + .onSuccess { items -> mutableState.update { it.copy(banlist = items, banlistLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(banlistLoading = false, banlistError = error.userMessage()) } } + } + } + + fun createBanEntry(matchType: String, value: String, reason: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banCreate(matchType, value, reason) } + .onSuccess { + mutableState.update { it.copy(banlistMutationLoading = false) } + onSuccess() + loadBanlist() + } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun bulkAddBanEntries(values: String, reason: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banBulkAdd(values, reason) } + .onSuccess { result -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistLastBulkResult = result) } + onSuccess() + loadBanlist() + } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun deleteBanEntry(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(banlistMutationLoading = true, banlistMutationError = null) } + runCatching { repository.banDelete(id) } + .onSuccess { mutableState.update { it.copy(banlistMutationLoading = false) }; loadBanlist() } + .onFailure { error -> + mutableState.update { it.copy(banlistMutationLoading = false, banlistMutationError = error.userMessage()) } + } + } + } + + fun clearBanlistMutationError() { + mutableState.update { it.copy(banlistMutationError = null) } + } + + fun clearBanlistBulkResult() { + mutableState.update { it.copy(banlistLastBulkResult = null) } + } + + // ─── Admin: Abteilungen ─────────────────────────────────────────────────── + + fun loadDepartments() { + viewModelScope.launch { + mutableState.update { it.copy(departments2Loading = true, departments2Error = null) } + runCatching { repository.departmentData() } + .onSuccess { d -> mutableState.update { it.copy(departments2 = d.departments, departmentEmailOptions = d.emailOptions, departmentStaffOptions = d.staffOptions, departmentSlaPlans = d.slaPlans, departments2Loading = false) } } + .onFailure { error -> mutableState.update { it.copy(departments2Loading = false, departments2Error = error.userMessage()) } } + } + } + + fun saveDepartment(id: Int?, input: DepartmentInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(departments2MutationLoading = true, departments2MutationError = null) } + runCatching { repository.departmentSave(id, input) } + .onSuccess { mutableState.update { it.copy(departments2MutationLoading = false) }; onSuccess(); loadDepartments() } + .onFailure { error -> mutableState.update { it.copy(departments2MutationLoading = false, departments2MutationError = error.userMessage()) } } + } + } + + fun deleteDepartment(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(departments2MutationLoading = true, departments2MutationError = null) } + runCatching { repository.departmentDelete(id) } + .onSuccess { mutableState.update { it.copy(departments2MutationLoading = false) }; loadDepartments() } + .onFailure { error -> mutableState.update { it.copy(departments2MutationLoading = false, departments2MutationError = error.userMessage()) } } + } + } + + fun clearDepartmentsError() = mutableState.update { it.copy(departments2MutationError = null) } + + // ─── Admin: Prioritäten ─────────────────────────────────────────────────── + + fun loadPrioritiesAdmin() { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminLoading = true, prioritiesAdminError = null) } + runCatching { repository.prioritiesAdmin() } + .onSuccess { items -> mutableState.update { it.copy(prioritiesAdmin = items, prioritiesAdminLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminLoading = false, prioritiesAdminError = error.userMessage()) } } + } + } + + fun savePriority(id: Int?, name: String, color: String, responseHours: Int, resolutionHours: Int, sortOrder: Int, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminMutationLoading = true, prioritiesAdminMutationError = null) } + runCatching { repository.prioritySave(id, name, color, responseHours, resolutionHours, sortOrder) } + .onSuccess { mutableState.update { it.copy(prioritiesAdminMutationLoading = false) }; onSuccess(); loadPrioritiesAdmin() } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminMutationLoading = false, prioritiesAdminMutationError = error.userMessage()) } } + } + } + + fun deletePriority(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(prioritiesAdminMutationLoading = true, prioritiesAdminMutationError = null) } + runCatching { repository.priorityDelete(id) } + .onSuccess { mutableState.update { it.copy(prioritiesAdminMutationLoading = false) }; loadPrioritiesAdmin() } + .onFailure { error -> mutableState.update { it.copy(prioritiesAdminMutationLoading = false, prioritiesAdminMutationError = error.userMessage()) } } + } + } + + /** Bericht ausführen: mit der übergebenen Konfiguration oder als gespeicherter Bericht (savedId). */ + fun runReport(config: de.deskhub.staff.data.remote.dto.ReportConfigDto? = null, savedId: Int? = null) { + viewModelScope.launch { + mutableState.update { it.copy(reportLoading = true, reportError = null) } + runCatching { repository.report(config, savedId) } + .onSuccess { r -> mutableState.update { it.copy(report = r, reportConfig = r.config, reportLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(reportLoading = false, reportError = error.userMessage()) } } + } + } + + fun saveReport(name: String, shared: Boolean) { + viewModelScope.launch { + runCatching { repository.reportSave(name, shared, state.value.reportConfig) } + .onSuccess { runReport(state.value.reportConfig) } + .onFailure { error -> mutableState.update { it.copy(reportError = error.userMessage()) } } + } + } + + fun deleteReport(id: Int) { + viewModelScope.launch { + runCatching { repository.reportDelete(id) }.onSuccess { runReport(state.value.reportConfig) } + } + } + + fun loadSavedViews() { + viewModelScope.launch { + runCatching { repository.savedViews() } + .onSuccess { views -> mutableState.update { it.copy(savedViews = views) } } + } + } + + fun applySavedView(view: de.deskhub.staff.domain.model.SavedView) { + mutableState.update { it.copy(filters = view.filters) } + refreshTickets() + } + + fun saveCurrentView(name: String, shared: Boolean) { + viewModelScope.launch { + mutableState.update { it.copy(savedViewError = null) } + runCatching { repository.savedViewSave(name, shared, state.value.filters) } + .onSuccess { loadSavedViews() } + .onFailure { error -> mutableState.update { it.copy(savedViewError = error.userMessage()) } } + } + } + + fun deleteSavedView(id: Int) { + viewModelScope.launch { + runCatching { repository.savedViewDelete(id) }.onSuccess { loadSavedViews() } + } + } + + fun clearSavedViewError() = mutableState.update { it.copy(savedViewError = null) } + + fun loadSlaPlans() { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansLoading = true, slaPlansError = null) } + runCatching { repository.slaPlans() } + .onSuccess { items -> mutableState.update { it.copy(slaPlans = items, slaPlansLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(slaPlansLoading = false, slaPlansError = error.userMessage()) } } + } + } + + fun saveSlaPlan(id: Int?, name: String, responseHours: Int, resolutionHours: Int, isActive: Boolean, notes: String, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansMutationLoading = true, slaPlansMutationError = null) } + runCatching { repository.slaPlanSave(id, name, responseHours, resolutionHours, isActive, notes) } + .onSuccess { mutableState.update { it.copy(slaPlansMutationLoading = false) }; onSuccess(); loadSlaPlans() } + .onFailure { error -> mutableState.update { it.copy(slaPlansMutationLoading = false, slaPlansMutationError = error.userMessage()) } } + } + } + + fun deleteSlaPlan(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(slaPlansMutationLoading = true, slaPlansMutationError = null) } + runCatching { repository.slaPlanDelete(id) } + .onSuccess { mutableState.update { it.copy(slaPlansMutationLoading = false) }; loadSlaPlans() } + .onFailure { error -> mutableState.update { it.copy(slaPlansMutationLoading = false, slaPlansMutationError = error.userMessage()) } } + } + } + + fun clearSlaPlansError() = mutableState.update { it.copy(slaPlansMutationError = null) } + + fun clearPrioritiesError() = mutableState.update { it.copy(prioritiesAdminMutationError = null) } + + // ─── Admin: Hilfe-Themen ────────────────────────────────────────────────── + + fun loadHelpTopics() { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsLoading = true, helpTopicsError = null) } + runCatching { repository.helpTopics() } + .onSuccess { data -> mutableState.update { it.copy(helpTopics = data, helpTopicsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(helpTopicsLoading = false, helpTopicsError = error.userMessage()) } } + } + } + + fun saveHelpTopic(id: Int?, input: HelpTopicInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsMutationLoading = true, helpTopicsMutationError = null) } + runCatching { repository.helpTopicSave(id, input) } + .onSuccess { mutableState.update { it.copy(helpTopicsMutationLoading = false) }; onSuccess(); loadHelpTopics() } + .onFailure { error -> mutableState.update { it.copy(helpTopicsMutationLoading = false, helpTopicsMutationError = error.userMessage()) } } + } + } + + fun deleteHelpTopic(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(helpTopicsMutationLoading = true, helpTopicsMutationError = null) } + runCatching { repository.helpTopicDelete(id) } + .onSuccess { mutableState.update { it.copy(helpTopicsMutationLoading = false) }; loadHelpTopics() } + .onFailure { error -> mutableState.update { it.copy(helpTopicsMutationLoading = false, helpTopicsMutationError = error.userMessage()) } } + } + } + + fun clearHelpTopicsError() = mutableState.update { it.copy(helpTopicsMutationError = null) } + + // ─── Admin: Organisationen ──────────────────────────────────────────────── + + fun loadOrganizations() { + viewModelScope.launch { + mutableState.update { it.copy(organizationsLoading = true, organizationsError = null) } + runCatching { repository.organizations() } + .onSuccess { items -> mutableState.update { it.copy(organizations = items, organizationsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(organizationsLoading = false, organizationsError = error.userMessage()) } } + } + } + + fun saveOrganization(id: Int?, name: String, notes: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(organizationsMutationLoading = true, organizationsMutationError = null) } + runCatching { repository.organizationSave(id, name, notes) } + .onSuccess { mutableState.update { it.copy(organizationsMutationLoading = false) }; onSuccess(); loadOrganizations() } + .onFailure { error -> mutableState.update { it.copy(organizationsMutationLoading = false, organizationsMutationError = error.userMessage()) } } + } + } + + fun deleteOrganization(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(organizationsMutationLoading = true, organizationsMutationError = null) } + runCatching { repository.organizationDelete(id) } + .onSuccess { mutableState.update { it.copy(organizationsMutationLoading = false) }; loadOrganizations() } + .onFailure { error -> mutableState.update { it.copy(organizationsMutationLoading = false, organizationsMutationError = error.userMessage()) } } + } + } + + fun clearOrganizationsError() = mutableState.update { it.copy(organizationsMutationError = null) } + + // ─── Admin: Teams ───────────────────────────────────────────────────────── + + fun loadTeams() { + viewModelScope.launch { + mutableState.update { it.copy(teamsLoading = true, teamsError = null) } + runCatching { repository.teams() } + .onSuccess { data -> mutableState.update { it.copy(teams = data, teamsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(teamsLoading = false, teamsError = error.userMessage()) } } + } + } + + fun saveTeam(id: Int?, name: String, leadId: Int?, notes: String?, memberIds: List, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(teamsMutationLoading = true, teamsMutationError = null) } + runCatching { repository.teamSave(id, name, leadId, notes, memberIds) } + .onSuccess { mutableState.update { it.copy(teamsMutationLoading = false) }; onSuccess(); loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationLoading = false, teamsMutationError = error.userMessage()) } } + } + } + + fun toggleTeam(id: Int) { + viewModelScope.launch { + runCatching { repository.teamToggle(id) } + .onSuccess { loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationError = error.userMessage()) } } + } + } + + fun deleteTeam(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(teamsMutationLoading = true, teamsMutationError = null) } + runCatching { repository.teamDelete(id) } + .onSuccess { mutableState.update { it.copy(teamsMutationLoading = false) }; loadTeams() } + .onFailure { error -> mutableState.update { it.copy(teamsMutationLoading = false, teamsMutationError = error.userMessage()) } } + } + } + + fun clearTeamsError() = mutableState.update { it.copy(teamsMutationError = null) } + + // ─── Admin: Rollen ──────────────────────────────────────────────────────── + + fun loadRoles() { + viewModelScope.launch { + mutableState.update { it.copy(rolesLoading = true, rolesError = null) } + runCatching { repository.roles() } + .onSuccess { data -> mutableState.update { it.copy(roles = data, rolesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(rolesLoading = false, rolesError = error.userMessage()) } } + } + } + + fun saveRole(id: Int?, name: String, permissions: Map, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(rolesMutationLoading = true, rolesMutationError = null) } + runCatching { repository.roleSave(id, name, permissions) } + .onSuccess { mutableState.update { it.copy(rolesMutationLoading = false) }; onSuccess(); loadRoles() } + .onFailure { error -> mutableState.update { it.copy(rolesMutationLoading = false, rolesMutationError = error.userMessage()) } } + } + } + + fun deleteRole(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(rolesMutationLoading = true, rolesMutationError = null) } + runCatching { repository.roleDelete(id) } + .onSuccess { mutableState.update { it.copy(rolesMutationLoading = false) }; loadRoles() } + .onFailure { error -> mutableState.update { it.copy(rolesMutationLoading = false, rolesMutationError = error.userMessage()) } } + } + } + + fun clearRolesError() = mutableState.update { it.copy(rolesMutationError = null) } + + // ─── Admin: Mitarbeiter ─────────────────────────────────────────────────── + + fun loadStaffAdmin() { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminLoading = true, staffAdminError = null) } + runCatching { repository.staffAdminList() } + .onSuccess { data -> mutableState.update { it.copy(staffAdmin = data, staffAdminLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(staffAdminLoading = false, staffAdminError = error.userMessage()) } } + } + } + + fun saveStaffAdmin(id: Int?, input: StaffAdminInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminMutationLoading = true, staffAdminMutationError = null) } + runCatching { repository.staffAdminSave(id, input) } + .onSuccess { mutableState.update { it.copy(staffAdminMutationLoading = false) }; onSuccess(); loadStaffAdmin() } + .onFailure { error -> mutableState.update { it.copy(staffAdminMutationLoading = false, staffAdminMutationError = error.userMessage()) } } + } + } + + fun deleteStaffAdmin(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(staffAdminMutationLoading = true, staffAdminMutationError = null) } + runCatching { repository.staffAdminDelete(id) } + .onSuccess { mutableState.update { it.copy(staffAdminMutationLoading = false) }; loadStaffAdmin() } + .onFailure { error -> mutableState.update { it.copy(staffAdminMutationLoading = false, staffAdminMutationError = error.userMessage()) } } + } + } + + fun clearStaffAdminError() = mutableState.update { it.copy(staffAdminMutationError = null) } + + // ─── Admin: Geschäftszeiten ─────────────────────────────────────────────── + + fun loadBusinessHours() { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursLoading = true, businessHoursError = null) } + runCatching { repository.businessHours() } + .onSuccess { data -> mutableState.update { it.copy(businessHours = data, businessHoursLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(businessHoursLoading = false, businessHoursError = error.userMessage()) } } + } + } + + fun saveBusinessHours(enabled: Boolean, hours: List, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursMutationLoading = true, businessHoursMutationError = null) } + runCatching { repository.businessHoursSave(enabled, hours) } + .onSuccess { mutableState.update { it.copy(businessHoursMutationLoading = false) }; onSuccess(); loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationLoading = false, businessHoursMutationError = error.userMessage()) } } + } + } + + fun addBusinessHoliday(date: String, name: String) { + viewModelScope.launch { + mutableState.update { it.copy(businessHoursMutationLoading = true, businessHoursMutationError = null) } + runCatching { repository.businessHolidayAdd(date, name) } + .onSuccess { mutableState.update { it.copy(businessHoursMutationLoading = false) }; loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationLoading = false, businessHoursMutationError = error.userMessage()) } } + } + } + + fun deleteBusinessHoliday(id: Int) { + viewModelScope.launch { + runCatching { repository.businessHolidayDelete(id) } + .onSuccess { loadBusinessHours() } + .onFailure { error -> mutableState.update { it.copy(businessHoursMutationError = error.userMessage()) } } + } + } + + fun clearBusinessHoursError() = mutableState.update { it.copy(businessHoursMutationError = null) } + + // ─── Admin: E-Mail-Adressen ─────────────────────────────────────────────── + + fun loadEmailAddresses() { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesLoading = true, emailAddressesError = null) } + runCatching { repository.emailAddresses() } + .onSuccess { data -> mutableState.update { it.copy(emailAddresses = data, emailAddressesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(emailAddressesLoading = false, emailAddressesError = error.userMessage()) } } + } + } + + fun saveEmailAddress(id: Int?, input: EmailAddressInput, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null, emailAddressesNotice = null) } + runCatching { repository.emailAddressSave(id, input) } + .onSuccess { mutableState.update { it.copy(emailAddressesMutationLoading = false) }; onSuccess(); loadEmailAddresses() } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + fun deleteEmailAddress(id: Int) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null) } + runCatching { repository.emailAddressDelete(id) } + .onSuccess { mutableState.update { it.copy(emailAddressesMutationLoading = false) }; loadEmailAddresses() } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + /** Test-Mail senden ("sendTest") bzw. IMAP-Verbindung prüfen ("testImap"); Ergebnis erscheint als Hinweis im Formular. */ + fun testEmailAddress(id: Int, action: String) { + viewModelScope.launch { + mutableState.update { it.copy(emailAddressesMutationLoading = true, emailAddressesMutationError = null, emailAddressesNotice = null) } + runCatching { repository.emailAddressAction(action, id) } + .onSuccess { msg -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesNotice = msg.ifBlank { "OK" }) } } + .onFailure { error -> mutableState.update { it.copy(emailAddressesMutationLoading = false, emailAddressesMutationError = error.userMessage()) } } + } + } + + fun clearEmailAddressesError() = mutableState.update { it.copy(emailAddressesMutationError = null, emailAddressesNotice = null) } + + // ─── Admin: E-Mail-Vorlagen ─────────────────────────────────────────────── + + fun loadEmailTemplates() { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesLoading = true, emailTemplatesError = null) } + runCatching { repository.emailTemplates() } + .onSuccess { items -> mutableState.update { it.copy(emailTemplates = items, emailTemplatesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesLoading = false, emailTemplatesError = error.userMessage()) } } + } + } + + fun saveEmailTemplate(key: String, subject: String, bodyText: String, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesMutationLoading = true, emailTemplatesMutationError = null) } + runCatching { repository.emailTemplateSave(key, subject, bodyText) } + .onSuccess { mutableState.update { it.copy(emailTemplatesMutationLoading = false) }; onSuccess(); loadEmailTemplates() } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesMutationLoading = false, emailTemplatesMutationError = error.userMessage()) } } + } + } + + fun resetEmailTemplate(key: String) { + viewModelScope.launch { + mutableState.update { it.copy(emailTemplatesMutationLoading = true, emailTemplatesMutationError = null) } + runCatching { repository.emailTemplateReset(key) } + .onSuccess { mutableState.update { it.copy(emailTemplatesMutationLoading = false) }; loadEmailTemplates() } + .onFailure { error -> mutableState.update { it.copy(emailTemplatesMutationLoading = false, emailTemplatesMutationError = error.userMessage()) } } + } + } + + fun clearEmailTemplatesError() = mutableState.update { it.copy(emailTemplatesMutationError = null) } + + // ─── Admin: Admin-Protokoll ─────────────────────────────────────────────── + + fun loadAuditLog(reset: Boolean = true) { + viewModelScope.launch { + val page = if (reset) 1 else state.value.auditLogPage + 1 + mutableState.update { it.copy(auditLogLoading = true, auditLogError = null) } + runCatching { repository.auditLog(page) } + .onSuccess { result -> + mutableState.update { + it.copy( + auditLog = if (reset) result.items else it.auditLog + result.items, + auditLogPage = result.page, + auditLogHasMore = result.hasMore, + auditLogLoading = false, + ) + } + } + .onFailure { error -> mutableState.update { it.copy(auditLogLoading = false, auditLogError = error.userMessage()) } } + } + } + + // ─── Aufgaben ───────────────────────────────────────────────────────────── + + fun loadTasks(filter: String = state.value.tasksFilter) { + viewModelScope.launch { + mutableState.update { it.copy(tasksLoading = true, tasksError = null, tasksFilter = filter) } + runCatching { repository.tasks(filter) } + .onSuccess { data -> mutableState.update { it.copy(tasks = data, tasksLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(tasksLoading = false, tasksError = error.userMessage()) } } + } + } + + fun createTask(title: String, description: String?, assignedToId: Int?, dueAt: String?, ticketNumber: String?, onSuccess: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(tasksMutationLoading = true, tasksMutationError = null) } + runCatching { repository.taskCreate(title, description, assignedToId, dueAt, ticketNumber) } + .onSuccess { mutableState.update { it.copy(tasksMutationLoading = false) }; onSuccess(); loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationLoading = false, tasksMutationError = error.userMessage()) } } + } + } + + fun updateTaskStatus(id: Int, status: String) { + viewModelScope.launch { + runCatching { repository.taskUpdateStatus(id, status) } + .onSuccess { loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationError = error.userMessage()) } } + } + } + + fun deleteTask(id: Int) { + viewModelScope.launch { + runCatching { repository.taskDelete(id) } + .onSuccess { loadTasks() } + .onFailure { error -> mutableState.update { it.copy(tasksMutationError = error.userMessage()) } } + } + } + + fun clearTasksError() = mutableState.update { it.copy(tasksMutationError = null) } + + // ─── Interner Chat ──────────────────────────────────────────────────────── + + fun loadChatContacts() { + viewModelScope.launch { + mutableState.update { it.copy(chatContactsLoading = true, chatContactsError = null) } + runCatching { repository.chatContacts() } + .onSuccess { items -> mutableState.update { it.copy(chatContacts = items, chatContactsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(chatContactsLoading = false, chatContactsError = error.userMessage()) } } + } + } + + fun openChatConversation(withId: Int) { + chatPollingJob?.cancel() + mutableState.update { it.copy(chatConversation = null, chatConversationLoading = true, chatConversationError = null) } + chatPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.chatConversation(withId) } + .onSuccess { conversation -> + mutableState.update { it.copy(chatConversation = conversation, chatConversationLoading = false) } + } + .onFailure { error -> + mutableState.update { it.copy(chatConversationLoading = false, chatConversationError = error.userMessage()) } + } + delay(8_000) + } + } + } + + fun closeChatConversation() { + chatPollingJob?.cancel() + mutableState.update { it.copy(chatConversation = null) } + loadChatContacts() + } + + fun sendChatMessage(toId: Int, message: String) { + viewModelScope.launch { + mutableState.update { it.copy(chatSendLoading = true, chatSendError = null) } + runCatching { repository.chatSend(toId, message) } + .onSuccess { + mutableState.update { it.copy(chatSendLoading = false) } + runCatching { repository.chatConversation(toId) } + .onSuccess { conversation -> mutableState.update { it.copy(chatConversation = conversation) } } + } + .onFailure { error -> mutableState.update { it.copy(chatSendLoading = false, chatSendError = error.userMessage()) } } + } + } + + fun clearChatSendError() = mutableState.update { it.copy(chatSendError = null) } + + // ─── Live-Chat (Kunden-Widget) ──────────────────────────────────────────── + // Eigener Tab, getrennt vom internen Chat oben – Chats mit Kunden über das + // Live-Chat-Widget im Kundenportal (siehe staff/live_chat.php im Web-Backend, + // gleiche Sperr-Logik über api/staff/v1/live_chat.php). + + /** Fügt neu eingetroffene Nachrichten hinzu, ohne bereits angezeigte zu duplizieren + * (der Server liefert bei jedem Poll immer die volle Historie, wie im Web-Widget). */ + private fun mergeLiveChatDetail(state: DeskHubState, detail: LiveChatDetail): LiveChatDetail { + val prevMessages = state.liveChatDetail?.messages.orEmpty() + val existingIds = prevMessages.map { it.id }.toSet() + val newOnes = detail.messages.filter { it.id !in existingIds } + return detail.copy(messages = prevMessages + newOnes) + } + + fun loadLiveChats() { + liveChatListPollingJob?.cancel() + mutableState.update { it.copy(liveChatsLoading = true, liveChatsError = null) } + liveChatListPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.liveChatList() } + .onSuccess { items -> mutableState.update { it.copy(liveChats = items, liveChatsLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(liveChatsLoading = false, liveChatsError = error.userMessage()) } } + delay(8_000) + } + } + } + + fun stopLiveChatListPolling() { + liveChatListPollingJob?.cancel() + } + + fun openLiveChat(number: String) { + liveChatDetailPollingJob?.cancel() + mutableState.update { it.copy(liveChatDetail = null, liveChatDetailLoading = true, liveChatDetailError = null) } + liveChatDetailPollingJob = viewModelScope.launch { + while (isActive) { + runCatching { repository.liveChatMessages(number) } + .onSuccess { detail -> + mutableState.update { it.copy(liveChatDetail = mergeLiveChatDetail(it, detail), liveChatDetailLoading = false) } + } + .onFailure { error -> mutableState.update { it.copy(liveChatDetailLoading = false, liveChatDetailError = error.userMessage()) } } + delay(8_000) + } + } + } + + fun closeLiveChat() { + liveChatDetailPollingJob?.cancel() + mutableState.update { it.copy(liveChatDetail = null) } + } + + fun sendLiveChatMessage(number: String, message: String) { + viewModelScope.launch { + mutableState.update { it.copy(liveChatSendLoading = true, liveChatSendError = null) } + runCatching { repository.liveChatSend(number, message) } + .onSuccess { + mutableState.update { it.copy(liveChatSendLoading = false) } + // Sofort einmal nachladen statt bis zu 8s auf den nächsten Poll zu warten. + runCatching { repository.liveChatMessages(number) } + .onSuccess { detail -> mutableState.update { it.copy(liveChatDetail = mergeLiveChatDetail(it, detail)) } } + } + .onFailure { error -> mutableState.update { it.copy(liveChatSendLoading = false, liveChatSendError = error.userMessage()) } } + } + } + + fun clearLiveChatSendError() = mutableState.update { it.copy(liveChatSendError = null) } + + /** Signalisiert dem Kunden-Widget "Mitarbeiter tippt gerade" – höchstens alle 3s, + * damit nicht bei jedem Tastenanschlag eine eigene Anfrage rausgeht. */ + fun pingLiveChatTyping(number: String) { + val now = System.currentTimeMillis() + if (now - lastLiveChatTypingPingAt < 3_000) return + lastLiveChatTypingPingAt = now + viewModelScope.launch { runCatching { repository.liveChatTyping(number) } } + } + + fun endLiveChat(number: String, onDone: () -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(liveChatSendLoading = true, liveChatSendError = null) } + runCatching { repository.liveChatEnd(number) } + .onSuccess { + mutableState.update { it.copy(liveChatSendLoading = false) } + onDone() + } + .onFailure { error -> mutableState.update { it.copy(liveChatSendLoading = false, liveChatSendError = error.userMessage()) } } + } + } + + // ─── Verfügbarkeit (Status) ──────────────────────────────────────────────── + + fun loadAvailability() { + viewModelScope.launch { + mutableState.update { it.copy(availabilityLoading = true, availabilityError = null) } + runCatching { repository.availability() } + .onSuccess { data -> mutableState.update { it.copy(availability = data, availabilityLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(availabilityLoading = false, availabilityError = error.userMessage()) } } + } + } + + fun setMyAvailability(status: String, note: String) { + viewModelScope.launch { + mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) } + runCatching { repository.setAvailability(status, note) } + .onSuccess { + mutableState.update { it.copy(availabilitySaving = false) } + loadAvailability() + } + .onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } } + } + } + + // ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────── + + fun loadTicketRules() { + viewModelScope.launch { + mutableState.update { it.copy(ticketRulesLoading = true, ticketRulesError = null) } + runCatching { repository.ticketRules() } + .onSuccess { data -> mutableState.update { it.copy(ticketRules = data, ticketRulesLoading = false) } } + .onFailure { error -> mutableState.update { it.copy(ticketRulesLoading = false, ticketRulesError = error.userMessage()) } } + } + } + + fun saveTicketRule( + id: Int?, + name: String, + matchType: String, + isActive: Boolean, + sortOrder: Int, + conditions: List, + actions: List, + onDone: () -> Unit, + ) { + viewModelScope.launch { + mutableState.update { it.copy(ticketRulesMutationLoading = true, ticketRulesMutationError = null) } + runCatching { repository.saveTicketRule(id, name, matchType, isActive, sortOrder, conditions, actions) } + .onSuccess { + mutableState.update { it.copy(ticketRulesMutationLoading = false) } + loadTicketRules() + onDone() + } + .onFailure { error -> mutableState.update { it.copy(ticketRulesMutationLoading = false, ticketRulesMutationError = error.userMessage()) } } + } + } + + fun toggleTicketRule(id: Int) { + viewModelScope.launch { + runCatching { repository.toggleTicketRule(id) }.onSuccess { loadTicketRules() } + } + } + + fun deleteTicketRule(id: Int) { + viewModelScope.launch { + runCatching { repository.deleteTicketRule(id) }.onSuccess { loadTicketRules() } + } + } + + fun clearTicketRulesMutationError() = mutableState.update { it.copy(ticketRulesMutationError = null) } + + fun saveAutoClose(enabled: Boolean, days: Int) { + viewModelScope.launch { + runCatching { repository.saveAutoClose(enabled, days) }.onSuccess { loadTicketRules() } + } + } + + // ─── Bild-Upload (Signaturen) ───────────────────────────────────────────── + + fun uploadSignatureImage(bytes: ByteArray, filename: String, mimeType: String, onSuccess: (String) -> Unit) { + viewModelScope.launch { + mutableState.update { it.copy(imageUploadLoading = true, imageUploadError = null) } + runCatching { repository.uploadImage(bytes, filename, mimeType) } + .onSuccess { url -> + mutableState.update { it.copy(imageUploadLoading = false) } + onSuccess(url) + } + .onFailure { error -> mutableState.update { it.copy(imageUploadLoading = false, imageUploadError = error.userMessage()) } } + } + } + + fun clearImageUploadError() = mutableState.update { it.copy(imageUploadError = null) } + + private fun restoreSession() { + viewModelScope.launch { + val restored = runCatching { repository.restoreSession() } + if (restored.isFailure || restored.getOrNull() == null) { + mutableState.update { + it.copy( + initializing = false, + authError = restored.exceptionOrNull()?.userMessage(), + savedServerUrl = repository.lastServerUrl(), + ) + } + return@launch + } + runCatching { repository.validateSession() } + .onSuccess { staff -> + mutableState.update { it.copy(initializing = false, staff = staff) } + loadWorkspace() + loadChatContacts() + startActivityPolling() + startTicketsPolling() + } + .onFailure { error -> + if (error is ApiException && error.status == 401) repository.clearSession() + mutableState.update { + it.copy(initializing = false, authError = error.userMessage(), savedServerUrl = repository.lastServerUrl()) + } + } + } + } + + private fun loadWorkspace() { + viewModelScope.launch { + mutableState.update { it.copy(ticketsLoading = true, ticketsError = null) } + val metadataRequest = async { repository.metadata() } + val ticketsRequest = async { repository.tickets(state.value.filters) } + val cannedRequest = async { runCatching { repository.cannedResponses() }.getOrDefault(emptyList()) } + runCatching { Triple(metadataRequest.await(), ticketsRequest.await(), cannedRequest.await()) } + .onSuccess { (metadata, page, canned) -> + mutableState.update { + it.copy( + metadata = metadata, + staff = metadata.staff, + cannedResponses = canned, + tickets = page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ticketsLoading = false, + ) + } + } + .onFailure { error -> + mutableState.update { + it.copy(ticketsLoading = false, ticketsError = error.userMessage()) + } + } + } + } + + private fun loadTickets(reset: Boolean) { + viewModelScope.launch { + val pageNumber = if (reset) 1 else state.value.ticketsPage + 1 + mutableState.update { it.copy(ticketsLoading = true, ticketsError = null) } + runCatching { repository.tickets(state.value.filters, pageNumber) } + .onSuccess { page -> + mutableState.update { + it.copy( + tickets = if (reset) page.items else it.tickets + page.items, + ticketsPage = page.page, + ticketsTotal = page.total, + ticketsHasMore = page.hasMore, + ticketsLoading = false, + ) + } + } + .onFailure { error -> + mutableState.update { + it.copy(ticketsLoading = false, ticketsError = error.userMessage()) + } + } + } + } +} + +class DeskHubViewModelFactory( + private val repository: TicketRepository, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(DeskHubViewModel::class.java)) + return DeskHubViewModel(repository) as T + } +} + +private fun Throwable.userMessage(): String = when (this) { + is ApiException -> message + is IllegalArgumentException -> message ?: "Ungültige Eingabe." + else -> message ?: "Die Aktion ist fehlgeschlagen." +} diff --git a/mobile/src/main/java/de/deskhub/staff/ui/Permissions.kt b/mobile/src/main/java/de/deskhub/staff/ui/Permissions.kt new file mode 100644 index 00000000..a61bb45b --- /dev/null +++ b/mobile/src/main/java/de/deskhub/staff/ui/Permissions.kt @@ -0,0 +1,12 @@ +package de.deskhub.staff.ui + +/** + * Darf der Mitarbeiter diese Aktion an einem Ticket ausführen? + * Rechte gelten pro Abteilung des Tickets; liefert ein älterer Server sie nicht mit, + * gelten die globalen Rechte des Mitarbeiters. + */ +fun ticketPermission( + ticketPermissions: Map?, + staffPermissions: Map?, + permission: String, +): Boolean = ticketPermissions?.get(permission) ?: (staffPermissions?.get(permission) == true) diff --git a/mobile/src/main/res/drawable/ic_launcher_background.xml b/mobile/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..82d55963 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/mobile/src/main/res/drawable/ic_launcher_foreground.xml b/mobile/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..bc816d61 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + diff --git a/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/mobile/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src/main/res/values-night/themes.xml b/mobile/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..751df6cc --- /dev/null +++ b/mobile/src/main/res/values-night/themes.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml new file mode 100644 index 00000000..acb900ce --- /dev/null +++ b/mobile/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + deskhub + deskhub-Server + diff --git a/mobile/src/main/res/values/themes.xml b/mobile/src/main/res/values/themes.xml new file mode 100644 index 00000000..ae018113 --- /dev/null +++ b/mobile/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/src/main/res/xml/network_security_config.xml b/mobile/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..b0e4af7a --- /dev/null +++ b/mobile/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/mobile/src/test/java/de/deskhub/staff/data/remote/ConnectionMonitorTest.kt b/mobile/src/test/java/de/deskhub/staff/data/remote/ConnectionMonitorTest.kt new file mode 100644 index 00000000..a5c52f18 --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/data/remote/ConnectionMonitorTest.kt @@ -0,0 +1,21 @@ +package de.deskhub.staff.data.remote + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionMonitorTest { + @After + fun reset() = ConnectionMonitor.set(false) + + @Test + fun `starts online and follows set`() { + ConnectionMonitor.set(false) + assertFalse(ConnectionMonitor.offline.value) + ConnectionMonitor.set(true) + assertTrue(ConnectionMonitor.offline.value) + ConnectionMonitor.set(false) + assertFalse(ConnectionMonitor.offline.value) + } +} diff --git a/mobile/src/test/java/de/deskhub/staff/data/remote/DeskHubUrlTest.kt b/mobile/src/test/java/de/deskhub/staff/data/remote/DeskHubUrlTest.kt new file mode 100644 index 00000000..7cc78ef9 --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/data/remote/DeskHubUrlTest.kt @@ -0,0 +1,29 @@ +package de.deskhub.staff.data.remote + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class DeskHubUrlTest { + @Test + fun `adds https and preserves installation path`() { + assertEquals( + "https://support.example.de/deskhub", + DeskHubUrl.normalize("support.example.de/deskhub/"), + ) + } + + @Test + fun `rejects cleartext server`() { + assertThrows(IllegalArgumentException::class.java) { + DeskHubUrl.normalize("http://support.example.de") + } + } + + @Test + fun `rejects credentials and query parameters`() { + assertThrows(IllegalArgumentException::class.java) { + DeskHubUrl.normalize("https://user@example.de?token=secret") + } + } +} diff --git a/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt b/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt new file mode 100644 index 00000000..4c8bfbaa --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/data/remote/dto/ApiDtoParsingTest.kt @@ -0,0 +1,84 @@ +package de.deskhub.staff.data.remote.dto + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Prüft, dass typische Server-Antworten (Format der PHP-API) korrekt gelesen werden. */ +class ApiDtoParsingTest { + private val gson = Gson() + + @Test + fun `metadata contains health issues and permissions`() { + val json = """{"staff":{"id":3,"name":"Max","email":"m@x.de","role":"agent","permissions":{"view_reports":true,"reply_ticket":false}}, + "departments":[{"id":1,"name":"Technik"}],"priorities":[],"agents":[],"statuses":[],"tags":[],"liveChatEnabled":true, + "health":[{"level":"error","code":"mailbox_down","message":"Postfach nicht erreichbar"}]}""" + val m = gson.fromJson(json, MetadataResponse::class.java) + assertEquals("Max", m.staff.name) + assertTrue(m.staff.permissions["view_reports"] == true) + assertFalse(m.staff.permissions["reply_ticket"] == true) + assertEquals(1, m.health.size) + assertEquals("mailbox_down", m.health[0].code) + assertEquals("error", m.health[0].level) + } + + @Test + fun `old server without health or permissions still parses`() { + val m = gson.fromJson("""{"staff":{"id":1,"name":"A","email":"a@x.de","role":"admin"}}""", MetadataResponse::class.java) + assertTrue(m.health.isEmpty()) + assertTrue(m.staff.permissions.isEmpty()) + val t = gson.fromJson("""{"id":5,"number":"T-1","subject":"S"}""", TicketDetailDto::class.java) + assertNull(t.permissions) + assertTrue(t.threads.isEmpty()) + } + + @Test + fun `ticket detail carries per ticket permissions`() { + val t = gson.fromJson("""{"id":5,"number":"T-1","subject":"S","permissions":{"reply_ticket":true,"close_ticket":false}}""", TicketDetailDto::class.java) + assertEquals(true, t.permissions?.get("reply_ticket")) + assertEquals(false, t.permissions?.get("close_ticket")) + } + + @Test + fun `activity response reports health`() { + val a = gson.fromJson("""{"newReplies":2,"newUnassigned":1,"serverTime":"2026-10-15 10:00:00","health":[{"level":"warning","code":"fetch_stale","message":"Abruf veraltet"}]}""", ActivityResponseDto::class.java) + assertEquals(2, a.newReplies) + assertEquals("fetch_stale", a.health.single().code) + } + + @Test + fun `saved views parse filters`() { + val v = gson.fromJson("""{"items":[{"id":1,"name":"Überfällige","isShared":true,"mine":false,"ownerName":"Eva","filters":{"status":"open","sla":"overdue"}}]}""", SavedViewListResponse::class.java) + val view = v.items.single() + assertEquals("Überfällige", view.name) + assertTrue(view.isShared) + assertFalse(view.mine) + assertEquals("overdue", view.filters["sla"]) + } + + @Test + fun `sla plans parse`() { + val p = gson.fromJson("""{"items":[{"id":2,"name":"Premium","responseHours":2,"resolutionHours":8,"isActive":true,"departmentCount":1,"helpTopicCount":3}]}""", SlaPlanListResponse::class.java) + assertEquals(8, p.items.single().resolutionHours) + assertEquals(3, p.items.single().helpTopicCount) + } + + @Test + fun `report response parses rows and totals`() { + val r = gson.fromJson("""{"config":{"dimension":"department"},"columns":[{"key":"label","label":"Abteilung"},{"key":"total","label":"Tickets"}], + "rows":[{"label":"Technik","total":42}],"totals":{"total":42},"dimensions":[],"metrics":[],"saved":[{"id":1,"name":"Monat","isShared":true,"mine":true}]}""", ReportResponseDto::class.java) + assertEquals(2, r.columns.size) + assertEquals("Technik", r.rows.single()["label"]) + assertEquals("Monat", r.saved.single().name) + } + + @Test + fun `error envelope falls back to defaults`() { + val e = gson.fromJson("""{"error":{"code":"FORBIDDEN","message":"Keine Berechtigung."}}""", ErrorEnvelope::class.java) + assertEquals("FORBIDDEN", e.error.code) + assertEquals("UNKNOWN", gson.fromJson("""{"error":{}}""", ErrorEnvelope::class.java).error.code) + } +} diff --git a/mobile/src/test/java/de/deskhub/staff/ui/ComposeUiTest.kt b/mobile/src/test/java/de/deskhub/staff/ui/ComposeUiTest.kt new file mode 100644 index 00000000..04cc011a --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/ui/ComposeUiTest.kt @@ -0,0 +1,88 @@ +package de.deskhub.staff.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import de.deskhub.staff.domain.model.HealthIssue +import de.deskhub.staff.domain.model.Metadata +import de.deskhub.staff.domain.model.Staff +import de.deskhub.staff.domain.model.StatusOption +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Compose-Oberflächentests auf der JVM (Robolectric) – kein Emulator nötig. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ComposeUiTest { + @get:Rule + val rule = createComposeRule() + + @Test + fun offlineBannerAppearsAndRetryWorks() { + var retried = 0 + rule.setContent { + MaterialTheme { StatusBanners(DeskHubState(offline = true), onRetry = { retried++ }) } + } + rule.onNodeWithText("Keine Verbindung zum Server", substring = true).assertIsDisplayed() + rule.onNodeWithText("Erneut").performClick() + assertEquals(1, retried) + } + + @Test + fun noBannerWhenOnlineAndHealthy() { + rule.setContent { MaterialTheme { StatusBanners(DeskHubState(offline = false), onRetry = {}) } } + rule.onNodeWithText("Keine Verbindung zum Server", substring = true).assertDoesNotExist() + rule.onNodeWithText("Störung", substring = true).assertDoesNotExist() + } + + @Test + fun serverHealthIssuesAreShownAsFaultOrHint() { + rule.setContent { + MaterialTheme { + StatusBanners( + DeskHubState(healthIssues = listOf(HealthIssue("error", "Postfach nicht erreichbar"), HealthIssue("warning", "Abruf veraltet"))), + onRetry = {}, + ) + } + } + rule.onNodeWithText("Störung: Postfach nicht erreichbar").assertIsDisplayed() + rule.onNodeWithText("Hinweis: Abruf veraltet").assertIsDisplayed() + } + + @Test + fun statusPillShowsLabelFromMetadata() { + val staff = Staff(id = 1, name = "Max", email = "m@x.de", role = "agent", permissions = emptyMap()) + val meta = Metadata(staff, emptyList(), emptyList(), emptyList(), listOf(StatusOption("open", "Offen", "#3b82f6"))) + rule.setContent { + MaterialTheme { + StatusPill("open", meta) + StatusPill("custom_state", meta) + } + } + rule.onNodeWithText("Offen").assertIsDisplayed() + rule.onNodeWithText("custom_state").assertIsDisplayed() // unbekannter Status: Rohwert statt Absturz + } + + @Test + fun themeMenuOffersAllModesAndReportsSelection() { + var chosen: ThemeMode? = null + rule.setContent { + MaterialTheme { + CompositionLocalProvider(LocalThemeController provides ThemeController(ThemeMode.System) { chosen = it }) { ThemeMenuButton() } + } + } + rule.onNodeWithContentDescription("Design wechseln").performClick() + listOf("Wie System", "Hell", "Dunkel").forEach { rule.onNodeWithText(it).assertIsDisplayed() } + rule.onNodeWithText("Dunkel").performClick() + assertTrue(chosen == ThemeMode.Dark) + } +} diff --git a/mobile/src/test/java/de/deskhub/staff/ui/PermissionsTest.kt b/mobile/src/test/java/de/deskhub/staff/ui/PermissionsTest.kt new file mode 100644 index 00000000..a52da301 --- /dev/null +++ b/mobile/src/test/java/de/deskhub/staff/ui/PermissionsTest.kt @@ -0,0 +1,27 @@ +package de.deskhub.staff.ui + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PermissionsTest { + @Test + fun `ticket permission overrides global permission`() { + // Globales Recht vorhanden, aber in der Abteilung dieses Tickets nicht erlaubt + assertFalse(ticketPermission(mapOf("reply_ticket" to false), mapOf("reply_ticket" to true), "reply_ticket")) + // Globales Recht fehlt, aber die Abteilungsrolle erlaubt es + assertTrue(ticketPermission(mapOf("close_ticket" to true), mapOf("close_ticket" to false), "close_ticket")) + } + + @Test + fun `falls back to global permissions for older servers`() { + assertTrue(ticketPermission(null, mapOf("post_note" to true), "post_note")) + assertFalse(ticketPermission(null, mapOf("post_note" to false), "post_note")) + } + + @Test + fun `unknown permission is denied`() { + assertFalse(ticketPermission(emptyMap(), emptyMap(), "assign_ticket")) + assertFalse(ticketPermission(null, null, "assign_ticket")) + } +}