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