Upload via GUI (2911 Dateien)

This commit is contained in:
2026-11-05 21:31:44 +00:00
parent e5bc238c58
commit 82287f3778
8 changed files with 182 additions and 38 deletions
File diff suppressed because one or more lines are too long
@@ -64,6 +64,7 @@ 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.PermissionGroup
import de.deskhub.staff.domain.model.BusinessHoliday
import de.deskhub.staff.domain.model.BusinessHour
import de.deskhub.staff.domain.model.BusinessHoursData
@@ -257,6 +258,20 @@ class TicketRepository(
suspend fun setFollowing(number: String, following: Boolean): TicketSummary =
requireApi().updateTicket(linkedMapOf("number" to number, "following" to following)).ticket.toDomain()
/** E-Mail-Abruf von Hand: Ergebnis (ohne Probleme?, fertiger Text). */
suspend fun fetchMailNow(): Pair<Boolean, String> {
val r = requireApi().mailFetch()
return Pair(r.ok || r.level != "error", r.message)
}
/** Anhänge, Bilder und Formatierung der Original-E-Mail nachladen: fertiger Text. */
suspend fun reloadMailAttachments(number: String): String {
val r = requireApi().ticketMailReload(number)
val n = r.added + r.inlineImages
val base = if (n > 0 || r.formatting) "Anhänge und Bilder aus der E-Mail nachgeladen." else "Es gab nichts Neues zu laden."
return if (r.failed.isEmpty()) base else base + " " + r.failed.joinToString(" | ")
}
suspend fun setTicketSpam(number: String, restore: Boolean): SpamActionResult {
val response = requireApi().ticketSpam(number, if (restore) "restore" else "mark")
return SpamActionResult(
@@ -588,6 +603,8 @@ class TicketRepository(
permissionKeys = r.permissionKeys,
defaultAgentPermissions = r.defaultAgentPermissions,
roles = r.items.map(RoleDto::toDomain),
permissionLabels = r.permissionLabels,
permissionGroups = r.permissionGroups.map { PermissionGroup(it.title, it.keys) },
)
}
@@ -832,7 +849,7 @@ private fun PriorityFullDto.toDomain() = PriorityFull(id, name, color, responseH
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 RoleDto.toDomain() = Role(id, name, permissions, staffCount, effectivePermissions)
private fun StaffAdminDto.toDomain() = StaffAdmin(
id, name, email, role, roleId, departmentId, departmentName, isActive, signature,
extraDepartments.map { StaffExtraDept(it.departmentId, it.notify, it.roleId) },
@@ -99,6 +99,9 @@ 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.MailFetchResponse
import de.deskhub.staff.data.remote.dto.MailReloadRequest
import de.deskhub.staff.data.remote.dto.MailReloadResponse
import de.deskhub.staff.data.remote.dto.TicketSummaryResponse
import de.deskhub.staff.data.remote.dto.TimeEntriesResponse
import kotlinx.coroutines.Dispatchers
@@ -196,6 +199,12 @@ class DeskHubApi(
suspend fun addMessage(request: MessageRequest): MessageResponse =
execute("ticket_message.php", "POST", request, MessageResponse::class.java)
suspend fun mailFetch(): MailFetchResponse =
execute("mail_fetch.php", "POST", emptyMap<String, String>(), MailFetchResponse::class.java)
suspend fun ticketMailReload(number: String): MailReloadResponse =
execute("ticket_mail_reload.php", "POST", MailReloadRequest(number = number), MailReloadResponse::class.java)
suspend fun ticketSpam(number: String, action: String): TicketSpamResponse =
execute("ticket_spam.php", "POST", TicketSpamRequest(number = number, action = action), TicketSpamResponse::class.java)
@@ -200,6 +200,31 @@ data class CustomFieldValueDto(
val value: String = "",
)
/** E-Mail-Abruf von Hand (Antwort der Schnittstelle mail_fetch.php). */
data class MailFetchResponse(
val ok: Boolean = false,
val level: String = "info",
val message: String = "",
val processed: Int = 0,
val newTickets: Int = 0,
val replies: Int = 0,
val available: Boolean = false,
)
/** Anhänge, eingebettete Bilder und Formatierung der Original-E-Mail nachladen (ticket_mail_reload.php). */
data class MailReloadRequest(
val number: String,
)
data class MailReloadResponse(
val added: Int = 0,
val inlineImages: Int = 0,
val formatting: Boolean = false,
val skipped: Int = 0,
val alreadyThere: Int = 0,
val failed: List<String> = emptyList(),
)
data class TicketSpamRequest(
val number: String,
val action: String,
@@ -817,11 +842,20 @@ data class RoleDto(
val id: Int = 0,
val name: String = "",
val permissions: Map<String, Boolean> = emptyMap(),
/** Wirksame Rechte (fehlende Rechte der Rolle mit Standardwert aufgefüllt); ältere Server liefern das nicht. */
val effectivePermissions: Map<String, Boolean>? = null,
val staffCount: Int = 0,
)
data class PermissionGroupDto(
val title: String = "",
val keys: List<String> = emptyList(),
)
data class RoleListResponse(
val permissionKeys: List<String> = emptyList(),
val permissionLabels: Map<String, String> = emptyMap(),
val permissionGroups: List<PermissionGroupDto> = emptyList(),
val defaultAgentPermissions: Map<String, Boolean> = emptyMap(),
val items: List<RoleDto> = emptyList(),
)
@@ -1215,11 +1249,11 @@ data class AutoCloseSaveRequest(val action: String = "save_auto_close", val enab
// ─── Bild-Upload (z.B. für Signaturen) ───────────────────────────────────────
data class UploadImageResponse(val url: String = "")
data class AppUpdateDto(
val available: Boolean = false,
val version: String = "",
val sha256: String = "",
val size: Long = 0,
val notes: String = "",
)
data class AppUpdateDto(
val available: Boolean = false,
val version: String = "",
val sha256: String = "",
val size: Long = 0,
val notes: String = "",
)
@@ -456,12 +456,17 @@ data class Role(
val name: String,
val permissions: Map<String, Boolean>,
val staffCount: Int,
val effectivePermissions: Map<String, Boolean>? = null,
)
data class PermissionGroup(val title: String, val keys: List<String>)
data class RoleData(
val permissionKeys: List<String>,
val defaultAgentPermissions: Map<String, Boolean>,
val roles: List<Role>,
val permissionLabels: Map<String, String> = emptyMap(),
val permissionGroups: List<PermissionGroup> = emptyList(),
)
// ─── Admin: Mitarbeiter ──────────────────────────────────────────────────────
@@ -289,6 +289,7 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
navController.navigate("detail")
},
onLogout = viewModel::logout,
onFetchMail = viewModel::fetchMailNow,
onNewTicket = {
viewModel.startNewTicket()
navController.navigate("newTicket")
@@ -394,6 +395,7 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
onUpdate = viewModel::updateTicket,
onMessage = viewModel::addMessage,
onSpamAction = viewModel::setTicketSpam,
onReloadMailAttachments = viewModel::reloadMailAttachments,
onSnooze = viewModel::snoozeTicket,
onFollow = viewModel::setFollowing,
onAddCc = viewModel::addCollaborator,
@@ -452,7 +454,7 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
onBack = { navController.popBackStack() },
onLoad = viewModel::loadAvailability,
onSetStatus = viewModel::setMyAvailability,
isAdmin = state.staff?.role == "admin",
isAdmin = state.can("manage_availability"),
onSetMemberStatus = viewModel::setMemberAvailability,
)
}
@@ -908,6 +910,7 @@ private fun TicketListScreen(
onLoadViews: () -> Unit,
onTicket: (String) -> Unit,
onLogout: () -> Unit,
onFetchMail: () -> Unit,
onNewTicket: () -> Unit,
) {
LaunchedEffect(Unit) { onLoadViews() }
@@ -938,6 +941,7 @@ private fun TicketListScreen(
Text("Tickets", style = MaterialTheme.typography.headlineMedium)
Text("${state.ticketsTotal} insgesamt", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (state.can("fetch_mail")) androidx.compose.material3.TextButton(onClick = onFetchMail, enabled = !state.mutationLoading) { Text("📥 E-Mails abrufen") }
IconButton(onClick = onRefresh) { Icon(Icons.Filled.Refresh, contentDescription = "Aktualisieren") }
}
TicketFilterBar(
@@ -951,6 +955,7 @@ private fun TicketListScreen(
onDeleteView = onDeleteView,
)
state.mutationError?.let { ErrorText(it, Modifier.padding(horizontal = 16.dp)) }
state.mutationInfo?.let { Text(it, modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
when {
state.ticketsLoading && state.tickets.isEmpty() -> LoadingState("Tickets werden geladen …")
state.ticketsError != null && state.tickets.isEmpty() -> ErrorState(state.ticketsError, onRefresh)
@@ -1059,7 +1064,7 @@ private fun TicketFilterBar(
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Speichert die aktuellen Filter.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
OutlinedTextField(value = viewName, onValueChange = { viewName = it }, label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
Row(verticalAlignment = Alignment.CenterVertically) {
if (metadata?.staff?.permissions?.get("share_views") == true) Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = viewShared, onCheckedChange = { viewShared = it })
Text("Für alle Mitarbeiter sichtbar")
}
@@ -1286,7 +1291,7 @@ private fun TicketFilterBar(
FilterChip(
selected = filters.snoozed,
onClick = { onFiltersChanged(filters.copy(snoozed = !filters.snoozed)); onSearch() },
label = { Text("💤 Wiedervorlage") },
label = { Text("Wiedervorlage") },
shape = CircleShape,
)
}
@@ -4110,13 +4115,41 @@ private val AdminSectionPermissionLabels = linkedMapOf(
"manage_business_hours" to "Geschäftszeiten verwalten",
)
private val RolePermissionLabels = TicketPermissionLabels + AdminSectionPermissionLabels
/** Rechte, die mit Server 1.0.85 dazugekommen sind (Beschriftungen für ältere Server ohne „permissionLabels“). */
private val NewPermissionLabels = linkedMapOf(
"merge_split_ticket" to "Tickets zusammenführen und teilen",
"link_tickets" to "Tickets verknüpfen",
"view_customers" to "Kundenprofile ansehen",
"manage_customers" to "Kundenverzeichnis verwalten",
"manage_banlist" to "Sperrliste und Absender sperren",
"schedule_reports" to "Berichte per E-Mail versenden",
"export_tickets" to "Tickets als CSV exportieren",
"fetch_mail" to "E-Mails jetzt abrufen",
"view_mail_queue" to "Mail-Warteschlange verwalten",
"manage_ticket_templates" to "Ticket-Vorlagen verwalten",
"manage_site_pages" to "Seiten im Kundenportal verwalten",
"manage_languages" to "Sprachen verwalten",
"manage_status_page" to "Statusseite verwalten",
"share_views" to "Ansichten mit dem Team teilen",
"manage_custom_fields" to "Ticket-Felder und Listen verwalten",
"manage_automation" to "Ticket-Regeln und wiederkehrende Tickets verwalten",
"manage_problems" to "Problemmanagement",
"manage_availability" to "Status und Schichten der Kollegen ändern",
"view_login_guard" to "Anmelde-Wächter ansehen",
"view_help" to "Hilfe und Handbuch"
)
private val RolePermissionLabels = TicketPermissionLabels + AdminSectionPermissionLabels + NewPermissionLabels
/** Hat der angemeldete Mitarbeiter dieses Recht? Echte Administratoren haben alle. */
private fun DeskHubState.can(permission: String): Boolean = staff?.role == "admin" || staff?.permissions?.get(permission) == true
/** Ist true für echte Admins UND für Agenten, denen eine der obigen Berechtigungen zugewiesen wurde. */
private fun Staff?.hasAnyAdminAccess(): Boolean {
if (this == null) return false
if (role == "admin") return true
return AdminSectionPermissionLabels.keys.any { permissions[it] == true } || permissions["view_reports"] == true
return AdminSectionPermissionLabels.keys.any { permissions[it] == true } || permissions["view_reports"] == true ||
permissions["manage_automation"] == true || permissions["manage_banlist"] == true || permissions["manage_availability"] == true
}
@Composable
@@ -4149,31 +4182,26 @@ private fun AdminRolesScreen(
showForm -> {
val role = data.roles.firstOrNull { it.id == editingId }
var name by rememberSaveable(role?.id) { mutableStateOf(role?.name ?: "") }
var perms by rememberSaveable(role?.id) { mutableStateOf(role?.permissions ?: emptyMap()) }
var perms by rememberSaveable(role?.id) { mutableStateOf(role?.effectivePermissions ?: role?.permissions ?: data.defaultAgentPermissions) }
val permLabels = RolePermissionLabels + data.permissionLabels
val permGroups: List<Pair<String, List<String>>> = if (data.permissionGroups.isNotEmpty()) data.permissionGroups.map { it.title to it.keys }
else listOf("Ticket-Berechtigungen" to TicketPermissionLabels.keys.toList(), "Adminportal-Bereiche (\"Co-Admin\")" to AdminSectionPermissionLabels.keys.toList())
Column(
modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(if (role == null) "Neue Rolle" else "Rolle bearbeiten", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text("Name") }, singleLine = true, shape = MaterialTheme.shapes.medium)
Text("Ticket-Berechtigungen", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
TicketPermissionLabels.forEach { (key, label) ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = perms[key] == true,
onCheckedChange = { checked -> perms = perms + (key to checked) },
)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}
Text("Adminportal-Bereiche (\"Co-Admin\")", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
AdminSectionPermissionLabels.forEach { (key, label) ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = perms[key] == true,
onCheckedChange = { checked -> perms = perms + (key to checked) },
)
Text(label, style = MaterialTheme.typography.bodyMedium)
permGroups.forEach { (groupTitle, keys) ->
Text(groupTitle, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
keys.forEach { key ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = perms[key] == true,
onCheckedChange = { checked -> perms = perms + (key to checked) },
)
Text(permLabels[key] ?: key, style = MaterialTheme.typography.bodyMedium)
}
}
}
state.rolesMutationError?.let { ErrorText(it) }
@@ -4201,7 +4229,7 @@ private fun RoleRowAdmin(role: Role, onEdit: () -> Unit, onDelete: () -> Unit) {
Column(Modifier.padding(14.dp)) {
Text(role.name, fontWeight = FontWeight.SemiBold)
Text("${role.staffCount} Mitarbeiter", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
val granted = role.permissions.filterValues { it }.keys.mapNotNull { RolePermissionLabels[it] }
val granted = (role.effectivePermissions ?: role.permissions).filterValues { it }.keys.mapNotNull { RolePermissionLabels[it] }
Text(
if (granted.isEmpty()) "Keine Berechtigungen" else granted.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
@@ -5368,6 +5396,7 @@ private fun TicketDetailScreen(
onUpdate: (String, Int?, String?) -> Unit,
onMessage: (String, String, String?, String?, String?, () -> Unit) -> Unit,
onSpamAction: (Boolean) -> Unit,
onReloadMailAttachments: () -> Unit,
onSnooze: (String?) -> Unit = {},
onFollow: (Boolean) -> Unit = {},
onAddCc: (String) -> Unit = {},
@@ -5402,6 +5431,8 @@ private fun TicketDetailScreen(
onUpdate = onUpdate,
onMessage = onMessage,
onSpamAction = onSpamAction,
onReloadMailAttachments = onReloadMailAttachments,
mutationInfo = state.mutationInfo,
onSnooze = onSnooze,
onFollow = onFollow,
onAddCc = onAddCc,
@@ -5421,9 +5452,11 @@ private fun TicketDetailContent(
cannedResponses: List<de.deskhub.staff.domain.model.CannedResponse>,
mutationLoading: Boolean,
mutationError: String?,
mutationInfo: String?,
onUpdate: (String, Int?, String?) -> Unit,
onMessage: (String, String, String?, String?, String?, () -> Unit) -> Unit,
onSpamAction: (Boolean) -> Unit,
onReloadMailAttachments: () -> Unit = {},
onSnooze: (String?) -> Unit = {},
onFollow: (Boolean) -> Unit = {},
onAddCc: (String) -> Unit = {},
@@ -5574,6 +5607,14 @@ private fun TicketDetailContent(
}
}
if (ticket.source == "email") {
item {
OutlinedButton(
onClick = onReloadMailAttachments,
enabled = !mutationLoading,
modifier = Modifier.fillMaxWidth(),
) { Text("📎 Anhänge und Bilder aus E-Mail laden") }
mutationInfo?.let { Text(it, modifier = Modifier.padding(top = 6.dp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
item {
if (!ticket.summary.isSpam) {
OutlinedButton(
@@ -111,6 +111,7 @@ data class DeskHubState(
val detailError: String? = null,
val mutationLoading: Boolean = false,
val mutationError: String? = null,
val mutationInfo: String? = null,
val dashboard: DashboardStats? = null,
val dashboardLoading: Boolean = false,
val dashboardError: String? = null,
@@ -696,6 +697,43 @@ class DeskHubViewModel(
fun statusClearMessage() = updateStatusPage { it.copy(message = null, error = null) }
/** E-Mail-Abruf von Hand starten (Knopf in der Ticketliste). */
fun fetchMailNow() {
viewModelScope.launch {
mutableState.update { it.copy(mutationLoading = true, mutationError = null, mutationInfo = null) }
runCatching { repository.fetchMailNow() }
.onSuccess { (ok, message) ->
mutableState.update {
if (ok) it.copy(mutationLoading = false, mutationInfo = message) else it.copy(mutationLoading = false, mutationError = message)
}
loadTickets(reset = true)
kotlinx.coroutines.delay(8000)
mutableState.update { it.copy(mutationInfo = null) }
}
.onFailure { error ->
mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) }
}
}
}
/** Lädt Anhänge, eingebettete Bilder und die Formatierung der Original-E-Mail des geöffneten Tickets nach. */
fun reloadMailAttachments() {
val ticket = state.value.selectedTicket ?: return
viewModelScope.launch {
mutableState.update { it.copy(mutationLoading = true, mutationError = null, mutationInfo = null) }
runCatching { repository.reloadMailAttachments(ticket.summary.number) }
.onSuccess { message ->
mutableState.update { it.copy(mutationLoading = false, mutationInfo = message) }
openTicket(ticket.summary.number)
kotlinx.coroutines.delay(8000)
mutableState.update { it.copy(mutationInfo = null) }
}
.onFailure { error ->
mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) }
}
}
}
/** Markiert das aktuell geöffnete Ticket als Spam, oder macht das (restore = true) rückgängig. */
fun setTicketSpam(restore: Boolean) {
val ticket = state.value.selectedTicket ?: return
@@ -111,7 +111,7 @@ fun MoreScreen(staff: Staff?, onNavigate: (String) -> Unit, onLogout: () -> Unit
val personal = listOf(
MoreEntry(Icons.AutoMirrored.Filled.Assignment, "Aufgaben", "To-dos für dich und dein Team", "adminTasks"),
MoreEntry(Icons.Filled.Circle, if (isFullAdmin) "Status der Mitarbeiter" else "Mein Status", "Verfügbarkeit und Team-Übersicht", "adminAvailability"),
MoreEntry(Icons.Filled.Circle, if (allowed("manage_availability")) "Status der Mitarbeiter" else "Mein Status", "Verfügbarkeit und Team-Übersicht", "adminAvailability"),
MoreEntry(Icons.Filled.Notifications, "Benachrichtigungen", "Popups und stille Zeiten", "pushPrefs"),
)
val manage = buildList {
@@ -135,8 +135,8 @@ fun MoreScreen(staff: Staff?, onNavigate: (String) -> Unit, onLogout: () -> Unit
if (allowed("view_time_entries")) add(MoreEntry(Icons.Filled.AccessTime, "Zeiterfassung", "Zeiten je Mitarbeiter", "adminTimeEntries"))
if (allowed("manage_escalations")) add(MoreEntry(Icons.AutoMirrored.Filled.Rule, "Eskalationsregeln", "Regeln für überfällige Tickets", "adminEscalation"))
if (allowed("manage_business_hours")) add(MoreEntry(Icons.Filled.Schedule, "Geschäftszeiten", "SLA-Zeitfenster, Feiertage", "adminBusinessHours"))
if (isFullAdmin) add(MoreEntry(Icons.Filled.Bolt, "Automatisierung", "Ticket-Regeln, Auto-Antworten", "adminTicketRules"))
if (isFullAdmin) add(MoreEntry(Icons.Filled.Block, "Sperrliste", "Absender sperren", "adminBanlist"))
if (allowed("manage_automation")) add(MoreEntry(Icons.Filled.Bolt, "Automatisierung", "Ticket-Regeln, Auto-Antworten", "adminTicketRules"))
if (allowed("manage_banlist")) add(MoreEntry(Icons.Filled.Block, "Sperrliste", "Absender sperren", "adminBanlist"))
add(MoreEntry(Icons.Filled.Flag, "Statusseite", "Dienste und Störungen für Kunden", "adminStatus"))
if (allowed("view_audit_log")) add(MoreEntry(Icons.Filled.History, "Admin-Protokoll", "Änderungen und Mailfehler", "adminAuditLog"))
}