Compare commits

...
9 changed files with 999 additions and 142 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 126 versionCode = 129
versionName = "1.0.73" versionName = "1.0.76"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -84,10 +84,11 @@ class DriverRepository(
type: String, type: String,
requestMode: String, requestMode: String,
requestedQuantity: Int?, requestedQuantity: Int?,
details: Map<String, String>,
): DriverLeaveForecastDto = ): DriverLeaveForecastDto =
api.leaveForecast( api.leaveForecast(
authHeader(requireToken()), authHeader(requireToken()),
LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity), LeaveForecastBody(type, dateFrom, dateTo, requestMode, requestedQuantity, details),
).data ).data
suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> = suspend fun leaveCalendar(from: String, to: String): List<DriverLeaveCalendarEntryDto> =
@@ -88,6 +88,13 @@ data class DriverAppSettingsDto(
data class LeaveRequestsConfigDto( data class LeaveRequestsConfigDto(
val enabled: Boolean = false, val enabled: Boolean = false,
val types: List<String> = listOf("URLOP"), val types: List<String> = listOf("URLOP"),
val usagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
) )
data class DispatchSheetReminderDto( data class DispatchSheetReminderDto(
@@ -236,6 +243,10 @@ data class LeaveRequestTypeDto(
val description: String? = null, val description: String? = null,
val decisionMode: String? = null, val decisionMode: String? = null,
val payrollMode: String? = null, val payrollMode: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
val legalBasis: String? = null, val legalBasis: String? = null,
) )
@@ -248,6 +259,13 @@ data class DriverLeaveSummaryDto(
val dailyNormMinutes: Int = 480, val dailyNormMinutes: Int = 480,
val balanceConfigured: Boolean = false, val balanceConfigured: Boolean = false,
val overdueQuantity: Int = 0, val overdueQuantity: Int = 0,
val visibleUsagePoolCodes: List<String> = listOf(
"ANNUAL",
"ANNUAL_DISABILITY",
"FORCE_MAJEURE",
"CHILD_CARE_14",
"CARE_LEAVE",
),
val availableTypes: List<LeaveRequestTypeDto> = emptyList(), val availableTypes: List<LeaveRequestTypeDto> = emptyList(),
val reminders: List<DriverLeaveReminderDto> = emptyList(), val reminders: List<DriverLeaveReminderDto> = emptyList(),
) )
@@ -279,6 +297,7 @@ data class LeaveForecastBody(
val dateTo: String, val dateTo: String,
val requestMode: String, val requestMode: String,
val requestedQuantity: Int? = null, val requestedQuantity: Int? = null,
val details: Map<String, String> = emptyMap(),
) )
data class LeaveForecastResponse( data class LeaveForecastResponse(
@@ -287,6 +306,11 @@ data class LeaveForecastResponse(
data class DriverLeaveForecastDto( data class DriverLeaveForecastDto(
val ruleCode: String, val ruleCode: String,
val payrollMode: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
val quantityUnit: String, val quantityUnit: String,
val days: List<DriverLeaveForecastDayDto> = emptyList(), val days: List<DriverLeaveForecastDayDto> = emptyList(),
val workingDaysCount: Int = 0, val workingDaysCount: Int = 0,
@@ -329,6 +353,7 @@ data class DriverLeaveRequestDto(
val typeLabel: String? = null, val typeLabel: String? = null,
val requestMode: String = "days", val requestMode: String = "days",
val requestedQuantity: Int? = null, val requestedQuantity: Int? = null,
val quantityUnit: String? = null,
val rule: DriverLeaveRuleSnapshotDto? = null, val rule: DriverLeaveRuleSnapshotDto? = null,
val details: Map<String, String> = emptyMap(), val details: Map<String, String> = emptyMap(),
val note: String? = null, val note: String? = null,
@@ -344,6 +369,13 @@ data class DriverLeaveRequestDto(
val allocations: List<DriverLeaveAllocationDto> = emptyList(), val allocations: List<DriverLeaveAllocationDto> = emptyList(),
val balanceConfigurationRequired: Boolean = false, val balanceConfigurationRequired: Boolean = false,
val missingDocumentTypes: List<String> = emptyList(), val missingDocumentTypes: List<String> = emptyList(),
val payrollMode: String? = null,
val payrollItemTypes: List<String> = emptyList(),
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresLegalVerification: Boolean = false,
val legalVerificationChecks: List<String> = emptyList(),
) )
data class DriverLeaveRuleSnapshotDto( data class DriverLeaveRuleSnapshotDto(
@@ -363,6 +395,10 @@ data class DriverLeaveRuleRequirementsDto(
val requiredDocuments: List<String> = emptyList(), val requiredDocuments: List<String> = emptyList(),
val fieldOptions: Map<String, Map<String, String>> = emptyMap(), val fieldOptions: Map<String, Map<String, String>> = emptyMap(),
val description: String? = null, val description: String? = null,
val financialTreatmentLabel: String? = null,
val affectsAnnualLeaveBalance: Boolean = false,
val annualEntitlementImpact: String? = null,
val requiresHrVerification: List<String> = emptyList(),
) )
data class DriverLeaveAllocationDto( data class DriverLeaveAllocationDto(
@@ -62,6 +62,7 @@ import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.BugReport import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Collections
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Factory import androidx.compose.material.icons.outlined.Factory
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
@@ -190,6 +191,11 @@ private enum class RouteLocationPermissionPurpose {
RestoreRequiredTracking, RestoreRequiredTracking,
} }
private enum class RouteStageCameraMode {
Single,
Multiple,
}
@Composable @Composable
fun DriverApp( fun DriverApp(
viewModel: DriverViewModel, viewModel: DriverViewModel,
@@ -1523,7 +1529,11 @@ private fun LeaveRequestsScreen(
item { EmptyState("Nie masz jeszcze żadnych wniosków") } item { EmptyState("Nie masz jeszcze żadnych wniosków") }
} else { } else {
items(state.leaveRequests, key = { it.id }) { request -> items(state.leaveRequests, key = { it.id }) { request ->
LeaveRequestCard(request, onClick = { onOpen(request.id) }) LeaveRequestCard(
request = request,
dailyNormMinutes = state.leaveSummary?.dailyNormMinutes ?: 480,
onClick = { onOpen(request.id) },
)
} }
} }
} }
@@ -1543,6 +1553,12 @@ private fun LeaveBalanceOverview(state: DriverUiState) {
val summary = state.leaveSummary val summary = state.leaveSummary
val totals = DriverLeaveRequestUiRules.annualTotals(summary) val totals = DriverLeaveRequestUiRules.annualTotals(summary)
val norm = summary?.dailyNormMinutes ?: 480 val norm = summary?.dailyNormMinutes ?: 480
val showAnnualUsage = DriverLeaveRequestUiRules.isUsageVisible(summary, "ANNUAL")
val otherPools = DriverLeaveRequestUiRules.otherEntitlementPools(summary)
if (summary != null && !showAnnualUsage && otherPools.isEmpty()) {
return
}
Card( Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
@@ -1551,8 +1567,14 @@ private fun LeaveBalanceOverview(state: DriverUiState) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { 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) Text(
if (showAnnualUsage) "Twój urlop wypoczynkowy" else "Twoje limity nieobecności",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
if (showAnnualUsage) {
when { when {
summary == null -> { summary == null -> {
Text("Pobieramy aktualne saldo…", color = TppTheme.colors.muted) Text("Pobieramy aktualne saldo…", color = TppTheme.colors.muted)
@@ -1635,6 +1657,58 @@ private fun LeaveBalanceOverview(state: DriverUiState) {
} }
} }
} }
if (otherPools.isNotEmpty()) {
if (showAnnualUsage) {
HorizontalDivider(color = TppTheme.colors.outline)
Text(
"Pozostałe dostępne limity",
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
fontSize = 17.sp,
)
}
Text(
"Te limity są rozliczane osobno i nie zmniejszają zwykłego urlopu wypoczynkowego.",
color = TppTheme.colors.muted,
fontSize = 13.sp,
)
otherPools.forEach { pool ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
"${DriverLeaveRequestUiRules.poolLabel(pool.poolCode)} · ${pool.year}",
color = TppTheme.colors.muted,
modifier = Modifier.weight(1f),
)
Text(
DriverLeaveRequestUiRules.formatQuantity(pool.availableQuantity, pool.unit, norm),
color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End,
)
}
if (pool.usedQuantity > 0 || pool.reservedQuantity > 0) {
Text(
buildString {
if (pool.usedQuantity > 0) {
append("Wykorzystano ")
append(DriverLeaveRequestUiRules.formatQuantity(pool.usedQuantity, pool.unit, norm))
}
if (pool.reservedQuantity > 0) {
if (isNotEmpty()) append(" · ")
append("W przyszłych wnioskach ")
append(DriverLeaveRequestUiRules.formatQuantity(pool.reservedQuantity, pool.unit, norm))
}
},
color = TppTheme.colors.muted,
fontSize = 12.sp,
)
}
}
}
}
}
} }
} }
@@ -1653,7 +1727,11 @@ private fun LeaveBalanceStat(title: String, value: String, modifier: Modifier =
} }
@Composable @Composable
private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit) { private fun LeaveRequestCard(
request: DriverLeaveRequestDto,
dailyNormMinutes: Int,
onClick: () -> Unit,
) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card), colors = CardDefaults.cardColors(containerColor = TppTheme.colors.card),
border = BorderStroke(1.dp, TppTheme.colors.outline), border = BorderStroke(1.dp, TppTheme.colors.outline),
@@ -1670,6 +1748,27 @@ private fun LeaveRequestCard(request: DriverLeaveRequestDto, onClick: () -> Unit
fontSize = 17.sp, fontSize = 17.sp,
) )
Text(leaveDateRange(request.dateFrom, request.dateTo), color = TppTheme.colors.muted, fontSize = 14.sp) Text(leaveDateRange(request.dateFrom, request.dateTo), color = TppTheme.colors.muted, fontSize = 14.sp)
request.requestedQuantity?.let { quantity ->
Text(
DriverLeaveRequestUiRules.formatQuantity(
quantity,
DriverLeaveRequestUiRules.requestQuantityUnit(request),
dailyNormMinutes,
),
color = TppTheme.colors.ink,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
}
Text(
if (request.affectsAnnualLeaveBalance) {
"Pomniejsza pulę urlopu wypoczynkowego"
} else {
"Nie pomniejsza puli urlopu wypoczynkowego"
},
color = if (request.affectsAnnualLeaveBalance) TppTheme.colors.forest else TppTheme.colors.muted,
fontSize = 12.sp,
)
if (!request.note.isNullOrBlank()) { if (!request.note.isNullOrBlank()) {
Text(request.note, color = TppTheme.colors.muted, maxLines = 2, overflow = TextOverflow.Ellipsis) Text(request.note, color = TppTheme.colors.muted, maxLines = 2, overflow = TextOverflow.Ellipsis)
} }
@@ -1732,20 +1831,36 @@ private fun LeaveRequestDetailScreen(
} }
DetailLine("Wybrany termin", leaveDateRange(request.dateFrom, request.dateTo)) DetailLine("Wybrany termin", leaveDateRange(request.dateFrom, request.dateTo))
request.requestedQuantity?.let { quantity -> request.requestedQuantity?.let { quantity ->
val unit = when {
request.requestMode == "hours" -> "minutes"
request.rule?.unit == "calendar_days" -> "days"
else -> "minutes"
}
DetailLine( DetailLine(
"Czas nieobecności", "Czas nieobecności",
DriverLeaveRequestUiRules.formatQuantity( DriverLeaveRequestUiRules.formatQuantity(
quantity, quantity,
unit, DriverLeaveRequestUiRules.requestQuantityUnit(request),
state.leaveSummary?.dailyNormMinutes ?: 480, state.leaveSummary?.dailyNormMinutes ?: 480,
), ),
) )
} }
DetailLine(
"Wpływ na urlop wypoczynkowy",
if (request.affectsAnnualLeaveBalance) {
"Pomniejsza odpowiednią pulę urlopu"
} else {
"Nie pomniejsza puli urlopu wypoczynkowego"
},
)
if (!request.financialTreatmentLabel.isNullOrBlank()) {
DetailLine("Sposób rozliczenia", request.financialTreatmentLabel)
}
DriverLeaveRequestUiRules.annualEntitlementImpact(request.annualEntitlementImpact)?.let {
Text(it, color = Color(0xFF7A5A00), fontSize = 13.sp)
}
if (request.requiresLegalVerification) {
Text(
"Kadry sprawdzą wymagane warunki i dokumenty przed ostatecznym rozliczeniem.",
color = Color(0xFF7A5A00),
fontSize = 13.sp,
)
}
DetailLine("Wysłano", request.submittedAt?.let(::shortDateTime) ?: "-") DetailLine("Wysłano", request.submittedAt?.let(::shortDateTime) ?: "-")
if (request.decidedAt != null) { if (request.decidedAt != null) {
DetailLine("Decyzję zapisano", shortDateTime(request.decidedAt)) DetailLine("Decyzję zapisano", shortDateTime(request.decidedAt))
@@ -1783,6 +1898,32 @@ private fun LeaveRequestDetailScreen(
} }
} }
} }
val submittedDetails = request.details
.filterValues { it.isNotBlank() }
.toList()
if (submittedDetails.isNotEmpty()) {
item {
Text("Podane informacje", 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(10.dp)) {
submittedDetails.forEach { (field, rawValue) ->
val displayedValue = request.rule?.rule
?.fieldOptions
?.get(field)
?.get(rawValue)
?: rawValue
DetailLine(DriverLeaveRequestUiRules.fieldLabel(field), displayedValue)
}
}
}
}
}
val requiredDocuments = request.rule?.rule?.requiredDocuments.orEmpty() val requiredDocuments = request.rule?.rule?.requiredDocuments.orEmpty()
if (requiredDocuments.isNotEmpty()) { if (requiredDocuments.isNotEmpty()) {
item { item {
@@ -1836,7 +1977,7 @@ private fun LeaveRequestDetailScreen(
} }
if (request.allocations.isNotEmpty()) { if (request.allocations.isNotEmpty()) {
item { item {
Text("Jak rozliczono urlop", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp) Text("Jak rozliczono nieobecność", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 18.sp)
} }
item { item {
Card( Card(
@@ -1845,22 +1986,31 @@ private fun LeaveRequestDetailScreen(
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
) { ) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
request.allocations.groupBy { it.sourceYear }.forEach { (year, allocations) -> request.allocations
.groupBy { it.sourceYear to it.status }
.forEach { (source, allocations) ->
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text( Text(
if (year != null) "Z puli $year" else "Rozliczenie", source.first?.let { "Z puli $it" } ?: "Czas nieobecności",
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
) )
Text( Text(
DriverLeaveRequestUiRules.formatQuantity( DriverLeaveRequestUiRules.formatQuantity(
allocations.sumOf { it.quantity }, allocations.sumOf { it.quantity },
if (request.rule?.unit == "calendar_days") "days" else "minutes", DriverLeaveRequestUiRules.requestQuantityUnit(request),
state.leaveSummary?.dailyNormMinutes ?: 480, state.leaveSummary?.dailyNormMinutes ?: 480,
), ),
color = TppTheme.colors.ink, color = TppTheme.colors.ink,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
Text(
DriverLeaveRequestUiRules.allocationStatusLabel(source.second),
color = TppTheme.colors.muted,
fontSize = 12.sp,
)
}
} }
} }
} }
@@ -2328,7 +2478,7 @@ private fun AddLeaveRequestScreen(
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Złóż wniosek krok po kroku", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 23.sp) Text("Złóż wniosek krok po kroku", color = TppTheme.colors.ink, fontWeight = FontWeight.Bold, fontSize = 23.sp)
Text( Text(
"Wybierz rodzaj, termin i sprawdź podsumowanie. Aplikacja policzy urlop za Ciebie.", "Wybierz rodzaj i termin. Aplikacja policzy dni lub godziny oraz pokaże wpływ na saldo.",
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
) )
} }
@@ -2341,7 +2491,15 @@ private fun AddLeaveRequestScreen(
color = Color(0xFFB91C1C), color = Color(0xFFB91C1C),
) )
} else { } else {
availableTypes.forEach { type -> availableTypes.groupBy { it.category }.forEach { (category, types) ->
Text(
DriverLeaveRequestUiRules.categoryLabel(category),
color = TppTheme.colors.muted,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
modifier = Modifier.padding(top = 4.dp),
)
types.forEach { type ->
val active = type.value == state.leaveRequestType val active = type.value == state.leaveRequestType
Card( Card(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -2368,6 +2526,25 @@ private fun AddLeaveRequestScreen(
if (!type.description.isNullOrBlank()) { if (!type.description.isNullOrBlank()) {
Text(type.description, color = TppTheme.colors.muted, fontSize = 13.sp) Text(type.description, color = TppTheme.colors.muted, fontSize = 13.sp)
} }
Text(
if (type.affectsAnnualLeaveBalance) {
"Pomniejsza odpowiednią pulę urlopu wypoczynkowego."
} else {
"Nie pomniejsza puli urlopu wypoczynkowego."
},
color = if (type.affectsAnnualLeaveBalance) TppTheme.colors.forest else TppTheme.colors.muted,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
DriverLeaveRequestUiRules.annualEntitlementImpact(type.annualEntitlementImpact)?.let {
Text(
it,
color = Color(0xFF7A5A00),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
}
}
} }
} }
} }
@@ -2375,7 +2552,7 @@ private fun AddLeaveRequestScreen(
} }
} }
item { item {
GuidedStepCard(number = "2", title = "Kiedy potrzebujesz wolnego?") { GuidedStepCard(number = "2", title = "Kiedy ma trwać nieobecność?") {
Card( Card(
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke( border = BorderStroke(
@@ -2470,7 +2647,7 @@ private fun AddLeaveRequestScreen(
if (active) Icon(Icons.Outlined.CheckCircle, contentDescription = "Wybrano") if (active) Icon(Icons.Outlined.CheckCircle, contentDescription = "Wybrano")
} }
} }
} else if (field.endsWith("Date")) { } else if (field.endsWith("Date") || field.endsWith("At")) {
Card( Card(
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = Color.White),
border = BorderStroke(1.dp, TppTheme.colors.outline), border = BorderStroke(1.dp, TppTheme.colors.outline),
@@ -2501,6 +2678,13 @@ private fun AddLeaveRequestScreen(
supportingText = DriverLeaveRequestUiRules.fieldHint(field)?.let { hint -> supportingText = DriverLeaveRequestUiRules.fieldHint(field)?.let { hint ->
{ Text(hint) } { Text(hint) }
}, },
keyboardOptions = KeyboardOptions(
keyboardType = if (field in setOf("eligibleHospitalDays", "partNumber")) {
KeyboardType.Number
} else {
KeyboardType.Text
},
),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
) )
@@ -2536,6 +2720,28 @@ private fun AddLeaveRequestScreen(
color = TppTheme.colors.muted, color = TppTheme.colors.muted,
fontSize = 13.sp, fontSize = 13.sp,
) )
if (!selectedType.financialTreatmentLabel.isNullOrBlank()) {
Text(
"Rozliczenie: ${selectedType.financialTreatmentLabel}",
color = TppTheme.colors.ink,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
}
DriverLeaveRequestUiRules.annualEntitlementImpact(selectedType.annualEntitlementImpact)?.let {
Text(
it,
color = Color(0xFF7A5A00),
fontSize = 13.sp,
)
}
if (selectedType.requiresHrVerification.isNotEmpty()) {
Text(
"Kadry sprawdzą indywidualne warunki i dokumenty przed zatwierdzeniem. Nie musisz samodzielnie obliczać limitu ani kwoty.",
color = Color(0xFF7A5A00),
fontSize = 13.sp,
)
}
} }
} }
} }
@@ -2585,6 +2791,16 @@ private fun AddLeaveRequestScreen(
) )
}", }",
) )
Text(
if (state.leaveForecast.affectsAnnualLeaveBalance) {
"Ten wniosek pomniejszy odpowiednią pulę urlopu wypoczynkowego."
} else {
"Ten wniosek nie pomniejszy puli urlopu wypoczynkowego."
},
)
if (!state.leaveForecast.financialTreatmentLabel.isNullOrBlank()) {
Text("Sposób rozliczenia: ${state.leaveForecast.financialTreatmentLabel}")
}
Text("Po wysłaniu zobaczysz status oraz dalsze wymagane kroki.") Text("Po wysłaniu zobaczysz status oraz dalsze wymagane kroki.")
} }
}, },
@@ -2659,13 +2875,17 @@ private fun LeaveForecastCard(state: DriverUiState, forecast: pl.firmatpp.kierow
) { ) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text( Text(
if (success) "Wniosek jest gotowy do wysłania" else "Za mało urlopu do wykorzystania", if (success) "Dane są gotowe do wysłania" else "Za mało dostępnego limitu",
color = if (success) TppTheme.colors.forest else Color(0xFFB91C1C), color = if (success) TppTheme.colors.forest else Color(0xFFB91C1C),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 18.sp, fontSize = 18.sp,
) )
DetailLine( DetailLine(
"Ten wniosek wykorzysta", if (forecast.affectsAnnualLeaveBalance) {
"Pomniejszy pulę urlopu o"
} else {
"Czas tej nieobecności"
},
DriverLeaveRequestUiRules.formatQuantity( DriverLeaveRequestUiRules.formatQuantity(
forecast.requestedQuantity, forecast.requestedQuantity,
forecast.quantityUnit, forecast.quantityUnit,
@@ -2675,6 +2895,35 @@ private fun LeaveForecastCard(state: DriverUiState, forecast: pl.firmatpp.kierow
if (forecast.workingDaysCount > 0) { if (forecast.workingDaysCount > 0) {
DetailLine("Dni pracy w wybranym terminie", DriverLeaveRequestUiRules.dayCountLabel(forecast.workingDaysCount)) DetailLine("Dni pracy w wybranym terminie", DriverLeaveRequestUiRules.dayCountLabel(forecast.workingDaysCount))
} }
if (!forecast.affectsAnnualLeaveBalance) {
Text(
"Ta nieobecność nie zmniejszy liczby dni urlopu wypoczynkowego.",
color = TppTheme.colors.muted,
fontSize = 13.sp,
)
}
if (!forecast.financialTreatmentLabel.isNullOrBlank()) {
Text(
"Rozliczenie: ${forecast.financialTreatmentLabel}",
color = TppTheme.colors.ink,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
}
DriverLeaveRequestUiRules.annualEntitlementImpact(forecast.annualEntitlementImpact)?.let {
Text(
it,
color = Color(0xFF7A5A00),
fontSize = 13.sp,
)
}
if (forecast.requiresHrVerification.isNotEmpty()) {
Text(
"Przed zatwierdzeniem kadry potwierdzą ustawowe warunki tego uprawnienia.",
color = Color(0xFF7A5A00),
fontSize = 13.sp,
)
}
if (!forecast.balanceConfigured) { if (!forecast.balanceConfigured) {
Text( Text(
"Saldo nie jest jeszcze potwierdzone. Wniosek zostanie zapisany, ale nie będzie mógł zostać zaakceptowany przed konfiguracją przez kadry.", "Saldo nie jest jeszcze potwierdzone. Wniosek zostanie zapisany, ale nie będzie mógł zostać zaakceptowany przed konfiguracją przez kadry.",
@@ -4182,6 +4431,8 @@ private fun RouteStageScreen(
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var cameraUri by remember { mutableStateOf<Uri?>(null) } var cameraUri by remember { mutableStateOf<Uri?>(null) }
var pendingCameraMode by remember { mutableStateOf<RouteStageCameraMode?>(null) }
var showMultiplePhotoCamera by remember(stage) { mutableStateOf(false) }
var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) } var showPreciseLocationPermissionDialog by remember { mutableStateOf(false) }
var voiceDictationActive by remember(stage) { mutableStateOf(false) } var voiceDictationActive by remember(stage) { mutableStateOf(false) }
val route = state.displaySelectedRoute val route = state.displaySelectedRoute
@@ -4236,6 +4487,7 @@ private fun RouteStageScreen(
cameraUri = null cameraUri = null
} }
val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
val requestedMode = pendingCameraMode
val pendingUri = cameraUri val pendingUri = cameraUri
val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA) val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA)
val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true || val fineLocationGranted = grants[Manifest.permission.ACCESS_FINE_LOCATION] == true ||
@@ -4243,14 +4495,43 @@ private fun RouteStageScreen(
if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) { if (!canLaunchCameraWithLocationPolicy(state.requirePreciseLocationForPhotos, fineLocationGranted)) {
showPreciseLocationPermissionDialog = true showPreciseLocationPermissionDialog = true
cameraUri = null cameraUri = null
pendingCameraMode = null
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri) if (cameraGranted) {
when (requestedMode) {
RouteStageCameraMode.Single -> {
if (pendingUri != null) cameraLauncher.launch(pendingUri)
}
RouteStageCameraMode.Multiple -> {
showMultiplePhotoCamera = true
}
null -> Unit
}
} else {
cameraUri = null
}
pendingCameraMode = null
} }
val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata()) if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata())
} }
if (showMultiplePhotoCamera) {
MultiplePhotoCameraScreen(
stage = stage,
onPhotoCaptured = { uri ->
coroutineScope.launch {
onUpload(uri, "camera", cameraPhotoMetadata(context))
}
},
onClose = {
showMultiplePhotoCamera = false
},
)
return
}
BoxWithConstraints(Modifier.fillMaxSize()) { BoxWithConstraints(Modifier.fillMaxSize()) {
val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp val horizontalPadding = screenHorizontalPaddingDp(maxWidth.value.toInt()).dp
Scaffold( Scaffold(
@@ -4330,19 +4611,37 @@ private fun RouteStageScreen(
if (showPhotoActions) { if (showPhotoActions) {
item { item {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
CargoActionButton("Zrób nowe zdjęcie", Icons.Outlined.CameraAlt, TppTheme.colors.forest) { RouteStageCameraActions(
onSinglePhoto = {
val newUri = createCameraUri(context) val newUri = createCameraUri(context)
cameraUri = newUri cameraUri = newUri
pendingCameraMode = RouteStageCameraMode.Single
val missingPermissions = cameraCapturePermissions( val missingPermissions = cameraCapturePermissions(
context = context, context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos, requirePreciseLocation = state.requirePreciseLocationForPhotos,
) )
if (missingPermissions.isEmpty()) { if (missingPermissions.isEmpty()) {
pendingCameraMode = null
cameraLauncher.launch(newUri) cameraLauncher.launch(newUri)
} else { } else {
cameraPermissionLauncher.launch(missingPermissions) cameraPermissionLauncher.launch(missingPermissions)
} }
},
onMultiplePhotos = {
cameraUri = null
pendingCameraMode = RouteStageCameraMode.Multiple
val missingPermissions = cameraCapturePermissions(
context = context,
requirePreciseLocation = state.requirePreciseLocationForPhotos,
)
if (missingPermissions.isEmpty()) {
pendingCameraMode = null
showMultiplePhotoCamera = true
} else {
cameraPermissionLauncher.launch(missingPermissions)
} }
},
)
if (state.allowGalleryUploads) { if (state.allowGalleryUploads) {
CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) { CargoActionButton("Dodaj zdjęcie z galerii", Icons.Outlined.AddPhotoAlternate, TppTheme.colors.containerGreen) {
pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) pickerLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
@@ -5249,6 +5548,107 @@ private fun RouteStageDocumentationBlock(
private fun formatRouteWeight(weight: Double): String = private fun formatRouteWeight(weight: Double): String =
if (weight % 1.0 == 0.0) "${weight.toInt()} t" else String.format(Locale("pl", "PL"), "%.3f t", weight) if (weight % 1.0 == 0.0) "${weight.toInt()} t" else String.format(Locale("pl", "PL"), "%.3f t", weight)
@Composable
private fun RouteStageCameraActions(
onSinglePhoto: () -> Unit,
onMultiplePhotos: () -> Unit,
) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
if (maxWidth >= 300.dp) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RouteStageCameraActionButton(
label = "Jedno zdjęcie",
icon = Icons.Outlined.CameraAlt,
color = TppTheme.colors.forest,
compact = true,
modifier = Modifier.weight(1f),
onClick = onSinglePhoto,
)
RouteStageCameraActionButton(
label = "Wiele zdjęć",
icon = Icons.Outlined.Collections,
color = TppTheme.colors.navy,
compact = true,
modifier = Modifier.weight(1f),
onClick = onMultiplePhotos,
)
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
RouteStageCameraActionButton(
label = "Jedno zdjęcie",
icon = Icons.Outlined.CameraAlt,
color = TppTheme.colors.forest,
compact = false,
modifier = Modifier.fillMaxWidth(),
onClick = onSinglePhoto,
)
RouteStageCameraActionButton(
label = "Wiele zdjęć",
icon = Icons.Outlined.Collections,
color = TppTheme.colors.navy,
compact = false,
modifier = Modifier.fillMaxWidth(),
onClick = onMultiplePhotos,
)
}
}
}
}
@Composable
private fun RouteStageCameraActionButton(
label: String,
icon: ImageVector,
color: Color,
compact: Boolean,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
Button(
onClick = onClick,
modifier = modifier.height(if (compact) 76.dp else 64.dp),
colors = ButtonDefaults.buttonColors(containerColor = color),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
shape = MaterialTheme.shapes.medium,
) {
if (compact) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp),
)
Text(
text = label,
color = Color.White,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
}
} else {
Icon(icon, contentDescription = null, tint = Color.White)
Spacer(Modifier.width(10.dp))
Text(
text = label,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable @Composable
private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) { private fun CargoActionButton(label: String, icon: ImageVector, color: Color, onClick: () -> Unit) {
Button( Button(
@@ -47,6 +47,7 @@ object DriverLeaveRequestUiRules {
"cancellation_rejected" -> "Anulowanie odrzucone" "cancellation_rejected" -> "Anulowanie odrzucone"
"period_amended" -> "Zmieniono termin wniosku" "period_amended" -> "Zmieniono termin wniosku"
"interrupted" -> "Nieobecność została przerwana" "interrupted" -> "Nieobecność została przerwana"
"annual_leave_interrupted" -> "Przywrócono urlop wypoczynkowy"
else -> "Aktualizacja wniosku" else -> "Aktualizacja wniosku"
} }
@@ -77,6 +78,8 @@ object DriverLeaveRequestUiRules {
"AUTHORITY_SUMMONS" -> "Wezwanie urzędowe" "AUTHORITY_SUMMONS" -> "Wezwanie urzędowe"
"RESCUE_DUTIES" -> "Obowiązki ratownicze" "RESCUE_DUTIES" -> "Obowiązki ratownicze"
"MILITARY_DUTIES" -> "Obowiązki wojskowe" "MILITARY_DUTIES" -> "Obowiązki wojskowe"
"ORGAN_DONOR_EXAMS" -> "Badania dawcy"
"CARE_SICKNESS" -> "Opieka nad chorym dzieckiem lub członkiem rodziny"
else -> type else -> type
} }
@@ -103,6 +106,63 @@ object DriverLeaveRequestUiRules {
.filter { it.poolCode == "ANNUAL" && it.status == "active" } .filter { it.poolCode == "ANNUAL" && it.status == "active" }
.sortedByDescending { it.year } .sortedByDescending { it.year }
fun isUsageVisible(summary: DriverLeaveSummaryDto?, poolCode: String): Boolean =
summary?.visibleUsagePoolCodes?.contains(poolCode) ?: true
fun otherEntitlementPools(summary: DriverLeaveSummaryDto?): List<DriverLeavePoolDto> =
summary?.pools.orEmpty()
.filter { it.poolCode != "ANNUAL" && it.status == "active" }
.sortedWith(compareBy<DriverLeavePoolDto>({ poolLabel(it.poolCode) }, { -it.year }))
fun poolLabel(poolCode: String): String =
when (poolCode) {
"ANNUAL" -> "Urlop wypoczynkowy"
"ANNUAL_DISABILITY" -> "Dodatkowy urlop wypoczynkowy"
"FORCE_MAJEURE" -> "Siła wyższa"
"CHILD_CARE_14" -> "Opieka nad dzieckiem do 14 lat"
"CARE_LEAVE" -> "Urlop opiekuńczy"
else -> typeLabel(poolCode)
}
fun requestQuantityUnit(request: DriverLeaveRequestDto): String {
request.quantityUnit?.takeIf { it == "days" || it == "minutes" }?.let { return it }
if (request.requestMode == "hours") return "minutes"
if (request.rule?.unit == "calendar_days") return "days"
val poolCode = request.rule?.poolCode
return if (
request.requestMode == "days" &&
poolCode != null &&
poolCode !in setOf("ANNUAL", "ANNUAL_DISABILITY")
) {
"days"
} else {
"minutes"
}
}
fun allocationStatusLabel(status: String): String =
when (status) {
"reserved" -> "Uwzględniono w przyszłej nieobecności"
"used" -> "Wykorzystano"
"released" -> "Przywrócono do wykorzystania"
else -> "Rozliczono"
}
fun categoryLabel(category: String?): String =
when (category) {
"annual" -> "Urlop wypoczynkowy"
"statutory_short" -> "Krótkie uprawnienia ustawowe"
"family" -> "Rodzina i opieka"
"parental" -> "Rodzicielstwo"
"health" -> "Zdrowie i rehabilitacja"
"training" -> "Nauka i szkolenia"
"statutory_exemption" -> "Pozostałe zwolnienia ustawowe"
"unpaid" -> "Nieobecności bezpłatne"
"operational" -> "Pozostałe nieobecności"
else -> "Inne rodzaje nieobecności"
}
fun formatQuantity(quantity: Int, unit: String, dailyNormMinutes: Int = 480): String { fun formatQuantity(quantity: Int, unit: String, dailyNormMinutes: Int = 480): String {
if (unit == "days") return dayCountLabel(quantity) if (unit == "days") return dayCountLabel(quantity)
@@ -149,6 +209,15 @@ object DriverLeaveRequestUiRules {
else -> "Wniosek zostanie przekazany do osoby, która podejmie decyzję." else -> "Wniosek zostanie przekazany do osoby, która podejmie decyzję."
} }
fun annualEntitlementImpact(impact: String?): String? =
when (impact) {
"proportional_after_one_month" ->
"Nie pomniejszy puli od razu, ale okres trwający co najmniej miesiąc może zmniejszyć roczny wymiar urlopu po powrocie do pracy."
"proportional_on_return_if_started_before_year" ->
"Po powrocie z urlopu rozpoczętego w poprzednim roku wymiar urlopu wypoczynkowego zostanie naliczony proporcjonalnie."
else -> null
}
fun fieldLabel(field: String): String = fun fieldLabel(field: String): String =
when (field) { when (field) {
"childBirthDate" -> "Data urodzenia dziecka" "childBirthDate" -> "Data urodzenia dziecka"
@@ -165,6 +234,16 @@ object DriverLeaveRequestUiRules {
"examType" -> "Rodzaj egzaminu lub nauki" "examType" -> "Rodzaj egzaminu lub nauki"
"educationProgramKey" -> "Nazwa szkoły lub programu" "educationProgramKey" -> "Nazwa szkoły lub programu"
"classification" -> "Czego dotyczy nieobecność" "classification" -> "Czego dotyczy nieobecność"
"urgentFamilyReason" -> "Jaka pilna sprawa rodzinna wymaga Twojej obecności?"
"childrenPlacedCount" -> "Liczba dzieci przyjętych na wychowanie"
"placementType" -> "Sposób przyjęcia dziecka na wychowanie"
"hospitalizationGroup" -> "Która sytuacja hospitalizacji dotyczy dziecka?"
"eligibleHospitalDays" -> "Liczba dni hospitalizacji potwierdzona przez szpital"
"maternityLeaveEndsAt" -> "Data zakończenia urlopu macierzyńskiego"
"parentalEntitlementVariant" -> "Wymiar urlopu rodzicielskiego"
"childcareEntitlementVariant" -> "Rodzaj urlopu wychowawczego"
"careRecipientType" -> "Kogo dotyczy opieka?"
"careEntitlementEventKey" -> "Opis okresu opieki"
else -> "Dodatkowa informacja" else -> "Dodatkowa informacja"
} }
@@ -176,6 +255,9 @@ object DriverLeaveRequestUiRules {
"childIdentityKey" -> "Wystarczy imię i rok urodzenia" "childIdentityKey" -> "Wystarczy imię i rok urodzenia"
"educationProgramKey" -> "Np. Technikum Transportowe w Poznaniu" "educationProgramKey" -> "Np. Technikum Transportowe w Poznaniu"
"classification" -> "Krótko opisz powód, aby kadry mogły go prawidłowo zakwalifikować" "classification" -> "Krótko opisz powód, aby kadry mogły go prawidłowo zakwalifikować"
"urgentFamilyReason" -> "Krótko opisz chorobę lub wypadek oraz dlaczego Twoja natychmiastowa obecność jest konieczna."
"eligibleHospitalDays" -> "Wpisz liczbę z zaświadczenia, np. 12"
"careEntitlementEventKey" -> "Wpisz prosty opis pozwalający kadrom połączyć części tego samego uprawnienia."
else -> null else -> null
} }
@@ -196,6 +278,9 @@ object DriverLeaveRequestUiRules {
"rescue_service_confirmation" -> "Potwierdzenie służby ratowniczej" "rescue_service_confirmation" -> "Potwierdzenie służby ratowniczej"
"military_document" -> "Dokument wojskowy" "military_document" -> "Dokument wojskowy"
"external_sickness_confirmation" -> "Potwierdzenie niezdolności do pracy" "external_sickness_confirmation" -> "Potwierdzenie niezdolności do pracy"
"other_parent_declaration" -> "Oświadczenie drugiego rodzica"
"organ_donor_confirmation" -> "Potwierdzenie badań lub zabiegu dawcy"
"external_care_confirmation" -> "Potwierdzenie opieki lub e-ZLA"
else -> "Wymagany dokument" else -> "Wymagany dokument"
} }
@@ -1259,6 +1259,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
type = snapshot.leaveRequestType, type = snapshot.leaveRequestType,
requestMode = snapshot.leaveRequestMode, requestMode = snapshot.leaveRequestMode,
requestedQuantity = requestedMinutes, requestedQuantity = requestedMinutes,
details = snapshot.leaveRequestDetails,
) )
}.onSuccess { forecast -> }.onSuccess { forecast ->
_state.update { it.copy(leaveForecast = forecast, error = null) } _state.update { it.copy(leaveForecast = forecast, error = null) }
@@ -0,0 +1,252 @@
package pl.firmatpp.kierowca.ui
import android.content.Context
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.view.CameraController
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.compose.LocalLifecycleOwner
import java.io.File
import pl.firmatpp.kierowca.ui.theme.TppTheme
@Composable
internal fun MultiplePhotoCameraScreen(
stage: String,
onPhotoCaptured: (Uri) -> Unit,
onClose: () -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnPhotoCaptured by rememberUpdatedState(onPhotoCaptured)
val cameraController = remember(context) {
LifecycleCameraController(context).apply {
setEnabledUseCases(CameraController.IMAGE_CAPTURE)
imageCaptureMode = ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY
}
}
var capturedCount by remember { mutableIntStateOf(0) }
var isCapturing by remember { mutableStateOf(false) }
var cameraUnavailable by remember { mutableStateOf(false) }
var cameraError by remember { mutableStateOf<String?>(null) }
DisposableEffect(cameraController, lifecycleOwner) {
runCatching {
cameraController.bindToLifecycle(lifecycleOwner)
}.onFailure {
cameraUnavailable = true
cameraError = "Nie udało się uruchomić aparatu."
}
onDispose {
cameraController.unbind()
}
}
BackHandler {
if (!isCapturing) onClose()
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
AndroidView(
factory = { previewContext ->
PreviewView(previewContext).apply {
controller = cameraController
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
},
modifier = Modifier.fillMaxSize(),
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.background(Color.Black.copy(alpha = 0.62f))
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onClose,
enabled = !isCapturing,
) {
Icon(
Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = "Wróć",
tint = Color.White,
)
}
Column {
Text(
text = if (stage == "loading") "Zdjęcia załadunku" else "Zdjęcia rozładunku",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = "Wykonano: $capturedCount",
color = Color.White.copy(alpha = 0.78f),
style = MaterialTheme.typography.bodyMedium,
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(Color.Black.copy(alpha = 0.68f))
.navigationBarsPadding()
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
cameraError?.let { message ->
Text(
text = message,
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF8C1D18), RoundedCornerShape(6.dp))
.padding(10.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Spacer(Modifier.size(112.dp, 52.dp))
Button(
onClick = {
if (isCapturing || cameraUnavailable) return@Button
val photoFile = createMultipleCameraFile(context)
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
isCapturing = true
cameraController.takePicture(
outputOptions,
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
isCapturing = false
cameraError = null
capturedCount += 1
currentOnPhotoCaptured(cameraFileUri(context, photoFile))
}
override fun onError(exception: ImageCaptureException) {
isCapturing = false
photoFile.delete()
cameraError = "Nie udało się zapisać zdjęcia. Spróbuj ponownie."
}
},
)
},
enabled = !isCapturing && !cameraUnavailable,
modifier = Modifier.size(78.dp),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
disabledContainerColor = Color.White.copy(alpha = 0.55f),
),
contentPadding = ButtonDefaults.ContentPadding,
) {
if (isCapturing) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
color = TppTheme.colors.forest,
strokeWidth = 3.dp,
)
} else {
Icon(
Icons.Outlined.CameraAlt,
contentDescription = "Zrób zdjęcie",
tint = TppTheme.colors.forest,
modifier = Modifier.size(32.dp),
)
}
}
Button(
onClick = onClose,
enabled = !isCapturing,
modifier = Modifier.size(width = 112.dp, height = 52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = TppTheme.colors.forest,
),
shape = RoundedCornerShape(6.dp),
) {
Icon(
Icons.Outlined.CheckCircle,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
Text(
text = "Gotowe",
color = Color.White,
modifier = Modifier.padding(start = 6.dp),
)
}
}
}
}
}
private fun createMultipleCameraFile(context: Context): File {
val directory = File(context.cacheDir, "camera").apply { mkdirs() }
return File.createTempFile("ladunek-", ".jpg", directory)
}
private fun cameraFileUri(context: Context, file: File): Uri =
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
@@ -19,6 +19,7 @@ class DriverLeaveApiContractTest {
"dailyNormMinutes": 480, "dailyNormMinutes": 480,
"balanceConfigured": true, "balanceConfigured": true,
"overdueQuantity": 480, "overdueQuantity": 480,
"visibleUsagePoolCodes": ["ANNUAL", "CHILD_CARE_14"],
"pools": [{ "pools": [{
"id": 7, "id": 7,
"poolCode": "ANNUAL", "poolCode": "ANNUAL",
@@ -38,7 +39,9 @@ class DriverLeaveApiContractTest {
"requiredFields": [], "requiredFields": [],
"requiredDocuments": [], "requiredDocuments": [],
"fieldOptions": {}, "fieldOptions": {},
"description": "Urlop rozliczany według grafiku." "description": "Urlop rozliczany według grafiku.",
"financialTreatmentLabel": "Płatny według danych przekazanych przez kadry i płace.",
"affectsAnnualLeaveBalance": true
}] }]
} }
} }
@@ -47,10 +50,12 @@ class DriverLeaveApiContractTest {
) )
assertEquals(480, response.data.dailyNormMinutes) assertEquals(480, response.data.dailyNormMinutes)
assertEquals(listOf("ANNUAL", "CHILD_CARE_14"), response.data.visibleUsagePoolCodes)
assertEquals(7680, response.data.pools.single().availableQuantity) assertEquals(7680, response.data.pools.single().availableQuantity)
assertEquals("URLOP", response.data.availableTypes.single().value) assertEquals("URLOP", response.data.availableTypes.single().value)
assertEquals("ANNUAL", response.data.availableTypes.single().code) assertEquals("ANNUAL", response.data.availableTypes.single().code)
assertTrue("hours" in response.data.availableTypes.single().requestModes) assertTrue("hours" in response.data.availableTypes.single().requestModes)
assertTrue(response.data.availableTypes.single().affectsAnnualLeaveBalance)
} }
@Test @Test
@@ -64,11 +69,14 @@ class DriverLeaveApiContractTest {
"type": "WOLNE", "type": "WOLNE",
"ruleCode": "CHILD_CARE_14", "ruleCode": "CHILD_CARE_14",
"requestMode": "days", "requestMode": "days",
"requestedQuantity": 480, "requestedQuantity": 1,
"quantityUnit": "days",
"details": { "details": {
"childrenBornCount": 1, "childrenBornCount": 1,
"childBirthDate": "2020-05-12" "childBirthDate": "2020-05-12"
}, },
"financialTreatmentLabel": "Płatne według zasad ustawowych.",
"affectsAnnualLeaveBalance": false,
"status": "pending", "status": "pending",
"missingDocumentTypes": ["parent_declaration"] "missingDocumentTypes": ["parent_declaration"]
} }
@@ -78,6 +86,9 @@ class DriverLeaveApiContractTest {
assertEquals("1", request.details["childrenBornCount"]) assertEquals("1", request.details["childrenBornCount"])
assertEquals("2020-05-12", request.details["childBirthDate"]) assertEquals("2020-05-12", request.details["childBirthDate"])
assertEquals("days", request.quantityUnit)
assertEquals(false, request.affectsAnnualLeaveBalance)
assertEquals("Płatne według zasad ustawowych.", request.financialTreatmentLabel)
assertEquals(listOf("parent_declaration"), request.missingDocumentTypes) assertEquals(listOf("parent_declaration"), request.missingDocumentTypes)
} }
} }
@@ -8,6 +8,8 @@ import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarDriverDto
import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto import pl.firmatpp.kierowca.data.model.DriverLeaveCalendarEntryDto
import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto import pl.firmatpp.kierowca.data.model.DriverLeavePoolDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverLeaveRuleSnapshotDto
import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto import pl.firmatpp.kierowca.data.model.DriverLeaveSummaryDto
class DriverLeaveRequestUiRulesTest { class DriverLeaveRequestUiRulesTest {
@@ -59,9 +61,78 @@ class DriverLeaveRequestUiRulesTest {
assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested")) assertEquals("Kierowca poprosił o anulowanie", DriverLeaveRequestUiRules.eventActionLabel("cancellation_requested"))
assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved")) assertEquals("Anulowanie zatwierdzone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_approved"))
assertEquals("Anulowanie odrzucone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_rejected")) assertEquals("Anulowanie odrzucone", DriverLeaveRequestUiRules.eventActionLabel("cancellation_rejected"))
assertEquals("Przywrócono urlop wypoczynkowy", DriverLeaveRequestUiRules.eventActionLabel("annual_leave_interrupted"))
assertEquals("Aktualizacja wniosku", DriverLeaveRequestUiRules.eventActionLabel("some_raw_action")) assertEquals("Aktualizacja wniosku", DriverLeaveRequestUiRules.eventActionLabel("some_raw_action"))
} }
@Test
fun formatsStatutoryDayLimitsAsDaysInsteadOfMinutes() {
val dayBasedRequest = DriverLeaveRequestDto(
id = "child-care",
dateFrom = "2026-08-03",
dateTo = "2026-08-03",
type = "CHILD_CARE_14",
requestMode = "days",
requestedQuantity = 1,
quantityUnit = "days",
status = "pending",
)
val legacyDayBasedRequest = dayBasedRequest.copy(
id = "force-majeure",
type = "FORCE_MAJEURE",
quantityUnit = null,
rule = DriverLeaveRuleSnapshotDto(
unit = "work_schedule",
poolCode = "FORCE_MAJEURE",
),
)
assertEquals("days", DriverLeaveRequestUiRules.requestQuantityUnit(dayBasedRequest))
assertEquals("1 dzień", DriverLeaveRequestUiRules.formatQuantity(1, "days"))
assertEquals("days", DriverLeaveRequestUiRules.requestQuantityUnit(legacyDayBasedRequest))
}
@Test
fun exposesOtherStatutoryBalancesSeparatelyFromAnnualLeave() {
val summary = DriverLeaveSummaryDto(
pools = listOf(
annualPool(year = 2026, available = 4_800, reserved = 0, used = 0, granted = 4_800),
DriverLeavePoolDto(
id = 31,
poolCode = "CHILD_CARE_14",
year = 2026,
unit = "days",
availableQuantity = 1,
usedQuantity = 1,
grantedQuantity = 2,
status = "active",
),
DriverLeavePoolDto(
id = 32,
poolCode = "CARE_LEAVE",
year = 2026,
unit = "days",
availableQuantity = 5,
grantedQuantity = 5,
status = "active",
),
),
)
val pools = DriverLeaveRequestUiRules.otherEntitlementPools(summary)
assertEquals(listOf("CHILD_CARE_14", "CARE_LEAVE"), pools.map { it.poolCode })
assertEquals("Urlop opiekuńczy", DriverLeaveRequestUiRules.poolLabel("CARE_LEAVE"))
assertEquals("Rodzina i opieka", DriverLeaveRequestUiRules.categoryLabel("family"))
assertTrue(DriverLeaveRequestUiRules.isUsageVisible(summary, "ANNUAL"))
assertFalse(
DriverLeaveRequestUiRules.isUsageVisible(
summary.copy(visibleUsagePoolCodes = emptyList()),
"ANNUAL",
),
)
}
@Test @Test
fun driverCanCancelPendingAndFutureApprovedRequests() { fun driverCanCancelPendingAndFutureApprovedRequests() {
val tomorrow = LocalDate.now().plusDays(1).toString() val tomorrow = LocalDate.now().plusDays(1).toString()