diff --git a/.gitignore b/.gitignore index 620a93d..dd19dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .gradle/ +.kotlin/ build/ local.properties app/google-services.json diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt index 6a66dbb..10b0703 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -2,6 +2,7 @@ package pl.firmatpp.kierowca.data import android.content.Context import android.net.Uri +import android.provider.OpenableColumns import java.io.File import java.security.MessageDigest import java.util.UUID @@ -20,8 +21,12 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto +import pl.firmatpp.kierowca.data.model.DriverLeaveDocumentDto +import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto +import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto import pl.firmatpp.kierowca.data.model.FinishRouteBody +import pl.firmatpp.kierowca.data.model.LeaveForecastBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.OtpResponse @@ -70,18 +75,92 @@ class DriverRepository( suspend fun leaveRequests(): List = api.leaveRequests(authHeader(requireToken())).data + suspend fun leaveSummary(): DriverLeaveSummaryDto = + api.leaveSummary(authHeader(requireToken())).data + + suspend fun leaveForecast( + dateFrom: String, + dateTo: String, + type: String, + requestMode: String, + requestedQuantity: Int?, + ): DriverLeaveForecastDto = + api.leaveForecast( + authHeader(requireToken()), + LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity), + ).data + suspend fun leaveCalendar(from: String, to: String): List = api.leaveCalendar(authHeader(requireToken()), from, to).data suspend fun leaveRequest(id: String): DriverLeaveRequestDto = api.leaveRequest(authHeader(requireToken()), id).data - suspend fun createLeaveRequest(dateFrom: String, dateTo: String, type: String, note: String?): DriverLeaveRequestDto = - api.createLeaveRequest(authHeader(requireToken()), CreateLeaveRequestBody(dateFrom, dateTo, type, note)).data + suspend fun createLeaveRequest( + dateFrom: String, + dateTo: String, + type: String, + requestMode: String, + requestedQuantity: Int?, + details: Map, + idempotencyKey: String, + note: String?, + ): DriverLeaveRequestDto = + api.createLeaveRequest( + authHeader(requireToken()), + CreateLeaveRequestBody( + dateFrom = dateFrom, + dateTo = dateTo, + type = type, + requestMode = requestMode, + requestedQuantity = requestedQuantity, + details = details, + idempotencyKey = idempotencyKey, + note = note, + ), + ).data suspend fun cancelLeaveRequest(id: String, expectedVersion: Long, comment: String? = null): DriverLeaveRequestDto = api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment, expectedVersion)).data + suspend fun leaveDocuments(id: String): List = + api.leaveDocuments(authHeader(requireToken()), id).data + + suspend fun uploadLeaveDocument(id: String, documentType: String, uri: Uri): DriverLeaveDocumentDto { + val resolver = context.contentResolver + val mimeType = resolver.getType(uri) ?: "application/octet-stream" + val fileMetadata = resolver.query( + uri, + arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + if (!cursor.moveToFirst()) { + null + } else { + cursor.getString(0) to cursor.getLong(1) + } + } + if ((fileMetadata?.second ?: 0L) > 20L * 1024L * 1024L) { + error("Dokument jest za duży. Maksymalny rozmiar pliku to 20 MB.") + } + val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } + ?: error("Nie można odczytać wybranego dokumentu.") + val originalName = fileMetadata?.first + ?.takeIf { it.isNotBlank() } + ?: "dokument" + val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) + val file = MultipartBody.Part.createFormData("file", originalName, body) + + return api.uploadLeaveDocument( + authHeader(requireToken()), + id, + file, + documentType.toPlainTextBody(), + ).data + } + suspend fun completeRoute(routeId: String) = api.completeRoute(authHeader(requireToken()), routeId) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt index c618cad..494d73d 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt @@ -13,9 +13,14 @@ import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.LeaveCalendarResponse +import pl.firmatpp.kierowca.data.model.LeaveDocumentListResponse +import pl.firmatpp.kierowca.data.model.LeaveDocumentResponse +import pl.firmatpp.kierowca.data.model.LeaveForecastBody +import pl.firmatpp.kierowca.data.model.LeaveForecastResponse import pl.firmatpp.kierowca.data.model.LeaveRequestListResponse import pl.firmatpp.kierowca.data.model.LeaveRequestResponse import pl.firmatpp.kierowca.data.model.LeaveRequestTypesResponse +import pl.firmatpp.kierowca.data.model.LeaveSummaryResponse import pl.firmatpp.kierowca.data.model.OtpResponse import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto @@ -80,6 +85,17 @@ interface MobileDriverApi { @Header("Authorization") authorization: String, ): LeaveRequestTypesResponse + @GET("mobile/driver/leave-summary") + suspend fun leaveSummary( + @Header("Authorization") authorization: String, + ): LeaveSummaryResponse + + @POST("mobile/driver/leave-forecast") + suspend fun leaveForecast( + @Header("Authorization") authorization: String, + @Body body: LeaveForecastBody, + ): LeaveForecastResponse + @GET("mobile/driver/leave-requests") suspend fun leaveRequests( @Header("Authorization") authorization: String, @@ -104,6 +120,21 @@ interface MobileDriverApi { @Path("id") id: String, ): LeaveRequestResponse + @GET("mobile/driver/leave-requests/{id}/documents") + suspend fun leaveDocuments( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): LeaveDocumentListResponse + + @Multipart + @POST("mobile/driver/leave-requests/{id}/documents") + suspend fun uploadLeaveDocument( + @Header("Authorization") authorization: String, + @Path("id") id: String, + @Part file: MultipartBody.Part, + @Part("documentType") documentType: RequestBody, + ): LeaveDocumentResponse + @POST("mobile/driver/leave-requests/{id}/cancel") suspend fun cancelLeaveRequest( @Header("Authorization") authorization: String, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index 5d57121..184124c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -223,13 +223,94 @@ data class LeaveRequestTypesResponse( data class LeaveRequestTypeDto( val value: String, + val code: String? = null, + val ruleCode: String? = null, val label: String, + val category: String? = null, + val unit: String? = null, + val poolCode: String? = null, + val requestModes: List = listOf("days"), + val requiredFields: List = emptyList(), + val requiredDocuments: List = emptyList(), + val fieldOptions: Map> = emptyMap(), + val description: String? = null, + val decisionMode: String? = null, + val payrollMode: String? = null, + val legalBasis: String? = null, +) + +data class LeaveSummaryResponse( + val data: DriverLeaveSummaryDto, +) + +data class DriverLeaveSummaryDto( + val pools: List = emptyList(), + val dailyNormMinutes: Int = 480, + val balanceConfigured: Boolean = false, + val overdueQuantity: Int = 0, + val availableTypes: List = emptyList(), + val reminders: List = emptyList(), +) + +data class DriverLeavePoolDto( + val id: Int, + val poolCode: String, + val year: Int, + val unit: String, + val calculatedQuantity: Int = 0, + val confirmedQuantity: Int? = null, + val availableQuantity: Int = 0, + val reservedQuantity: Int = 0, + val usedQuantity: Int = 0, + val grantedQuantity: Int = 0, + val overdue: Boolean = false, + val status: String, +) + +data class DriverLeaveReminderDto( + val code: String, + val message: String, + val poolId: Int? = null, +) + +data class LeaveForecastBody( + val type: String, + val dateFrom: String, + val dateTo: String, + val requestMode: String, + val requestedQuantity: Int? = null, +) + +data class LeaveForecastResponse( + val data: DriverLeaveForecastDto, +) + +data class DriverLeaveForecastDto( + val ruleCode: String, + val quantityUnit: String, + val days: List = emptyList(), + val workingDaysCount: Int = 0, + val requestedQuantity: Int = 0, + val availableQuantity: Int = 0, + val balanceConfigured: Boolean = false, + val sufficientBalance: Boolean = true, + val pools: List = emptyList(), +) + +data class DriverLeaveForecastDayDto( + val date: String, + val quantity: Int, + val source: String? = null, ) data class CreateLeaveRequestBody( val dateFrom: String, val dateTo: String, val type: String, + val requestMode: String = "days", + val requestedQuantity: Int? = null, + val details: Map = emptyMap(), + val idempotencyKey: String? = null, val note: String?, ) @@ -244,7 +325,12 @@ data class DriverLeaveRequestDto( val dateFrom: String?, val dateTo: String?, val type: String, + val ruleCode: String? = null, val typeLabel: String? = null, + val requestMode: String = "days", + val requestedQuantity: Int? = null, + val rule: DriverLeaveRuleSnapshotDto? = null, + val details: Map = emptyMap(), val note: String? = null, val status: String, val version: Long = 1, @@ -255,6 +341,54 @@ data class DriverLeaveRequestDto( val decidedAt: String? = null, val decisionComment: String? = null, val events: List = emptyList(), + val allocations: List = emptyList(), + val balanceConfigurationRequired: Boolean = false, + val missingDocumentTypes: List = emptyList(), +) + +data class DriverLeaveRuleSnapshotDto( + val code: String? = null, + val label: String? = null, + val unit: String? = null, + val decisionMode: String? = null, + val payrollMode: String? = null, + val poolCode: String? = null, + val rule: DriverLeaveRuleRequirementsDto? = null, + val legalBasis: String? = null, +) + +data class DriverLeaveRuleRequirementsDto( + val requestModes: List = listOf("days"), + val requiredFields: List = emptyList(), + val requiredDocuments: List = emptyList(), + val fieldOptions: Map> = emptyMap(), + val description: String? = null, +) + +data class DriverLeaveAllocationDto( + val id: Int, + val date: String?, + val quantity: Int, + val status: String, + val poolId: Int? = null, + val sourceYear: Int? = null, +) + +data class LeaveDocumentListResponse( + val data: List = emptyList(), +) + +data class LeaveDocumentResponse( + val data: DriverLeaveDocumentDto, +) + +data class DriverLeaveDocumentDto( + val id: Int, + val documentType: String, + val originalName: String, + val mimeType: String? = null, + val sizeBytes: Long = 0, + val uploadedAt: String? = null, ) data class DriverLeaveCalendarEntryDto( diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 9949ea6..165acc5 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -65,6 +65,7 @@ import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Description import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.LocationOff import androidx.compose.material.icons.outlined.LocationOn @@ -92,6 +93,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField @@ -217,6 +219,14 @@ fun DriverApp( } val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current + var pendingLeaveDocumentType by remember { mutableStateOf(null) } + val leaveDocumentPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + val documentType = pendingLeaveDocumentType + pendingLeaveDocumentType = null + if (uri != null && documentType != null) { + viewModel.uploadSelectedLeaveDocument(documentType, uri) + } + } var appInForeground by remember { mutableStateOf(true) } val activeTrackingRouteId = state.displaySelectedRoute ?.takeIf { it.driverLifecycleStatus() == "W TRAKCIE" } @@ -534,6 +544,10 @@ fun DriverApp( state = state, onBack = viewModel::back, onCancel = viewModel::cancelSelectedLeaveRequest, + onUploadDocument = { documentType -> + pendingLeaveDocumentType = documentType + leaveDocumentPicker.launch(arrayOf("application/pdf", "image/jpeg", "image/png")) + }, ) DriverScreen.LeaveCalendar -> LeaveCalendarScreen( state = state, @@ -545,8 +559,13 @@ fun DriverApp( DriverScreen.AddLeaveRequest -> AddLeaveRequestScreen( state = state, onBack = viewModel::back, - onDraft = viewModel::updateLeaveRequestDraft, + onType = viewModel::updateLeaveRequestType, + onMode = viewModel::updateLeaveRequestMode, + onHours = viewModel::updateLeaveRequestHours, + onDetail = viewModel::updateLeaveRequestDetail, + onNote = viewModel::updateLeaveRequestNote, onChangePeriod = viewModel::reopenLeaveCalendarFromDraft, + onForecast = viewModel::refreshLeaveForecast, onSubmit = viewModel::submitLeaveRequest, ) } @@ -1419,8 +1438,10 @@ private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modi } @Composable -private fun LeaveRequestsEntryCard(requests: List, onOpen: () -> Unit) { +private fun LeaveRequestsEntryCard(state: DriverUiState, onOpen: () -> Unit) { + val requests = state.leaveRequests val decisionCount = requests.count { it.status == "pending" || it.status == "cancel_requested" } + val totals = DriverLeaveRequestUiRules.annualTotals(state.leaveSummary) Card( colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), border = BorderStroke(1.dp, TppTheme.colors.outline), @@ -1436,9 +1457,14 @@ private fun LeaveRequestsEntryCard(requests: List, onOpen Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppTheme.colors.forest) } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text("Wnioski urlopowe", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text("Urlopy i nieobecności", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) Text( - if (decisionCount > 0) "$decisionCount czeka na decyzję" else "Lista, status i nowy wniosek", + when { + state.leaveSummary != null -> + "Do wykorzystania: ${DriverLeaveRequestUiRules.formatQuantity(totals.availableMinutes, "minutes", state.leaveSummary.dailyNormMinutes)}" + decisionCount > 0 -> "$decisionCount czeka na decyzję" + else -> "Sprawdź saldo lub złóż wniosek" + }, color = TppTheme.colors.muted, fontSize = 14.sp, ) @@ -1459,7 +1485,7 @@ private fun LeaveRequestsScreen( ) { val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) Scaffold( - topBar = { SimpleTopBar("Wnioski urlopowe", onBack) }, + topBar = { SimpleTopBar("Urlopy i nieobecności", onBack) }, containerColor = TppTheme.colors.surface, ) { padding -> Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) { @@ -1470,6 +1496,7 @@ private fun LeaveRequestsScreen( ) { item { OfflineStaleBanner(state) } item { FeedbackAndError(state.feedback, state.error) } + item { LeaveBalanceOverview(state) } item { Button( onClick = onAdd, @@ -1480,11 +1507,20 @@ private fun LeaveRequestsScreen( ) { Icon(Icons.Outlined.CalendarToday, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text("Nowy wniosek", fontWeight = FontWeight.Bold) + Text("Złóż nowy wniosek", fontWeight = FontWeight.Bold) } } + item { + Text( + "Twoje wnioski", + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + fontSize = 19.sp, + modifier = Modifier.padding(top = 6.dp), + ) + } if (state.leaveRequests.isEmpty()) { - item { EmptyState("Nie masz jeszcze wniosków urlopowych") } + item { EmptyState("Nie masz jeszcze żadnych wniosków") } } else { items(state.leaveRequests, key = { it.id }) { request -> LeaveRequestCard(request, onClick = { onOpen(request.id) }) @@ -1502,6 +1538,120 @@ private fun LeaveRequestsScreen( } } +@Composable +private fun LeaveBalanceOverview(state: DriverUiState) { + val summary = state.leaveSummary + val totals = DriverLeaveRequestUiRules.annualTotals(summary) + val norm = summary?.dailyNormMinutes ?: 480 + + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text("Twój urlop wypoczynkowy", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 20.sp) + + when { + summary == null -> { + Text("Pobieramy aktualne saldo…", color = TppTheme.colors.muted) + } + !summary.balanceConfigured -> { + Text( + "Kadry przygotowują Twoje saldo", + color = Color(0xFF7A5A00), + fontWeight = FontWeight.Bold, + ) + Text( + "Możesz zapisać wniosek, ale jego godziny zostaną odjęte dopiero po potwierdzeniu puli urlopu.", + color = TppTheme.colors.muted, + ) + } + else -> { + Text("Pozostało do wykorzystania", color = TppTheme.colors.muted, fontWeight = FontWeight.SemiBold) + Text( + DriverLeaveRequestUiRules.formatQuantity(totals.availableMinutes, "minutes", norm), + color = TppTheme.colors.forest, + fontWeight = FontWeight.Bold, + fontSize = 26.sp, + ) + val denominator = (totals.grantedMinutes).coerceAtLeast(1) + LinearProgressIndicator( + progress = { (totals.usedMinutes.toFloat() / denominator).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().height(7.dp), + color = TppTheme.colors.forest, + trackColor = TppTheme.colors.successContainer, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LeaveBalanceStat( + title = "Wykorzystano", + value = DriverLeaveRequestUiRules.formatQuantity(totals.usedMinutes, "minutes", norm), + modifier = Modifier.weight(1f), + ) + LeaveBalanceStat( + title = "W przyszłych urlopach", + value = DriverLeaveRequestUiRules.formatQuantity(totals.reservedMinutes, "minutes", norm), + modifier = Modifier.weight(1f), + ) + } + } + } + + if (totals.overdueMinutes > 0) { + Row( + Modifier + .fillMaxWidth() + .background(TppTheme.colors.warningContainer, RoundedCornerShape(6.dp)) + .border(1.dp, TppTheme.colors.warningOutline, RoundedCornerShape(6.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top, + ) { + Icon(Icons.Outlined.Info, contentDescription = null, tint = Color(0xFF7A5A00)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text("Masz zaległy urlop", color = Color(0xFF7A5A00), fontWeight = FontWeight.Bold) + Text( + "${DriverLeaveRequestUiRules.formatQuantity(totals.overdueMinutes, "minutes", norm)} zaplanuj możliwie szybko, najpóźniej do 30 września. Saldo nie zniknie automatycznie.", + color = Color(0xFF7A5A00), + fontSize = 13.sp, + ) + } + } + } + + DriverLeaveRequestUiRules.annualPools(summary).forEach { pool -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + if (pool.year < LocalDate.now().year) "Urlop zaległy z ${pool.year} r." else "Pula na ${pool.year} r.", + color = TppTheme.colors.muted, + ) + Text( + DriverLeaveRequestUiRules.formatQuantity(pool.availableQuantity, pool.unit, norm), + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End, + ) + } + } + } + } +} + +@Composable +private fun LeaveBalanceStat(title: String, value: String, modifier: Modifier = Modifier) { + Column( + modifier + .background(TppTheme.colors.surface, RoundedCornerShape(6.dp)) + .border(1.dp, TppTheme.colors.outline.copy(alpha = 0.7f), RoundedCornerShape(6.dp)) + .padding(11.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text(title, color = TppTheme.colors.muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text(value, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } +} + @Composable private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit) { Card( @@ -1514,14 +1664,23 @@ private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { Column(Modifier.weight(1f)) { Text( - "${DriverLeaveRequestUiRules.typeLabel(request.type)} · ${leaveDateRange(request.dateFrom, request.dateTo)}", + DriverLeaveRequestUiRules.requestType(request), color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 17.sp, ) + Text(leaveDateRange(request.dateFrom, request.dateTo), color = TppTheme.colors.muted, fontSize = 14.sp) if (!request.note.isNullOrBlank()) { Text(request.note, color = TppTheme.colors.muted, maxLines = 2, overflow = TextOverflow.Ellipsis) } + if (request.missingDocumentTypes.isNotEmpty()) { + Text( + "Wymaga dodania dokumentu", + color = Color(0xFFB45309), + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + ) + } } LeaveStatusPill(request.status) } @@ -1535,8 +1694,10 @@ private fun LeaveRequestDetailScreen( state: DriverUiState, onBack: () -> Unit, onCancel: () -> Unit, + onUploadDocument: (String) -> Unit, ) { val request = state.selectedLeaveRequest + var showCancelConfirmation by remember { mutableStateOf(false) } Scaffold(topBar = { SimpleTopBar("Szczegóły wniosku", onBack) }, containerColor = TppTheme.colors.surface) { padding -> if (request == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { @@ -1560,17 +1721,57 @@ private fun LeaveRequestDetailScreen( ) { Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { - Text(DriverLeaveRequestUiRules.typeLabel(request.type), color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 22.sp) + Text( + DriverLeaveRequestUiRules.requestType(request), + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + modifier = Modifier.weight(1f), + ) LeaveStatusPill(request.status) } - DetailLine("Zakres", leaveDateRange(request.dateFrom, request.dateTo)) - DetailLine("Złożono", request.submittedAt?.let(::shortDateTime) ?: "-") - DetailLine("Decyzja", request.decidedAt?.let(::shortDateTime) ?: "-") - DetailLine("Notatka", request.note ?: "-") - DetailLine("Komentarz", request.decisionComment ?: "-") + DetailLine("Wybrany termin", leaveDateRange(request.dateFrom, request.dateTo)) + request.requestedQuantity?.let { quantity -> + val unit = when { + request.requestMode == "hours" -> "minutes" + request.rule?.unit == "calendar_days" -> "days" + else -> "minutes" + } + DetailLine( + "Czas nieobecności", + DriverLeaveRequestUiRules.formatQuantity( + quantity, + unit, + state.leaveSummary?.dailyNormMinutes ?: 480, + ), + ) + } + DetailLine("Wysłano", request.submittedAt?.let(::shortDateTime) ?: "-") + if (request.decidedAt != null) { + DetailLine("Decyzję zapisano", shortDateTime(request.decidedAt)) + } + if (!request.note.isNullOrBlank()) DetailLine("Twoja wiadomość", request.note) + if (!request.decisionComment.isNullOrBlank()) DetailLine("Odpowiedź", request.decisionComment) + if (request.balanceConfigurationRequired) { + Row( + Modifier + .fillMaxWidth() + .background(TppTheme.colors.warningContainer, RoundedCornerShape(6.dp)) + .border(1.dp, TppTheme.colors.warningOutline, RoundedCornerShape(6.dp)) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(Icons.Outlined.Info, contentDescription = null, tint = Color(0xFF7A5A00)) + Text( + "Kadry muszą potwierdzić Twoje saldo i grafik. Dopiero wtedy wniosek będzie mógł zostać zaakceptowany.", + color = Color(0xFF7A5A00), + modifier = Modifier.weight(1f), + ) + } + } if (DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) { Button( - onClick = onCancel, + onClick = { showCancelConfirmation = true }, enabled = state.isOnline, modifier = Modifier.fillMaxWidth().height(54.dp), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB45309)), @@ -1582,8 +1783,91 @@ private fun LeaveRequestDetailScreen( } } } + val requiredDocuments = request.rule?.rule?.requiredDocuments.orEmpty() + if (requiredDocuments.isNotEmpty()) { + item { + Text("Dokumenty", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + } + items(requiredDocuments, key = { "document-$it" }) { documentType -> + val uploaded = state.leaveDocuments.firstOrNull { it.documentType == documentType } + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke( + 1.dp, + if (uploaded != null) TppTheme.colors.forest else TppTheme.colors.warningOutline, + ), + shape = MaterialTheme.shapes.medium, + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + if (uploaded != null) Icons.Outlined.CheckCircle else Icons.Outlined.Description, + contentDescription = null, + tint = if (uploaded != null) TppTheme.colors.forest else Color(0xFF7A5A00), + ) + Column(Modifier.weight(1f)) { + Text( + DriverLeaveRequestUiRules.documentLabel(documentType), + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + ) + Text( + uploaded?.originalName ?: "Ten dokument jest potrzebny przed akceptacją wniosku.", + color = TppTheme.colors.muted, + fontSize = 13.sp, + ) + } + } + OutlinedButton( + onClick = { onUploadDocument(documentType) }, + enabled = state.isOnline && !state.leaveDocumentUploading, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) { + Text(if (uploaded == null) "Dodaj dokument" else "Zastąp dokument") + } + } + } + } + } + if (request.allocations.isNotEmpty()) { + item { + Text("Jak rozliczono urlop", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + } + item { + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + request.allocations.groupBy { it.sourceYear }.forEach { (year, allocations) -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + if (year != null) "Z puli $year" else "Rozliczenie", + color = TppTheme.colors.muted, + ) + Text( + DriverLeaveRequestUiRules.formatQuantity( + allocations.sumOf { it.quantity }, + if (request.rule?.unit == "calendar_days") "days" else "minutes", + state.leaveSummary?.dailyNormMinutes ?: 480, + ), + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } + } item { - Text("Historia", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text("Co działo się z wnioskiem", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) } items(request.events, key = { it.id }) { event -> Card( @@ -1604,6 +1888,38 @@ private fun LeaveRequestDetailScreen( } } } + + if (showCancelConfirmation && request != null) { + AlertDialog( + onDismissRequest = { showCancelConfirmation = false }, + title = { Text(if (request.status == "approved") "Poprosić o anulowanie?" else "Anulować wniosek?") }, + text = { + Text( + if (request.status == "approved") { + "Wniosek jest już zaakceptowany. Wyślemy prośbę o anulowanie do osoby odpowiedzialnej." + } else { + "Wniosek przestanie czekać na decyzję, a zarezerwowane saldo wróci do wykorzystania." + }, + ) + }, + confirmButton = { + Button( + onClick = { + showCancelConfirmation = false + onCancel() + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB45309)), + ) { + Text("Tak, kontynuuj") + } + }, + dismissButton = { + TextButton(onClick = { showCancelConfirmation = false }) { + Text("Wróć") + } + }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -1648,7 +1964,7 @@ private fun LeaveCalendarScreen( } Scaffold( - topBar = { SimpleTopBar("Kalendarz urlopów", onBack) }, + topBar = { SimpleTopBar("Wybierz termin", onBack) }, bottomBar = { LeaveCalendarContinueBar( state = state, @@ -1667,6 +1983,12 @@ private fun LeaveCalendarScreen( ) { item { OfflineStaleBanner(state) } item { FeedbackAndError(null, state.error) } + item { + Text( + "Dotknij jednego dnia albo wybierz pierwszy i ostatni dzień dłuższej nieobecności.", + color = TppTheme.colors.muted, + ) + } items(months, key = { it.toString() }) { month -> LeaveCalendarMonth( month = month, @@ -1980,10 +2302,20 @@ private fun LeaveCalendarDetailsEntry(entry: DriverLeaveCalendarEntryDto, onCall private fun AddLeaveRequestScreen( state: DriverUiState, onBack: () -> Unit, - onDraft: (String?, String?, String?, String?) -> Unit, + onType: (String) -> Unit, + onMode: (String) -> Unit, + onHours: (String) -> Unit, + onDetail: (String, String) -> Unit, + onNote: (String) -> Unit, onChangePeriod: () -> Unit, + onForecast: () -> Unit, onSubmit: () -> Unit, ) { + val availableTypes = state.leaveSummary?.availableTypes.orEmpty() + val selectedType = availableTypes.firstOrNull { it.value == state.leaveRequestType } + var showSubmitConfirmation by remember { mutableStateOf(false) } + var dateFieldToPick by remember { mutableStateOf(null) } + Scaffold(topBar = { SimpleTopBar("Nowy wniosek", onBack) }, containerColor = TppTheme.colors.surface) { padding -> LazyColumn( Modifier.fillMaxSize().padding(padding), @@ -1993,76 +2325,427 @@ private fun AddLeaveRequestScreen( item { OfflineStaleBanner(state) } item { FeedbackAndError(null, state.error) } item { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Typ", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(state.leaveRequestsConfig?.types.orEmpty()) { type -> - val active = type == state.leaveRequestType - Button( - onClick = { onDraft(null, null, type, null) }, - colors = ButtonDefaults.buttonColors( - containerColor = if (active) TppTheme.colors.forest else Color.White, - contentColor = if (active) Color.White else TppTheme.colors.ink, + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text("Złóż wniosek krok po kroku", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 23.sp) + Text( + "Wybierz rodzaj, termin i sprawdź podsumowanie. Aplikacja policzy urlop za Ciebie.", + color = TppTheme.colors.muted, + ) + } + } + item { + GuidedStepCard(number = "1", title = "Co chcesz zgłosić?") { + if (availableTypes.isEmpty()) { + Text( + "Nie udało się pobrać rodzajów wniosków. Wróć do listy i odśwież ekran.", + color = Color(0xFFB91C1C), + ) + } else { + availableTypes.forEach { type -> + val active = type.value == state.leaveRequestType + Card( + colors = CardDefaults.cardColors( + containerColor = if (active) TppTheme.colors.successContainer else Color.White, + ), + border = BorderStroke( + 1.dp, + if (active) TppTheme.colors.forest else TppTheme.colors.outline, ), - border = BorderStroke(1.dp, if (active) TppTheme.colors.forest else TppTheme.colors.outline), shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().clickable { onType(type.value) }, ) { - Text(DriverLeaveRequestUiRules.typeLabel(type)) + Column(Modifier.padding(13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(type.label, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + if (active) { + Icon(Icons.Outlined.CheckCircle, contentDescription = "Wybrano", tint = TppTheme.colors.forest) + } + } + if (!type.description.isNullOrBlank()) { + Text(type.description, color = TppTheme.colors.muted, fontSize = 13.sp) + } + } } } } } } item { - Card( - colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), - border = BorderStroke(1.dp, TppTheme.colors.outline), - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth().clickable { onChangePeriod() }, - ) { - Row( - Modifier.fillMaxWidth().padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, + GuidedStepCard(number = "2", title = "Kiedy potrzebujesz wolnego?") { + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke( + 1.dp, + if (state.leaveCalendarHasSelection) TppTheme.colors.forest else TppTheme.colors.outline, + ), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().clickable { onChangePeriod() }, ) { - Box( - Modifier.size(44.dp).background(TppTheme.colors.successContainer, MaterialTheme.shapes.medium), - contentAlignment = Alignment.Center, + Row( + Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppTheme.colors.forest) + Box( + Modifier.size(44.dp).background(TppTheme.colors.successContainer, MaterialTheme.shapes.medium), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppTheme.colors.forest) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + if (state.leaveCalendarHasSelection) "Wybrany termin" else "Wybierz termin w kalendarzu", + color = TppTheme.colors.ink, + fontWeight = FontWeight.Bold, + ) + if (state.leaveCalendarHasSelection) { + Text( + leaveDateRange(state.leaveRequestDateFrom, state.leaveRequestDateTo), + color = TppTheme.colors.muted, + ) + } + } + Text(if (state.leaveCalendarHasSelection) "Zmień" else "Wybierz", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Okres", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) - Text( - leaveDateRange(state.leaveRequestDateFrom, state.leaveRequestDateTo), - color = TppTheme.colors.muted, - ) + } + + if (selectedType?.requestModes?.size.orZero() > 1) { + Text("Czy potrzebujesz całych dni, czy tylko kilku godzin?", color = TppTheme.colors.ink, fontWeight = FontWeight.SemiBold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + selectedType?.requestModes.orEmpty().forEach { mode -> + val active = state.leaveRequestMode == mode + OutlinedButton( + onClick = { onMode(mode) }, + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (active) TppTheme.colors.successContainer else Color.White, + contentColor = TppTheme.colors.ink, + ), + border = BorderStroke(1.dp, if (active) TppTheme.colors.forest else TppTheme.colors.outline), + modifier = Modifier.weight(1f), + shape = MaterialTheme.shapes.medium, + ) { + Text(DriverLeaveRequestUiRules.modeLabel(mode), textAlign = TextAlign.Center) + } + } } - Text("Zmień", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) + } + if (state.leaveRequestMode == "hours") { + OutlinedTextField( + value = state.leaveRequestHoursText, + onValueChange = onHours, + label = { Text("Ile godzin?") }, + supportingText = { Text("Możesz wpisać np. 2 lub 3,5") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) } } } - item { - LeaveTextField( - label = "Notatka (opcjonalnie)", - value = state.leaveRequestNote, - onValue = { onDraft(null, null, null, it) }, - minLines = 4, - ) + if (selectedType != null) { + item { + GuidedStepCard(number = "3", title = "Uzupełnij i sprawdź") { + selectedType.requiredFields.filterNot { it == "settlementMode" }.forEach { field -> + val options = selectedType.fieldOptions[field].orEmpty() + Text(DriverLeaveRequestUiRules.fieldLabel(field), color = TppTheme.colors.ink, fontWeight = FontWeight.SemiBold) + if (options.isNotEmpty()) { + options.forEach { (value, label) -> + val active = state.leaveRequestDetails[field] == value + OutlinedButton( + onClick = { onDetail(field, value) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (active) TppTheme.colors.successContainer else Color.White, + contentColor = TppTheme.colors.ink, + ), + border = BorderStroke(1.dp, if (active) TppTheme.colors.forest else TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, + ) { + Text(label, modifier = Modifier.weight(1f), textAlign = TextAlign.Start) + if (active) Icon(Icons.Outlined.CheckCircle, contentDescription = "Wybrano") + } + } + } else if (field.endsWith("Date")) { + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth().clickable { dateFieldToPick = field }, + ) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppTheme.colors.forest) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(DriverLeaveRequestUiRules.fieldLabel(field), color = TppTheme.colors.ink, fontWeight = FontWeight.Bold) + Text( + state.leaveRequestDetails[field]?.takeIf { it.isNotBlank() } ?: "Dotknij, aby wybrać datę", + color = TppTheme.colors.muted, + ) + } + Text("Wybierz", color = TppTheme.colors.forest, fontWeight = FontWeight.Bold) + } + } + } else { + OutlinedTextField( + value = state.leaveRequestDetails[field].orEmpty(), + onValueChange = { onDetail(field, it) }, + label = { Text(DriverLeaveRequestUiRules.fieldLabel(field)) }, + supportingText = DriverLeaveRequestUiRules.fieldHint(field)?.let { hint -> + { Text(hint) } + }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + } + } + LeaveTextField( + label = "Wiadomość do osoby rozpatrującej (opcjonalnie)", + value = state.leaveRequestNote, + onValue = onNote, + minLines = 3, + ) + if (selectedType.requiredDocuments.isNotEmpty()) { + Row( + Modifier + .fillMaxWidth() + .background(TppTheme.colors.warningContainer, RoundedCornerShape(6.dp)) + .padding(11.dp), + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Icon(Icons.Outlined.Description, contentDescription = null, tint = Color(0xFF7A5A00)) + Text( + "Po wysłaniu poprosimy Cię o dodanie: ${ + selectedType.requiredDocuments.joinToString { DriverLeaveRequestUiRules.documentLabel(it) } + }.", + color = Color(0xFF7A5A00), + modifier = Modifier.weight(1f), + fontSize = 13.sp, + ) + } + } + Text( + DriverLeaveRequestUiRules.decisionExplanation(selectedType), + color = TppTheme.colors.muted, + fontSize = 13.sp, + ) + } + } + } + state.leaveForecast?.let { forecast -> + item { LeaveForecastCard(state, forecast) } } item { Button( - onClick = onSubmit, - enabled = state.isOnline, + onClick = if (state.leaveForecast == null) onForecast else ({ showSubmitConfirmation = true }), + enabled = state.isOnline && !state.leaveForecastLoading && selectedType != null && + (state.leaveForecast?.sufficientBalance != false), modifier = Modifier.fillMaxWidth().height(58.dp), colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), shape = MaterialTheme.shapes.medium, ) { - Text("Wyślij wniosek", fontWeight = FontWeight.Bold) + if (state.leaveForecastLoading) { + CircularProgressIndicator(color = Color.White, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(9.dp)) + } + Text( + when { + state.leaveForecastLoading -> "Sprawdzam…" + state.leaveForecast == null -> "Sprawdź wniosek" + else -> "Wyślij wniosek" + }, + fontWeight = FontWeight.Bold, + ) } } } } + + if (showSubmitConfirmation && state.leaveForecast != null && selectedType != null) { + AlertDialog( + onDismissRequest = { showSubmitConfirmation = false }, + title = { Text("Wysłać ten wniosek?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(selectedType.label, fontWeight = FontWeight.Bold) + Text(leaveDateRange(state.leaveRequestDateFrom, state.leaveRequestDateTo)) + Text( + "Zostanie rozliczone: ${ + DriverLeaveRequestUiRules.formatQuantity( + state.leaveForecast.requestedQuantity, + state.leaveForecast.quantityUnit, + state.leaveSummary?.dailyNormMinutes ?: 480, + ) + }", + ) + Text("Po wysłaniu zobaczysz status oraz dalsze wymagane kroki.") + } + }, + confirmButton = { + Button( + onClick = { + showSubmitConfirmation = false + onSubmit() + }, + colors = ButtonDefaults.buttonColors(containerColor = TppTheme.colors.forest), + ) { + Text("Tak, wyślij") + } + }, + dismissButton = { + TextButton(onClick = { showSubmitConfirmation = false }) { + Text("Jeszcze sprawdzę") + } + }, + ) + } + + dateFieldToPick?.let { field -> + LeaveDetailDatePickerDialog( + selectedDate = state.leaveRequestDetails[field], + onDismiss = { dateFieldToPick = null }, + onDateSelected = { date -> + onDetail(field, date) + dateFieldToPick = null + }, + ) + } +} + +@Composable +private fun GuidedStepCard( + number: String, + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), + border = BorderStroke(1.dp, TppTheme.colors.outline), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(30.dp).background(TppTheme.colors.forest, RoundedCornerShape(999.dp)), + contentAlignment = Alignment.Center, + ) { + Text(number, color = Color.White, fontWeight = FontWeight.Bold) + } + Text(title, color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + } + content() + } + } +} + +@Composable +private fun LeaveForecastCard(state: DriverUiState, forecast: pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto) { + val success = forecast.sufficientBalance + Card( + colors = CardDefaults.cardColors( + containerColor = if (success) TppTheme.colors.successContainer else Color(0xFFFFEBEE), + ), + border = BorderStroke(1.dp, if (success) TppTheme.colors.forest else Color(0xFFB91C1C)), + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + if (success) "Wniosek jest gotowy do wysłania" else "Za mało urlopu do wykorzystania", + color = if (success) TppTheme.colors.forest else Color(0xFFB91C1C), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + DetailLine( + "Ten wniosek wykorzysta", + DriverLeaveRequestUiRules.formatQuantity( + forecast.requestedQuantity, + forecast.quantityUnit, + state.leaveSummary?.dailyNormMinutes ?: 480, + ), + ) + if (forecast.workingDaysCount > 0) { + DetailLine("Dni pracy w wybranym terminie", DriverLeaveRequestUiRules.dayCountLabel(forecast.workingDaysCount)) + } + if (!forecast.balanceConfigured) { + Text( + "Saldo nie jest jeszcze potwierdzone. Wniosek zostanie zapisany, ale nie będzie mógł zostać zaakceptowany przed konfiguracją przez kadry.", + color = Color(0xFF7A5A00), + fontSize = 13.sp, + ) + } else if (forecast.pools.isNotEmpty()) { + DetailLine( + "Po wysłaniu pozostanie", + DriverLeaveRequestUiRules.formatQuantity( + (forecast.availableQuantity - forecast.requestedQuantity).coerceAtLeast(0), + forecast.quantityUnit, + state.leaveSummary?.dailyNormMinutes ?: 480, + ), + ) + } + } + } +} + +private fun Int?.orZero(): Int = this ?: 0 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LeaveDetailDatePickerDialog( + selectedDate: String?, + onDismiss: () -> Unit, + onDateSelected: (String) -> Unit, +) { + val initialDate = selectedDate + ?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + ?: LocalDate.now().minusYears(1) + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = initialDate + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .toEpochMilli(), + yearRange = 1900..LocalDate.now().year, + ) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + datePickerState.selectedDateMillis + ?.let { Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate() } + ?.let { onDateSelected(it.toString()) } + }, + enabled = datePickerState.selectedDateMillis != null, + ) { + Text("Wybierz") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Anuluj") + } + }, + ) { + DatePicker( + state = datePickerState, + title = { + Text( + "Wybierz datę", + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp), + ) + }, + showModeToggle = false, + ) + } } @Composable @@ -3000,7 +3683,7 @@ private fun ProfileScreen( } } if (DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) { - LeaveRequestsEntryCard(state.leaveRequests, onLeaveRequests) + LeaveRequestsEntryCard(state, onLeaveRequests) } Card( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt index e8d47ca..e632811 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt @@ -2,24 +2,36 @@ package pl.firmatpp.kierowca.ui import java.time.LocalDate import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto +import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto +import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto +import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto +import pl.firmatpp.kierowca.data.model.LeaveRequestTypeDto data class LeaveRequestsConfig( val enabled: Boolean = false, val types: List = listOf("URLOP"), ) +data class AnnualLeaveTotals( + val availableMinutes: Int, + val reservedMinutes: Int, + val usedMinutes: Int, + val grantedMinutes: Int, + val overdueMinutes: Int, +) + object DriverLeaveRequestUiRules { fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean = config?.enabled == true && config.types.isNotEmpty() fun statusLabel(status: String): String = when (status) { - "pending" -> "Oczekuje" - "approved" -> "Zatwierdzony" + "pending" -> "Czeka na decyzję" + "approved" -> "Zaakceptowany" "rejected" -> "Odrzucony" - "cancel_requested" -> "Anulowanie do decyzji" + "cancel_requested" -> "Czeka na anulowanie" "cancelled" -> "Anulowany" - "revoked" -> "Cofnięto decyzję" + "revoked" -> "Akceptacja cofnięta" else -> "Nieznany status" } @@ -33,19 +45,160 @@ object DriverLeaveRequestUiRules { "cancellation_requested" -> "Kierowca poprosił o anulowanie" "cancellation_approved" -> "Anulowanie zatwierdzone" "cancellation_rejected" -> "Anulowanie odrzucone" + "period_amended" -> "Zmieniono termin wniosku" + "interrupted" -> "Nieobecność została przerwana" else -> "Aktualizacja wniosku" } fun typeLabel(type: String): String = when (type) { - "URLOP" -> "Urlop" - "CHOROBOWE" -> "Chorobowe" - "SZKOLENIE" -> "Szkolenie" + "URLOP", "ANNUAL" -> "Urlop wypoczynkowy" + "ANNUAL_ON_DEMAND" -> "Urlop na żądanie" + "ANNUAL_DISABILITY" -> "Dodatkowy urlop" + "CHOROBOWE", "SICKNESS" -> "Niezdolność do pracy" + "SZKOLENIE", "TRAINING" -> "Urlop szkoleniowy" "WOLNE" -> "Dzień wolny" - "INNE" -> "Inne" + "INNE", "OTHER_OPERATIONAL" -> "Inna nieobecność" + "FORCE_MAJEURE" -> "Siła wyższa" + "CHILD_CARE_14" -> "Opieka nad dzieckiem do 14 lat" + "CARE_LEAVE" -> "Urlop opiekuńczy" + "MATERNITY" -> "Urlop macierzyński" + "MATERNITY_EQUIVALENT" -> "Urlop na warunkach macierzyńskiego" + "MATERNITY_SUPPLEMENTARY" -> "Uzupełniający urlop macierzyński" + "PARENTAL" -> "Urlop rodzicielski" + "PATERNITY" -> "Urlop ojcowski" + "CHILDCARE_UNPAID" -> "Urlop wychowawczy" + "OCCASIONAL" -> "Zwolnienie okolicznościowe" + "REHABILITATION" -> "Turnus rehabilitacyjny" + "UNPAID" -> "Urlop bezpłatny" + "PREGNANCY_EXAMS" -> "Badania w ciąży" + "OCCUPATIONAL_EXAMS" -> "Badania pracownicze" + "BLOOD_DONATION" -> "Zwolnienie krwiodawcy" + "AUTHORITY_SUMMONS" -> "Wezwanie urzędowe" + "RESCUE_DUTIES" -> "Obowiązki ratownicze" + "MILITARY_DUTIES" -> "Obowiązki wojskowe" else -> type } + fun requestType(request: DriverLeaveRequestDto): String = + request.typeLabel?.takeIf { it.isNotBlank() } + ?: request.rule?.label?.takeIf { it.isNotBlank() } + ?: typeLabel(request.ruleCode ?: request.type) + + fun annualTotals(summary: DriverLeaveSummaryDto?): AnnualLeaveTotals { + val annualPools = summary?.pools.orEmpty().filter { + it.poolCode == "ANNUAL" && it.unit == "minutes" && it.status == "active" + } + return AnnualLeaveTotals( + availableMinutes = annualPools.sumOf { it.availableQuantity }, + reservedMinutes = annualPools.sumOf { it.reservedQuantity }, + usedMinutes = annualPools.sumOf { it.usedQuantity }, + grantedMinutes = annualPools.sumOf { it.grantedQuantity }, + overdueMinutes = annualPools.filter { it.year < LocalDate.now().year }.sumOf { it.availableQuantity }, + ) + } + + fun annualPools(summary: DriverLeaveSummaryDto?): List = + summary?.pools.orEmpty() + .filter { it.poolCode == "ANNUAL" && it.status == "active" } + .sortedByDescending { it.year } + + fun formatQuantity(quantity: Int, unit: String, dailyNormMinutes: Int = 480): String { + if (unit == "days") return dayCountLabel(quantity) + + val safeNorm = dailyNormMinutes.coerceAtLeast(1) + val fullDays = quantity / safeNorm + val remainingMinutes = quantity % safeNorm + val hours = quantity / 60 + val minutes = quantity % 60 + val dayPart = when { + fullDays == 0 && remainingMinutes == 0 -> "0 dni" + remainingMinutes == 0 -> dayCountLabel(fullDays) + fullDays == 0 -> "mniej niż 1 dzień" + else -> "${dayCountLabel(fullDays)} + ${formatHours(remainingMinutes)}" + } + + return "$dayPart · ${hours} godz.${if (minutes > 0) " ${minutes} min" else ""}" + } + + fun formatHours(minutes: Int): String { + val hours = minutes / 60 + val remainder = minutes % 60 + return when { + hours == 0 -> "$remainder min" + remainder == 0 -> "$hours godz." + else -> "$hours godz. $remainder min" + } + } + + fun dayCountLabel(days: Int): String { + val suffix = when { + days == 1 -> "dzień" + days % 10 in 2..4 && days % 100 !in 12..14 -> "dni" + else -> "dni" + } + return "$days $suffix" + } + + fun modeLabel(mode: String): String = if (mode == "hours") "Kilka godzin" else "Całe dni" + + fun decisionExplanation(type: LeaveRequestTypeDto): String = + when (type.decisionMode) { + "mandatory_registration" -> "Po spełnieniu wymaganych warunków wniosek jest rejestrowany zgodnie z przepisami." + "external_event" -> "Nieobecność wymaga potwierdzenia odpowiednim dokumentem." + else -> "Wniosek zostanie przekazany do osoby, która podejmie decyzję." + } + + fun fieldLabel(field: String): String = + when (field) { + "childBirthDate" -> "Data urodzenia dziecka" + "settlementMode" -> "Sposób wykorzystania w tym roku" + "careRecipientName" -> "Imię i nazwisko osoby wymagającej opieki" + "careRecipientRelationship" -> "Kim jest dla Ciebie ta osoba" + "careReason" -> "Dlaczego potrzebna jest opieka" + "careRecipientAddress" -> "Adres osoby wymagającej opieki" + "childrenBornCount" -> "Liczba dzieci urodzonych przy jednym porodzie" + "entitlementEventKey" -> "Opis zdarzenia" + "partNumber" -> "Która to część urlopu" + "childIdentityKey" -> "Imię dziecka" + "occasionType" -> "Rodzaj zdarzenia rodzinnego" + "examType" -> "Rodzaj egzaminu lub nauki" + "educationProgramKey" -> "Nazwa szkoły lub programu" + "classification" -> "Czego dotyczy nieobecność" + else -> "Dodatkowa informacja" + } + + fun fieldHint(field: String): String? = + when (field) { + "childBirthDate" -> "Wpisz datę w formacie RRRR-MM-DD, np. 2020-05-12" + "entitlementEventKey" -> "Np. poród z 15.06.2026" + "partNumber" -> "Np. 1" + "childIdentityKey" -> "Wystarczy imię i rok urodzenia" + "educationProgramKey" -> "Np. Technikum Transportowe w Poznaniu" + "classification" -> "Krótko opisz powód, aby kadry mogły go prawidłowo zakwalifikować" + else -> null + } + + fun documentLabel(type: String): String = + when (type) { + "disability_certificate" -> "Orzeczenie o niepełnosprawności" + "parent_declaration" -> "Oświadczenie rodzica" + "birth_document", "birth_or_adoption_document" -> "Dokument potwierdzający urodzenie lub przysposobienie" + "adoption_or_care_document" -> "Dokument przysposobienia lub przyjęcia na wychowanie" + "hospitalization_certificate" -> "Zaświadczenie o hospitalizacji" + "parental_leave_declaration", "childcare_leave_declaration" -> "Oświadczenie dotyczące urlopu rodzicielskiego" + "event_document" -> "Dokument potwierdzający zdarzenie" + "training_confirmation" -> "Potwierdzenie nauki lub egzaminu" + "rehabilitation_referral" -> "Skierowanie na turnus rehabilitacyjny" + "medical_referral", "medical_referral_or_confirmation" -> "Skierowanie lub potwierdzenie badania" + "blood_donation_certificate" -> "Zaświadczenie o oddaniu krwi" + "authority_summons" -> "Wezwanie urzędowe" + "rescue_service_confirmation" -> "Potwierdzenie służby ratowniczej" + "military_document" -> "Dokument wojskowy" + "external_sickness_confirmation" -> "Potwierdzenie niezdolności do pracy" + else -> "Wymagany dokument" + } + fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean { if (status == "pending") return true if (status != "approved") return false diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 0c43dac..f65289b 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -9,6 +9,8 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.messaging.FirebaseMessaging import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -18,6 +20,7 @@ import kotlinx.coroutines.sync.withLock import java.time.LocalDate import java.time.Instant import java.io.IOException +import java.util.UUID import pl.firmatpp.kierowca.data.AppPreferencesStore import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.ApiErrorKind @@ -31,7 +34,10 @@ import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.AppUpdateDto import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto +import pl.firmatpp.kierowca.data.model.DriverLeaveDocumentDto +import pl.firmatpp.kierowca.data.model.DriverLeaveForecastDto import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto +import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.model.TachographReminderDto @@ -109,12 +115,21 @@ data class DriverUiState( val appUpdate: AppUpdateDto? = null, val dispatchSheetUploads: List = emptyList(), val leaveRequestsConfig: LeaveRequestsConfig? = null, + val leaveSummary: DriverLeaveSummaryDto? = null, val leaveRequests: List = emptyList(), val selectedLeaveRequest: DriverLeaveRequestDto? = null, val leaveRequestDateFrom: String = LocalDate.now().toString(), val leaveRequestDateTo: String = LocalDate.now().toString(), val leaveRequestType: String = "URLOP", + val leaveRequestMode: String = "days", + val leaveRequestHoursText: String = "", + val leaveRequestDetails: Map = emptyMap(), + val leaveRequestIdempotencyKey: String = "", val leaveRequestNote: String = "", + val leaveForecast: DriverLeaveForecastDto? = null, + val leaveForecastLoading: Boolean = false, + val leaveDocuments: List = emptyList(), + val leaveDocumentUploading: Boolean = false, val leaveCalendarEntries: List = emptyList(), val leaveCalendarLoading: Boolean = false, val leaveCalendarLoadedUntil: String? = null, @@ -221,6 +236,7 @@ internal fun screenAfterBootstrap( !leaveRequestsEnabled && currentScreen in setOf( DriverScreen.LeaveRequests, DriverScreen.LeaveRequestDetail, + DriverScreen.LeaveCalendar, DriverScreen.AddLeaveRequest, ) -> DriverScreen.Routes navigateToRoutes -> DriverScreen.Routes @@ -745,6 +761,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) fun openProfile() { _state.update { it.copy(screen = DriverScreen.Profile, error = null) } + if ( + DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig) && + _state.value.isOnline + ) { + viewModelScope.launch { loadLeaveOverview(showLoading = false, navigateToList = false) } + } } fun openDiagnostics() { @@ -961,13 +983,34 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } private suspend fun loadLeaveRequests(showLoading: Boolean, navigateToList: Boolean): Boolean { + return loadLeaveOverview(showLoading, navigateToList) + } + + private suspend fun loadLeaveOverview(showLoading: Boolean, navigateToList: Boolean): Boolean { _state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) } - val result = runCatching { repository.leaveRequests() } - result.onSuccess { requests -> + val (requestsResult, summaryResult) = coroutineScope { + val requests = async { runCatching { repository.leaveRequests() } } + val summary = async { runCatching { repository.leaveSummary() } } + requests.await() to summary.await() + } + requestsResult.onSuccess { requests -> _state.update { it.withLoadedLeaveRequests(requests, navigateToList) } }.onFailure { throwable -> _state.update { it.withApiError(throwable) } } + summaryResult.onSuccess { summary -> + _state.update { + it.copy( + leaveSummary = summary, + leaveForecast = if (it.screen == DriverScreen.AddLeaveRequest) null else it.leaveForecast, + ) + } + }.onFailure { throwable -> + if (requestsResult.isSuccess) { + val message = ApiErrorMapper.map(throwable).message + _state.update { it.copy(error = "Nie udało się odświeżyć salda urlopu. $message") } + } + } _state.update { it.copy(loading = false, refreshing = false) } - return result.isSuccess + return requestsResult.isSuccess } fun openLeaveRequest(id: String) { @@ -985,9 +1028,23 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } viewModelScope.launch { _state.update { it.copy(loading = true, error = null, feedback = null) } - runCatching { repository.leaveRequest(id) } - .onSuccess { request -> - _state.update { it.copy(screen = DriverScreen.LeaveRequestDetail, selectedLeaveRequest = request, error = null) } + val (requestResult, documentsResult) = coroutineScope { + val leaveRequest = async { runCatching { repository.leaveRequest(id) } } + val documents = async { runCatching { repository.leaveDocuments(id) } } + leaveRequest.await() to documents.await() + } + requestResult + .onSuccess { leaveRequest -> + _state.update { + it.copy( + screen = DriverScreen.LeaveRequestDetail, + selectedLeaveRequest = leaveRequest, + leaveDocuments = documentsResult.getOrDefault(emptyList()), + error = documentsResult.exceptionOrNull()?.let { error -> + "Nie udało się pobrać listy dokumentów. ${ApiErrorMapper.map(error).message}" + }, + ) + } } .onFailure { throwable -> _state.update { it.withApiError(throwable) } } _state.update { it.copy(loading = false) } @@ -1001,14 +1058,44 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(error = "Złożenie wniosku urlopowego wymaga połączenia z internetem.") } return } + if (_state.value.leaveSummary?.availableTypes.isNullOrEmpty()) { + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null) } + runCatching { repository.leaveSummary() } + .onSuccess { summary -> + _state.update { it.copy(leaveSummary = summary, loading = false) } + beginLeaveRequestDraft(config) + } + .onFailure { throwable -> + _state.update { it.withApiError(throwable).copy(loading = false) } + } + } + return + } + beginLeaveRequestDraft(config) + } + + private fun beginLeaveRequestDraft(config: LeaveRequestsConfig?) { val today = LocalDate.now().toString() + val defaultType = _state.value.leaveSummary?.availableTypes?.firstOrNull() + val type = defaultType?.value ?: config?.types?.firstOrNull() ?: "URLOP" + val mode = defaultType?.requestModes?.firstOrNull() ?: "days" _state.update { it.copy( - screen = DriverScreen.LeaveCalendar, + screen = DriverScreen.AddLeaveRequest, leaveRequestDateFrom = today, leaveRequestDateTo = today, - leaveRequestType = config?.types?.firstOrNull() ?: "URLOP", + leaveRequestType = type, + leaveRequestMode = mode, + leaveRequestHoursText = "", + leaveRequestDetails = if ("settlementMode" in defaultType?.requiredFields.orEmpty()) { + mapOf("settlementMode" to mode) + } else { + emptyMap() + }, + leaveRequestIdempotencyKey = UUID.randomUUID().toString(), leaveRequestNote = "", + leaveForecast = null, leaveCalendarEntries = emptyList(), leaveCalendarLoadedUntil = null, leaveCalendarHasSelection = false, @@ -1017,22 +1104,64 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) feedback = null, ) } - loadMoreLeaveCalendarMonths(reset = true) } - fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) { + fun updateLeaveRequestType(type: String) { + val metadata = _state.value.leaveSummary?.availableTypes?.firstOrNull { it.value == type } _state.update { - val nextFrom = dateFrom ?: it.leaveRequestDateFrom - val nextTo = dateTo ?: it.leaveRequestDateTo it.copy( - leaveRequestDateFrom = nextFrom, - leaveRequestDateTo = if (nextTo < nextFrom) nextFrom else nextTo, - leaveRequestType = type ?: it.leaveRequestType, - leaveRequestNote = note ?: it.leaveRequestNote, + leaveRequestType = type, + leaveRequestMode = metadata?.requestModes?.firstOrNull() ?: "days", + leaveRequestHoursText = "", + leaveRequestDetails = if ("settlementMode" in metadata?.requiredFields.orEmpty()) { + mapOf("settlementMode" to (metadata?.requestModes?.firstOrNull() ?: "days")) + } else { + emptyMap() + }, + leaveForecast = null, + error = null, ) } } + fun updateLeaveRequestMode(mode: String) { + val metadata = selectedLeaveRequestType() ?: return + if (mode !in metadata.requestModes) return + _state.update { + it.copy( + leaveRequestMode = mode, + leaveRequestDateTo = if (mode == "hours") it.leaveRequestDateFrom else it.leaveRequestDateTo, + leaveRequestHoursText = if (mode == "hours") it.leaveRequestHoursText else "", + leaveRequestDetails = if ("settlementMode" in metadata.requiredFields) { + it.leaveRequestDetails + ("settlementMode" to mode) + } else { + it.leaveRequestDetails + }, + leaveForecast = null, + error = null, + ) + } + } + + fun updateLeaveRequestHours(value: String) { + val normalized = value.filter { it.isDigit() || it == ',' || it == '.' }.take(5) + _state.update { it.copy(leaveRequestHoursText = normalized, leaveForecast = null, error = null) } + } + + fun updateLeaveRequestDetail(field: String, value: String) { + _state.update { + it.copy( + leaveRequestDetails = it.leaveRequestDetails + (field to value), + leaveForecast = null, + error = null, + ) + } + } + + fun updateLeaveRequestNote(value: String) { + _state.update { it.copy(leaveRequestNote = value, error = null) } + } + fun selectLeaveCalendarDate(date: String) { val selected = runCatching { LocalDate.parse(date) }.getOrNull() ?: return if (selected.isBefore(LocalDate.now())) return @@ -1068,7 +1197,15 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) return } - _state.update { it.copy(screen = DriverScreen.AddLeaveRequest, error = null, feedback = null) } + _state.update { + it.copy( + screen = DriverScreen.AddLeaveRequest, + leaveRequestDateTo = if (it.leaveRequestMode == "hours") it.leaveRequestDateFrom else it.leaveRequestDateTo, + leaveForecast = null, + error = null, + feedback = null, + ) + } } fun reopenLeaveCalendarFromDraft() { @@ -1086,6 +1223,52 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + fun refreshLeaveForecast() { + val snapshot = _state.value + if (!snapshot.isOnline || snapshot.leaveForecastLoading) return + if (!snapshot.leaveCalendarHasSelection) { + _state.update { it.copy(error = "Najpierw wybierz termin nieobecności.") } + return + } + val type = selectedLeaveRequestType() + if (type == null) { + _state.update { it.copy(error = "Wybierz rodzaj nieobecności.") } + return + } + val missingField = type.requiredFields + .filterNot { it == "settlementMode" } + .firstOrNull { snapshot.leaveRequestDetails[it].isNullOrBlank() } + if (missingField != null) { + _state.update { + it.copy(error = "Uzupełnij pole „${DriverLeaveRequestUiRules.fieldLabel(missingField)}”.") + } + return + } + val requestedMinutes = requestedLeaveMinutes(snapshot) + if (snapshot.leaveRequestMode == "hours" && requestedMinutes == null) { + _state.update { it.copy(error = "Wpisz liczbę godzin, o które chcesz wnioskować.") } + return + } + + viewModelScope.launch { + _state.update { it.copy(leaveForecastLoading = true, leaveForecast = null, error = null) } + runCatching { + repository.leaveForecast( + dateFrom = snapshot.leaveRequestDateFrom, + dateTo = snapshot.leaveRequestDateTo, + type = snapshot.leaveRequestType, + requestMode = snapshot.leaveRequestMode, + requestedQuantity = requestedMinutes, + ) + }.onSuccess { forecast -> + _state.update { it.copy(leaveForecast = forecast, error = null) } + }.onFailure { throwable -> + _state.update { it.withApiError(throwable) } + } + _state.update { it.copy(leaveForecastLoading = false) } + } + } + fun loadMoreLeaveCalendarMonths(reset: Boolean = false) { val snapshot = _state.value if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig) || snapshot.leaveCalendarLoading) return @@ -1140,6 +1323,14 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(error = "Wysłanie wniosku wymaga połączenia z internetem.") } return } + if (snapshot.leaveForecast == null) { + _state.update { it.copy(error = "Najpierw wybierz „Sprawdź wniosek”, aby zobaczyć jego podsumowanie.") } + return + } + if (!snapshot.leaveForecast.sufficientBalance) { + _state.update { it.copy(error = "Nie możesz wysłać tego wniosku, ponieważ dostępne saldo jest za małe.") } + return + } if (!leaveMutationMutex.tryLock()) return if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) { _state.update { it.copy(error = "Data od nie może być z przeszłości.") } @@ -1155,16 +1346,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) dateFrom = snapshot.leaveRequestDateFrom, dateTo = snapshot.leaveRequestDateTo, type = snapshot.leaveRequestType, + requestMode = snapshot.leaveRequestMode, + requestedQuantity = requestedLeaveMinutes(snapshot), + details = snapshot.leaveRequestDetails, + idempotencyKey = snapshot.leaveRequestIdempotencyKey.ifBlank { UUID.randomUUID().toString() }, note = snapshot.leaveRequestNote.takeIf { it.isNotBlank() }, ) }.onSuccess { created -> - val requests = runCatching { repository.leaveRequests() }.getOrElse { listOf(created) } + val (requests, summary) = coroutineScope { + val refreshedRequests = async { runCatching { repository.leaveRequests() }.getOrElse { listOf(created) } } + val refreshedSummary = async { runCatching { repository.leaveSummary() }.getOrNull() } + refreshedRequests.await() to refreshedSummary.await() + } _state.update { it.copy( screen = DriverScreen.LeaveRequestDetail, selectedLeaveRequest = created, + leaveDocuments = emptyList(), leaveRequests = requests, - feedback = "Wniosek został wysłany do decyzji.", + leaveSummary = summary ?: it.leaveSummary, + feedback = if (created.balanceConfigurationRequired) { + "Wniosek zapisano. Kadry muszą jeszcze potwierdzić saldo, zanim będzie można go zaakceptować." + } else { + "Gotowe. Wniosek został wysłany i odpowiednia część salda jest już zarezerwowana." + }, error = null, ) } @@ -1179,6 +1384,51 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + fun uploadSelectedLeaveDocument(documentType: String, uri: Uri) { + val request = _state.value.selectedLeaveRequest ?: return + if (!_state.value.isOnline || _state.value.leaveDocumentUploading) { + if (!_state.value.isOnline) { + _state.update { it.copy(error = "Dodanie dokumentu wymaga połączenia z internetem.") } + } + return + } + + viewModelScope.launch { + _state.update { it.copy(leaveDocumentUploading = true, error = null, feedback = null) } + runCatching { repository.uploadLeaveDocument(request.id, documentType, uri) } + .onSuccess { document -> + _state.update { + val updatedRequest = it.selectedLeaveRequest?.copy( + missingDocumentTypes = it.selectedLeaveRequest.missingDocumentTypes - documentType, + ) + it.copy( + selectedLeaveRequest = updatedRequest, + leaveRequests = it.leaveRequests.map { leaveRequest -> + if (leaveRequest.id == updatedRequest?.id) updatedRequest else leaveRequest + }, + leaveDocuments = (it.leaveDocuments.filterNot { existing -> + existing.documentType == document.documentType + } + document).sortedBy { item -> item.documentType }, + feedback = "Dokument został bezpiecznie dołączony do wniosku.", + error = null, + ) + } + } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } + _state.update { it.copy(leaveDocumentUploading = false) } + } + } + + private fun selectedLeaveRequestType() = + _state.value.leaveSummary?.availableTypes?.firstOrNull { it.value == _state.value.leaveRequestType } + + private fun requestedLeaveMinutes(state: DriverUiState): Int? { + if (state.leaveRequestMode != "hours") return null + val hours = state.leaveRequestHoursText.replace(',', '.').toDoubleOrNull() ?: return null + val minutes = (hours * 60).toInt() + return minutes.takeIf { it > 0 } + } + fun cancelSelectedLeaveRequest() { val request = _state.value.selectedLeaveRequest ?: return if (!_state.value.isOnline) { @@ -1193,10 +1443,12 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) try { runCatching { repository.cancelLeaveRequest(request.id, request.version) } .onSuccess { updated -> + val refreshedSummary = runCatching { repository.leaveSummary() }.getOrNull() _state.update { it.copy( selectedLeaveRequest = updated, leaveRequests = it.leaveRequests.map { existing -> if (existing.id == updated.id) updated else existing }, + leaveSummary = refreshedSummary ?: it.leaveSummary, feedback = if (updated.status == "cancel_requested") "Anulowanie wysłane do decyzji." else "Wniosek został anulowany.", error = null, ) @@ -1694,7 +1946,10 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) ) DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes) DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null) - DriverScreen.LeaveCalendar -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null) + DriverScreen.LeaveCalendar -> it.copy( + screen = if (it.leaveRequestIdempotencyKey.isNotBlank()) DriverScreen.AddLeaveRequest else DriverScreen.LeaveRequests, + feedback = null, + ) DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null) DriverScreen.Detail -> { photoUploadsJob?.cancel() @@ -1947,7 +2202,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) if (pending == null || hint.version > pending.version) pendingLeaveHint = hint return } - if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) { + if (snapshot.screen in setOf( + DriverScreen.Profile, + DriverScreen.LeaveRequests, + DriverScreen.LeaveRequestDetail, + DriverScreen.AddLeaveRequest, + ) + ) { if (loadLeaveRequests(showLoading = false, navigateToList = false)) { syncRepository.saveSyncScope(hint.asScope()) } diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/DriverLeaveApiContractTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/DriverLeaveApiContractTest.kt new file mode 100644 index 0000000..f5d7cc8 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/DriverLeaveApiContractTest.kt @@ -0,0 +1,83 @@ +package pl.firmatpp.kierowca.data + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto +import pl.firmatpp.kierowca.data.model.LeaveSummaryResponse + +class DriverLeaveApiContractTest { + private val gson = Gson() + + @Test + fun parsesNewLeaveSummaryWithoutTechnicalFallbacks() { + val response = gson.fromJson( + """ + { + "data": { + "dailyNormMinutes": 480, + "balanceConfigured": true, + "overdueQuantity": 480, + "pools": [{ + "id": 7, + "poolCode": "ANNUAL", + "year": 2026, + "unit": "minutes", + "availableQuantity": 7680, + "reservedQuantity": 960, + "usedQuantity": 960, + "grantedQuantity": 9600, + "status": "active" + }], + "availableTypes": [{ + "value": "URLOP", + "code": "ANNUAL", + "label": "Urlop wypoczynkowy", + "requestModes": ["days", "hours"], + "requiredFields": [], + "requiredDocuments": [], + "fieldOptions": {}, + "description": "Urlop rozliczany według grafiku." + }] + } + } + """.trimIndent(), + LeaveSummaryResponse::class.java, + ) + + assertEquals(480, response.data.dailyNormMinutes) + assertEquals(7680, response.data.pools.single().availableQuantity) + assertEquals("URLOP", response.data.availableTypes.single().value) + assertEquals("ANNUAL", response.data.availableTypes.single().code) + assertTrue("hours" in response.data.availableTypes.single().requestModes) + } + + @Test + fun parsesSensitiveOwnRequestDetailsAsFormValues() { + val request = gson.fromJson( + """ + { + "id": "12", + "dateFrom": "2026-08-03", + "dateTo": "2026-08-03", + "type": "WOLNE", + "ruleCode": "CHILD_CARE_14", + "requestMode": "days", + "requestedQuantity": 480, + "details": { + "childrenBornCount": 1, + "childBirthDate": "2020-05-12" + }, + "status": "pending", + "missingDocumentTypes": ["parent_declaration"] + } + """.trimIndent(), + DriverLeaveRequestDto::class.java, + ) + + assertEquals("1", request.details["childrenBornCount"]) + assertEquals("2020-05-12", request.details["childBirthDate"]) + assertEquals(listOf("parent_declaration"), request.missingDocumentTypes) + } +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt index 30d3be7..fd8b179 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt @@ -7,6 +7,8 @@ import org.junit.Assert.assertTrue import org.junit.Test import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto +import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto +import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto class DriverLeaveRequestUiRulesTest { @Test @@ -18,15 +20,35 @@ class DriverLeaveRequestUiRulesTest { @Test fun exposesPolishStatusLabels() { - assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending")) - assertEquals("Zatwierdzony", DriverLeaveRequestUiRules.statusLabel("approved")) + assertEquals("Czeka na decyzję", DriverLeaveRequestUiRules.statusLabel("pending")) + assertEquals("Zaakceptowany", DriverLeaveRequestUiRules.statusLabel("approved")) assertEquals("Odrzucony", DriverLeaveRequestUiRules.statusLabel("rejected")) - assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested")) + assertEquals("Czeka na anulowanie", DriverLeaveRequestUiRules.statusLabel("cancel_requested")) assertEquals("Anulowany", DriverLeaveRequestUiRules.statusLabel("cancelled")) - assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked")) + assertEquals("Akceptacja cofnięta", DriverLeaveRequestUiRules.statusLabel("revoked")) assertEquals("Nieznany status", DriverLeaveRequestUiRules.statusLabel("some_raw_status")) } + @Test + fun explainsAnnualBalanceInDaysAndHours() { + val summary = DriverLeaveSummaryDto( + dailyNormMinutes = 480, + balanceConfigured = true, + pools = listOf( + annualPool(year = 2025, available = 480, reserved = 0, used = 480, granted = 960), + annualPool(year = 2026, available = 3_840, reserved = 960, used = 960, granted = 5_760), + ), + ) + + val totals = DriverLeaveRequestUiRules.annualTotals(summary) + + assertEquals(4_320, totals.availableMinutes) + assertEquals(960, totals.reservedMinutes) + assertEquals(1_440, totals.usedMinutes) + assertEquals("9 dni · 72 godz.", DriverLeaveRequestUiRules.formatQuantity(totals.availableMinutes, "minutes", 480)) + assertEquals("2 dni + 2 godz. · 18 godz.", DriverLeaveRequestUiRules.formatQuantity(1_080, "minutes", 480)) + } + @Test fun exposesPolishEventActionLabels() { assertEquals("Wniosek złożony", DriverLeaveRequestUiRules.eventActionLabel("submitted")) @@ -105,4 +127,23 @@ class DriverLeaveRequestUiRulesTest { phoneNumber = "+48 600 700 800", ), ) + + private fun annualPool( + year: Int, + available: Int, + reserved: Int, + used: Int, + granted: Int, + ): DriverLeavePoolDto = + DriverLeavePoolDto( + id = year, + poolCode = "ANNUAL", + year = year, + unit = "minutes", + availableQuantity = available, + reservedQuantity = reserved, + usedQuantity = used, + grantedQuantity = granted, + status = "active", + ) }