From 1e5f412f82a19e92962ec4f989e381b4d406e5c3 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Thu, 5 Feb 2026 10:31:15 +0530 Subject: [PATCH] ablity to checkout stay --- AGENTS.md | 1 + .../trisolarispms/core/auth/AuthzPolicy.kt | 6 + .../ui/navigation/MainRoutesBooking.kt | 5 +- .../ui/roomstay/BookingCheckoutPolicy.kt | 57 ++++++ .../ui/roomstay/BookingDetailsTabsScreen.kt | 177 +++++++++++++----- .../ui/roomstay/BookingRoomStaysScreen.kt | 68 ++----- .../ui/roomstay/BookingRoomStaysState.kt | 6 +- .../ui/roomstay/BookingRoomStaysViewModel.kt | 116 +++++++++++- .../ui/roomstay/RoomStayCheckoutPolicy.kt | 25 +++ .../ui/roomstay/RoomStayListSection.kt | 98 ++++++++++ 10 files changed, 452 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingCheckoutPolicy.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomstay/RoomStayCheckoutPolicy.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomstay/RoomStayListSection.kt diff --git a/AGENTS.md b/AGENTS.md index 389b7c4..01f6b62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -538,6 +538,7 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance ### Non-negotiable coding rules +- Duplicate code is forbidden. - Never add duplicate business logic in multiple files. - Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns. - If similar logic appears 2+ times, extract shared function/class immediately. diff --git a/app/src/main/java/com/android/trisolarispms/core/auth/AuthzPolicy.kt b/app/src/main/java/com/android/trisolarispms/core/auth/AuthzPolicy.kt index 63baf3e..f6f73e8 100644 --- a/app/src/main/java/com/android/trisolarispms/core/auth/AuthzPolicy.kt +++ b/app/src/main/java/com/android/trisolarispms/core/auth/AuthzPolicy.kt @@ -38,6 +38,12 @@ class AuthzPolicy( fun canCreateBookingFor(propertyId: String): Boolean = hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF) + fun canCheckOutRoomStay(propertyId: String): Boolean = + hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER) + + fun canCheckOutBooking(propertyId: String): Boolean = + hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF) + fun canDisableAdmin(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN) fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER) diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt index 4765675..5b98806 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesBooking.kt @@ -16,6 +16,7 @@ internal fun renderBookingRoutes( is AppRoute.BookingRoomStays -> BookingRoomStaysScreen( propertyId = currentRoute.propertyId, bookingId = currentRoute.bookingId, + canCheckOutRoomStay = authz.canCheckOutRoomStay(currentRoute.propertyId), onBack = { refs.openActiveRoomStays(currentRoute.propertyId) } ) @@ -45,7 +46,9 @@ internal fun renderBookingRoutes( bookingId = currentRoute.bookingId ) }, - canManageDocuments = authz.canManageGuestDocuments(currentRoute.propertyId) + canManageDocuments = authz.canManageGuestDocuments(currentRoute.propertyId), + canCheckOutRoomStay = authz.canCheckOutRoomStay(currentRoute.propertyId), + canCheckOutBooking = authz.canCheckOutBooking(currentRoute.propertyId) ) is AppRoute.BookingPayments -> BookingPaymentsScreen( diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingCheckoutPolicy.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingCheckoutPolicy.kt new file mode 100644 index 0000000..cfa8ec7 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingCheckoutPolicy.kt @@ -0,0 +1,57 @@ +package com.android.trisolarispms.ui.roomstay + +import com.android.trisolarispms.data.api.model.BookingDetailsResponse +import retrofit2.Response +import java.time.OffsetDateTime + +private const val CONFLICT_BOOKING_NOT_CHECKED_IN = "Booking not checked in" +private const val CONFLICT_PRIMARY_GUEST_MISSING = "Primary guest missing" +private const val CONFLICT_GUEST_NAME_MISSING = "Guest name missing" +private const val CONFLICT_GUEST_PHONE_MISSING = "Guest phone missing" +private const val CONFLICT_GUEST_SIGNATURE_MISSING = "Guest signature missing" +private const val CONFLICT_INVALID_CHECKOUT_TIME = "checkOutAt must be after checkInAt" + +internal fun deriveBookingCheckoutBlockedReason( + details: BookingDetailsResponse?, + now: OffsetDateTime = OffsetDateTime.now() +): String? { + details ?: return null + if (!details.status.equals("CHECKED_IN", ignoreCase = true)) { + return CONFLICT_BOOKING_NOT_CHECKED_IN + } + if (details.guestId.isNullOrBlank()) return CONFLICT_PRIMARY_GUEST_MISSING + if (details.guestName.isNullOrBlank()) return CONFLICT_GUEST_NAME_MISSING + if (details.guestPhone.isNullOrBlank()) return CONFLICT_GUEST_PHONE_MISSING + if (details.guestSignatureUrl.isNullOrBlank()) return CONFLICT_GUEST_SIGNATURE_MISSING + + val checkInAt = details.checkInAt?.takeIf { it.isNotBlank() } + ?: details.expectedCheckInAt?.takeIf { it.isNotBlank() } + val checkIn = checkInAt?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() } + if (checkIn != null && !now.isAfter(checkIn)) { + return CONFLICT_INVALID_CHECKOUT_TIME + } + return null +} + +internal fun isBookingLevelCheckoutConflict(message: String): Boolean { + val normalized = message.lowercase() + return normalized.contains("booking not checked in") || + normalized.contains("primary guest missing") || + normalized.contains("primary guest required before checkout") || + normalized.contains("guest name missing") || + normalized.contains("guest name required before checkout") || + normalized.contains("guest phone missing") || + normalized.contains("guest phone required before checkout") || + normalized.contains("guest signature missing") || + normalized.contains("guest signature required before checkout") || + normalized.contains("checkoutat must be after checkinat") || + normalized.contains("room stay amount outside allowed range") || + normalized.contains("ledger mismatch") +} + +internal fun extractApiErrorMessage(response: Response<*>): String? { + val raw = runCatching { response.errorBody()?.string() }.getOrNull()?.trim().orEmpty() + if (raw.isBlank()) return null + val match = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"").find(raw) + return match?.groupValues?.getOrNull(1)?.trim()?.takeIf { it.isNotBlank() } ?: raw +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt index e642bb2..7b0a657 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.QrCode @@ -59,6 +60,7 @@ import com.android.trisolarispms.data.api.core.ApiConstants import com.android.trisolarispms.data.api.core.FirebaseAuthTokenProvider import com.android.trisolarispms.data.api.model.BookingBillingMode import com.android.trisolarispms.data.api.model.BookingCancelRequest +import com.android.trisolarispms.data.api.model.BookingCheckOutRequest import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest import com.android.trisolarispms.ui.booking.BookingDatePickerDialog @@ -89,6 +91,8 @@ fun BookingDetailsTabsScreen( onOpenRazorpayQr: (Long?, String?) -> Unit, onOpenPayments: () -> Unit, canManageDocuments: Boolean, + canCheckOutRoomStay: Boolean, + canCheckOutBooking: Boolean, staysViewModel: BookingRoomStaysViewModel = viewModel(), detailsViewModel: BookingDetailsViewModel = viewModel() ) { @@ -101,11 +105,18 @@ fun BookingDetailsTabsScreen( val showCancelConfirm = remember { mutableStateOf(false) } val cancelLoading = remember { mutableStateOf(false) } val cancelError = remember { mutableStateOf(null) } + val showCheckoutConfirm = remember { mutableStateOf(false) } + val checkoutLoading = remember { mutableStateOf(false) } + val checkoutError = remember { mutableStateOf(null) } val canModifyDocuments = canManageDocuments && when (detailsState.details?.status) { "OPEN", "CHECKED_IN" -> true else -> false } + val checkoutBlockedReason = deriveBookingCheckoutBlockedReason(detailsState.details) val canCancelBooking = detailsState.details?.status?.uppercase() == "OPEN" + val canShowCheckoutIcon = canCheckOutBooking && + detailsState.details?.status?.equals("CHECKED_IN", ignoreCase = true) == true && + checkoutBlockedReason == null LaunchedEffect(propertyId, bookingId, guestId) { staysViewModel.load(propertyId, bookingId) @@ -120,6 +131,20 @@ fun BookingDetailsTabsScreen( title = "Details", onBack = onBack, actions = { + if (canShowCheckoutIcon) { + IconButton( + enabled = !checkoutLoading.value, + onClick = { + checkoutError.value = null + showCheckoutConfirm.value = true + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Logout, + contentDescription = "Check out stay" + ) + } + } if (canCancelBooking) { IconButton(onClick = { actionsMenuExpanded.value = true }) { Icon(Icons.Default.MoreVert, contentDescription = "More options") @@ -193,7 +218,13 @@ fun BookingDetailsTabsScreen( onOpenRazorpayQr = onOpenRazorpayQr, onOpenPayments = onOpenPayments ) - 1 -> BookingRoomStaysTabContent(staysState, staysViewModel) + 1 -> BookingRoomStaysTabContent( + propertyId = propertyId, + bookingId = bookingId, + state = staysState, + viewModel = staysViewModel, + canCheckOutRoomStay = canCheckOutRoomStay + ) 2 -> if (canManageDocuments) { val resolvedGuestId = detailsState.details?.guestId ?: guestId if (!resolvedGuestId.isNullOrBlank()) { @@ -218,6 +249,76 @@ fun BookingDetailsTabsScreen( } } + if (showCheckoutConfirm.value && canShowCheckoutIcon) { + AlertDialog( + onDismissRequest = { + if (!checkoutLoading.value) { + showCheckoutConfirm.value = false + } + }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.Logout, + contentDescription = null + ) + }, + title = { Text("Check out booking?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("This will check out all active room stays for this booking.") + checkoutError.value?.let { message -> + Text( + text = message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + TextButton( + enabled = !checkoutLoading.value, + onClick = { + scope.launch { + checkoutLoading.value = true + checkoutError.value = null + try { + val response = ApiClient.create().checkOut( + propertyId = propertyId, + bookingId = bookingId, + body = BookingCheckOutRequest() + ) + if (response.isSuccessful) { + showCheckoutConfirm.value = false + onBack() + } else if (response.code() == 409) { + checkoutError.value = + extractApiErrorMessage(response) ?: "Checkout conflict" + } else { + checkoutError.value = "Checkout failed: ${response.code()}" + } + } catch (e: Exception) { + checkoutError.value = e.localizedMessage ?: "Checkout failed" + } finally { + checkoutLoading.value = false + } + } + } + ) { + Text(if (checkoutLoading.value) "Checking out..." else "Check out") + } + }, + dismissButton = { + TextButton( + enabled = !checkoutLoading.value, + onClick = { showCheckoutConfirm.value = false } + ) { + Text("Cancel") + } + } + ) + } + if (showCancelConfirm.value && canCancelBooking) { AlertDialog( onDismissRequest = { @@ -739,8 +840,11 @@ private fun SignaturePreview( @Composable private fun BookingRoomStaysTabContent( + propertyId: String, + bookingId: String, state: BookingRoomStaysState, - viewModel: BookingRoomStaysViewModel + viewModel: BookingRoomStaysViewModel, + canCheckOutRoomStay: Boolean ) { val displayZone = remember { ZoneId.of("Asia/Kolkata") } val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yy HH:mm") } @@ -749,50 +853,31 @@ private fun BookingRoomStaysTabContent( .fillMaxSize() .padding(16.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically - ) { - Text(text = "Show all (including checkout)") - androidx.compose.material3.Switch( - checked = state.showAll, - onCheckedChange = viewModel::toggleShowAll - ) - } - Spacer(modifier = Modifier.height(12.dp)) - if (state.isLoading) { - CircularProgressIndicator() - Spacer(modifier = Modifier.height(8.dp)) - } - state.error?.let { - Text(text = it, color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(8.dp)) - } - if (!state.isLoading && state.error == null) { - if (state.stays.isEmpty()) { - Text(text = "No stays found") - } else { - state.stays.forEach { stay -> - val roomLine = "Room ${stay.roomNumber ?: "-"} • ${stay.roomTypeName ?: ""}".trim() - Text(text = roomLine, style = MaterialTheme.typography.titleMedium) - val fromAt = stay.fromAt?.let { - runCatching { - OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter) - }.getOrNull() - } - val toAt = stay.expectedCheckoutAt?.let { - runCatching { - OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter) - }.getOrNull() - } - val timeLine = listOfNotNull(fromAt, toAt).joinToString(" → ") - if (timeLine.isNotBlank()) { - Text(text = timeLine, style = MaterialTheme.typography.bodySmall) - } - Spacer(modifier = Modifier.height(12.dp)) + RoomStayListSection( + state = state, + canCheckOutRoomStay = canCheckOutRoomStay, + onToggleShowAll = viewModel::toggleShowAll, + onCheckoutRoomStay = { roomStayId -> + viewModel.checkoutRoomStay( + propertyId = propertyId, + bookingId = bookingId, + roomStayId = roomStayId + ) + }, + formatTimeLine = { stay -> + val fromAt = stay.fromAt?.let { + runCatching { + OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter) + }.getOrNull() } - } - } + val toAt = stay.expectedCheckoutAt?.let { + runCatching { + OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter) + }.getOrNull() + } + listOfNotNull(fromAt, toAt).joinToString(" → ").ifBlank { null } + }, + showGuestLine = false + ) } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysScreen.kt index b56c642..ef10318 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysScreen.kt @@ -1,22 +1,12 @@ package com.android.trisolarispms.ui.roomstay -import androidx.compose.foundation.layout.Arrangement 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.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -26,6 +16,7 @@ import com.android.trisolarispms.ui.common.BackTopBarScaffold fun BookingRoomStaysScreen( propertyId: String, bookingId: String, + canCheckOutRoomStay: Boolean, onBack: () -> Unit, viewModel: BookingRoomStaysViewModel = viewModel() ) { @@ -45,45 +36,24 @@ fun BookingRoomStaysScreen( .padding(padding) .padding(16.dp) ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "Show all (including checkout)") - Switch( - checked = state.showAll, - onCheckedChange = viewModel::toggleShowAll - ) - } - Spacer(modifier = Modifier.height(12.dp)) - if (state.isLoading) { - CircularProgressIndicator() - Spacer(modifier = Modifier.height(8.dp)) - } - state.error?.let { - Text(text = it, color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(8.dp)) - } - if (!state.isLoading && state.error == null) { - if (state.stays.isEmpty()) { - Text(text = "No stays found") - } else { - state.stays.forEach { stay -> - val roomLine = "Room ${stay.roomNumber ?: "-"} • ${stay.roomTypeName ?: ""}".trim() - Text(text = roomLine, style = MaterialTheme.typography.titleMedium) - val guestLine = listOfNotNull(stay.guestName, stay.guestPhone).joinToString(" • ") - if (guestLine.isNotBlank()) { - Text(text = guestLine, style = MaterialTheme.typography.bodyMedium) - } - val timeLine = listOfNotNull(stay.fromAt, stay.expectedCheckoutAt).joinToString(" → ") - if (timeLine.isNotBlank()) { - Text(text = timeLine, style = MaterialTheme.typography.bodySmall) - } - Spacer(modifier = Modifier.height(12.dp)) - } - } - } + RoomStayListSection( + state = state, + canCheckOutRoomStay = canCheckOutRoomStay, + onToggleShowAll = viewModel::toggleShowAll, + onCheckoutRoomStay = { roomStayId -> + viewModel.checkoutRoomStay( + propertyId = propertyId, + bookingId = bookingId, + roomStayId = roomStayId + ) + }, + formatTimeLine = { stay -> + listOfNotNull(stay.fromAt, stay.expectedCheckoutAt) + .joinToString(" → ") + .ifBlank { null } + }, + showGuestLine = true + ) } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysState.kt index d232b1d..5a739d6 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysState.kt @@ -6,5 +6,9 @@ data class BookingRoomStaysState( val isLoading: Boolean = false, val error: String? = null, val stays: List = emptyList(), - val showAll: Boolean = false + val showAll: Boolean = false, + val checkingOutRoomStayId: String? = null, + val checkoutError: String? = null, + val checkoutBlockedReason: String? = null, + val conflictRoomStayIds: Set = emptySet() ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt index 1e1ae15..d8145ab 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt @@ -1,11 +1,16 @@ package com.android.trisolarispms.ui.roomstay import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.core.ApiClient +import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest import com.android.trisolarispms.core.viewmodel.launchRequest +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch class BookingRoomStaysViewModel : ViewModel() { private val _state = MutableStateFlow(BookingRoomStaysState()) @@ -24,19 +29,110 @@ class BookingRoomStaysViewModel : ViewModel() { defaultError = "Load failed" ) { val api = ApiClient.create() - val response = api.listActiveRoomStays(propertyId) - if (response.isSuccessful) { - val filtered = response.body().orEmpty().filter { it.bookingId == bookingId } - _state.update { - it.copy( - isLoading = false, - stays = filtered, - error = null + val (staysResponse, detailsResponse) = coroutineScope { + val staysDeferred = async { api.listActiveRoomStays(propertyId) } + val detailsDeferred = async { api.getBookingDetails(propertyId, bookingId) } + staysDeferred.await() to detailsDeferred.await() + } + if (!staysResponse.isSuccessful) { + _state.update { it.copy(isLoading = false, error = "Load failed: ${staysResponse.code()}") } + return@launchRequest + } + + val filtered = staysResponse.body().orEmpty().filter { it.bookingId == bookingId } + val details = detailsResponse.body().takeIf { detailsResponse.isSuccessful } + val blockedReason = deriveBookingCheckoutBlockedReason(details) + _state.update { current -> + current.copy( + isLoading = false, + stays = filtered, + error = null, + checkoutBlockedReason = blockedReason, + checkoutError = blockedReason, + conflictRoomStayIds = current.conflictRoomStayIds.intersect( + filtered.mapNotNull { stay -> stay.roomStayId }.toSet() + ) + ) + } + } + } + + fun checkoutRoomStay( + propertyId: String, + bookingId: String, + roomStayId: String + ) { + if (propertyId.isBlank() || bookingId.isBlank() || roomStayId.isBlank()) return + if (_state.value.checkingOutRoomStayId != null) return + viewModelScope.launch { + _state.update { + it.copy( + checkingOutRoomStayId = roomStayId, + checkoutError = null + ) + } + try { + val api = ApiClient.create() + val response = api.checkOutRoomStay( + propertyId = propertyId, + bookingId = bookingId, + roomStayId = roomStayId, + body = BookingRoomStayCheckOutRequest() + ) + when { + response.isSuccessful -> { + _state.update { current -> + current.copy( + stays = current.stays.filterNot { it.roomStayId == roomStayId }, + checkingOutRoomStayId = null, + checkoutError = null, + checkoutBlockedReason = null + ) + } + } + + response.code() == 409 -> { + val message = extractApiErrorMessage(response) ?: "Checkout conflict" + _state.update { current -> handleCheckoutConflict(current, roomStayId, message) } + } + + else -> { + _state.update { current -> + current.copy( + checkingOutRoomStayId = null, + checkoutError = "Checkout failed: ${response.code()}" + ) + } + } + } + } catch (e: Exception) { + _state.update { current -> + current.copy( + checkingOutRoomStayId = null, + checkoutError = e.localizedMessage ?: "Checkout failed" ) } - } else { - _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } } } } + +private fun handleCheckoutConflict( + current: BookingRoomStaysState, + roomStayId: String, + message: String +): BookingRoomStaysState { + return if (isBookingLevelCheckoutConflict(message)) { + current.copy( + checkingOutRoomStayId = null, + checkoutError = message, + checkoutBlockedReason = message + ) + } else { + current.copy( + checkingOutRoomStayId = null, + checkoutError = message, + conflictRoomStayIds = current.conflictRoomStayIds + roomStayId + ) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/RoomStayCheckoutPolicy.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/RoomStayCheckoutPolicy.kt new file mode 100644 index 0000000..bd930f3 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/RoomStayCheckoutPolicy.kt @@ -0,0 +1,25 @@ +package com.android.trisolarispms.ui.roomstay + +import com.android.trisolarispms.data.api.model.ActiveRoomStayDto +import java.time.Duration +import java.time.OffsetDateTime + +internal fun canShowRoomStayCheckoutButton( + canCheckOutRoomStay: Boolean, + stay: ActiveRoomStayDto, + state: BookingRoomStaysState, + now: OffsetDateTime = OffsetDateTime.now() +): Boolean { + if (!canCheckOutRoomStay) return false + if (!state.checkoutBlockedReason.isNullOrBlank()) return false + val roomStayId = stay.roomStayId?.takeIf { it.isNotBlank() } ?: return false + if (roomStayId in state.conflictRoomStayIds) return false + if (stay.guestName.isNullOrBlank()) return false + if (!hasMinimumCheckoutDuration(stay.fromAt, now)) return false + return true +} + +private fun hasMinimumCheckoutDuration(fromAt: String?, now: OffsetDateTime): Boolean { + val parsedFromAt = fromAt?.let { runCatching { OffsetDateTime.parse(it) }.getOrNull() } ?: return false + return Duration.between(parsedFromAt, now).toMinutes() >= 60 +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/RoomStayListSection.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/RoomStayListSection.kt new file mode 100644 index 0000000..fe55b43 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/RoomStayListSection.kt @@ -0,0 +1,98 @@ +package com.android.trisolarispms.ui.roomstay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.android.trisolarispms.data.api.model.ActiveRoomStayDto + +@Composable +internal fun RoomStayListSection( + state: BookingRoomStaysState, + canCheckOutRoomStay: Boolean, + onToggleShowAll: (Boolean) -> Unit, + onCheckoutRoomStay: (String) -> Unit, + formatTimeLine: (ActiveRoomStayDto) -> String?, + showGuestLine: Boolean +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Show all (including checkout)") + Switch( + checked = state.showAll, + onCheckedChange = onToggleShowAll + ) + } + Spacer(modifier = Modifier.height(12.dp)) + if (state.isLoading) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + } + state.error?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + state.checkoutError?.takeIf { it != state.checkoutBlockedReason }?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + state.checkoutBlockedReason?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + } + if (!state.isLoading && state.error == null) { + if (state.stays.isEmpty()) { + Text(text = "No stays found") + } else { + state.stays.forEach { stay -> + val roomLine = "Room ${stay.roomNumber ?: "-"} • ${stay.roomTypeName ?: ""}".trim() + Text(text = roomLine, style = MaterialTheme.typography.titleMedium) + if (showGuestLine) { + val guestLine = listOfNotNull(stay.guestName, stay.guestPhone).joinToString(" • ") + if (guestLine.isNotBlank()) { + Text(text = guestLine, style = MaterialTheme.typography.bodyMedium) + } + } + val timeLine = formatTimeLine(stay) + if (!timeLine.isNullOrBlank()) { + Text(text = timeLine, style = MaterialTheme.typography.bodySmall) + } + val roomStayId = stay.roomStayId.orEmpty() + val canCheckoutThisStay = canShowRoomStayCheckoutButton( + canCheckOutRoomStay = canCheckOutRoomStay, + stay = stay, + state = state + ) + if (canCheckoutThisStay) { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { onCheckoutRoomStay(roomStayId) }, + enabled = state.checkingOutRoomStayId == null + ) { + val label = if (state.checkingOutRoomStayId == roomStayId) { + "Checking out..." + } else { + "Check-out this room" + } + Text(text = label) + } + } + Spacer(modifier = Modifier.height(12.dp)) + } + } + } +}