diff --git a/backend/src/services/TimefixService.js b/backend/src/services/TimefixService.js index 27937b9..625a11e 100644 --- a/backend/src/services/TimefixService.js +++ b/backend/src/services/TimefixService.js @@ -33,15 +33,15 @@ class TimefixService { }); // Hole auch die Timefixes für diese Einträge - const entryIds = entries.map(e => e.id); + const entryIds = entries.map(e => e.id).filter(Boolean); if (entryIds.length === 0) { return []; } const timefixesForEntries = await sequelize.query( - `SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (?)`, + `SELECT worklog_id, fix_type, fix_date_time FROM timefix WHERE worklog_id IN (:entryIds)`, { - replacements: [entryIds], + replacements: { entryIds }, type: sequelize.QueryTypes.SELECT } ); diff --git a/mobile-app/build.gradle.kts b/mobile-app/build.gradle.kts index 7cd342d..9bd2d9c 100644 --- a/mobile-app/build.gradle.kts +++ b/mobile-app/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "9.1.1" apply false + id("com.android.application") version "9.2.1" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false } diff --git a/mobile-app/composeApp/src/main/AndroidManifest.xml b/mobile-app/composeApp/src/main/AndroidManifest.xml index 3df365b..50a6859 100644 --- a/mobile-app/composeApp/src/main/AndroidManifest.xml +++ b/mobile-app/composeApp/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="Stechuhr" + android:name=".TimeClockApplication" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.TimeClock"> diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/MainActivity.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/MainActivity.kt index 56df0e8..31f1d2c 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/MainActivity.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/MainActivity.kt @@ -2,6 +2,8 @@ package de.tsschulz.timeclock import android.graphics.Color import android.os.Bundle +import android.util.Log +import android.widget.TextView import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.core.view.WindowCompat @@ -18,23 +20,36 @@ class MainActivity : ComponentActivity() { @Suppress("DEPRECATION") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + Log.i(TAG, "MainActivity.onCreate") window.statusBarColor = Color.rgb(240, 255, 236) window.navigationBarColor = Color.WHITE - setContent { - val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application)) - val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application)) - val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application)) - val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application)) - val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application)) - TimeClockTheme { - TimeClockApp( - authViewModel = authViewModel, - timeViewModel = timeViewModel, - bookingViewModel = bookingViewModel, - settingsViewModel = settingsViewModel, - adminViewModel = adminViewModel, - ) + try { + setContent { + val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application)) + val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application)) + val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application)) + val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application)) + val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application)) + TimeClockTheme { + TimeClockApp( + authViewModel = authViewModel, + timeViewModel = timeViewModel, + bookingViewModel = bookingViewModel, + settingsViewModel = settingsViewModel, + adminViewModel = adminViewModel, + ) + } } + } catch (e: Throwable) { + Log.e(TAG, "Failed to create Compose content", e) + setContentView( + TextView(this).apply { + text = "Die App konnte nicht gestartet werden.\n${e.message.orEmpty()}" + setTextColor(Color.BLACK) + setBackgroundColor(Color.WHITE) + setPadding(32, 32, 32, 32) + }, + ) } window.decorView.post { val controller = WindowCompat.getInsetsController(window, window.decorView) @@ -42,4 +57,8 @@ class MainActivity : ComponentActivity() { controller.isAppearanceLightNavigationBars = true } } + + private companion object { + const val TAG = "TimeClockStartup" + } } diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/TimeClockApplication.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/TimeClockApplication.kt new file mode 100644 index 0000000..8250a02 --- /dev/null +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/TimeClockApplication.kt @@ -0,0 +1,20 @@ +package de.tsschulz.timeclock + +import android.app.Application +import android.util.Log + +class TimeClockApplication : Application() { + override fun onCreate() { + super.onCreate() + Log.i(TAG, "Application started") + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + Log.e(TAG, "Uncaught exception on ${thread.name}", throwable) + previousHandler?.uncaughtException(thread, throwable) + } + } + + private companion object { + const val TAG = "TimeClockStartup" + } +} 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 b5002ae..32a1476 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 @@ -56,8 +56,11 @@ data class TimefixDto( @Serializable data class WorklogEntryDto( val id: String, + @Serializable(with = FlexibleStringSerializer::class) val time: String? = null, + @Serializable(with = FlexibleStringSerializer::class) val action: String? = null, + @Serializable(with = FlexibleStringSerializer::class) val tstamp: String? = null, ) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/FlexibleStringSerializer.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/FlexibleStringSerializer.kt new file mode 100644 index 0000000..1d03ad3 --- /dev/null +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/data/api/FlexibleStringSerializer.kt @@ -0,0 +1,35 @@ +package de.tsschulz.timeclock.data.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +object FlexibleStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FlexibleString", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): String? { + val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString() + return when (val element = jsonDecoder.decodeJsonElement()) { + JsonNull -> null + is JsonPrimitive -> element.contentOrNull + else -> null + } + } + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize(encoder: Encoder, value: String?) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeString(value) + } + } +} 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 0fabf28..839a1a2 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 @@ -94,7 +94,6 @@ fun TimeClockApp( val sections = if (user.isAdmin) adminSections else userSections LaunchedEffect(user.id) { timeViewModel.start() } - LaunchedEffect(user.id) { bookingViewModel.loadPhase4() } LaunchedEffect(user.id) { settingsViewModel.loadPhase5() } LaunchedEffect(user.id, user.isAdmin) { adminViewModel.loadPhase6(user.isAdmin) } @@ -169,27 +168,42 @@ private fun DemoScreen( onWeekOffset = onWeekOffset, isTablet = isTablet, ) - AppRoute.Timefix -> TimefixScreen( - state = bookingState, - isTablet = isTablet, - onDate = { bookingViewModel.setTimefixDate(it) }, - onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) }, - onDelete = { bookingViewModel.deleteTimefix(it) }, - ) - AppRoute.Vacation -> VacationScreen( - state = bookingState, - isTablet = isTablet, - onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) }, - onDelete = { bookingViewModel.deleteVacation(it) }, - ) - AppRoute.Sick -> SickScreen( - state = bookingState, - isTablet = isTablet, - onCreate = { type, start, end -> bookingViewModel.createSick(type, start, end) }, - onDelete = { bookingViewModel.deleteSick(it) }, - ) - AppRoute.Workdays -> WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) }) - AppRoute.Calendar -> CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(it) }) + AppRoute.Timefix -> { + LaunchedEffect(route) { bookingViewModel.loadTimefix() } + TimefixScreen( + state = bookingState, + isTablet = isTablet, + onDate = { bookingViewModel.setTimefixDate(it) }, + onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) }, + onDelete = { bookingViewModel.deleteTimefix(it) }, + ) + } + AppRoute.Vacation -> { + LaunchedEffect(route) { bookingViewModel.loadVacations() } + VacationScreen( + state = bookingState, + isTablet = isTablet, + onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) }, + onDelete = { bookingViewModel.deleteVacation(it) }, + ) + } + AppRoute.Sick -> { + LaunchedEffect(route) { bookingViewModel.loadSick() } + SickScreen( + state = bookingState, + isTablet = isTablet, + onCreate = { type, start, end -> bookingViewModel.createSick(type, start, end) }, + onDelete = { bookingViewModel.deleteSick(it) }, + ) + } + AppRoute.Workdays -> { + LaunchedEffect(route) { bookingViewModel.loadWorkdays(bookingState.workdaysYear) } + WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) }) + } + AppRoute.Calendar -> { + LaunchedEffect(route) { bookingViewModel.loadCalendar(bookingState.calendarYear, bookingState.calendarMonth) } + CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(it) }) + } AppRoute.Entries -> { LaunchedEffect(Unit) { timeViewModel.loadEntries() } EntriesScreen( 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 51049a8..0c2941c 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 @@ -1,7 +1,10 @@ package de.tsschulz.timeclock.ui.booking +import android.app.DatePickerDialog +import android.app.TimePickerDialog import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -13,13 +16,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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 @@ -49,9 +56,13 @@ fun VacationScreen(state: BookingUiState, isTablet: Boolean, onCreate: (Int, Str var type by rememberSaveable { mutableStateOf("0") } var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } - Phase4Frame(state) { + Phase4Frame(loading = state.vacationLoading, error = state.vacationError) { FormCard("Urlaub eintragen", isTablet) { TcTextField("Umfang (0 Zeitraum, 1 Halber Tag)", type, { type = it }, placeholder = "0") + 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" }) + } 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 = { @@ -70,11 +81,23 @@ fun SickScreen(state: BookingUiState, isTablet: Boolean, onCreate: (String, Stri var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var end by rememberSaveable { mutableStateOf(LocalDate.now().toString()) } var typeId by rememberSaveable { mutableStateOf(state.sickTypes.firstOrNull()?.id?.toString().orEmpty()) } - Phase4Frame(state) { + LaunchedEffect(state.sickTypes) { + if (typeId.isBlank()) typeId = state.sickTypes.firstOrNull()?.id.orEmpty() + } + 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}" }) + FlowRow(horizontalArrangement = Arrangement.spacedBy(TcSpacing.Sm), verticalArrangement = Arrangement.spacedBy(TcSpacing.Sm)) { + state.sickTypes.forEach { type -> + TcButton( + text = type.name, + variant = if (type.id == typeId) ButtonVariant.Primary else ButtonVariant.Default, + onClick = { typeId = type.id }, + ) + } + } TcButton("Erkrankung eintragen", variant = ButtonVariant.Primary, onClick = { if (typeId.isNotBlank()) onCreate(typeId, start, end.ifBlank { start }) }) @@ -85,6 +108,7 @@ fun SickScreen(state: BookingUiState, isTablet: Boolean, onCreate: (String, Stri } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun TimefixScreen( state: BookingUiState, @@ -94,16 +118,38 @@ fun TimefixScreen( onDelete: (String) -> Unit, ) { var worklogId by rememberSaveable { mutableStateOf("") } + var newDate by rememberSaveable { mutableStateOf(state.timefixDate) } var time by rememberSaveable { mutableStateOf("08:00") } var action by rememberSaveable { mutableStateOf("start work") } - Phase4Frame(state) { + LaunchedEffect(state.worklogEntries) { + val selectedStillExists = state.worklogEntries.any { it.id == worklogId } + if (worklogId.isBlank() || !selectedStillExists) { + val first = state.worklogEntries.firstOrNull() + worklogId = first?.id.orEmpty() + first?.time?.let { time = it } + first?.action?.let { action = it } + } + } + LaunchedEffect(state.timefixDate) { + if (newDate.isBlank()) newDate = state.timefixDate + } + Phase4Frame(loading = state.timefixLoading, error = state.timefixError) { FormCard("Zeitkorrektur", isTablet) { - TcTextField("Datum", state.timefixDate, onDate, placeholder = "YYYY-MM-DD") - TcTextField("Worklog-ID", worklogId, { worklogId = it }, placeholder = state.worklogEntries.firstOrNull()?.id?.toString() ?: "") - TcTextField("Neue Uhrzeit", time, { time = it }, placeholder = "HH:MM") - TcTextField("Aktion", action, { action = it }, placeholder = "start work") + DateField("Datum des Original-Eintrags", state.timefixDate, onDate) + WorklogDropdown( + entries = state.worklogEntries, + selectedId = worklogId, + onSelect = { entry -> + worklogId = entry.id + entry.time?.let { time = it } + entry.action?.let { action = it } + }, + ) + DateField("Neues Datum", newDate, { newDate = it }) + TimeField("Neue Uhrzeit", time, { time = it }) + ActionDropdown(action, { action = it }) TcButton("Korrektur erstellen", variant = ButtonVariant.Primary, onClick = { - if (worklogId.isNotBlank()) onCreate(worklogId, state.timefixDate, time, action) + if (worklogId.isNotBlank()) onCreate(worklogId, newDate, time, action) }) if (state.worklogEntries.isEmpty()) { Text("Für dieses Datum sind keine Worklog-Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 13.sp) @@ -117,7 +163,7 @@ fun TimefixScreen( @Composable fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) { var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) } - Phase4Frame(state) { + 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) }) @@ -128,7 +174,7 @@ fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) { @Composable fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) { - Phase4Frame(state) { + Phase4Frame(loading = state.calendarLoading, error = state.calendarError) { TcCard { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { TcButton("←", variant = ButtonVariant.Secondary, onClick = { onMonth(-1) }) @@ -147,9 +193,9 @@ fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) { } @Composable -private fun Phase4Frame(state: BookingUiState, content: @Composable () -> Unit) { - if (state.loading) TcLoading("Lade Daten...") - state.error?.let { TcError(it) } +private fun Phase4Frame(loading: Boolean, error: String?, content: @Composable () -> Unit) { + if (loading) TcLoading("Lade Daten...") + error?.let { TcError(it) } content() } @@ -161,6 +207,121 @@ private fun FormCard(title: String, isTablet: Boolean, content: @Composable Colu } } +@Composable +private fun DateField(label: String, value: String, onValueChange: (String) -> Unit) { + val context = LocalContext.current + val parsed = runCatching { LocalDate.parse(value) }.getOrDefault(LocalDate.now()) + FieldLabel(label) + TcButton( + text = parsed.format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY)), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { + DatePickerDialog( + context, + { _, year, month, day -> + onValueChange(LocalDate.of(year, month + 1, day).toString()) + }, + parsed.year, + parsed.monthValue - 1, + parsed.dayOfMonth, + ).show() + }, + ) +} + +@Composable +private fun TimeField(label: String, value: String, onValueChange: (String) -> Unit) { + val context = LocalContext.current + val parts = value.split(":") + val hour = parts.getOrNull(0)?.toIntOrNull() ?: 8 + val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0 + FieldLabel(label) + TcButton( + text = "%02d:%02d".format(hour, minute), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { + TimePickerDialog( + context, + { _, selectedHour, selectedMinute -> + onValueChange("%02d:%02d".format(selectedHour, selectedMinute)) + }, + hour, + minute, + true, + ).show() + }, + ) +} + +@Composable +private fun WorklogDropdown( + entries: List, + selectedId: String, + onSelect: (WorklogEntryDto) -> Unit, +) { + var expanded by rememberSaveable { mutableStateOf(false) } + val selected = entries.firstOrNull { it.id == selectedId } + FieldLabel("Eintrag") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = selected?.toWorklogLabel() ?: if (entries.isEmpty()) "Keine Einträge für dieses Datum" else "Bitte wählen", + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { if (entries.isNotEmpty()) expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + entries.forEach { entry -> + DropdownMenuItem( + text = { Text(entry.toWorklogLabel(), color = TcColors.Text) }, + onClick = { + onSelect(entry) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun ActionDropdown(value: String, onValueChange: (String) -> Unit) { + var expanded by rememberSaveable { mutableStateOf(false) } + val actions = listOf("start work", "stop work", "start pause", "stop pause") + FieldLabel("Neuer Eintrags-Typ") + Box(modifier = Modifier.fillMaxWidth()) { + TcButton( + text = value.toActionLabel(), + variant = ButtonVariant.Default, + modifier = Modifier.fillMaxWidth(), + onClick = { expanded = true }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + actions.forEach { action -> + DropdownMenuItem( + text = { Text(action.toActionLabel(), color = TcColors.Text) }, + onClick = { + onValueChange(action) + expanded = false + }, + ) + } + } + } +} + +@Composable +private fun FieldLabel(label: String) { + Text( + text = label, + color = Color333, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + modifier = Modifier.padding(bottom = TcSpacing.Sm), + ) +} + @Composable private fun ListCard(title: String, items: List, row: @Composable (T) -> Unit) { TcCard { @@ -191,14 +352,14 @@ private fun SickRow(item: SickEntryDto, onDelete: (String) -> Unit) = DataRow( @Composable private fun TimefixRow(item: TimefixDto, onDelete: (String) -> Unit) = DataRow( - title = item.newAction ?: "Zeitkorrektur", + title = item.newAction?.toActionLabel() ?: "Zeitkorrektur", details = "${item.originalDate.toDisplayDate()} ${item.originalTime ?: "—"} → ${item.newDate.toDisplayDate()} ${item.newTime ?: "—"}", onDelete = { onDelete(item.id) }, ) @Composable private fun WorklogRow(item: WorklogEntryDto) = DataRow( - title = "#${item.id} ${item.action ?: "—"}", + title = item.action?.toActionLabel() ?: "Eintrag", details = "${item.time ?: "—"} (${item.tstamp ?: "—"})", ) @@ -298,3 +459,17 @@ private fun String?.toDisplayDate(): String { LocalDate.parse(this.substring(0, 10)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY)) }.getOrDefault(this) } + +private fun String.toActionLabel(): String = + when (this) { + "start work" -> "Arbeit beginnen" + "stop work" -> "Arbeit beenden" + "start pause" -> "Pause beginnen" + "stop pause" -> "Pause beenden" + else -> this + } + +private fun WorklogEntryDto.toWorklogLabel(): String = + "${time ?: "—"} - ${(action ?: "—").toActionLabel()}" + +private val Color333 = androidx.compose.ui.graphics.Color(0xFF333333) diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingViewModel.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingViewModel.kt index 27d24fa..b9b658a 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingViewModel.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/booking/BookingViewModel.kt @@ -25,6 +25,16 @@ import java.time.YearMonth data class BookingUiState( val loading: Boolean = false, val error: String? = null, + val vacationLoading: Boolean = false, + val vacationError: String? = null, + val sickLoading: Boolean = false, + val sickError: String? = null, + val timefixLoading: Boolean = false, + val timefixError: String? = null, + val workdaysLoading: Boolean = false, + val workdaysError: String? = null, + val calendarLoading: Boolean = false, + val calendarError: String? = null, val vacations: List = emptyList(), val sickEntries: List = emptyList(), val sickTypes: List = emptyList(), @@ -46,13 +56,14 @@ class BookingViewModel( fun loadPhase4() { loadVacations() - loadSick() - loadTimefix() - loadWorkdays(_uiState.value.workdaysYear) - loadCalendar(_uiState.value.calendarYear, _uiState.value.calendarMonth) } - fun loadVacations() = launchLoad { copy(vacations = repository.getVacations()) } + fun loadVacations() = launchSection( + start = { copy(vacationLoading = true, vacationError = null) }, + success = { vacations -> copy(vacationLoading = false, vacationError = null, vacations = vacations) }, + failure = { message -> copy(vacationLoading = false, vacationError = message) }, + block = { repository.getVacations() }, + ) fun createVacation(type: Int, start: String, end: String?) = launchMutation { repository.createVacation(type, start, end) loadVacations() @@ -62,9 +73,12 @@ class BookingViewModel( loadVacations() } - fun loadSick() = launchLoad { - copy(sickEntries = repository.getSickEntries(), sickTypes = repository.getSickTypes()) - } + fun loadSick() = launchSection( + start = { copy(sickLoading = true, sickError = null) }, + success = { result -> copy(sickLoading = false, sickError = null, sickEntries = result.first, sickTypes = result.second) }, + failure = { message -> copy(sickLoading = false, sickError = message) }, + block = { repository.getSickEntries() to repository.getSickTypes() }, + ) fun createSick(typeId: String, start: String, end: String?) = launchMutation { repository.createSick(typeId, start, end) loadSick() @@ -79,9 +93,12 @@ class BookingViewModel( loadTimefix(date) } - fun loadTimefix(date: String = _uiState.value.timefixDate) = launchLoad { - copy(timefixes = repository.getTimefixes(), worklogEntries = repository.getWorklogEntries(date)) - } + fun loadTimefix(date: String = _uiState.value.timefixDate) = launchSection( + start = { copy(timefixLoading = true, timefixError = null) }, + success = { result -> copy(timefixLoading = false, timefixError = null, timefixes = result.first, worklogEntries = result.second) }, + failure = { message -> copy(timefixLoading = false, timefixError = message) }, + block = { repository.getTimefixes() to repository.getWorklogEntries(date) }, + ) fun createTimefix(worklogId: String, date: String, time: String, action: String) = launchMutation { repository.createTimefix(worklogId, date, time, action) loadTimefix(date) @@ -91,25 +108,39 @@ class BookingViewModel( loadTimefix() } - fun loadWorkdays(year: Int) = launchLoad { - copy(workdaysYear = year, workdays = repository.getWorkdays(year)) - } + fun loadWorkdays(year: Int) = launchSection( + start = { copy(workdaysLoading = true, workdaysError = null, workdaysYear = year) }, + success = { workdays -> copy(workdaysLoading = false, workdaysError = null, workdays = workdays) }, + failure = { message -> copy(workdaysLoading = false, workdaysError = message) }, + block = { repository.getWorkdays(year) }, + ) fun changeCalendarMonth(delta: Int) { val current = YearMonth.of(_uiState.value.calendarYear, _uiState.value.calendarMonth).plusMonths(delta.toLong()) loadCalendar(current.year, current.monthValue) } - fun loadCalendar(year: Int, month: Int) = launchLoad { - copy(calendarYear = year, calendarMonth = month, calendar = repository.getCalendar(year, month)) - } + fun loadCalendar(year: Int, month: Int) = launchSection( + start = { copy(calendarLoading = true, calendarError = null, calendarYear = year, calendarMonth = month) }, + success = { calendar -> copy(calendarLoading = false, calendarError = null, calendar = calendar) }, + failure = { message -> copy(calendarLoading = false, calendarError = message) }, + block = { repository.getCalendar(year, month) }, + ) - private fun launchLoad(reducer: suspend BookingUiState.() -> BookingUiState) { + private fun launchSection( + start: BookingUiState.() -> BookingUiState, + success: BookingUiState.(T) -> BookingUiState, + failure: BookingUiState.(String) -> BookingUiState, + block: suspend () -> T, + ) { viewModelScope.launch { - _uiState.update { it.copy(loading = true, error = null) } - runCatching { _uiState.value.reducer() } - .onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) } - .onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } } + _uiState.update { it.start().copy(error = null) } + runCatching { block() } + .onSuccess { value -> _uiState.update { it.success(value) } } + .onFailure { e -> + val message = e.message ?: "Daten konnten nicht geladen werden" + _uiState.update { it.failure(message) } + } } } diff --git a/mobile-app/gradle/wrapper/gradle-wrapper.properties b/mobile-app/gradle/wrapper/gradle-wrapper.properties index 37f78a6..c61a118 100644 --- a/mobile-app/gradle/wrapper/gradle-wrapper.properties +++ b/mobile-app/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME