Add driver leave request screens

This commit is contained in:
admin
2026-07-04 23:11:08 +02:00
parent 0bbdbdb4f1
commit 2c806ef14e
12 changed files with 835 additions and 4 deletions
@@ -13,14 +13,20 @@ import pl.firmatpp.kierowca.ui.theme.TppKierowcaTheme
class MainActivity : ComponentActivity() {
private var notificationRouteId by mutableStateOf<String?>(null)
private var notificationLeaveRequestId by mutableStateOf<String?>(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"
}
}
@@ -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<DriverLeaveRequestDto> =
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)
@@ -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,
@@ -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<String> = listOf("URLOP"),
)
data class DispatchSheetReminderDto(
@@ -157,6 +163,58 @@ data class NotificationPreferencesBody(
val notifyNewRoutes: Boolean,
)
data class LeaveRequestListResponse(
val data: List<DriverLeaveRequestDto> = emptyList(),
)
data class LeaveRequestResponse(
val data: DriverLeaveRequestDto,
)
data class LeaveRequestTypesResponse(
val data: List<LeaveRequestTypeDto> = 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<DriverLeaveRequestEventDto> = 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,
@@ -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"
}
}
@@ -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(
@@ -47,6 +47,10 @@ class DriverSyncWorker(
syncRepository.bootstrap(scope.date ?: date)
refreshed = true
}
DriverSyncRepository.SCOPE_LEAVE_REQUESTS -> {
syncRepository.saveSyncStates(response)
refreshed = true
}
}
}
}
@@ -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<LeaveRequestDecisionNotificationWorker>()
.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,
)
}
}
}
@@ -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<DriverLeaveRequestDto>, 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?,
@@ -0,0 +1,42 @@
package pl.firmatpp.kierowca.ui
import java.time.LocalDate
data class LeaveRequestsConfig(
val enabled: Boolean = false,
val types: List<String> = 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)
}
}
@@ -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<PhotoUploadEntity> = emptyList(),
val dispatchSheetReminder: DispatchSheetReminderDto? = null,
val dispatchSheetUploads: List<DispatchSheetUploadEntity> = emptyList(),
val leaveRequestsConfig: LeaveRequestsConfig? = null,
val leaveRequests: List<DriverLeaveRequestDto> = 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<String> = 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()
}
}
}
}
@@ -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))
}
}