diff --git a/mobile-app/composeApp/build.gradle.kts b/mobile-app/composeApp/build.gradle.kts index 1dbc36b..2fc1e81 100644 --- a/mobile-app/composeApp/build.gradle.kts +++ b/mobile-app/composeApp/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "de.tsschulz.timeclock" minSdk = 26 targetSdk = 36 - versionCode = 3 - versionName = "0.8.0-alpha2" + versionCode = 4 + versionName = "0.8.0-alpha3" buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"") } diff --git a/mobile-app/composeApp/release/composeApp-release.aab b/mobile-app/composeApp/release/composeApp-release.aab index be780df..fa6931a 100644 Binary files a/mobile-app/composeApp/release/composeApp-release.aab and b/mobile-app/composeApp/release/composeApp-release.aab differ diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/BookingDtos.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/BookingDtos.kt index 32a1476..21ea856 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/BookingDtos.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/BookingDtos.kt @@ -24,7 +24,7 @@ data class SickEntryDto( val id: String, val startDate: String? = null, val endDate: String? = null, - val sickTypeId: Int? = null, + val sickTypeId: String? = null, val sickTypeName: String? = null, ) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/TimeDtos.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/TimeDtos.kt index 3116fe7..9577dcb 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/TimeDtos.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/TimeDtos.kt @@ -77,6 +77,9 @@ data class WeekOverviewDto( val weekEnd: String? = null, val weekTotal: String? = null, val totalAll: String? = null, + val nonWorkingTotal: String? = null, + val nonWorkingDays: Int = 0, + val nonWorkingDetails: List = emptyList(), val days: List = emptyList(), ) @@ -88,6 +91,9 @@ data class WeekDayDto( val workTime: String? = null, val totalWorkTime: String? = null, val netWorkTime: String? = null, + val holiday: WeekDayHolidayDto? = null, + val vacation: WeekDayVacationDto? = null, + val sick: WeekDaySickDto? = null, val status: String? = null, val statusText: String? = null, val workBlocks: List = emptyList(), @@ -99,3 +105,28 @@ data class WorkBlockDto( val totalWorkTime: String? = null, val netWorkTime: String? = null, ) + +@Serializable +data class WeekDayHolidayDto( + val hours: Double = 0.0, + val description: String? = null, +) + +@Serializable +data class WeekDayVacationDto( + val hours: Double = 0.0, + val halfDay: Boolean = false, +) + +@Serializable +data class WeekDaySickDto( + val hours: Double = 0.0, + val type: String? = null, +) + +@Serializable +data class NonWorkingDetailDto( + val date: String? = null, + val type: String? = null, + val hours: Double = 0.0, +) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt index 839a1a2..9cbc9e2 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt @@ -1,12 +1,15 @@ package de.tsschulz.timeclock.ui import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -14,12 +17,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.tsschulz.timeclock.data.api.WeekDayDto import de.tsschulz.timeclock.data.api.WeekOverviewDto +import de.tsschulz.timeclock.data.api.WorkBlockDto import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.tsschulz.timeclock.ui.admin.AdminViewModel import de.tsschulz.timeclock.ui.admin.HolidaysAdminScreen @@ -49,6 +54,7 @@ import de.tsschulz.timeclock.ui.settings.ProfileScreen import de.tsschulz.timeclock.ui.settings.SettingsViewModel import de.tsschulz.timeclock.ui.settings.TimewishScreen import de.tsschulz.timeclock.ui.theme.TcColors +import de.tsschulz.timeclock.ui.theme.TcRadius import de.tsschulz.timeclock.ui.theme.TcSpacing import de.tsschulz.timeclock.ui.time.EntriesScreen import de.tsschulz.timeclock.ui.time.StatsScreen @@ -224,34 +230,46 @@ private fun DemoScreen( ) } AppRoute.Export -> TcCard { Text("Export ist in der Android-App deaktiviert.", color = TcColors.TextMuted) } - AppRoute.Profile -> ProfileScreen( - state = settingsState, - isTablet = isTablet, - onSave = { name, stateId, weekWorkdays, dailyHours, titleType -> - settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType) - }, - ) + AppRoute.Profile -> { + LaunchedEffect(route) { settingsViewModel.loadProfile() } + ProfileScreen( + state = settingsState, + isTablet = isTablet, + onSave = { name, stateId, weekWorkdays, dailyHours, titleType -> + settingsViewModel.updateProfile(name, stateId, weekWorkdays, dailyHours, titleType) + }, + ) + } AppRoute.Password -> PasswordScreen( state = settingsState, onChange = { old, new, confirm -> settingsViewModel.changePassword(old, new, confirm) }, ) - AppRoute.Timewish -> TimewishScreen( - state = settingsState, - isTablet = isTablet, - onCreate = { day, type, hours, start, end -> settingsViewModel.createTimewish(day, type, hours, start, end) }, - onDelete = { settingsViewModel.deleteTimewish(it) }, - ) - AppRoute.Permissions -> PermissionsScreen( - state = settingsState, - isTablet = isTablet, - onAdd = { settingsViewModel.addWatcher(it) }, - onDelete = { settingsViewModel.deleteWatcher(it) }, - ) - AppRoute.Invite -> InviteScreen( - state = settingsState, - isTablet = isTablet, - onSend = { settingsViewModel.sendInvite(it) }, - ) + AppRoute.Timewish -> { + LaunchedEffect(route) { settingsViewModel.loadTimewishes() } + TimewishScreen( + state = settingsState, + isTablet = isTablet, + onCreate = { day, type, hours, start, end -> settingsViewModel.createTimewish(day, type, hours, start, end) }, + onDelete = { settingsViewModel.deleteTimewish(it) }, + ) + } + AppRoute.Permissions -> { + LaunchedEffect(route) { settingsViewModel.loadWatchers() } + PermissionsScreen( + state = settingsState, + isTablet = isTablet, + onAdd = { settingsViewModel.addWatcher(it) }, + onDelete = { settingsViewModel.deleteWatcher(it) }, + ) + } + AppRoute.Invite -> { + LaunchedEffect(route) { settingsViewModel.loadInvites() } + InviteScreen( + state = settingsState, + isTablet = isTablet, + onSend = { settingsViewModel.sendInvite(it) }, + ) + } AppRoute.Holidays -> HolidaysAdminScreen( state = adminState, isTablet = isTablet, @@ -298,55 +316,203 @@ private fun WeekOverviewScreen( @Composable private fun WeekTablet(week: WeekOverviewDto) { - Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Xl), modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(TcSpacing.Lg)) { - week.days.forEach { day -> WeekDayCard(day) } - } - TcCard(modifier = Modifier.weight(1f)) { - SectionTitle("Wochensumme") - DetailRow("Arbeitszeit", week.weekTotal ?: "0:00") - DetailRow("Gesamt", week.totalAll ?: week.weekTotal ?: "0:00") - val today = week.days.firstOrNull { it.isToday } ?: week.days.firstOrNull() - today?.let { - SectionTitle("Aktueller Tag") - DetailRow("Tag", it.name ?: "—") - DetailRow("Datum", it.date.toDisplayDate()) - DetailRow("Status", it.statusText ?: "—") - DetailRow("Arbeitszeit", it.netWorkTime ?: it.totalWorkTime ?: "—") - } - } - } + WeekTable(week, compact = false) } @Composable private fun WeekPhone(week: WeekOverviewDto) { - week.days.forEach { day -> WeekDayCard(day) } + WeekTable(week, compact = true) +} + +@Composable +private fun WeekTable(week: WeekOverviewDto, compact: Boolean) { TcCard { - SectionTitle("Wochensumme") - DetailRow("Arbeitszeit", week.weekTotal ?: "0:00") - DetailRow("Gesamt", week.totalAll ?: week.weekTotal ?: "0:00") + if (!compact) { + WeekHeaderRow() + } + Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) { + week.days.forEach { day -> WeekDayRow(day, compact) } + WeekSummaryRows(week, compact) + } } } @Composable -private fun WeekDayCard(day: WeekDayDto) { - TcCard { - SectionTitle("${day.name ?: "Tag"} ${day.date.toDisplayDate()}") - val blocks = day.workBlocks - if (blocks.isNotEmpty()) { - blocks.forEachIndexed { index, block -> - val label = if (blocks.size > 1) "Arbeitszeit ${index + 1}" else "Arbeitszeit" - DetailRow(label, block.workTime ?: day.workTime ?: "—") - DetailRow("Netto", block.netWorkTime ?: block.totalWorkTime ?: "—") - } - } else { - DetailRow("Arbeitszeit", day.workTime ?: "—") - DetailRow("Netto", day.netWorkTime ?: day.totalWorkTime ?: "—") - } - DetailRow("Status", day.statusText ?: "—") +private fun WeekHeaderRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .background(TcColors.Background, RoundedCornerShape(TcRadius.Medium)) + .padding(horizontal = TcSpacing.Md, vertical = TcSpacing.Sm), + horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md), + verticalAlignment = Alignment.CenterVertically, + ) { + WeekCell("Tag", weight = 1.0f, header = true) + WeekCell("Datum", weight = 1.0f, header = true) + WeekCell("Zeiten", weight = 2.4f, header = true) + WeekCell("Arbeitszeit", weight = 1.4f, header = true) + WeekCell("Status", weight = 1.2f, header = true) } } +@Composable +private fun WeekDayRow(day: WeekDayDto, compact: Boolean) { + val bg = if (day.isToday) TcColors.ActiveMenu else TcColors.Background + if (compact) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(bg, RoundedCornerShape(TcRadius.Medium)) + .border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium)) + .padding(TcSpacing.Md), + verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm), + ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(day.name ?: "Tag", color = TcColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(day.date.toDisplayDate(), color = TcColors.TextMuted, fontSize = 14.sp) + } + WeekDetailBlock("Zeiten", day.timeLines()) + WeekDetailBlock("Arbeitszeit", day.workTotalLines()) + day.statusText?.let { WeekStatusBadge(it) } + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .background(bg, RoundedCornerShape(TcRadius.Medium)) + .border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium)) + .padding(TcSpacing.Md), + horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md), + verticalAlignment = Alignment.Top, + ) { + WeekCell(day.name ?: "Tag", weight = 1.0f, strong = true) + WeekCell(day.date.toDisplayDate(), weight = 1.0f) + WeekMultilineCell(day.timeLines(), weight = 2.4f) + WeekMultilineCell(day.workTotalLines(), weight = 1.4f) + WeekCell(day.statusText ?: "—", weight = 1.2f) + } + } +} + +@Composable +private fun WeekSummaryRows(week: WeekOverviewDto, compact: Boolean) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(TcColors.Card, RoundedCornerShape(TcRadius.Medium)) + .border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium)) + .padding(TcSpacing.Md), + verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm), + ) { + WeekSummaryLine("Wochensumme", week.weekTotal ?: "0:00", compact) + if (week.nonWorkingDays > 0) { + WeekSummaryLine( + "Arbeitsfreie Tage (${week.nonWorkingDays})", + week.nonWorkingTotal ?: "0:00", + compact, + week.nonWorkingDetails.joinToString { "${it.date.toDisplayDate()}: ${it.type ?: "frei"} (${it.hours.toHourLabel()}h)" }, + ) + } + WeekSummaryLine("Gesamtsumme", week.totalAll ?: week.weekTotal ?: "0:00", compact) + } +} + +@Composable +private fun WeekSummaryLine(label: String, value: String, compact: Boolean, detail: String? = null) { + if (compact) { + Column { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Text(value, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + } + detail?.let { Text(it, color = TcColors.TextMuted, fontSize = 12.sp) } + } + } else { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(TcSpacing.Md)) { + Text(label, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(5.4f)) + Text(value, color = TcColors.Text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1.4f)) + Text(detail.orEmpty(), color = TcColors.TextMuted, fontSize = 12.sp, modifier = Modifier.weight(1.2f)) + } + } +} + +@Composable +private fun WeekDetailBlock(label: String, lines: List) { + Text(label, color = TcColors.TextMuted, fontSize = 13.sp, fontWeight = FontWeight.Medium) + lines.ifEmpty { listOf("—") }.forEach { line -> + Text(line, color = TcColors.Text, fontSize = 14.sp) + } +} + +@Composable +private fun WeekStatusBadge(text: String) { + Text( + text = text, + color = TcColors.Text, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .background(TcColors.Button, RoundedCornerShape(TcRadius.Medium)) + .border(1.dp, TcColors.ButtonBorder, RoundedCornerShape(TcRadius.Medium)) + .padding(horizontal = TcSpacing.Sm, vertical = 4.dp), + ) +} + +@Composable +private fun RowScope.WeekCell(text: String, weight: Float, header: Boolean = false, strong: Boolean = false) { + Text( + text = text, + color = if (header) TcColors.TextMuted else TcColors.Text, + fontSize = if (header) 13.sp else 14.sp, + fontWeight = if (header || strong) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.weight(weight), + ) +} + +@Composable +private fun RowScope.WeekMultilineCell(lines: List, weight: Float) { + Column(modifier = Modifier.weight(weight), verticalArrangement = Arrangement.spacedBy(4.dp)) { + lines.ifEmpty { listOf("—") }.forEach { + Text(it, color = TcColors.Text, fontSize = 14.sp) + } + } +} + +private fun WeekDayDto.timeLines(): List { + val blocks = workBlocks + return if (blocks.isNotEmpty()) { + blocks.flatMapIndexed { index, block -> + listOf("${if (blocks.size > 1) "Arbeitszeit ${index + 1}" else "Arbeitszeit"}: ${block.workTime ?: workTime ?: "—"}") + } + } else if (!workTime.isNullOrBlank()) { + listOf("Arbeitszeit: $workTime") + } else { + emptyList() + } +} + +private fun WeekDayDto.workTotalLines(): List { + val lines = mutableListOf() + val blocks = workBlocks + if (blocks.isNotEmpty()) { + blocks.forEach { block -> lines.addAll(block.totalLines()) } + if (blocks.size > 1 && !netWorkTime.isNullOrBlank()) lines.add("Gesamt: $netWorkTime") + } else { + totalWorkTime?.let { lines.add(it) } + netWorkTime?.let { lines.add("= $it") } + } + holiday?.let { lines.add("+ ${it.hours.toHourLabel()}:00 ${it.description ?: "Feiertag"}") } + vacation?.let { lines.add("+ ${it.hours.toHourLabel()}:00 Urlaub") } + sick?.let { lines.add("${it.hours.toHourLabel()}:00 (${it.type.toSickLabel()})") } + return lines +} + +private fun WorkBlockDto.totalLines(): List = + buildList { + totalWorkTime?.let { add(it) } + netWorkTime?.let { add("= $it") } + } + @Composable private fun CalendarDemo(isTablet: Boolean) { if (isTablet) { @@ -408,6 +574,19 @@ private fun String?.toDisplayDate(): String { }.getOrDefault(this) } +private fun Double.toHourLabel(): String = + if (this % 1.0 == 0.0) toInt().toString() else toString() + +private fun String?.toSickLabel(): String = + when (this) { + "self" -> "Krank" + "child" -> "Kind krank" + "parents" -> "Eltern krank" + "partner" -> "Partner krank" + null -> "Krank" + else -> this + } + @Composable private fun SectionTitle(text: String) { Text( diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/admin/AdminScreens.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/admin/AdminScreens.kt index 8c82465..f41e77a 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/admin/AdminScreens.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/admin/AdminScreens.kt @@ -1,5 +1,6 @@ package de.tsschulz.timeclock.ui.admin +import android.app.DatePickerDialog import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -19,6 +20,7 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -34,6 +36,8 @@ import de.tsschulz.timeclock.ui.theme.TcColors import de.tsschulz.timeclock.ui.theme.TcRadius import de.tsschulz.timeclock.ui.theme.TcSpacing import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale @Composable fun HolidaysAdminScreen( @@ -42,7 +46,7 @@ fun HolidaysAdminScreen( onCreate: (String, Double, String, List) -> Unit, onDelete: (String) -> Unit, ) { - var date by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } + var date by rememberSaveable { mutableStateOf(LocalDate.now().toGermanDate()) } var hours by rememberSaveable { mutableStateOf("8") } var description by rememberSaveable { mutableStateOf("") } var stateIds by rememberSaveable { mutableStateOf("") } @@ -61,8 +65,11 @@ fun HolidaysAdminScreen( stateIds = stateIds, onStateIds = { stateIds = it }, onCreate = { - onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds)) - description = "" + normalizeHolidayDate(date)?.let { isoDate -> + onCreate(isoDate, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds)) + date = LocalDate.parse(isoDate).toGermanDate() + description = "" + } }, modifier = Modifier.weight(0.9f), ) @@ -83,8 +90,11 @@ fun HolidaysAdminScreen( stateIds = stateIds, onStateIds = { stateIds = it }, onCreate = { - onCreate(date, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds)) - description = "" + normalizeHolidayDate(date)?.let { isoDate -> + onCreate(isoDate, hours.toDoubleOrNull() ?: 8.0, description, parseStateIds(stateIds)) + date = LocalDate.parse(isoDate).toGermanDate() + description = "" + } }, ) HolidayList("Zukünftige Feiertage", state.futureHolidays, onDelete) @@ -129,15 +139,9 @@ private fun HolidayForm( modifier: Modifier = Modifier, ) { FormCard("Feiertag hinzufügen", modifier) { - TcTextField("Datum", date, onDate, placeholder = "YYYY-MM-DD") + HolidayDateInput(date, onDate) TcTextField("Freie Stunden", hours, onHours, placeholder = "8") TcTextField("Beschreibung", description, onDescription, placeholder = "z.B. Tag der Deutschen Einheit") - TcTextField( - label = "Bundesland-IDs", - value = stateIds, - onValueChange = onStateIds, - placeholder = "Leer lassen für Bundesfeiertag", - ) if (state.holidayStates.isNotEmpty()) { Text("Bundesländer", color = TcColors.TextMuted, fontSize = 13.sp) FlowRow( @@ -160,6 +164,36 @@ private fun HolidayForm( } } +@Composable +private fun HolidayDateInput(value: String, onValueChange: (String) -> Unit) { + val context = LocalContext.current + val selectedDate = parseHolidayDate(value) ?: LocalDate.now() + Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalAlignment = Alignment.Bottom) { + TcTextField( + label = "Datum", + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + placeholder = "TT.MM.JJJJ oder YYYY-MM-DD", + ) + TcButton( + text = "Auswählen", + variant = ButtonVariant.Default, + onClick = { + DatePickerDialog( + context, + { _, year, month, day -> + onValueChange(LocalDate.of(year, month + 1, day).toGermanDate()) + }, + selectedDate.year, + selectedDate.monthValue - 1, + selectedDate.dayOfMonth, + ).show() + }, + ) + } +} + @Composable private fun HolidayList(title: String, holidays: List, onDelete: (String) -> Unit) { ListCard(title, holidays) { holiday -> HolidayRow(holiday, onDelete) } @@ -269,6 +303,20 @@ private fun toggleStateId(raw: String, id: String): String { return ids.joinToString(",") } +private val germanDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY) + +private fun LocalDate.toGermanDate(): String = format(germanDateFormatter) + +private fun parseHolidayDate(raw: String): LocalDate? { + val value = raw.trim() + if (value.isBlank()) return null + return runCatching { LocalDate.parse(value) } + .recoverCatching { LocalDate.parse(value, germanDateFormatter) } + .getOrNull() +} + +private fun normalizeHolidayDate(raw: String): String? = parseHolidayDate(raw)?.toString() + private fun String?.toDisplayDate(): String { if (this.isNullOrBlank()) return "-" return runCatching { diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingScreens.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingScreens.kt index 0c2941c..4b27759 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingScreens.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingScreens.kt @@ -50,6 +50,7 @@ import java.time.LocalDate import java.time.YearMonth import java.time.format.DateTimeFormatter import java.util.Locale +import kotlin.math.roundToInt @Composable fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, String, String?) -> Unit, onDelete: (String) -> Unit) { @@ -58,13 +59,21 @@ fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, Str var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } Phase4Frame(loading = state.vacationLoading, error = state.vacationError) { FormCard("Urlaub eintragen", isTablet) { - TcTextField("Umfang (0 Zeitraum, 1 Halber Tag)", type, { type = it }, placeholder = "0") + FieldLabel("Umfang") Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) { TcButton("Zeitraum", variant = if (type == "0") ButtonVariant.Primary else ButtonVariant.Default, onClick = { type = "0" }) - TcButton("Halber Tag", variant = if (type == "1") ButtonVariant.Primary else ButtonVariant.Default, onClick = { type = "1" }) + TcButton("Halber Tag", variant = if (type == "1") ButtonVariant.Primary else ButtonVariant.Default, onClick = { + type = "1" + end = start + }) + } + DateField("Urlaubsbeginn", start, { + start = it + if (type == "1") end = it + }) + if (type != "1") { + DateField("Urlaubsende", end, { end = it }) } - TcTextField("Urlaubsbeginn", start, { start = it }, placeholder = "YYYY-MM-DD") - TcTextField("Urlaubsende", end, { end = it }, placeholder = "YYYY-MM-DD") TcButton("Urlaub eintragen", variant = ButtonVariant.Primary, onClick = { val typeValue = type.toIntOrNull() ?: 0 onCreate(typeValue, start, if (typeValue == 1) start else end) @@ -86,9 +95,9 @@ fun SickScreen(state: BookingUiState, isTablet: Boolean, onCreate: (String, Stri } Phase4Frame(loading = state.sickLoading, error = state.sickError) { FormCard("Erkrankung eintragen", isTablet) { - TcTextField("Erster Krankheitstag", start, { start = it }, placeholder = "YYYY-MM-DD") - TcTextField("Letzter Krankheitstag", end, { end = it }, placeholder = "YYYY-MM-DD") - TcTextField("Krankheitstyp-ID", typeId, { typeId = it }, placeholder = state.sickTypes.joinToString { "${it.id}=${it.name}" }) + DateField("Erster Krankheitstag", start, { start = it }) + DateField("Letzter Krankheitstag", end, { end = it }) + FieldLabel("Krankheitstyp") FlowRow(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) { state.sickTypes.forEach { type -> TcButton( @@ -162,11 +171,16 @@ fun TimefixScreen( @Composable fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) { - var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) } + var selectedYear by rememberSaveable { mutableStateOf(state.workdaysYear) } + LaunchedEffect(state.workdaysYear) { + selectedYear = state.workdaysYear + } Phase4Frame(loading = state.workdaysLoading, error = state.workdaysError) { FormCard("Arbeitstage", isTablet = false) { - TcTextField("Jahr", year, { year = it }, placeholder = "2026") - TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) }) + YearDropdown(selectedYear) { year -> + selectedYear = year + onYear(year) + } } WorkdaysCard(state.workdays) } @@ -311,6 +325,32 @@ private fun ActionDropdown(value: String, onValueChange: (String) -> Unit) { } } +@Composable +private fun YearDropdown(value: Int, onValueChange: (Int) -> Unit) { + var expanded by rememberSaveable { mutableStateOf(false) } + val years = 2000..2100 + FieldLabel("Jahr") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = value.toString(), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + years.forEach { year -> + DropdownMenuItem( + text = { Text(year.toString(), color = TcColors.Text) }, + onClick = { + onValueChange(year) + expanded = false + }, + ) + } + } + } +} + @Composable private fun FieldLabel(label: String) { Text( @@ -432,11 +472,14 @@ private fun CalendarCell(day: CalendarDayDto) { .border(1.dp, TcColors.Border, RoundedCornerShape(TcRadius.Medium)) .padding(TcSpacing.Sm), ) { - Text(day.day.toString(), color = TcColors.Text, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.Bottom) { + Text(day.toWeekdayLabel(), color = TcColors.TextMuted, fontSize = 11.sp) + Text(day.day.toString(), color = TcColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + } day.holiday?.let { Text(it, color = TcColors.Danger, fontSize = 11.sp) } if (day.sick) Text("Krank", color = TcColors.Secondary, fontSize = 11.sp) day.vacation?.let { Text(if (it == "half") "Urlaub 1/2" else "Urlaub", color = TcColors.Primary, fontSize = 11.sp) } - day.workedHours?.let { Text("${it}h", color = TcColors.TextMuted, fontSize = 11.sp) } + day.workedHours?.let { Text(it.toHourMinuteLabel(), color = TcColors.TextMuted, fontSize = 11.sp) } } } @@ -472,4 +515,16 @@ private fun String.toActionLabel(): String = private fun WorklogEntryDto.toWorklogLabel(): String = "${time ?: "—"} - ${(action ?: "—").toActionLabel()}" +private fun CalendarDayDto.toWeekdayLabel(): String = + runCatching { + LocalDate.parse(date).format(DateTimeFormatter.ofPattern("EEE", Locale.GERMANY)) + }.getOrDefault("") + +private fun Double.toHourMinuteLabel(): String { + val totalMinutes = (this * 60).roundToInt().coerceAtLeast(0) + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + return "%d:%02d".format(hours, minutes) +} + private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/MockData.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/MockData.kt index 2ae6c83..b759040 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/MockData.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/MockData.kt @@ -40,13 +40,14 @@ val adminSections = userSections + MenuSection( ) val mockStatusRows = listOf( - StatusRow(label = "Heute", isHeading = true), - StatusRow(label = "Status", value = "Arbeit läuft"), - StatusRow(label = "Beginn", value = "08:12"), - StatusRow(label = "Arbeitszeit", value = "04:37:18"), - StatusRow(label = "Offen", value = "03:23"), - StatusRow(label = "Reguläres Ende", value = "16:42"), + StatusRow(label = "Derzeit gearbeitet", value = "04:37:18 h"), + StatusRow(label = "Offen", value = "03:23 h"), + StatusRow(label = "Normales Arbeitsende", value = "16:42 Uhr"), + StatusRow(label = "Überstunden (Woche)", value = "00:18 h"), + StatusRow(label = "Wochenarbeitszeit", value = "20:37 h"), + StatusRow(label = "Bereinigtes Arbeitsende (heute)", isHeading = true), + StatusRow(label = "- Generell", value = "16:42 Uhr"), ) -val mockPrimaryAction = StatusAction("Pause starten", ButtonVariant.Success) -val mockSecondaryAction = StatusAction("Gehen", ButtonVariant.Secondary) +val mockPrimaryAction = StatusAction("Arbeit beenden", ButtonVariant.Danger) +val mockSecondaryAction = StatusAction("Pause beginnen", ButtonVariant.Secondary) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsScreens.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsScreens.kt index ece0235..7c51b29 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsScreens.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsScreens.kt @@ -1,14 +1,18 @@ package de.tsschulz.timeclock.ui.settings +import android.app.DatePickerDialog import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -18,11 +22,13 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.tsschulz.timeclock.data.api.InvitationDto import de.tsschulz.timeclock.data.api.ProfileDto +import de.tsschulz.timeclock.data.api.StateDto import de.tsschulz.timeclock.data.api.TimewishDto import de.tsschulz.timeclock.data.api.WatcherDto import de.tsschulz.timeclock.ui.components.TcButton @@ -35,6 +41,8 @@ import de.tsschulz.timeclock.ui.theme.TcColors import de.tsschulz.timeclock.ui.theme.TcRadius import de.tsschulz.timeclock.ui.theme.TcSpacing import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale @Composable fun ProfileScreen( @@ -47,7 +55,7 @@ fun ProfileScreen( var stateId by rememberSaveable { mutableStateOf("") } var weekWorkdays by rememberSaveable { mutableStateOf("5") } var dailyHours by rememberSaveable { mutableStateOf("8.0") } - var preferredTitleType by rememberSaveable { mutableStateOf("0") } + var preferredTitleType by rememberSaveable { mutableStateOf(2) } LaunchedEffect(profile) { profile?.let { @@ -55,7 +63,7 @@ fun ProfileScreen( stateId = it.stateId.orEmpty() weekWorkdays = (it.weekWorkdays ?: 5).toString() dailyHours = (it.dailyHours ?: 8.0).toString() - preferredTitleType = (it.preferredTitleType ?: 0).toString() + preferredTitleType = it.preferredTitleType ?: 2 } } @@ -63,17 +71,22 @@ fun ProfileScreen( ResponsiveSettings(isTablet) { FormCard("Persönliche Daten") { TcTextField("Name", fullName, { fullName = it }) - TcTextField("Bundesland-ID", stateId, { stateId = it }, placeholder = state.states.joinToString { "${it.id}=${it.name}" }) + StateDropdown( + states = state.states, + selectedId = stateId, + selectedName = profile?.stateName, + onSelect = { stateId = it.orEmpty() }, + ) TcTextField("Arbeitstage pro Woche", weekWorkdays, { weekWorkdays = it }, placeholder = "5") TcTextField("Stunden pro Tag", dailyHours, { dailyHours = it }, placeholder = "8.0") - TcTextField("Titeltyp", preferredTitleType, { preferredTitleType = it }, placeholder = "0") + TitleTypeDropdown(preferredTitleType, { preferredTitleType = it }) TcButton("Speichern", variant = ButtonVariant.Primary, onClick = { onSave( fullName, stateId.ifBlank { null }, weekWorkdays.toIntOrNull() ?: 5, dailyHours.toDoubleOrNull() ?: 8.0, - preferredTitleType.toIntOrNull() ?: 0, + preferredTitleType, ) }) } @@ -113,8 +126,8 @@ fun TimewishScreen( onCreate: (Int, Int, Double?, String, String?) -> Unit, onDelete: (String) -> Unit, ) { - var day by rememberSaveable { mutableStateOf("1") } - var wishtype by rememberSaveable { mutableStateOf("1") } + var day by rememberSaveable { mutableStateOf(1) } + var wishtype by rememberSaveable { mutableStateOf(2) } var hours by rememberSaveable { mutableStateOf("8.0") } var startDate by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var endDate by rememberSaveable { mutableStateOf("") } @@ -122,16 +135,21 @@ fun TimewishScreen( SettingsFrame(state) { ResponsiveSettings(isTablet) { FormCard("Zeitwunsch eintragen") { - TcTextField("Wochentag", day, { day = it }, placeholder = "1") - TcTextField("Wunschtyp", wishtype, { wishtype = it }, placeholder = "1") - TcTextField("Stunden", hours, { hours = it }, placeholder = "8.0") - TcTextField("Gueltig ab", startDate, { startDate = it }, placeholder = "YYYY-MM-DD") - TcTextField("Gueltig bis", endDate, { endDate = it }, placeholder = "YYYY-MM-DD") + WeekdayDropdown(day, { day = it }) + WishtypeDropdown(wishtype) { + wishtype = it + hours = if (it == 2) "8.0" else "0" + } + if (wishtype == 2) { + TcTextField("Stunden", hours, { hours = it }, placeholder = "8.0") + } + SettingsDateField("Gültig ab", startDate, { startDate = it }) + SettingsDateField("Gültig bis (optional)", endDate, { endDate = it }, allowBlank = true) TcButton("Zeitwunsch speichern", variant = ButtonVariant.Primary, onClick = { onCreate( - day.toIntOrNull() ?: 1, - wishtype.toIntOrNull() ?: 1, - hours.toDoubleOrNull(), + day, + wishtype, + if (wishtype == 2) hours.toDoubleOrNull() else null, startDate, endDate.ifBlank { null }, ) @@ -191,6 +209,189 @@ fun PermissionsScreen( } } +@Composable +private fun TitleTypeDropdown(value: Int, onValueChange: (Int) -> Unit) { + var expanded by rememberSaveable { mutableStateOf(false) } + FieldLabel("Anzeige in Seitentitel") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = titleTypeLabel(value), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + (0..7).forEach { type -> + DropdownMenuItem( + text = { Text(titleTypeLabel(type), color = TcColors.Text) }, + onClick = { + onValueChange(type) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun WeekdayDropdown(value: Int, onValueChange: (Int) -> Unit) { + var expanded by rememberSaveable { mutableStateOf(false) } + FieldLabel("Wochentag") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = weekdayLabel(value), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + (1..7).forEach { day -> + DropdownMenuItem( + text = { Text(weekdayLabel(day), color = TcColors.Text) }, + onClick = { + onValueChange(day) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun WishtypeDropdown(value: Int, onValueChange: (Int) -> Unit) { + var expanded by rememberSaveable { mutableStateOf(false) } + FieldLabel("Typ") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = wishtypeLabel(value), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + (0..2).forEach { type -> + DropdownMenuItem( + text = { Text(wishtypeLabel(type), color = TcColors.Text) }, + onClick = { + onValueChange(type) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun SettingsDateField( + label: String, + value: String, + onValueChange: (String) -> Unit, + allowBlank: Boolean = false, +) { + val context = LocalContext.current + val parsed = runCatching { LocalDate.parse(value) }.getOrDefault(LocalDate.now()) + FieldLabel(label) + Row(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalAlignment = Alignment.CenterVertically) { + TcButton( + text = if (allowBlank && value.isBlank()) "Kein Enddatum" else parsed.toGermanDate(), + variant = ButtonVariant.Default, + modifier = Modifier.weight(1f), + onClick = { + DatePickerDialog( + context, + { _, year, month, day -> + onValueChange(LocalDate.of(year, month + 1, day).toString()) + }, + parsed.year, + parsed.monthValue - 1, + parsed.dayOfMonth, + ).show() + }, + ) + if (allowBlank) { + TcButton("Leeren", variant = ButtonVariant.Default, onClick = { onValueChange("") }) + } + } +} + +@Composable +private fun StateDropdown( + states: List, + selectedId: String, + selectedName: String?, + onSelect: (String?) -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val selected = states.firstOrNull { it.id == selectedId } + FieldLabel("Bundesland") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = selected?.name ?: selectedName?.takeIf { selectedId.isNotBlank() } ?: "Kein Bundesland ausgewählt", + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Kein Bundesland ausgewählt", color = TcColors.Text) }, + onClick = { + onSelect(null) + expanded = false + }, + ) + states.forEach { state -> + DropdownMenuItem( + text = { Text(state.name, color = TcColors.Text) }, + onClick = { + onSelect(state.id) + expanded = false + }, + ) + } + } + } +} + +private fun titleTypeLabel(value: Int): String = + when (value) { + 0 -> "Restzeit für Tag ohne Korrektur" + 1 -> "Restzeit für Tag, korrigiert nach Wochenarbeitszeit" + 2 -> "Restzeit für Tag, korrigiert nach Gesamtarbeitszeit" + 3 -> "Heute gearbeitete Zeit" + 4 -> "Uhrzeit für Arbeitsende ohne Korrektur" + 5 -> "Uhrzeit für Arbeitsende, korrigiert nach Wochenarbeitszeit" + 6 -> "Uhrzeit für Arbeitsende, korrigiert nach Gesamtarbeitszeit" + 7 -> "Nur App-Name" + else -> "Restzeit für Tag, korrigiert nach Gesamtarbeitszeit" + } + +private fun weekdayLabel(value: Int): String = + when (value) { + 1 -> "Montag" + 2 -> "Dienstag" + 3 -> "Mittwoch" + 4 -> "Donnerstag" + 5 -> "Freitag" + 6 -> "Samstag" + 7 -> "Sonntag" + else -> "Montag" + } + +private fun wishtypeLabel(value: Int): String = + when (value) { + 0 -> "Kein Wunsch" + 1 -> "Frei" + 2 -> "Arbeit" + else -> "Arbeit" + } + +private val germanDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY) + +private fun LocalDate.toGermanDate(): String = format(germanDateFormatter) + @Composable private fun SettingsFrame(state: SettingsUiState, content: @Composable () -> Unit) { if (state.loading) TcLoading("Lade Daten...") @@ -295,6 +496,16 @@ private fun SectionTitle(title: String) { ) } +@Composable +private fun FieldLabel(label: String) { + Text( + text = label, + color = TcColors.Text, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + ) +} + @Composable private fun Detail(label: String, value: String) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsViewModel.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsViewModel.kt index 1328de3..7e99cb8 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsViewModel.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/SettingsViewModel.kt @@ -37,9 +37,6 @@ class SettingsViewModel( fun loadPhase5() { loadProfile() - loadTimewishes() - loadInvites() - loadWatchers() } fun loadProfile() = launchLoad { diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/TimeViewModel.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/TimeViewModel.kt index 7668e5c..11de6c9 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/TimeViewModel.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/TimeViewModel.kt @@ -22,16 +22,13 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter -import java.util.Locale data class TimeUiState( val loading: Boolean = false, val clockInProgress: Boolean = false, val error: String? = null, val dashboard: TimeDashboard? = null, - val statusRows: List = listOf(StatusRow(label = "Heute", isHeading = true), StatusRow("Status", "—")), + val statusRows: List = listOf(StatusRow("Derzeit gearbeitet", "—")), val primaryAction: StatusAction? = null, val secondaryAction: StatusAction? = null, val weekLoading: Boolean = false, @@ -169,46 +166,54 @@ class TimeViewModel( private fun TimeDashboard.toStatusRows(): List { val stats = stats return buildList { - add(StatusRow(label = "Heute", isHeading = true)) - add(StatusRow("Status", state.toStatusText())) - if (runningStartTime != null) add(StatusRow("Beginn", runningStartTime.toDisplayTime())) - if (currentPauseStart != null) add(StatusRow("Pause seit", currentPauseStart.toDisplayTime())) - add(StatusRow("Arbeitszeit", stats.currentlyWorked ?: "—")) - add(StatusRow("Offen", stats.open ?: "—")) - add(StatusRow("Woche", stats.weekWorktime ?: "—")) - add(StatusRow("Überstunden", stats.overtime ?: "—")) - add(StatusRow("Gesamt", stats.totalOvertime ?: "—")) - add(StatusRow("Arbeitsende", stats.adjustedEndTodayGeneral ?: stats.regularEnd ?: "—")) + add(StatusRow("Derzeit gearbeitet", stats.currentlyWorked.toHoursLabel())) + add(StatusRow("Offen", stats.open.toHoursLabel())) + add(StatusRow("Normales Arbeitsende", stats.regularEnd.toClockLabel())) + addOvertimeRow("Woche", stats.overtime) + addOvertimeRow("Gesamt", stats.totalOvertime) + add(StatusRow("Wochenarbeitszeit", stats.weekWorktime.toHoursLabel())) + add(StatusRow("Arbeitsfreie Stunden", stats.nonWorkingHours.toHoursLabel())) + add(StatusRow("Offen für Woche", stats.openForWeek.toHoursLabel())) + add(StatusRow("Bereinigtes Arbeitsende (heute)", isHeading = true)) + add(StatusRow("- Generell", stats.adjustedEndTodayGeneral.toClockLabel())) + add(StatusRow("- Woche", stats.adjustedEndTodayWeek.toClockLabel())) } } private fun TimeDashboard.primaryAction(): StatusAction? = when (state) { - null, "stop work" -> StatusAction("Kommen", ButtonVariant.Success, "start work") - "start work", "stop pause" -> StatusAction("Pause starten", ButtonVariant.Default, "start pause") - "start pause" -> StatusAction("Pause beenden", ButtonVariant.Success, "stop pause") - else -> StatusAction("Kommen", ButtonVariant.Success, "start work") + null, "stop work" -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work") + "start work", "stop pause" -> StatusAction("Arbeit beenden", ButtonVariant.Danger, "stop work") + "start pause" -> null + else -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work") } private fun TimeDashboard.secondaryAction(): StatusAction? = when (state) { - "start work", "stop pause" -> StatusAction("Gehen", ButtonVariant.Secondary, "stop work") + "start work", "stop pause" -> StatusAction("Pause beginnen", ButtonVariant.Secondary, "start pause") + "start pause" -> StatusAction("Pause beenden", ButtonVariant.Secondary, "stop pause") else -> null } - private fun String?.toStatusText(): String = - when (this) { - null, "stop work" -> "Nicht eingestempelt" - "start work" -> "Arbeit läuft" - "start pause" -> "Pause läuft" - "stop pause" -> "Arbeit läuft" - else -> this - } + private fun MutableList.addOvertimeRow(scope: String, value: String?) { + val raw = value?.takeIf { it.isNotBlank() && it != "—" } ?: "—" + val isNegative = raw.startsWith("-") + val label = if (isNegative) "Fehlzeit ($scope)" else "Überstunden ($scope)" + val displayValue = if (isNegative) raw.removePrefix("-").toHoursLabel() else raw.toHoursLabel() + add(StatusRow(label, displayValue)) + } - private fun String.toDisplayTime(): String = - runCatching { - OffsetDateTime.parse(this).toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm", Locale.GERMANY)) - }.getOrDefault(this) + private fun String?.toHoursLabel(): String { + val value = this?.takeIf { it.isNotBlank() } ?: return "—" + if (value == "—" || value.contains("erreicht")) return value + return if (value.endsWith(" h")) value else "$value h" + } + + private fun String?.toClockLabel(): String { + val value = this?.takeIf { it.isNotBlank() } ?: return "—" + if (value == "—" || value.contains("erreicht") || value.endsWith(" Uhr")) return value + return "$value Uhr" + } class Factory( private val application: Application,