Upload via GUI (7592 Dateien)
This commit is contained in:
@@ -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