Upload via GUI (80 Dateien)
This commit is contained in:
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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user