Upload via GUI (80 Dateien)

This commit is contained in:
2026-10-24 20:16:47 +00:00
parent 628efdf457
commit 19bfd99e63
27 changed files with 865 additions and 59 deletions
File diff suppressed because one or more lines are too long
@@ -93,6 +93,7 @@ class DeskHubWidget : AppWidgetProvider() {
}
private fun paint(context: Context, manager: AppWidgetManager, ids: IntArray, s: Snapshot) {
de.deskhub.staff.ui.I18n.init(context)
ids.forEach { manager.updateAppWidget(it, views(context, s, layoutFor(manager.getAppWidgetOptions(it)))) }
}
@@ -139,7 +140,7 @@ class DeskHubWidget : AppWidgetProvider() {
v.setTextViewText(R.id.widget_overdue, s.overdue.toString())
v.setTextViewText(R.id.widget_unanswered, s.unanswered.toString())
v.setInt(R.id.widget_tile_overdue, "setBackgroundResource", if (s.overdue > 0) R.drawable.widget_tile_alert else R.drawable.widget_tile)
v.setTextViewText(R.id.widget_time, "Stand " + SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date(s.at)))
v.setTextViewText(R.id.widget_time, context.getString(R.string.widget_as_of, SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(s.at))))
v.setViewVisibility(R.id.widget_message, View.GONE)
v.setViewVisibility(R.id.widget_tiles, View.VISIBLE)
listOf(R.id.widget_t1, R.id.widget_t2, R.id.widget_t3).forEachIndexed { index, id ->
@@ -153,7 +154,7 @@ class DeskHubWidget : AppWidgetProvider() {
}
}
} else {
v.setTextViewText(R.id.widget_message, s.message ?: "Lade …")
v.setTextViewText(R.id.widget_message, s.message?.let { de.deskhub.staff.ui.I18n.tr(it) } ?: context.getString(R.string.widget_loading))
v.setViewVisibility(R.id.widget_message, View.VISIBLE)
v.setViewVisibility(R.id.widget_tiles, View.INVISIBLE)
v.setTextViewText(R.id.widget_time, "")
@@ -40,6 +40,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
de.deskhub.staff.ui.I18n.init(applicationContext)
handleNotificationIntent(intent)
setContent {
val state by viewModel.state.collectAsState()
@@ -234,6 +234,13 @@ class TicketRepository(
return requireApi().updateTicket(request).ticket.toDomain()
}
suspend fun collaboratorAction(number: String, action: String, email: String? = null, id: Int? = null) =
requireApi().collaboratorAction(number, action, email, id).collaborators
suspend fun ticketTemplates() = requireApi().ticketTemplates().templates
suspend fun csatStats(days: Int) = requireApi().csatStats(days)
suspend fun statusPage() = requireApi().statusPage()
suspend fun statusAction(request: Map<String, Any?>) = requireApi().statusAction(request)
suspend fun assistantOverview() = requireApi().assistantOverview()
suspend fun assistantAnswers() = requireApi().assistantAnswers().items
suspend fun assistantLog(query: Map<String, String>) = requireApi().assistantLog(query)
@@ -798,7 +805,7 @@ class TicketRepository(
}
private fun requireApi(): DeskHubApi =
checkNotNull(api) { "Keine aktive deskhub-Sitzung." }
checkNotNull(api) { "Keine aktive DeskHub-Sitzung." }
}
private fun StaffDto.toDomain() = Staff(id, name, email, role, permissions)
@@ -912,6 +919,9 @@ private fun TicketDetailDto.toDomain() = TicketDetail(
following = following,
),
similar = similar?.let { s -> SimilarTickets(s.articles.map { it.title to it.url }, s.tickets.map { it.number to it.subject }) },
collaborators = collaborators.map { de.deskhub.staff.domain.model.Collaborator(it.id, it.email, it.name) },
followers = followers.map { it.name },
csat = csat?.let { de.deskhub.staff.domain.model.Csat(it.rating, it.comment, it.sentAt, it.answeredAt) },
source = source,
firstResponseDueAt = firstResponseDueAt,
resolutionDueAt = resolutionDueAt,
@@ -521,6 +521,26 @@ class DeskHubApi(
suspend fun availability(): AvailabilityResponse =
execute("availability.php", "GET", null, AvailabilityResponse::class.java)
// ─── Cc-Empfänger, Vorlagen, Zufriedenheit, Statusseite ──────────────────
suspend fun collaboratorAction(number: String, action: String, email: String? = null, id: Int? = null): de.deskhub.staff.data.remote.dto.CollaboratorsResponse =
execute(
"ticket_collaborators.php", "POST", mapOf("number" to number, "action" to action, "email" to email, "id" to id),
de.deskhub.staff.data.remote.dto.CollaboratorsResponse::class.java,
)
suspend fun ticketTemplates(): de.deskhub.staff.data.remote.dto.TicketTemplatesResponse =
execute("ticket_templates.php", "GET", null, de.deskhub.staff.data.remote.dto.TicketTemplatesResponse::class.java)
suspend fun csatStats(days: Int): de.deskhub.staff.data.remote.dto.CsatResponse =
execute("csat.php", "GET", null, de.deskhub.staff.data.remote.dto.CsatResponse::class.java, query = mapOf("days" to days.toString()))
suspend fun statusPage(): de.deskhub.staff.data.remote.dto.StatusResponse =
execute("status.php", "GET", null, de.deskhub.staff.data.remote.dto.StatusResponse::class.java)
suspend fun statusAction(request: Map<String, Any?>): de.deskhub.staff.data.remote.dto.StatusResponse =
execute("status.php", "POST", request, de.deskhub.staff.data.remote.dto.StatusResponse::class.java)
suspend fun unlockTicket(number: String): de.deskhub.staff.data.remote.dto.TicketUnlockResponse =
execute("ticket_unlock.php", "POST", de.deskhub.staff.data.remote.dto.TicketUnlockRequest(number), de.deskhub.staff.data.remote.dto.TicketUnlockResponse::class.java)
@@ -179,6 +179,10 @@ data class TicketDetailDto(
val following: Boolean = false,
// Passende Artikel und ähnliche gelöste Tickets; null bei älteren Servern
val similar: SimilarDto? = null,
// Cc-Empfänger, beobachtende Mitarbeiter und Bewertung des Kunden; leer/null bei älteren Servern
val collaborators: List<CollaboratorDto> = emptyList(),
val followers: List<FollowerDto> = emptyList(),
val csat: CsatDto? = null,
)
data class SimilarArticleDto(val title: String = "", val url: String = "")
@@ -0,0 +1,118 @@
package de.deskhub.staff.data.remote.dto
// ── Cc-Empfänger, Beobachter, Zufriedenheitsabfrage ──────────────────────────
data class CollaboratorDto(
val id: Int = 0,
val email: String = "",
val name: String = "",
)
data class CollaboratorsResponse(val collaborators: List<CollaboratorDto> = emptyList())
data class FollowerDto(
val id: Int = 0,
val name: String = "",
)
/** Bewertung des Kunden zu einem Ticket; rating = null, solange der Kunde nicht geantwortet hat. */
data class CsatDto(
val rating: Int? = null,
val comment: String = "",
val sentAt: String? = null,
val answeredAt: String? = null,
)
data class CsatDistDto(val rating: Int = 0, val count: Int = 0)
data class CsatStaffDto(val id: Int = 0, val name: String = "", val count: Int = 0, val avg: Double = 0.0)
data class CsatRecentDto(
val rating: Int = 0,
val comment: String = "",
val answeredAt: String = "",
val number: String = "",
val subject: String = "",
)
data class CsatStatsDto(
val sent: Int = 0,
val answered: Int = 0,
val avg: Double? = null,
val rate: Double? = null,
val dist: List<CsatDistDto> = emptyList(),
val byStaff: List<CsatStaffDto> = emptyList(),
val recent: List<CsatRecentDto> = emptyList(),
)
data class CsatResponse(
val enabled: Boolean = false,
val days: Int = 30,
val stats: CsatStatsDto = CsatStatsDto(),
)
// ── Ticket-Vorlagen ──────────────────────────────────────────────────────────
data class TicketTemplateDto(
val id: Int = 0,
val name: String = "",
val description: String = "",
val subject: String = "",
val body: String = "",
val departmentId: Int? = null,
val helpTopicId: Int? = null,
val priorityId: Int? = null,
val isPublic: Boolean = true,
)
data class TicketTemplatesResponse(val templates: List<TicketTemplateDto> = emptyList())
// ── Statusseite ──────────────────────────────────────────────────────────────
data class StatusOverallDto(val key: String = "operational", val label: String = "", val color: String = "#157a45")
data class StatusStateDto(val key: String = "", val label: String = "")
data class StatusComponentDto(
val id: Int = 0,
val name: String = "",
val description: String = "",
val status: String = "operational",
val statusLabel: String = "",
)
data class StatusUpdateDto(
val status: String = "",
val statusLabel: String = "",
val body: String = "",
val createdAt: String = "",
)
data class StatusIncidentDto(
val id: Int = 0,
val title: String = "",
val status: String = "",
val statusLabel: String = "",
val component: String? = null,
val componentId: Int? = null,
val createdAt: String = "",
val updatedAt: String = "",
val resolvedAt: String? = null,
val updates: List<StatusUpdateDto> = emptyList(),
)
data class StatusSummaryDto(
val enabled: Boolean = false,
val title: String = "Systemstatus",
val url: String = "",
val overall: StatusOverallDto = StatusOverallDto(),
val componentStates: List<StatusStateDto> = emptyList(),
val incidentStates: List<StatusStateDto> = emptyList(),
val components: List<StatusComponentDto> = emptyList(),
val incidents: List<StatusIncidentDto> = emptyList(),
)
data class StatusResponse(
val status: StatusSummaryDto = StatusSummaryDto(),
val canManage: Boolean = false,
)
@@ -0,0 +1,7 @@
package de.deskhub.staff.domain.model
/** Cc-Empfänger eines Tickets: bekommt die öffentlichen Antworten per E-Mail in Kopie. */
data class Collaborator(val id: Int, val email: String, val name: String)
/** Bewertung des Kunden nach dem Schließen; rating = null, solange keine Antwort da ist. */
data class Csat(val rating: Int?, val comment: String, val sentAt: String?, val answeredAt: String?)
@@ -94,6 +94,10 @@ data class TicketDetail(
val canUnlock: Boolean = false,
/** Passende Wissensdatenbank-Artikel und ähnliche gelöste Tickets (null bei älteren Servern). */
val similar: SimilarTickets? = null,
/** Cc-Empfänger, beobachtende Mitarbeiter (Namen) und die Bewertung des Kunden (null = keine Abfrage). */
val collaborators: List<Collaborator> = emptyList(),
val followers: List<String> = emptyList(),
val csat: Csat? = null,
)
data class SimilarTickets(
@@ -32,7 +32,6 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -72,7 +71,7 @@ fun AdminAssistantScreen(state: AssistantUiState, viewModel: DeskHubViewModel, o
}
}
LaunchedEffect(state.message) {
state.message?.let { snackbar.showSnackbar(it); viewModel.assistantClearMessage() }
state.message?.let { snackbar.showSnackbar(I18n.tr(it)); viewModel.assistantClearMessage() }
}
Scaffold(
@@ -109,7 +109,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.toArgb
import androidx.compose.material.icons.filled.DarkMode
@@ -301,6 +300,7 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
onLoad = {
viewModel.loadDashboard()
viewModel.loadAvailability()
viewModel.loadCsat()
},
onSetStatus = viewModel::setMyAvailability,
onTicket = { number ->
@@ -371,6 +371,7 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
NewTicketScreen(
state = state,
onBack = { navController.popBackStack() },
onLoadTemplates = viewModel::loadTemplates,
onCreate = { requesterName, requesterEmail, subject, message, departmentId, priorityId, assignedStaffId, notify ->
viewModel.createTicket(
requesterName, requesterEmail, subject, message,
@@ -394,6 +395,8 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
onSpamAction = viewModel::setTicketSpam,
onSnooze = viewModel::snoozeTicket,
onFollow = viewModel::setFollowing,
onAddCc = viewModel::addCollaborator,
onRemoveCc = viewModel::removeCollaborator,
onUnlock = { state.selectedTicket?.summary?.number?.let(viewModel::unlockTicket) },
)
}
@@ -452,6 +455,9 @@ private fun StaffNavigation(state: DeskHubState, viewModel: DeskHubViewModel) {
onSetMemberStatus = viewModel::setMemberAvailability,
)
}
composable("adminStatus") {
AdminStatusScreen(state = state.statusPage, viewModel = viewModel, onBack = { navController.popBackStack() })
}
composable("adminAssistant") {
AdminAssistantScreen(state = state.assistant, viewModel = viewModel, onBack = { navController.popBackStack() })
}
@@ -676,6 +682,7 @@ internal fun LoginScreen(state: DeskHubState, viewModel: DeskHubViewModel) {
.background(LocalDeskExtras.current.gradient),
contentAlignment = Alignment.Center,
) {
LanguageFlags(Modifier.align(Alignment.TopEnd).statusBarsPadding().padding(12.dp), onDark = true)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
@@ -1336,6 +1343,7 @@ private fun DashboardScreen(
StatTile("SLA-Quote", stats.slaPercent?.let { "${it.toInt()}%" } ?: "–", Modifier.weight(1f))
}
}
state.csat?.takeIf { it.enabled || it.stats.sent > 0 }?.let { csat -> item { CsatCard(csat) } }
if (stats.recent.isNotEmpty()) {
item { SectionTitle("Priorisiert: mir zugewiesen oder unbearbeitet") }
items(stats.recent, key = TicketSummary::id) { ticket -> TicketCard(ticket, state.metadata, onTicket) }
@@ -1450,7 +1458,7 @@ private fun AdminMenuRow(icon: androidx.compose.ui.graphics.vector.ImageVector,
}
@Composable
private fun AdminTopBar(title: String, onBack: () -> Unit) {
internal fun AdminTopBar(title: String, onBack: () -> Unit) {
TopAppBar(
title = { Text(title, fontWeight = FontWeight.Bold) },
navigationIcon = {
@@ -3402,8 +3410,8 @@ private fun AdminReportsScreen(
fun format(value: Any?, unit: String): String {
val n = (value as? Number)?.toDouble() ?: return "–"
return when (unit) {
"%" -> String.format(java.util.Locale.GERMANY, "%.1f %%", n)
"Std" -> String.format(java.util.Locale.GERMANY, "%.1f Std", n)
"%" -> String.format(I18n.locale(), "%.1f %%", n)
"Std" -> String.format(I18n.locale(), "%.1f ${if (I18n.lang == "en") "hrs" else "Std"}", n)
else -> n.toLong().toString()
}
}
@@ -5124,7 +5132,11 @@ private fun NewTicketScreen(
state: DeskHubState,
onBack: () -> Unit,
onCreate: (String, String, String, String, Int, Int, Int?, Boolean) -> Unit,
onLoadTemplates: () -> Unit = {},
) {
LaunchedEffect(Unit) { onLoadTemplates() }
var templateName by rememberSaveable { mutableStateOf("") }
var templateExpanded by remember { mutableStateOf(false) }
var name by rememberSaveable { mutableStateOf(state.newTicketPrefillName) }
var email by rememberSaveable { mutableStateOf(state.newTicketPrefillEmail) }
var subject by rememberSaveable { mutableStateOf("") }
@@ -5159,6 +5171,31 @@ private fun NewTicketScreen(
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Ticket-Vorlagen füllen Betreff, Text, Abteilung und Priorität vor
if (state.templates.isNotEmpty()) {
SelectionField(
label = "Vorlage",
value = templateName.ifBlank { "– ohne Vorlage –" },
expanded = templateExpanded,
onExpand = { templateExpanded = true },
onDismiss = { templateExpanded = false },
) {
DropdownMenuItem(text = { Text("– ohne Vorlage –") }, onClick = { templateName = ""; templateExpanded = false })
state.templates.forEach { t ->
DropdownMenuItem(
text = { Text(t.name) },
onClick = {
templateName = t.name
subject = t.subject
message = t.body
t.departmentId?.let { id -> if (departments.any { it.id == id }) departmentId = id }
t.priorityId?.let { id -> if (priorities.any { it.id == id }) priorityId = id }
templateExpanded = false
},
)
}
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -5269,6 +5306,8 @@ private fun TicketDetailScreen(
onSpamAction: (Boolean) -> Unit,
onSnooze: (String?) -> Unit = {},
onFollow: (Boolean) -> Unit = {},
onAddCc: (String) -> Unit = {},
onRemoveCc: (Int) -> Unit = {},
onUnlock: () -> Unit = {},
) {
Scaffold(
@@ -5301,6 +5340,8 @@ private fun TicketDetailScreen(
onSpamAction = onSpamAction,
onSnooze = onSnooze,
onFollow = onFollow,
onAddCc = onAddCc,
onRemoveCc = onRemoveCc,
onUnlock = onUnlock,
)
else -> Box(Modifier.padding(padding)) { EmptyState("Kein Ticket ausgewählt.") }
@@ -5321,6 +5362,8 @@ private fun TicketDetailContent(
onSpamAction: (Boolean) -> Unit,
onSnooze: (String?) -> Unit = {},
onFollow: (Boolean) -> Unit = {},
onAddCc: (String) -> Unit = {},
onRemoveCc: (Int) -> Unit = {},
onUnlock: () -> Unit = {},
) {
var selectedStatus by remember(ticket.summary.number) { mutableStateOf(ticket.summary.status) }
@@ -5517,6 +5560,60 @@ private fun TicketDetailContent(
}
}
}
ticket.csat?.let { csat ->
item {
DeskCard {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Kundenzufriedenheit", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (csat.rating != null) {
Text("★".repeat(csat.rating) + "☆".repeat(5 - csat.rating) + " ${csat.rating} von 5", style = MaterialTheme.typography.titleMedium, color = Color(0xFFF5B301))
if (csat.comment.isNotBlank()) Text("„${csat.comment}“", style = MaterialTheme.typography.bodyMedium)
csat.answeredAt?.let { Text("Bewertet am ${it.take(16).replace('T', ' ')}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else {
Text("Abfrage am ${csat.sentAt.orEmpty().take(16).replace('T', ' ')} gesendet, noch keine Antwort.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
item {
var ccInput by rememberSaveable(ticket.summary.number) { mutableStateOf("") }
DeskCard {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Beobachter und Cc", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text("Beobachter", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
Text(
if (ticket.followers.isEmpty()) "Niemand. Beobachter bekommen Benachrichtigungen, ohne dass ihnen das Ticket zugewiesen ist." else ticket.followers.joinToString(", "),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("Cc-Empfänger", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
if (ticket.collaborators.isEmpty()) {
Text("Niemand. Cc-Empfänger bekommen die öffentlichen Antworten per E-Mail in Kopie.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
ticket.collaborators.forEach { c ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(if (c.name.isNotBlank()) "${c.name} <${c.email}>" else c.email, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
if (canReply) IconButton(onClick = { onRemoveCc(c.id) }, enabled = !mutationLoading) { Icon(Icons.Filled.Close, contentDescription = "Cc entfernen") }
}
}
if (canReply) {
OutlinedTextField(
value = ccInput,
onValueChange = { ccInput = it },
label = { Text("Cc-Adresse hinzufügen") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { onAddCc(ccInput); ccInput = "" },
enabled = !mutationLoading && ccInput.contains("@"),
modifier = Modifier.fillMaxWidth(),
) { Text("Cc hinzufügen") }
}
}
}
}
item {
DeskCard {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -5949,7 +6046,7 @@ private fun ticketSourceLabel(source: String): String = when (source) {
}
@Composable
private fun LoadingState(message: String) {
internal fun LoadingState(message: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(strokeWidth = 3.dp)
@@ -5972,7 +6069,7 @@ private fun EmptyState(message: String) {
}
@Composable
private fun ErrorState(message: String, retry: () -> Unit) {
internal fun ErrorState(message: String, retry: () -> Unit) {
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.size(64.dp).clip(CircleShape).background(MaterialTheme.colorScheme.errorContainer), contentAlignment = Alignment.Center) {
@@ -73,6 +73,15 @@ data class AssistantUiState(
val message: String? = null,
)
/** Zustand der Statusseite (Dienste und Störungsmeldungen für Kunden). */
data class StatusUiState(
val data: de.deskhub.staff.data.remote.dto.StatusSummaryDto? = null,
val canManage: Boolean = false,
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
data class DeskHubState(
val initializing: Boolean = true,
val staff: Staff? = null,
@@ -83,6 +92,10 @@ data class DeskHubState(
val savedServerUrl: String? = null,
val metadata: Metadata? = null,
val assistant: AssistantUiState = AssistantUiState(),
/** Ticket-Vorlagen für „Neues Ticket“, Auswertung der Zufriedenheitsabfrage und Statusseite. */
val templates: List<de.deskhub.staff.data.remote.dto.TicketTemplateDto> = emptyList(),
val csat: de.deskhub.staff.data.remote.dto.CsatResponse? = null,
val statusPage: StatusUiState = StatusUiState(),
/** Von außen angeforderter Bildschirm (zum Beispiel aus dem Widget): "newTicket" oder "tickets". */
val requestedRoute: String? = null,
val cannedResponses: List<CannedResponse> = emptyList(),
@@ -579,6 +592,62 @@ class DeskHubViewModel(
}
}
/** Cc-Empfänger zum geöffneten Ticket hinzufügen. */
fun addCollaborator(email: String) = collaboratorChange { number -> repository.collaboratorAction(number, "add", email = email.trim()) }
/** Cc-Empfänger vom geöffneten Ticket entfernen. */
fun removeCollaborator(id: Int) = collaboratorChange { number -> repository.collaboratorAction(number, "remove", id = id) }
private fun collaboratorChange(block: suspend (String) -> Unit) {
val ticket = state.value.selectedTicket ?: return
viewModelScope.launch {
mutableState.update { it.copy(mutationLoading = true, mutationError = null) }
runCatching { block(ticket.summary.number) }
.onSuccess {
mutableState.update { it.copy(mutationLoading = false) }
openTicket(ticket.summary.number)
}
.onFailure { error -> mutableState.update { it.copy(mutationLoading = false, mutationError = error.userMessage()) } }
}
}
/** Ticket-Vorlagen laden (für den Bildschirm „Neues Ticket“). Fehler sind nicht schlimm: dann gibt es keine Auswahl. */
fun loadTemplates() {
viewModelScope.launch {
runCatching { repository.ticketTemplates() }.onSuccess { list -> mutableState.update { it.copy(templates = list) } }
}
}
/** Auswertung der Zufriedenheitsabfrage laden (für die Übersicht). */
fun loadCsat(days: Int = 30) {
viewModelScope.launch {
runCatching { repository.csatStats(days) }.onSuccess { r -> mutableState.update { it.copy(csat = r) } }
}
}
private fun updateStatusPage(block: (StatusUiState) -> StatusUiState) = mutableState.update { it.copy(statusPage = block(it.statusPage)) }
fun statusLoad() {
viewModelScope.launch {
updateStatusPage { it.copy(loading = true, error = null) }
runCatching { repository.statusPage() }
.onSuccess { r -> updateStatusPage { it.copy(loading = false, data = r.status, canManage = r.canManage) } }
.onFailure { e -> updateStatusPage { it.copy(loading = false, error = e.userMessage()) } }
}
}
/** Aktion auf der Statusseite (nur Administratoren), zum Beispiel Zustand eines Dienstes ändern oder eine Meldung veröffentlichen. */
fun statusAct(request: Map<String, Any?>, success: String) {
viewModelScope.launch {
updateStatusPage { it.copy(loading = true, error = null) }
runCatching { repository.statusAction(request) }
.onSuccess { r -> updateStatusPage { it.copy(loading = false, data = r.status, canManage = r.canManage, message = success) } }
.onFailure { e -> updateStatusPage { it.copy(loading = false, error = e.userMessage()) } }
}
}
fun statusClearMessage() = updateStatusPage { it.copy(message = null, error = null) }
/** 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
@@ -49,7 +49,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -138,6 +137,7 @@ fun MoreScreen(staff: Staff?, onNavigate: (String) -> Unit, onLogout: () -> Unit
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"))
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"))
}
@@ -152,6 +152,7 @@ fun MoreScreen(staff: Staff?, onNavigate: (String) -> Unit, onLogout: () -> Unit
Text(staff?.email.orEmpty(), style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.85f))
Text(if (isFullAdmin) "Administrator" else "Mitarbeiter", style = MaterialTheme.typography.labelMedium, color = Color.White.copy(alpha = 0.85f))
}
LanguageFlags(onDark = true)
}
}
}
@@ -15,7 +15,6 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
@@ -0,0 +1,152 @@
package de.deskhub.staff.ui
import android.content.Context
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.Locale
/**
* Sprachen der App: Deutsch (Quelltext) und English.
* Der Quelltext ist deutsch. Bei English übersetzt [tr] jeden Text über das Wörterbuch assets/lang_en.json
* (Schlüssel = deutscher Text, "{}" = beliebiger Teil, "{#}" = Zahl, "{1}"/"{2}" = Reihenfolge in der Übersetzung).
* Was nicht im Wörterbuch steht, bleibt deutsch. Alle Textfelder der App gehen über das übersetzende [Text] (siehe I18nText.kt).
* [lang] ist ein Compose-Zustand: nach dem Umschalten (Flagge) aktualisiert sich die Oberfläche sofort.
*/
object I18n {
private const val PREFS = "deskhub_i18n"
private var ready = false
private var exact: Map<String, String> = emptyMap()
private var patterns: List<Pair<Regex, String>> = emptyList()
private val spaces = Regex("\\s+")
var lang by mutableStateOf("de")
private set
/** Liest die gespeicherte Sprache (sonst die des Telefons) und das Wörterbuch. Mehrfacher Aufruf ist harmlos. */
fun init(context: Context) {
if (ready) return
ready = true
val app = context.applicationContext
val saved = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("lang", null)
lang = if (saved == "de" || saved == "en") saved else if (Locale.getDefault().language == "de") "de" else "en"
runCatching { load(app.assets.open("lang_en.json").bufferedReader(Charsets.UTF_8).use { it.readText() }) }
}
/** Liest das Wörterbuch (JSON-Objekt: deutscher Text -> English). */
internal fun load(json: String) {
run {
val map: Map<String, String> = Gson().fromJson(json, object : TypeToken<Map<String, String>>() {}.type)
val ex = HashMap<String, String>()
val pa = ArrayList<Pair<Regex, String>>()
for ((k, v) in map) {
if (v.isEmpty() || k.startsWith("_")) continue
val key = normalize(k)
if (key.contains("{}") || key.contains("{#}")) {
val sb = StringBuilder("^")
var i = 0
while (i < key.length) {
when {
key.startsWith("{#}", i) -> { sb.append("(\\d+)"); i += 3 }
key.startsWith("{}", i) -> { sb.append("(.+?)"); i += 2 }
else -> { sb.append(Regex.escape(key[i].toString())); i++ }
}
}
sb.append("$")
pa.add(Regex(sb.toString(), RegexOption.DOT_MATCHES_ALL) to v)
} else {
ex[key] = v
}
}
exact = ex
patterns = pa
}
}
internal fun useLanguage(code: String) { lang = code }
fun set(context: Context, code: String) {
if (code != "de" && code != "en") return
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("lang", code).apply()
lang = code
}
private fun normalize(s: String) = s.replace(spaces, " ").trim()
/** Übersetzt einen Text (bei Deutsch unverändert). Leerraum am Rand bleibt erhalten. */
fun tr(s: String): String {
if (lang != "en" || s.isEmpty()) return s
val core = normalize(s)
if (core.isEmpty()) return s
val hit = exact[core] ?: patterns.firstNotNullOfOrNull { (re, v) ->
re.find(core)?.let { m ->
var seq = 0
// Eingefügte Teile werden selbst noch einmal übersetzt (zum Beispiel „in 2 Std 5 Min“ in „Lösung fällig: {}“)
v.replace(Regex("\\{(\\d+)}")) { x -> tr(m.groupValues.getOrNull(x.groupValues[1].toInt()) ?: "") }
.replace("{}") { seq++; tr(m.groupValues.getOrNull(seq) ?: "") }
}
} ?: return s
val lead = s.takeWhile { it.isWhitespace() }
val trail = s.takeLastWhile { it.isWhitespace() }
return lead + hit + trail
}
private fun String.replace(token: String, transform: () -> String): String {
val sb = StringBuilder()
var from = 0
while (true) {
val i = indexOf(token, from)
if (i < 0) break
sb.append(this, from, i).append(transform())
from = i + token.length
}
return sb.append(this, from, length).toString()
}
/** Datumsformat-Sprache für Anzeigen, die die App selbst formatiert. */
fun locale(): Locale = if (lang == "en") Locale.UK else Locale.GERMANY
}
/** Kurzform zum Übersetzen in Kotlin-Code außerhalb von Text (zum Beispiel für Snackbar-Meldungen). */
fun tr(s: String): String = I18n.tr(s)
/** Flaggen zum Umschalten der Sprache (🇩🇪 / 🇬🇧). */
@Composable
fun LanguageFlags(modifier: Modifier = Modifier, onDark: Boolean = false) {
val context = LocalContext.current
val current = I18n.lang
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
for ((code, flag, name) in listOf(Triple("de", "🇩🇪", "Deutsch"), Triple("en", "🇬🇧", "English"))) {
val on = code == current
androidx.compose.material3.Text(
flag,
fontSize = 22.sp,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.border(BorderStroke(if (on) 2.dp else 0.dp, if (onDark) Color.White else Color(0xFF0F6F6A)), RoundedCornerShape(8.dp))
.clickable { I18n.set(context, code) }
.padding(horizontal = 6.dp, vertical = 2.dp)
.semantics { contentDescription = name },
)
}
}
}
@@ -0,0 +1,81 @@
package de.deskhub.staff.ui
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.material3.LocalTextStyle
/**
* Übersetzendes Text-Element: Alle Dateien im Paket de.deskhub.staff.ui rufen dieses Text statt des von Material auf
* (der Import androidx.compose.material3.Text wurde entfernt, damit dieses hier gilt). Es übersetzt den Text über [I18n.tr]
* und reicht ihn sonst unverändert an Material weiter.
*/
@Composable
fun Text(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
style: TextStyle = LocalTextStyle.current,
) {
androidx.compose.material3.Text(
text = I18n.tr(text), modifier = modifier, color = color, fontSize = fontSize, fontStyle = fontStyle, fontWeight = fontWeight,
fontFamily = fontFamily, letterSpacing = letterSpacing, textDecoration = textDecoration, textAlign = textAlign, lineHeight = lineHeight,
overflow = overflow, softWrap = softWrap, maxLines = maxLines, minLines = minLines, onTextLayout = onTextLayout, style = style,
)
}
@Composable
fun Text(
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
inlineContent: Map<String, InlineTextContent> = mapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
// Nur reine Texte ohne Formatierung werden übersetzt; formatierte Texte bleiben, wie sie sind
val shown = if (text.spanStyles.isEmpty() && text.paragraphStyles.isEmpty() && I18n.lang == "en") buildAnnotatedString { append(I18n.tr(text.text)) } else text
androidx.compose.material3.Text(
text = shown, modifier = modifier, color = color, fontSize = fontSize, fontStyle = fontStyle, fontWeight = fontWeight,
fontFamily = fontFamily, letterSpacing = letterSpacing, textDecoration = textDecoration, textAlign = textAlign, lineHeight = lineHeight,
overflow = overflow, softWrap = softWrap, maxLines = maxLines, minLines = minLines, inlineContent = inlineContent,
onTextLayout = onTextLayout, style = style,
)
}
@@ -18,7 +18,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
@@ -0,0 +1,220 @@
package de.deskhub.staff.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Button
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.deskhub.staff.data.remote.dto.CsatResponse
import de.deskhub.staff.data.remote.dto.StatusStateDto
private fun statusColor(key: String): Color = when (key) {
"operational" -> Color(0xFF157A45)
"maintenance" -> Color(0xFF2563EB)
"degraded" -> Color(0xFFB45309)
"outage" -> Color(0xFFDC2626)
else -> Color(0xFF64748B)
}
@Composable
private fun StatusPicker(label: String, value: String, options: List<StatusStateDto>, emptyLabel: String? = null, onPick: (String) -> Unit) {
var open by remember { mutableStateOf(false) }
Box {
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
Text("$label: " + (options.firstOrNull { it.key == value }?.label ?: emptyLabel ?: "–"))
}
androidx.compose.material3.DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
if (emptyLabel != null) DropdownMenuItem(text = { Text(emptyLabel) }, onClick = { onPick(""); open = false })
options.forEach { o -> DropdownMenuItem(text = { Text(o.label) }, onClick = { onPick(o.key); open = false }) }
}
}
}
/** Statusseite für Kunden: Dienste, Zustände und Störungsmeldungen. Ändern dürfen nur Administratoren. */
@Composable
fun AdminStatusScreen(state: StatusUiState, viewModel: DeskHubViewModel, onBack: () -> Unit) {
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(Unit) { viewModel.statusLoad() }
LaunchedEffect(state.message, state.error) {
(state.message ?: state.error)?.let { snackbar.showSnackbar(I18n.tr(it)); viewModel.statusClearMessage() }
}
val data = state.data
var title by rememberSaveable { mutableStateOf("") }
var body by rememberSaveable { mutableStateOf("") }
var level by rememberSaveable { mutableStateOf("investigating") }
var componentId by rememberSaveable { mutableStateOf("") }
var componentState by rememberSaveable { mutableStateOf("") }
Scaffold(
topBar = { AdminTopBar("Statusseite", onBack) },
snackbarHost = { SnackbarHost(snackbar) },
) { padding ->
when {
state.loading && data == null -> Box(Modifier.padding(padding)) { LoadingState("Statusseite wird geladen …") }
data == null -> Box(Modifier.padding(padding)) { ErrorState(state.error ?: "Keine Daten verfügbar.", viewModel::statusLoad) }
else -> LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
DeskCard {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(data.overall.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = statusColor(data.overall.key))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Statusseite für Kunden anzeigen", Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
Switch(checked = data.enabled, enabled = state.canManage && !state.loading, onCheckedChange = { on ->
viewModel.statusAct(mapOf("action" to "set_enabled", "enabled" to on), if (on) "Statusseite eingeschaltet" else "Statusseite ausgeschaltet")
})
}
if (data.enabled && data.url.isNotBlank()) Text("Adresse für Kunden: ${data.url}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (!state.canManage) Text("Nur Administratoren dürfen die Statusseite ändern.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
item { SectionTitle("Dienste") }
if (data.components.isEmpty()) item { Text("Noch keine Dienste eingetragen.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
items(data.components, key = { it.id }) { c ->
DeskCard {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(c.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (c.description.isNotBlank()) Text(c.description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (state.canManage) {
StatusPicker("Zustand", c.status, data.componentStates) { v ->
if (v.isNotBlank()) viewModel.statusAct(mapOf("action" to "set_component", "id" to c.id, "status" to v), "Zustand geändert")
}
} else {
Text(c.statusLabel, color = statusColor(c.status), fontWeight = FontWeight.SemiBold)
}
}
}
}
if (state.canManage) {
item { SectionTitle("Neue Meldung (Störung oder Wartung)") }
item {
DeskCard {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Titel") }, singleLine = true, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text("Beschreibung") }, minLines = 3, modifier = Modifier.fillMaxWidth())
StatusPicker("Stufe", level, data.incidentStates) { if (it.isNotBlank()) level = it }
StatusPicker(
"Betrifft", componentId,
data.components.map { StatusStateDto(it.id.toString(), it.name) }, emptyLabel = "kein bestimmter Dienst",
) { componentId = it }
StatusPicker("Dienst setzen auf", componentState, data.componentStates, emptyLabel = "nicht ändern") { componentState = it }
Button(
onClick = {
viewModel.statusAct(
mapOf(
"action" to "create_incident", "title" to title.trim(), "body" to body.trim(), "status" to level,
"componentId" to componentId.toIntOrNull(), "componentState" to componentState.ifBlank { null },
),
"Meldung veröffentlicht",
)
title = ""; body = ""
},
enabled = title.isNotBlank() && !state.loading,
modifier = Modifier.fillMaxWidth(),
) { Text("Veröffentlichen") }
}
}
}
}
item { SectionTitle("Meldungen (letzte 14 Tage)") }
if (data.incidents.isEmpty()) item { Text("Keine Meldungen.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
items(data.incidents, key = { it.id }) { i ->
var update by rememberSaveable(i.id) { mutableStateOf("") }
DeskCard {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(i.title, Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (state.canManage) IconButton(onClick = { viewModel.statusAct(mapOf("action" to "delete_incident", "id" to i.id), "Meldung gelöscht") }) {
Icon(Icons.Filled.Close, contentDescription = "Meldung löschen")
}
}
Text(i.statusLabel + (i.component?.let { " · $it" } ?: "") + " · " + i.createdAt.take(16).replace('T', ' '), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
i.updates.forEach { u ->
Text(u.statusLabel + if (u.body.isNotBlank()) ": ${u.body}" else "", style = MaterialTheme.typography.bodySmall)
}
if (state.canManage && i.status != "resolved") {
var next by rememberSaveable(i.id) { mutableStateOf(i.status) }
StatusPicker("Neue Stufe", next, data.incidentStates) { if (it.isNotBlank()) next = it }
OutlinedTextField(value = update, onValueChange = { update = it }, label = { Text("Update für die Kunden") }, modifier = Modifier.fillMaxWidth())
OutlinedButton(
onClick = { viewModel.statusAct(mapOf("action" to "update_incident", "id" to i.id, "status" to next, "body" to update.trim()), "Update veröffentlicht"); update = "" },
enabled = !state.loading,
modifier = Modifier.fillMaxWidth(),
) { Text("Update senden") }
}
}
}
}
}
}
}
}
/** Kachel für die Übersicht: durchschnittliche Bewertung der letzten Tage. Nur sichtbar, wenn die Abfrage eingeschaltet ist oder es Antworten gibt. */
@Composable
fun CsatCard(response: CsatResponse, modifier: Modifier = Modifier) {
val s = response.stats
DeskCard(modifier) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Kundenzufriedenheit (${response.days} Tage)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (s.sent == 0) {
Text("Noch keine Abfragen verschickt.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Text(
(s.avg?.let { String.format(I18n.locale(), "%.1f", it) + " ★" } ?: "–"),
style = MaterialTheme.typography.headlineSmall, color = Color(0xFFF5B301), fontWeight = FontWeight.Bold,
)
Text(
tr("${s.answered} Antworten von ${s.sent} Abfragen") + (s.rate?.let { " (${String.format(I18n.locale(), "%.0f", it)} %)" } ?: ""),
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
s.dist.sortedByDescending { it.rating }.forEach { d ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text("★".repeat(d.rating), Modifier.weight(1f), color = Color(0xFFF5B301), style = MaterialTheme.typography.bodySmall)
Text(d.count.toString(), style = MaterialTheme.typography.bodySmall)
}
}
if (s.byStaff.isNotEmpty()) {
Text(
tr("Nach Mitarbeiter:") + " " + s.byStaff.joinToString(" · ") { "${it.name} ${String.format(I18n.locale(), "%.1f", it.avg)} ★ (${it.count})" },
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -1,33 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- DeskHub-Zeichen: „D“ weiß, „H“ orange (wie das Logo „Desk“ + „Hub“), innerhalb der Sicherheitszone von 66 × 66 dp -->
<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 -->
<!-- D -->
<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) -->
android:fillType="evenOdd"
android:pathData="M24,38 H35 A16,16 0 0 1 35,70 H24 Z M32.5,46.5 H35 A7.5,7.5 0 0 1 35,61.5 H32.5 Z" />
<!-- H -->
<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" />
android:pathData="M57,38 h8.5 v12.5 h10 v-12.5 h8.5 v32 h-8.5 v-12.5 h-10 v12.5 h-8.5 z" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Einfarbige Fassung für „Designte Symbole“ (Android 13+) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M24,38 H35 A16,16 0 0 1 35,70 H24 Z M32.5,46.5 H35 A7.5,7.5 0 0 1 35,61.5 H32.5 Z M57,38 h8.5 v12.5 h10 v-12.5 h8.5 v32 h-8.5 v-12.5 h-10 v12.5 h-8.5 z" />
</vector>
@@ -48,7 +48,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Lade …"
android:text="@string/widget_loading"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
@@ -82,7 +82,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Meine"
android:text="@string/widget_mine"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -108,7 +108,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Nicht zugew."
android:text="@string/widget_unassigned"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -135,7 +135,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Überfällig"
android:text="@string/widget_overdue"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -161,7 +161,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unbeantw."
android:text="@string/widget_unanswered"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -48,7 +48,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="Lade …"
android:text="@string/widget_loading"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
@@ -82,7 +82,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Meine"
android:text="@string/widget_mine"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -108,7 +108,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Nicht zugew."
android:text="@string/widget_unassigned"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -135,7 +135,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Überfällig"
android:text="@string/widget_overdue"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -161,7 +161,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Unbeantw."
android:text="@string/widget_unanswered"
android:textColor="#E6FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -252,7 +252,7 @@
android:gravity="center"
android:paddingTop="9dp"
android:paddingBottom="9dp"
android:text="Alle Tickets"
android:text="@string/widget_all_tickets"
android:textColor="#FFFFFF"
android:textSize="13sp" />
</LinearLayout>
@@ -273,7 +273,7 @@
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:text="Verfügbar"
android:text="@string/widget_available"
android:textColor="#FFFFFF"
android:textSize="11sp" />
@@ -288,7 +288,7 @@
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:text="Beschäftigt"
android:text="@string/widget_busy"
android:textColor="#FFFFFF"
android:textSize="11sp" />
@@ -303,7 +303,7 @@
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:text="Pause"
android:text="@string/widget_break"
android:textColor="#FFFFFF"
android:textSize="11sp" />
@@ -318,7 +318,7 @@
android:maxLines="1"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:text="Offline"
android:text="@string/widget_offline"
android:textColor="#FFFFFF"
android:textSize="11sp" />
</LinearLayout>
@@ -39,7 +39,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Lade …"
android:text="@string/widget_loading"
android:textColor="#FFFFFF"
android:textSize="13sp"
android:visibility="gone" />
@@ -75,7 +75,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Meine"
android:text="@string/widget_mine"
android:textColor="#E6FFFFFF"
android:textSize="12sp" />
</LinearLayout>
@@ -105,7 +105,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Überfällig"
android:text="@string/widget_overdue"
android:textColor="#E6FFFFFF"
android:textSize="12sp" />
</LinearLayout>
@@ -2,4 +2,5 @@
<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" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
@@ -2,4 +2,5 @@
<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" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>
+14
View File
@@ -0,0 +1,14 @@
<resources>
<string name="widget_description">Open, unassigned and overdue tickets, the most important tickets to tap, and your status. In three sizes.</string>
<string name="widget_mine">My</string>
<string name="widget_unassigned">Unassigned</string>
<string name="widget_unanswered">Unanswered</string>
<string name="widget_overdue">Overdue</string>
<string name="widget_all_tickets">All tickets</string>
<string name="widget_busy">Busy</string>
<string name="widget_offline">Offline</string>
<string name="widget_break">Break</string>
<string name="widget_available">Available</string>
<string name="widget_loading">Loading …</string>
<string name="widget_as_of">As of %1$s</string>
</resources>
+16 -5
View File
@@ -1,5 +1,16 @@
<resources>
<string name="app_name">deskhub</string>
<string name="server_label">deskhub-Server</string>
<string name="widget_description">Offene, nicht zugewiesene und überfällige Tickets, die wichtigsten Tickets zum Antippen und dein Status. In drei Größen.</string>
</resources>
<resources>
<string name="app_name">DeskHub</string>
<string name="server_label">DeskHub-Server</string>
<string name="widget_description">Offene, nicht zugewiesene und überfällige Tickets, die wichtigsten Tickets zum Antippen und dein Status. In drei Größen.</string>
<string name="widget_mine">Meine</string>
<string name="widget_unassigned">Nicht zugew.</string>
<string name="widget_unanswered">Unbeantw.</string>
<string name="widget_overdue">Überfällig</string>
<string name="widget_all_tickets">Alle Tickets</string>
<string name="widget_busy">Beschäftigt</string>
<string name="widget_offline">Offline</string>
<string name="widget_break">Pause</string>
<string name="widget_available">Verfügbar</string>
<string name="widget_loading">Lade …</string>
<string name="widget_as_of">Stand %1$s</string>
</resources>