Refactor TimefixService to improve query handling and enhance error management in MainActivity. Update AndroidManifest to specify application class and increment Gradle version. Enhance BookingViewModel for better state management and loading indicators. Update UI components for improved user experience in booking screens.
This commit is contained in:
@@ -33,15 +33,15 @@ class TimefixService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Hole auch die Timefixes für diese Einträge
|
// 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) {
|
if (entryIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const timefixesForEntries = await sequelize.query(
|
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
|
type: sequelize.QueryTypes.SELECT
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
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.compose") version "2.2.20" apply false
|
||||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.2.20" apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="Stechuhr"
|
android:label="Stechuhr"
|
||||||
|
android:name=".TimeClockApplication"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.TimeClock">
|
android:theme="@style/Theme.TimeClock">
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package de.tsschulz.timeclock
|
|||||||
|
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
@@ -18,23 +20,36 @@ class MainActivity : ComponentActivity() {
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
Log.i(TAG, "MainActivity.onCreate")
|
||||||
window.statusBarColor = Color.rgb(240, 255, 236)
|
window.statusBarColor = Color.rgb(240, 255, 236)
|
||||||
window.navigationBarColor = Color.WHITE
|
window.navigationBarColor = Color.WHITE
|
||||||
setContent {
|
try {
|
||||||
val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application))
|
setContent {
|
||||||
val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application))
|
val authViewModel: AuthViewModel = viewModel(factory = AuthViewModel.Factory(application))
|
||||||
val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application))
|
val timeViewModel: TimeViewModel = viewModel(factory = TimeViewModel.Factory(application))
|
||||||
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application))
|
val bookingViewModel: BookingViewModel = viewModel(factory = BookingViewModel.Factory(application))
|
||||||
val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application))
|
val settingsViewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(application))
|
||||||
TimeClockTheme {
|
val adminViewModel: AdminViewModel = viewModel(factory = AdminViewModel.Factory(application))
|
||||||
TimeClockApp(
|
TimeClockTheme {
|
||||||
authViewModel = authViewModel,
|
TimeClockApp(
|
||||||
timeViewModel = timeViewModel,
|
authViewModel = authViewModel,
|
||||||
bookingViewModel = bookingViewModel,
|
timeViewModel = timeViewModel,
|
||||||
settingsViewModel = settingsViewModel,
|
bookingViewModel = bookingViewModel,
|
||||||
adminViewModel = adminViewModel,
|
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 {
|
window.decorView.post {
|
||||||
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
||||||
@@ -42,4 +57,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
controller.isAppearanceLightNavigationBars = true
|
controller.isAppearanceLightNavigationBars = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "TimeClockStartup"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,8 +56,11 @@ data class TimefixDto(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class WorklogEntryDto(
|
data class WorklogEntryDto(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
@Serializable(with = FlexibleStringSerializer::class)
|
||||||
val time: String? = null,
|
val time: String? = null,
|
||||||
|
@Serializable(with = FlexibleStringSerializer::class)
|
||||||
val action: String? = null,
|
val action: String? = null,
|
||||||
|
@Serializable(with = FlexibleStringSerializer::class)
|
||||||
val tstamp: String? = null,
|
val tstamp: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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<String?> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,6 @@ fun TimeClockApp(
|
|||||||
|
|
||||||
val sections = if (user.isAdmin) adminSections else userSections
|
val sections = if (user.isAdmin) adminSections else userSections
|
||||||
LaunchedEffect(user.id) { timeViewModel.start() }
|
LaunchedEffect(user.id) { timeViewModel.start() }
|
||||||
LaunchedEffect(user.id) { bookingViewModel.loadPhase4() }
|
|
||||||
LaunchedEffect(user.id) { settingsViewModel.loadPhase5() }
|
LaunchedEffect(user.id) { settingsViewModel.loadPhase5() }
|
||||||
LaunchedEffect(user.id, user.isAdmin) { adminViewModel.loadPhase6(user.isAdmin) }
|
LaunchedEffect(user.id, user.isAdmin) { adminViewModel.loadPhase6(user.isAdmin) }
|
||||||
|
|
||||||
@@ -169,27 +168,42 @@ private fun DemoScreen(
|
|||||||
onWeekOffset = onWeekOffset,
|
onWeekOffset = onWeekOffset,
|
||||||
isTablet = isTablet,
|
isTablet = isTablet,
|
||||||
)
|
)
|
||||||
AppRoute.Timefix -> TimefixScreen(
|
AppRoute.Timefix -> {
|
||||||
state = bookingState,
|
LaunchedEffect(route) { bookingViewModel.loadTimefix() }
|
||||||
isTablet = isTablet,
|
TimefixScreen(
|
||||||
onDate = { bookingViewModel.setTimefixDate(it) },
|
state = bookingState,
|
||||||
onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) },
|
isTablet = isTablet,
|
||||||
onDelete = { bookingViewModel.deleteTimefix(it) },
|
onDate = { bookingViewModel.setTimefixDate(it) },
|
||||||
)
|
onCreate = { id, date, time, action -> bookingViewModel.createTimefix(id, date, time, action) },
|
||||||
AppRoute.Vacation -> VacationScreen(
|
onDelete = { bookingViewModel.deleteTimefix(it) },
|
||||||
state = bookingState,
|
)
|
||||||
isTablet = isTablet,
|
}
|
||||||
onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) },
|
AppRoute.Vacation -> {
|
||||||
onDelete = { bookingViewModel.deleteVacation(it) },
|
LaunchedEffect(route) { bookingViewModel.loadVacations() }
|
||||||
)
|
VacationScreen(
|
||||||
AppRoute.Sick -> SickScreen(
|
state = bookingState,
|
||||||
state = bookingState,
|
isTablet = isTablet,
|
||||||
isTablet = isTablet,
|
onCreate = { type, start, end -> bookingViewModel.createVacation(type, start, end) },
|
||||||
onCreate = { type, start, end -> bookingViewModel.createSick(type, start, end) },
|
onDelete = { bookingViewModel.deleteVacation(it) },
|
||||||
onDelete = { bookingViewModel.deleteSick(it) },
|
)
|
||||||
)
|
}
|
||||||
AppRoute.Workdays -> WorkdaysScreen(state = bookingState, onYear = { bookingViewModel.loadWorkdays(it) })
|
AppRoute.Sick -> {
|
||||||
AppRoute.Calendar -> CalendarScreen(state = bookingState, onMonth = { bookingViewModel.changeCalendarMonth(it) })
|
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 -> {
|
AppRoute.Entries -> {
|
||||||
LaunchedEffect(Unit) { timeViewModel.loadEntries() }
|
LaunchedEffect(Unit) { timeViewModel.loadEntries() }
|
||||||
EntriesScreen(
|
EntriesScreen(
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package de.tsschulz.timeclock.ui.booking
|
package de.tsschulz.timeclock.ui.booking
|
||||||
|
|
||||||
|
import android.app.DatePickerDialog
|
||||||
|
import android.app.TimePickerDialog
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
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.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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 type by rememberSaveable { mutableStateOf("0") }
|
||||||
var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
|
var start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
|
||||||
var end 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) {
|
FormCard("Urlaub eintragen", isTablet) {
|
||||||
TcTextField("Umfang (0 Zeitraum, 1 Halber Tag)", type, { type = it }, placeholder = "0")
|
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("Urlaubsbeginn", start, { start = it }, placeholder = "YYYY-MM-DD")
|
||||||
TcTextField("Urlaubsende", end, { end = it }, placeholder = "YYYY-MM-DD")
|
TcTextField("Urlaubsende", end, { end = it }, placeholder = "YYYY-MM-DD")
|
||||||
TcButton("Urlaub eintragen", variant = ButtonVariant.Primary, onClick = {
|
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 start by rememberSaveable { mutableStateOf(LocalDate.now().toString()) }
|
||||||
var end 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()) }
|
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) {
|
FormCard("Erkrankung eintragen", isTablet) {
|
||||||
TcTextField("Erster Krankheitstag", start, { start = it }, placeholder = "YYYY-MM-DD")
|
TcTextField("Erster Krankheitstag", start, { start = it }, placeholder = "YYYY-MM-DD")
|
||||||
TcTextField("Letzter Krankheitstag", end, { end = 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}" })
|
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 = {
|
TcButton("Erkrankung eintragen", variant = ButtonVariant.Primary, onClick = {
|
||||||
if (typeId.isNotBlank()) onCreate(typeId, start, end.ifBlank { start })
|
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
|
@Composable
|
||||||
fun TimefixScreen(
|
fun TimefixScreen(
|
||||||
state: BookingUiState,
|
state: BookingUiState,
|
||||||
@@ -94,16 +118,38 @@ fun TimefixScreen(
|
|||||||
onDelete: (String) -> Unit,
|
onDelete: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var worklogId by rememberSaveable { mutableStateOf("") }
|
var worklogId by rememberSaveable { mutableStateOf("") }
|
||||||
|
var newDate by rememberSaveable { mutableStateOf(state.timefixDate) }
|
||||||
var time by rememberSaveable { mutableStateOf("08:00") }
|
var time by rememberSaveable { mutableStateOf("08:00") }
|
||||||
var action by rememberSaveable { mutableStateOf("start work") }
|
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) {
|
FormCard("Zeitkorrektur", isTablet) {
|
||||||
TcTextField("Datum", state.timefixDate, onDate, placeholder = "YYYY-MM-DD")
|
DateField("Datum des Original-Eintrags", state.timefixDate, onDate)
|
||||||
TcTextField("Worklog-ID", worklogId, { worklogId = it }, placeholder = state.worklogEntries.firstOrNull()?.id?.toString() ?: "")
|
WorklogDropdown(
|
||||||
TcTextField("Neue Uhrzeit", time, { time = it }, placeholder = "HH:MM")
|
entries = state.worklogEntries,
|
||||||
TcTextField("Aktion", action, { action = it }, placeholder = "start work")
|
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 = {
|
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()) {
|
if (state.worklogEntries.isEmpty()) {
|
||||||
Text("Für dieses Datum sind keine Worklog-Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 13.sp)
|
Text("Für dieses Datum sind keine Worklog-Einträge vorhanden.", color = TcColors.TextMuted, fontSize = 13.sp)
|
||||||
@@ -117,7 +163,7 @@ fun TimefixScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) {
|
fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) {
|
||||||
var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) }
|
var year by rememberSaveable { mutableStateOf(state.workdaysYear.toString()) }
|
||||||
Phase4Frame(state) {
|
Phase4Frame(loading = state.workdaysLoading, error = state.workdaysError) {
|
||||||
FormCard("Arbeitstage", isTablet = false) {
|
FormCard("Arbeitstage", isTablet = false) {
|
||||||
TcTextField("Jahr", year, { year = it }, placeholder = "2026")
|
TcTextField("Jahr", year, { year = it }, placeholder = "2026")
|
||||||
TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) })
|
TcButton("Jahr laden", variant = ButtonVariant.Primary, onClick = { year.toIntOrNull()?.let(onYear) })
|
||||||
@@ -128,7 +174,7 @@ fun WorkdaysScreen(state: BookingUiState, onYear: (Int) -> Unit) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) {
|
fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) {
|
||||||
Phase4Frame(state) {
|
Phase4Frame(loading = state.calendarLoading, error = state.calendarError) {
|
||||||
TcCard {
|
TcCard {
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||||
TcButton("←", variant = ButtonVariant.Secondary, onClick = { onMonth(-1) })
|
TcButton("←", variant = ButtonVariant.Secondary, onClick = { onMonth(-1) })
|
||||||
@@ -147,9 +193,9 @@ fun CalendarScreen(state: BookingUiState, onMonth: (Int) -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Phase4Frame(state: BookingUiState, content: @Composable () -> Unit) {
|
private fun Phase4Frame(loading: Boolean, error: String?, content: @Composable () -> Unit) {
|
||||||
if (state.loading) TcLoading("Lade Daten...")
|
if (loading) TcLoading("Lade Daten...")
|
||||||
state.error?.let { TcError(it) }
|
error?.let { TcError(it) }
|
||||||
content()
|
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<WorklogEntryDto>,
|
||||||
|
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
|
@Composable
|
||||||
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
|
private fun <T> ListCard(title: String, items: List<T>, row: @Composable (T) -> Unit) {
|
||||||
TcCard {
|
TcCard {
|
||||||
@@ -191,14 +352,14 @@ private fun SickRow(item: SickEntryDto, onDelete: (String) -> Unit) = DataRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TimefixRow(item: TimefixDto, onDelete: (String) -> Unit) = DataRow(
|
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 ?: "—"}",
|
details = "${item.originalDate.toDisplayDate()} ${item.originalTime ?: "—"} → ${item.newDate.toDisplayDate()} ${item.newTime ?: "—"}",
|
||||||
onDelete = { onDelete(item.id) },
|
onDelete = { onDelete(item.id) },
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WorklogRow(item: WorklogEntryDto) = DataRow(
|
private fun WorklogRow(item: WorklogEntryDto) = DataRow(
|
||||||
title = "#${item.id} ${item.action ?: "—"}",
|
title = item.action?.toActionLabel() ?: "Eintrag",
|
||||||
details = "${item.time ?: "—"} (${item.tstamp ?: "—"})",
|
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))
|
LocalDate.parse(this.substring(0, 10)).format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
|
||||||
}.getOrDefault(this)
|
}.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)
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ import java.time.YearMonth
|
|||||||
data class BookingUiState(
|
data class BookingUiState(
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val error: String? = null,
|
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<VacationDto> = emptyList(),
|
val vacations: List<VacationDto> = emptyList(),
|
||||||
val sickEntries: List<SickEntryDto> = emptyList(),
|
val sickEntries: List<SickEntryDto> = emptyList(),
|
||||||
val sickTypes: List<SickTypeDto> = emptyList(),
|
val sickTypes: List<SickTypeDto> = emptyList(),
|
||||||
@@ -46,13 +56,14 @@ class BookingViewModel(
|
|||||||
|
|
||||||
fun loadPhase4() {
|
fun loadPhase4() {
|
||||||
loadVacations()
|
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 {
|
fun createVacation(type: Int, start: String, end: String?) = launchMutation {
|
||||||
repository.createVacation(type, start, end)
|
repository.createVacation(type, start, end)
|
||||||
loadVacations()
|
loadVacations()
|
||||||
@@ -62,9 +73,12 @@ class BookingViewModel(
|
|||||||
loadVacations()
|
loadVacations()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadSick() = launchLoad {
|
fun loadSick() = launchSection(
|
||||||
copy(sickEntries = repository.getSickEntries(), sickTypes = repository.getSickTypes())
|
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 {
|
fun createSick(typeId: String, start: String, end: String?) = launchMutation {
|
||||||
repository.createSick(typeId, start, end)
|
repository.createSick(typeId, start, end)
|
||||||
loadSick()
|
loadSick()
|
||||||
@@ -79,9 +93,12 @@ class BookingViewModel(
|
|||||||
loadTimefix(date)
|
loadTimefix(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadTimefix(date: String = _uiState.value.timefixDate) = launchLoad {
|
fun loadTimefix(date: String = _uiState.value.timefixDate) = launchSection(
|
||||||
copy(timefixes = repository.getTimefixes(), worklogEntries = repository.getWorklogEntries(date))
|
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 {
|
fun createTimefix(worklogId: String, date: String, time: String, action: String) = launchMutation {
|
||||||
repository.createTimefix(worklogId, date, time, action)
|
repository.createTimefix(worklogId, date, time, action)
|
||||||
loadTimefix(date)
|
loadTimefix(date)
|
||||||
@@ -91,25 +108,39 @@ class BookingViewModel(
|
|||||||
loadTimefix()
|
loadTimefix()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadWorkdays(year: Int) = launchLoad {
|
fun loadWorkdays(year: Int) = launchSection(
|
||||||
copy(workdaysYear = year, workdays = repository.getWorkdays(year))
|
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) {
|
fun changeCalendarMonth(delta: Int) {
|
||||||
val current = YearMonth.of(_uiState.value.calendarYear, _uiState.value.calendarMonth).plusMonths(delta.toLong())
|
val current = YearMonth.of(_uiState.value.calendarYear, _uiState.value.calendarMonth).plusMonths(delta.toLong())
|
||||||
loadCalendar(current.year, current.monthValue)
|
loadCalendar(current.year, current.monthValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadCalendar(year: Int, month: Int) = launchLoad {
|
fun loadCalendar(year: Int, month: Int) = launchSection(
|
||||||
copy(calendarYear = year, calendarMonth = month, calendar = repository.getCalendar(year, month))
|
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 <T> launchSection(
|
||||||
|
start: BookingUiState.() -> BookingUiState,
|
||||||
|
success: BookingUiState.(T) -> BookingUiState,
|
||||||
|
failure: BookingUiState.(String) -> BookingUiState,
|
||||||
|
block: suspend () -> T,
|
||||||
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(loading = true, error = null) }
|
_uiState.update { it.start().copy(error = null) }
|
||||||
runCatching { _uiState.value.reducer() }
|
runCatching { block() }
|
||||||
.onSuccess { next -> _uiState.value = next.copy(loading = false, error = null) }
|
.onSuccess { value -> _uiState.update { it.success(value) } }
|
||||||
.onFailure { e -> _uiState.update { it.copy(loading = false, error = e.message ?: "Daten konnten nicht geladen werden") } }
|
.onFailure { e ->
|
||||||
|
val message = e.message ?: "Daten konnten nicht geladen werden"
|
||||||
|
_uiState.update { it.failure(message) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
Reference in New Issue
Block a user