Upload via GUI (7592 Dateien)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.DeskHub">
|
||||
<meta-data
|
||||
android:name="de.deskhub.staff.creator"
|
||||
android:value="M_Viper" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Any?>("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<CannedResponse> =
|
||||
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<Customer> =
|
||||
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<BanEntry> = 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<Int, Int> {
|
||||
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<String, String> {
|
||||
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<de.deskhub.staff.domain.model.SavedView> = 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<de.deskhub.staff.domain.model.SlaPlan> =
|
||||
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<PriorityFull> = 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<Organization> = 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>): 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<String, Boolean>): 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<BusinessHour>) =
|
||||
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<EmailTemplate> = 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<ChatContact> = 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<LiveChatSummary> =
|
||||
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<TicketRuleCondition>,
|
||||
actions: List<TicketRuleAction>,
|
||||
): 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,
|
||||
)
|
||||
@@ -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<Boolean> = mutableOffline
|
||||
|
||||
fun set(isOffline: Boolean) {
|
||||
mutableOffline.value = isOffline
|
||||
}
|
||||
}
|
||||
@@ -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<String, String>): 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<String, Any?>): 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<String, String>(), 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<String, String>): 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<String, String>): 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<BusinessHourDto>) {
|
||||
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 <T> execute(
|
||||
path: String,
|
||||
method: String,
|
||||
payload: Any?,
|
||||
responseType: Class<T>,
|
||||
authenticated: Boolean = true,
|
||||
query: Map<String, String> = 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String, Boolean>,
|
||||
)
|
||||
|
||||
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<ThreadAttachment>,
|
||||
val comments: List<ThreadComment>,
|
||||
)
|
||||
|
||||
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<TicketThread>,
|
||||
// Rechte für dieses Ticket (gelten pro Abteilung); null = ältere Serverversion -> globale Rechte
|
||||
val permissions: Map<String, Boolean>? = null,
|
||||
)
|
||||
|
||||
data class SpamActionResult(
|
||||
val ticket: TicketSummary,
|
||||
val moved: Int,
|
||||
val failed: List<String>,
|
||||
)
|
||||
|
||||
data class Metadata(
|
||||
val staff: Staff,
|
||||
val departments: List<NamedOption>,
|
||||
val priorities: List<Priority>,
|
||||
val agents: List<NamedOption>,
|
||||
val statuses: List<StatusOption>,
|
||||
val tags: List<NamedOption> = emptyList(),
|
||||
val liveChatEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
data class TicketPage(
|
||||
val items: List<TicketSummary>,
|
||||
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<TicketSummary> = emptyList(),
|
||||
val departments: List<DepartmentStat>?,
|
||||
)
|
||||
|
||||
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<TicketSummary>,
|
||||
)
|
||||
|
||||
/** 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<HealthIssue> = emptyList(),
|
||||
)
|
||||
|
||||
data class TicketFilters(
|
||||
val query: String = "",
|
||||
val statuses: Set<String> = 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<KbCategory>,
|
||||
val articles: List<KbArticle>,
|
||||
)
|
||||
|
||||
// ─── 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<NamedOption>,
|
||||
val entries: List<TimeEntry>,
|
||||
val totalsByStaff: List<TimeEntryTotal>,
|
||||
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<NamedOption>,
|
||||
val staffOptions: List<NamedOption>,
|
||||
val departments: List<NamedOption>,
|
||||
val rules: List<EscalationRule>,
|
||||
)
|
||||
|
||||
// ─── 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<DepartmentMember> = 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<Department>,
|
||||
val emailOptions: List<DepartmentEmailOption>,
|
||||
val staffOptions: List<NamedOption>,
|
||||
val slaPlans: List<NamedOption> = 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<NamedOption>,
|
||||
val priorities: List<NamedOption>,
|
||||
val slaPlans: List<NamedOption> = emptyList(),
|
||||
val topics: List<HelpTopic>,
|
||||
)
|
||||
|
||||
// ─── 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<Int>,
|
||||
)
|
||||
|
||||
data class TeamData(
|
||||
val staffOptions: List<NamedOption>,
|
||||
val teams: List<Team>,
|
||||
)
|
||||
|
||||
// ─── Admin: Rollen ───────────────────────────────────────────────────────────
|
||||
|
||||
data class Role(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val permissions: Map<String, Boolean>,
|
||||
val staffCount: Int,
|
||||
)
|
||||
|
||||
data class RoleData(
|
||||
val permissionKeys: List<String>,
|
||||
val defaultAgentPermissions: Map<String, Boolean>,
|
||||
val roles: List<Role>,
|
||||
)
|
||||
|
||||
// ─── 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<StaffExtraDept>,
|
||||
val teamIds: List<Int> = emptyList(),
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
data class StaffAdminData(
|
||||
val departments: List<NamedOption>,
|
||||
val roles: List<NamedOption>,
|
||||
val staff: List<StaffAdmin>,
|
||||
val teams: List<NamedOption> = 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<StaffExtraDept>,
|
||||
val teamIds: List<Int>?,
|
||||
)
|
||||
|
||||
// ─── 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<BusinessHour>,
|
||||
val holidays: List<BusinessHoliday>,
|
||||
)
|
||||
|
||||
// ─── 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<NamedOption>,
|
||||
val priorities: List<NamedOption>,
|
||||
val helpTopics: List<NamedOption> = emptyList(),
|
||||
val addresses: List<EmailAddress>,
|
||||
)
|
||||
|
||||
// ─── Admin: E-Mail-Vorlagen ──────────────────────────────────────────────────
|
||||
|
||||
data class EmailTemplate(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val placeholders: List<String>,
|
||||
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<AuditLogEntry>,
|
||||
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<NamedOption>,
|
||||
val tasks: List<StaffTask>,
|
||||
)
|
||||
|
||||
// ─── 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<ChatMessage>,
|
||||
)
|
||||
|
||||
// ─── 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<LiveChatAttachment>,
|
||||
)
|
||||
|
||||
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<LiveChatMessage>,
|
||||
)
|
||||
|
||||
// ─── 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<TeamMember>,
|
||||
)
|
||||
|
||||
// ─── 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<TicketRuleCondition>,
|
||||
val actions: List<TicketRuleAction>,
|
||||
)
|
||||
|
||||
data class TicketRulesData(
|
||||
val fieldOptions: List<KeyLabel>,
|
||||
val operatorOptions: List<KeyLabel>,
|
||||
val actionOptions: List<KeyLabel>,
|
||||
val departments: List<NamedOption>,
|
||||
val priorities: List<NamedOption>,
|
||||
val helpTopics: List<NamedOption>,
|
||||
val staffOptions: List<NamedOption>,
|
||||
val cannedResponses: List<NamedOption>,
|
||||
val rules: List<TicketRule>,
|
||||
val autoCloseEnabled: Boolean,
|
||||
val autoCloseDays: Int,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<String, Boolean>?,
|
||||
staffPermissions: Map<String, Boolean>?,
|
||||
permission: String,
|
||||
): Boolean = ticketPermissions?.get(permission) ?: (staffPermissions?.get(permission) == true)
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Base: deep ink -->
|
||||
<path
|
||||
android:fillColor="#16202A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<!-- Soft teal glow, upper-left -->
|
||||
<path
|
||||
android:fillColor="#0F6F6A"
|
||||
android:fillAlpha="0.65"
|
||||
android:pathData="M-24,-20 A72,72 0 1,0 120,-20 A72,72 0 1,0 -24,-20 Z" />
|
||||
<!-- Warm accent glow, lower-right -->
|
||||
<path
|
||||
android:fillColor="#D97706"
|
||||
android:fillAlpha="0.20"
|
||||
android:pathData="M62,58 A46,46 0 1,0 154,58 A46,46 0 1,0 62,58 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Headset band -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="7"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M28,55 A26,26 0 0 1 80,55" />
|
||||
<!-- Left ear cup -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M27,50 L28,50 A5,5 0 0 1 33,55 L33,65 A5,5 0 0 1 28,70 L27,70 A5,5 0 0 1 22,65 L22,55 A5,5 0 0 1 27,50 Z" />
|
||||
<!-- Right ear cup -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M80,50 L81,50 A5,5 0 0 1 86,55 L86,65 A5,5 0 0 1 81,70 L80,70 A5,5 0 0 1 75,65 L75,55 A5,5 0 0 1 80,50 Z" />
|
||||
<!-- Mic boom -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="4.5"
|
||||
android:strokeLineCap="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M81,66 Q70,74 63,80" />
|
||||
<!-- Mic tip (accent) -->
|
||||
<path
|
||||
android:fillColor="#D97706"
|
||||
android:pathData="M59.5,80 A3.5,3.5 0 1,0 66.5,80 A3.5,3.5 0 1,0 59.5,80 Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="Theme.DeskHub" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:statusBarColor">#0A1119</item>
|
||||
<item name="android:navigationBarColor">#0A1119</item>
|
||||
<item name="android:windowBackground">#0D141D</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">deskhub</string>
|
||||
<string name="server_label">deskhub-Server</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="Theme.DeskHub" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:statusBarColor">#28405A</item>
|
||||
<item name="android:navigationBarColor">#28405A</item>
|
||||
<item name="android:windowBackground">#EEF1F5</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user