From 2c806ef14e5cbde9555dacb347e56960fce8793f Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 4 Jul 2026 23:11:08 +0200 Subject: [PATCH] Add driver leave request screens --- .../java/pl/firmatpp/kierowca/MainActivity.kt | 10 +- .../kierowca/data/DriverRepository.kt | 15 + .../kierowca/data/api/MobileDriverApi.kt | 34 ++ .../kierowca/data/model/DriverModels.kt | 58 +++ .../data/sync/DriverSyncRepository.kt | 1 + .../sync/DriverFirebaseMessagingService.kt | 15 + .../kierowca/sync/DriverSyncWorker.kt | 4 + .../LeaveRequestDecisionNotificationWorker.kt | 110 ++++++ .../java/pl/firmatpp/kierowca/ui/DriverApp.kt | 358 +++++++++++++++++- .../kierowca/ui/DriverLeaveRequestUiRules.kt | 42 ++ .../firmatpp/kierowca/ui/DriverViewModel.kt | 158 +++++++- .../ui/DriverLeaveRequestUiRulesTest.kt | 34 ++ 12 files changed, 835 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/pl/firmatpp/kierowca/sync/LeaveRequestDecisionNotificationWorker.kt create mode 100644 app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt create mode 100644 app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt diff --git a/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt b/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt index fa877c8..df50dd8 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/MainActivity.kt @@ -13,14 +13,20 @@ import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme class MainActivity : ComponentActivity() { private var notificationRouteId by mutableStateOf(null) + private var notificationLeaveRequestId by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) + notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) setContent { TppKierowcaTheme { val viewModel: DriverViewModel = viewModel() - DriverApp(viewModel = viewModel, initialRouteId = notificationRouteId) + DriverApp( + viewModel = viewModel, + initialRouteId = notificationRouteId, + initialLeaveRequestId = notificationLeaveRequestId, + ) } } } @@ -29,9 +35,11 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) notificationRouteId = intent.getStringExtra(EXTRA_ROUTE_ID) + notificationLeaveRequestId = intent.getStringExtra(EXTRA_LEAVE_REQUEST_ID) } companion object { const val EXTRA_ROUTE_ID = "pl.firmatpp.kierowca.EXTRA_ROUTE_ID" + const val EXTRA_LEAVE_REQUEST_ID = "pl.firmatpp.kierowca.EXTRA_LEAVE_REQUEST_ID" } } 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 13b02d3..74208e3 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/DriverRepository.kt @@ -14,8 +14,11 @@ import pl.firmatpp.kierowca.data.api.MobileDriverApi import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse +import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody 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.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto import pl.firmatpp.kierowca.data.model.OtpResponse @@ -51,6 +54,18 @@ class DriverRepository( suspend fun route(routeId: String): RouteResponse = api.route(authHeader(requireToken()), routeId) + suspend fun leaveRequests(): List = + api.leaveRequests(authHeader(requireToken())).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 cancelLeaveRequest(id: String, comment: String? = null): DriverLeaveRequestDto = + api.cancelLeaveRequest(authHeader(requireToken()), id, CancelLeaveRequestBody(comment)).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 cc83ad5..bfd4d7b 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 @@ -5,8 +5,13 @@ import okhttp3.RequestBody import pl.firmatpp.kierowca.data.model.BootstrapResponse import pl.firmatpp.kierowca.data.model.BroadcastAuthBody import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse +import pl.firmatpp.kierowca.data.model.CancelLeaveRequestBody import pl.firmatpp.kierowca.data.model.CompleteRouteResponse +import pl.firmatpp.kierowca.data.model.CreateLeaveRequestBody import pl.firmatpp.kierowca.data.model.DispatchSheetUploadResponse +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.OtpResponse import pl.firmatpp.kierowca.data.model.NotificationPreferencesBody import pl.firmatpp.kierowca.data.model.NotificationPreferencesDto @@ -56,6 +61,35 @@ interface MobileDriverApi { @Query("date") date: String?, ): BootstrapResponse + @GET("mobile/driver/leave-request-types") + suspend fun leaveRequestTypes( + @Header("Authorization") authorization: String, + ): LeaveRequestTypesResponse + + @GET("mobile/driver/leave-requests") + suspend fun leaveRequests( + @Header("Authorization") authorization: String, + ): LeaveRequestListResponse + + @POST("mobile/driver/leave-requests") + suspend fun createLeaveRequest( + @Header("Authorization") authorization: String, + @Body body: CreateLeaveRequestBody, + ): LeaveRequestResponse + + @GET("mobile/driver/leave-requests/{id}") + suspend fun leaveRequest( + @Header("Authorization") authorization: String, + @Path("id") id: String, + ): LeaveRequestResponse + + @POST("mobile/driver/leave-requests/{id}/cancel") + suspend fun cancelLeaveRequest( + @Header("Authorization") authorization: String, + @Path("id") id: String, + @Body body: CancelLeaveRequestBody, + ): LeaveRequestResponse + @GET("mobile/driver/routes/{routeId}") suspend fun route( @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 70afe87..8a78d96 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 @@ -65,6 +65,12 @@ data class DriverAppSettingsDto( val dispatchSheetRemindersEnabled: Boolean? = null, val dispatchSheetOnFridays: Boolean? = null, val dispatchSheetOnLastWorkingDay: Boolean? = null, + val leaveRequests: LeaveRequestsConfigDto? = null, +) + +data class LeaveRequestsConfigDto( + val enabled: Boolean = false, + val types: List = listOf("URLOP"), ) data class DispatchSheetReminderDto( @@ -157,6 +163,58 @@ data class NotificationPreferencesBody( val notifyNewRoutes: Boolean, ) +data class LeaveRequestListResponse( + val data: List = emptyList(), +) + +data class LeaveRequestResponse( + val data: DriverLeaveRequestDto, +) + +data class LeaveRequestTypesResponse( + val data: List = emptyList(), +) + +data class LeaveRequestTypeDto( + val value: String, + val label: String, +) + +data class CreateLeaveRequestBody( + val dateFrom: String, + val dateTo: String, + val type: String, + val note: String?, +) + +data class CancelLeaveRequestBody( + val comment: String? = null, +) + +data class DriverLeaveRequestDto( + val id: String, + val driver: DriverDto? = null, + val dateFrom: String?, + val dateTo: String?, + val type: String, + val typeLabel: String? = null, + val note: String? = null, + val status: String, + val submittedAt: String? = null, + val decidedAt: String? = null, + val decisionComment: String? = null, + val events: List = emptyList(), +) + +data class DriverLeaveRequestEventDto( + val id: String, + val action: String, + val fromStatus: String? = null, + val toStatus: String? = null, + val comment: String? = null, + val createdAt: String? = null, +) + data class RealtimeStatusBody( val reverbStatus: String, val socketId: String? = null, diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt index fa0dd9d..d917e41 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/sync/DriverSyncRepository.kt @@ -151,5 +151,6 @@ class DriverSyncRepository( const val SCOPE_ROUTE_DETAIL = "route_detail" const val SCOPE_SETTINGS = "settings" const val SCOPE_DISPATCH_SHEET = "dispatch_sheet" + const val SCOPE_LEAVE_REQUESTS = "leave_requests" } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt index effce86..92f4041 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverFirebaseMessagingService.kt @@ -31,6 +31,21 @@ class DriverFirebaseMessagingService : FirebaseMessagingService() { return } + if (data["type"] == "driver_leave_request_decision") { + LeaveRequestDecisionNotificationWorker.enqueue( + context = applicationContext, + leaveRequestId = data["leaveRequestId"], + title = data["title"], + body = data["body"], + ) + DriverSyncWorker.enqueue( + context = applicationContext, + date = data["date"], + routeId = data["routeId"], + ) + return + } + if (data["type"] != "driver_sync_hint") return DriverSyncWorker.enqueue( diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt index d75e1ca..0e41fc8 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/DriverSyncWorker.kt @@ -47,6 +47,10 @@ class DriverSyncWorker( syncRepository.bootstrap(scope.date ?: date) refreshed = true } + DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> { + syncRepository.saveSyncStates(response) + refreshed = true + } } } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/sync/LeaveRequestDecisionNotificationWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/sync/LeaveRequestDecisionNotificationWorker.kt new file mode 100644 index 0000000..9384f34 --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/sync/LeaveRequestDecisionNotificationWorker.kt @@ -0,0 +1,110 @@ +package pl.firmatpp.kierowca.sync + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import pl.firmatpp.kierowca.MainActivity +import pl.firmatpp.kierowca.R + +class LeaveRequestDecisionNotificationWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val leaveRequestId = inputData.getString(KEY_LEAVE_REQUEST_ID)?.takeIf(String::isNotBlank) + ?: return Result.success() + + if (!canShowNotifications(applicationContext)) { + return Result.success() + } + + createChannel(applicationContext) + val title = inputData.getString(KEY_TITLE)?.takeIf(String::isNotBlank) ?: "Decyzja w sprawie urlopu" + val body = inputData.getString(KEY_BODY)?.takeIf(String::isNotBlank) ?: "Status wniosku urlopowego został zmieniony." + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra(MainActivity.EXTRA_LEAVE_REQUEST_ID, leaveRequestId) + } + val pendingIntent = PendingIntent.getActivity( + applicationContext, + leaveRequestId.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(body) + .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + + NotificationManagerCompat.from(applicationContext).notify(leaveRequestId.hashCode(), notification) + return Result.success() + } + + private fun createChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val channel = NotificationChannel( + CHANNEL_ID, + "Wnioski urlopowe", + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Powiadomienia o decyzjach w sprawie wniosków urlopowych." + } + context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel) + } + + private fun canShowNotifications(context: Context): Boolean { + val hasRuntimePermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + + return hasRuntimePermission && NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + companion object { + private const val CHANNEL_ID = "leave_request_decisions" + private const val KEY_LEAVE_REQUEST_ID = "leaveRequestId" + private const val KEY_TITLE = "title" + private const val KEY_BODY = "body" + + fun enqueue(context: Context, leaveRequestId: String?, title: String?, body: String?) { + if (leaveRequestId.isNullOrBlank()) return + + val request = OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + KEY_LEAVE_REQUEST_ID to leaveRequestId, + KEY_TITLE to title, + KEY_BODY to body, + ), + ) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + "driver-leave-request-decision-$leaveRequestId", + ExistingWorkPolicy.REPLACE, + request, + ) + } + } +} 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 5c6f5f5..c98cce1 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -133,6 +133,7 @@ import java.util.Locale import pl.firmatpp.kierowca.R import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto +import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.NavigationPointDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto @@ -144,7 +145,7 @@ import pl.firmatpp.kierowca.domain.RouteDisplayMapper import pl.firmatpp.kierowca.ui.theme.TppColors @Composable -fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) { +fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null, initialLeaveRequestId: String? = null) { val state by viewModel.state.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current @@ -192,6 +193,11 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) { LaunchedEffect(initialRouteId) { viewModel.openRouteFromNotification(initialRouteId) } + LaunchedEffect(initialLeaveRequestId, state.leaveRequestsConfig) { + if (!initialLeaveRequestId.isNullOrBlank() && DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) { + viewModel.openLeaveRequest(initialLeaveRequestId) + } + } Box(Modifier.fillMaxSize().background(TppColors.Surface)) { when (state.screen) { @@ -204,6 +210,7 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) { onDate = viewModel::selectRouteDate, onProfile = viewModel::openProfile, onPhotoQueue = viewModel::openPhotoQueue, + onLeaveRequests = viewModel::openLeaveRequests, onDispatchSheetUpload = viewModel::uploadDispatchSheetPhoto, onRoute = viewModel::openRoute, onDismissLiveUpdate = viewModel::dismissRouteDayLiveUpdate, @@ -243,6 +250,24 @@ fun DriverApp(viewModel: DriverViewModel, initialRouteId: String? = null) { onBack = viewModel::back, onRetryUpload = viewModel::retryPhotoUpload, ) + DriverScreen.LeaveRequests -> LeaveRequestsScreen( + state = state, + onBack = viewModel::back, + onRefresh = viewModel::refreshLeaveRequests, + onAdd = viewModel::openAddLeaveRequest, + onOpen = viewModel::openLeaveRequest, + ) + DriverScreen.LeaveRequestDetail -> LeaveRequestDetailScreen( + state = state, + onBack = viewModel::back, + onCancel = viewModel::cancelSelectedLeaveRequest, + ) + DriverScreen.AddLeaveRequest -> AddLeaveRequestScreen( + state = state, + onBack = viewModel::back, + onDraft = viewModel::updateLeaveRequestDraft, + onSubmit = viewModel::submitLeaveRequest, + ) } if (state.loading && state.screen != DriverScreen.Initializing) { @@ -549,6 +574,7 @@ private fun RoutesScreen( onDate: (String) -> Unit, onProfile: () -> Unit, onPhotoQueue: () -> Unit, + onLeaveRequests: () -> Unit, onDispatchSheetUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onRoute: (String) -> Unit, onDismissLiveUpdate: () -> Unit, @@ -660,6 +686,9 @@ private fun RoutesScreen( onDate = onDate, ) } + if (DriverLeaveRequestUiRules.isFeatureVisible(state.leaveRequestsConfig)) { + item { LeaveRequestsEntryCard(state.leaveRequests, onLeaveRequests) } + } if (shouldShowDispatchSheetReminderCard(state.dispatchSheetReminder)) { item { DispatchSheetReminderCard( @@ -761,6 +790,333 @@ private fun RouteDayLiveUpdateBanner(message: String?, modifier: Modifier = Modi } } +@Composable +private fun LeaveRequestsEntryCard(requests: List, onOpen: () -> Unit) { + val decisionCount = requests.count { it.status == "pending" || it.status == "cancel_requested" } + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke(1.dp, TppColors.Outline), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen), + ) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(44.dp).background(Color(0xFFEAF7EF), RoundedCornerShape(8.dp)), contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.CalendarToday, contentDescription = null, tint = TppColors.Forest) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text("Wnioski urlopowe", color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + Text( + if (decisionCount > 0) "$decisionCount czeka na decyzję" else "Lista, status i nowy wniosek", + color = TppColors.Muted, + fontSize = 14.sp, + ) + } + Text("Otwórz", color = TppColors.Forest, fontWeight = FontWeight.Bold) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun LeaveRequestsScreen( + state: DriverUiState, + onBack: () -> Unit, + onRefresh: () -> Unit, + onAdd: () -> Unit, + onOpen: (String) -> Unit, +) { + val pullRefreshState = rememberPullRefreshState(state.refreshing, onRefresh) + Scaffold( + topBar = { SimpleTopBar("Wnioski urlopowe", onBack) }, + containerColor = TppColors.Surface, + floatingActionButton = { + Button( + onClick = onAdd, + colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), + shape = RoundedCornerShape(8.dp), + ) { + Text("Nowy wniosek") + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding).pullRefresh(pullRefreshState)) { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { FeedbackAndError(state.feedback, state.error) } + if (state.leaveRequests.isEmpty()) { + item { EmptyState("Nie masz jeszcze wniosków urlopowych") } + } else { + items(state.leaveRequests, key = { it.id }) { request -> + LeaveRequestCard(request, onClick = { onOpen(request.id) }) + } + } + } + PullRefreshIndicator( + refreshing = state.refreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + backgroundColor = Color.White, + contentColor = TppColors.Forest, + ) + } + } +} + +@Composable +private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke(1.dp, TppColors.Outline), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text( + "${DriverLeaveRequestUiRules.typeLabel(request.type)} · ${leaveDateRange(request.dateFrom, request.dateTo)}", + color = TppColors.Ink, + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + ) + if (!request.note.isNullOrBlank()) { + Text(request.note, color = TppColors.Muted, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + LeaveStatusPill(request.status) + } + Text("Szczegóły", color = TppColors.Forest, fontWeight = FontWeight.Bold, fontSize = 14.sp) + } + } +} + +@Composable +private fun LeaveRequestDetailScreen( + state: DriverUiState, + onBack: () -> Unit, + onCancel: () -> Unit, +) { + val request = state.selectedLeaveRequest + Scaffold(topBar = { SimpleTopBar("Szczegóły wniosku", onBack) }, containerColor = TppColors.Surface) { padding -> + if (request == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text("Nie znaleziono wniosku", color = TppColors.Muted) + } + return@Scaffold + } + + LazyColumn( + Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item { FeedbackAndError(state.feedback, state.error) } + item { + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke(1.dp, TppColors.Outline), + shape = RoundedCornerShape(8.dp), + ) { + 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 = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 22.sp) + 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 ?: "-") + if (DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) { + Button( + onClick = onCancel, + modifier = Modifier.fillMaxWidth().height(54.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB45309)), + shape = RoundedCornerShape(8.dp), + ) { + Text(if (request.status == "approved") "Poproś o anulowanie" else "Anuluj wniosek") + } + } + } + } + } + item { + Text("Historia", color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) + } + items(request.events, key = { it.id }) { event -> + Card( + colors = CardDefaults.cardColors(containerColor = Color.White), + border = BorderStroke(1.dp, TppColors.Outline.copy(alpha = 0.65f)), + shape = RoundedCornerShape(8.dp), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "${event.action}${event.toStatus?.let { " · ${DriverLeaveRequestUiRules.statusLabel(it)}" } ?: ""}", + color = TppColors.Ink, + fontWeight = FontWeight.Bold, + ) + Text(event.createdAt?.let(::shortDateTime) ?: "-", color = TppColors.Muted, fontSize = 13.sp) + if (!event.comment.isNullOrBlank()) Text(event.comment, color = TppColors.Muted) + } + } + } + } + } +} + +@Composable +private fun AddLeaveRequestScreen( + state: DriverUiState, + onBack: () -> Unit, + onDraft: (String?, String?, String?, String?) -> Unit, + onSubmit: () -> Unit, +) { + Scaffold(topBar = { SimpleTopBar("Nowy wniosek", onBack) }, containerColor = TppColors.Surface) { padding -> + LazyColumn( + Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { FeedbackAndError(null, state.error) } + item { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Typ", color = TppColors.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) TppColors.Forest else Color.White, + contentColor = if (active) Color.White else TppColors.Ink, + ), + border = BorderStroke(1.dp, if (active) TppColors.Forest else TppColors.Outline), + shape = RoundedCornerShape(8.dp), + ) { + Text(DriverLeaveRequestUiRules.typeLabel(type)) + } + } + } + } + } + item { + LeaveTextField( + label = "Data od (RRRR-MM-DD)", + value = state.leaveRequestDateFrom, + onValue = { onDraft(it, null, null, null) }, + ) + } + item { + LeaveTextField( + label = "Data do (RRRR-MM-DD)", + value = state.leaveRequestDateTo, + onValue = { onDraft(null, it, null, null) }, + ) + } + item { + LeaveTextField( + label = "Notatka (opcjonalnie)", + value = state.leaveRequestNote, + onValue = { onDraft(null, null, null, it) }, + minLines = 4, + ) + } + item { + Button( + onClick = onSubmit, + modifier = Modifier.fillMaxWidth().height(58.dp), + colors = ButtonDefaults.buttonColors(containerColor = TppColors.Forest), + shape = RoundedCornerShape(8.dp), + ) { + Text("Wyślij wniosek", fontWeight = FontWeight.Bold) + } + } + } + } +} + +@Composable +private fun SimpleTopBar(title: String, onBack: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .statusBarsPadding() + .background(Color.White) + .border(0.5.dp, TppColors.Outline.copy(alpha = 0.65f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { + Icon(Icons.Outlined.ArrowBack, contentDescription = "Wstecz", tint = TppColors.Ink) + } + Text(title, color = TppColors.Ink, fontWeight = FontWeight.Bold, fontSize = 20.sp) + } +} + +@Composable +private fun LeaveStatusPill(status: String) { + val color = when (status) { + "approved" -> Color(0xFFEAF7EF) to TppColors.Forest + "rejected" -> Color(0xFFFFEBEE) to Color(0xFFB91C1C) + "cancel_requested" -> Color(0xFFFFF7ED) to Color(0xFFB45309) + "cancelled", "revoked" -> Color(0xFFF1F5F9) to Color(0xFF475569) + else -> Color(0xFFFFF8DB) to Color(0xFF7A5A00) + } + Text( + DriverLeaveRequestUiRules.statusLabel(status), + color = color.second, + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + modifier = Modifier.background(color.first, RoundedCornerShape(999.dp)).padding(horizontal = 10.dp, vertical = 6.dp), + ) +} + +@Composable +private fun DetailLine(label: String, value: String) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(label.uppercase(Locale("pl", "PL")), color = TppColors.Muted, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text(value, color = TppColors.Ink, fontSize = 16.sp) + } +} + +@Composable +private fun FeedbackAndError(feedback: String?, error: String?) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (!feedback.isNullOrBlank()) { + Text(feedback, color = TppColors.Forest, fontWeight = FontWeight.Bold) + } + ErrorText(error) + } +} + +@Composable +private fun LeaveTextField(label: String, value: String, onValue: (String) -> Unit, minLines: Int = 1) { + OutlinedTextField( + value = value, + onValueChange = onValue, + label = { Text(label) }, + minLines = minLines, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) +} + +private fun leaveDateRange(from: String?, to: String?): String = + if (from == to) from ?: "-" else "${from ?: "?"} - ${to ?: "?"}" + +private fun shortDateTime(value: String): String = + runCatching { + DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale("pl", "PL")) + .format(Instant.parse(value).atZone(ZoneId.systemDefault())) + }.getOrDefault(value) + @Composable private fun DispatchSheetReminderCard( reminder: DispatchSheetReminderDto?, diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt new file mode 100644 index 0000000..a41c96f --- /dev/null +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRules.kt @@ -0,0 +1,42 @@ +package pl.firmatpp.kierowca.ui + +import java.time.LocalDate + +data class LeaveRequestsConfig( + val enabled: Boolean = false, + val types: List = listOf("URLOP"), +) + +object DriverLeaveRequestUiRules { + fun isFeatureVisible(config: LeaveRequestsConfig?): Boolean = + config?.enabled == true && config.types.isNotEmpty() + + fun statusLabel(status: String): String = + when (status) { + "pending" -> "Oczekuje" + "approved" -> "Zatwierdzony" + "rejected" -> "Odrzucony" + "cancel_requested" -> "Anulowanie do decyzji" + "cancelled" -> "Anulowany" + "revoked" -> "Cofnięto decyzję" + else -> status + } + + fun typeLabel(type: String): String = + when (type) { + "URLOP" -> "Urlop" + "CHOROBOWE" -> "Chorobowe" + "SZKOLENIE" -> "Szkolenie" + "WOLNE" -> "Dzień wolny" + "INNE" -> "Inne" + else -> type + } + + fun canCancel(status: String, dateFrom: String, today: LocalDate = LocalDate.now()): Boolean { + if (status == "pending") return true + if (status != "approved") return false + + val start = runCatching { LocalDate.parse(dateFrom) }.getOrNull() ?: return false + return start.isAfter(today) + } +} 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 8609f0c..63929ea 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -19,6 +19,7 @@ import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.data.model.DispatchSheetReminderDto import pl.firmatpp.kierowca.data.model.DriverDto +import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.data.upload.DispatchSheetUploadEntity @@ -29,7 +30,7 @@ import pl.firmatpp.kierowca.sync.DriverLiveSyncClient import pl.firmatpp.kierowca.sync.DriverSyncHint import pl.firmatpp.kierowca.sync.DriverSyncWorker -enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue } +enum class DriverScreen { Initializing, Phone, Otp, Routes, Profile, Detail, Photo, PhotoQueue, LeaveRequests, LeaveRequestDetail, AddLeaveRequest } data class DriverUiState( val screen: DriverScreen = DriverScreen.Initializing, @@ -55,6 +56,13 @@ data class DriverUiState( val queuedPhotoUploads: List = emptyList(), val dispatchSheetReminder: DispatchSheetReminderDto? = null, val dispatchSheetUploads: List = emptyList(), + val leaveRequestsConfig: LeaveRequestsConfig? = 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 leaveRequestNote: String = "", val deletingPhotoIds: Set = emptySet(), val completingRoute: Boolean = false, val imageAuthHeader: String? = null, @@ -164,7 +172,6 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) val settings = response.driverAppSettings _state.update { it.copy( - screen = if (navigateToRoutes) DriverScreen.Routes else it.screen, driver = response.session.driver, routes = response.routes.today, dispatchSheetReminder = response.dispatchSheetReminder, @@ -176,6 +183,13 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) allowRouteCompletion = settings?.allowRouteCompletion ?: it.allowRouteCompletion, requirePreciseLocationForPhotos = settings?.requirePreciseLocationForPhotos ?: it.requirePreciseLocationForPhotos, + leaveRequestsConfig = settings?.leaveRequests?.let { config -> + LeaveRequestsConfig(enabled = config.enabled, types = config.types) + }, + screen = if ( + settings?.leaveRequests?.enabled != true && + it.screen in setOf(DriverScreen.LeaveRequests, DriverScreen.LeaveRequestDetail, DriverScreen.AddLeaveRequest) + ) DriverScreen.Routes else if (navigateToRoutes) DriverScreen.Routes else it.screen, notifyNewRoutes = response.notificationPreferences?.notifyNewRoutes ?: it.notifyNewRoutes, imageAuthHeader = repository.imageAuthHeader(), isStale = cached.stale || !it.isOnline, @@ -292,6 +306,135 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(screen = DriverScreen.PhotoQueue, error = null) } } + fun openLeaveRequests() { + if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return + loadLeaveRequests(showLoading = true) + } + + fun refreshLeaveRequests() { + if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return + loadLeaveRequests(showLoading = false) + } + + private fun loadLeaveRequests(showLoading: Boolean) { + viewModelScope.launch { + _state.update { it.copy(loading = showLoading, refreshing = !showLoading, error = null, feedback = null) } + runCatching { repository.leaveRequests() } + .onSuccess { requests -> + _state.update { + it.copy( + screen = DriverScreen.LeaveRequests, + leaveRequests = requests, + selectedLeaveRequest = requests.firstOrNull { request -> request.id == it.selectedLeaveRequest?.id } ?: it.selectedLeaveRequest, + error = null, + ) + } + } + .onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } } + _state.update { it.copy(loading = false, refreshing = false) } + } + } + + fun openLeaveRequest(id: String) { + if (!DriverLeaveRequestUiRules.isFeatureVisible(_state.value.leaveRequestsConfig)) return + 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) } + } + .onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } } + _state.update { it.copy(loading = false) } + } + } + + fun openAddLeaveRequest() { + val config = _state.value.leaveRequestsConfig + if (!DriverLeaveRequestUiRules.isFeatureVisible(config)) return + val today = LocalDate.now().toString() + _state.update { + it.copy( + screen = DriverScreen.AddLeaveRequest, + leaveRequestDateFrom = today, + leaveRequestDateTo = today, + leaveRequestType = config?.types?.firstOrNull() ?: "URLOP", + leaveRequestNote = "", + error = null, + feedback = null, + ) + } + } + + fun updateLeaveRequestDraft(dateFrom: String? = null, dateTo: String? = null, type: String? = null, note: String? = null) { + _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, + ) + } + } + + fun submitLeaveRequest() { + val snapshot = _state.value + if (!DriverLeaveRequestUiRules.isFeatureVisible(snapshot.leaveRequestsConfig)) return + if (snapshot.leaveRequestDateFrom < LocalDate.now().toString()) { + _state.update { it.copy(error = "Data od nie może być z przeszłości.") } + return + } + + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null, feedback = null) } + runCatching { + repository.createLeaveRequest( + dateFrom = snapshot.leaveRequestDateFrom, + dateTo = snapshot.leaveRequestDateTo, + type = snapshot.leaveRequestType, + note = snapshot.leaveRequestNote.takeIf { it.isNotBlank() }, + ) + }.onSuccess { created -> + val requests = runCatching { repository.leaveRequests() }.getOrElse { listOf(created) } + _state.update { + it.copy( + screen = DriverScreen.LeaveRequestDetail, + selectedLeaveRequest = created, + leaveRequests = requests, + feedback = "Wniosek został wysłany do decyzji.", + error = null, + ) + } + }.onFailure { throwable -> + _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } + } + _state.update { it.copy(loading = false) } + } + } + + fun cancelSelectedLeaveRequest() { + val request = _state.value.selectedLeaveRequest ?: return + if (!DriverLeaveRequestUiRules.canCancel(request.status, request.dateFrom.orEmpty())) return + + viewModelScope.launch { + _state.update { it.copy(loading = true, error = null, feedback = null) } + runCatching { repository.cancelLeaveRequest(request.id) } + .onSuccess { updated -> + _state.update { + it.copy( + selectedLeaveRequest = updated, + leaveRequests = it.leaveRequests.map { existing -> if (existing.id == updated.id) updated else existing }, + feedback = if (updated.status == "cancel_requested") "Anulowanie wysłane do decyzji." else "Wniosek został anulowany.", + error = null, + ) + } + } + .onFailure { throwable -> _state.update { it.copy(error = ApiErrorMapper.map(throwable).message) } } + _state.update { it.copy(loading = false) } + } + } + fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) { val route = _state.value.selectedRoute ?: return viewModelScope.launch { @@ -425,6 +568,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) when (it.screen) { DriverScreen.Photo -> it.copy(screen = DriverScreen.Detail, selectedPhoto = null) DriverScreen.PhotoQueue -> it.copy(screen = DriverScreen.Routes) + DriverScreen.LeaveRequests -> it.copy(screen = DriverScreen.Routes) + DriverScreen.LeaveRequestDetail -> it.copy(screen = DriverScreen.LeaveRequests, selectedLeaveRequest = null, feedback = null) + DriverScreen.AddLeaveRequest -> it.copy(screen = DriverScreen.LeaveRequests, feedback = null) DriverScreen.Detail -> { photoUploadsJob?.cancel() it.copy(screen = DriverScreen.Routes, selectedRoute = null, photoUploads = emptyList(), feedback = null) @@ -489,6 +635,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) if (snapshot.screen == DriverScreen.Detail) { refreshSelectedRoute() } + if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) { + refreshLeaveRequests() + } } private suspend fun checkRemoteSyncState() { @@ -530,6 +679,11 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } DriverSyncRepository.SCOPE_SETTINGS -> refreshRoutesSilently() DriverSyncRepository.SCOPE_DISPATCH_SHEET -> refreshRoutesSilently() + DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> { + if (snapshot.screen == DriverScreen.LeaveRequests || snapshot.screen == DriverScreen.LeaveRequestDetail) { + refreshLeaveRequests() + } + } } } diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt new file mode 100644 index 0000000..5f0fa22 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverLeaveRequestUiRulesTest.kt @@ -0,0 +1,34 @@ +package pl.firmatpp.kierowca.ui + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DriverLeaveRequestUiRulesTest { + @Test + fun featureIsVisibleOnlyWhenServerConfigEnablesIt() { + assertFalse(DriverLeaveRequestUiRules.isFeatureVisible(null)) + assertFalse(DriverLeaveRequestUiRules.isFeatureVisible(LeaveRequestsConfig(enabled = false, types = listOf("URLOP")))) + assertTrue(DriverLeaveRequestUiRules.isFeatureVisible(LeaveRequestsConfig(enabled = true, types = listOf("URLOP")))) + } + + @Test + fun exposesPolishStatusLabels() { + assertEquals("Oczekuje", DriverLeaveRequestUiRules.statusLabel("pending")) + assertEquals("Anulowanie do decyzji", DriverLeaveRequestUiRules.statusLabel("cancel_requested")) + assertEquals("Cofnięto decyzję", DriverLeaveRequestUiRules.statusLabel("revoked")) + } + + @Test + fun driverCanCancelPendingAndFutureApprovedRequests() { + val tomorrow = LocalDate.now().plusDays(1).toString() + val yesterday = LocalDate.now().minusDays(1).toString() + + assertTrue(DriverLeaveRequestUiRules.canCancel(status = "pending", dateFrom = yesterday)) + assertTrue(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = tomorrow)) + assertFalse(DriverLeaveRequestUiRules.canCancel(status = "approved", dateFrom = yesterday)) + assertFalse(DriverLeaveRequestUiRules.canCancel(status = "rejected", dateFrom = tomorrow)) + } +}