ablity to checkout stay

This commit is contained in:
androidlover5842
2026-02-05 10:31:15 +05:30
parent a67eacd77f
commit 1e5f412f82
10 changed files with 452 additions and 107 deletions

View File

@@ -538,6 +538,7 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
### Non-negotiable coding rules ### Non-negotiable coding rules
- Duplicate code is forbidden.
- Never add duplicate business logic in multiple files. - Never add duplicate business logic in multiple files.
- Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns. - Never copy-paste permission checks, navigation decisions, mapping logic, or API call patterns.
- If similar logic appears 2+ times, extract shared function/class immediately. - If similar logic appears 2+ times, extract shared function/class immediately.

View File

@@ -38,6 +38,12 @@ class AuthzPolicy(
fun canCreateBookingFor(propertyId: String): Boolean = fun canCreateBookingFor(propertyId: String): Boolean =
hasAnyRole(propertyId, Role.ADMIN, Role.MANAGER, Role.STAFF) 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 canDisableAdmin(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER) fun canDisableManager(propertyId: String): Boolean = hasRole(propertyId, Role.MANAGER)

View File

@@ -16,6 +16,7 @@ internal fun renderBookingRoutes(
is AppRoute.BookingRoomStays -> BookingRoomStaysScreen( is AppRoute.BookingRoomStays -> BookingRoomStaysScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,
canCheckOutRoomStay = authz.canCheckOutRoomStay(currentRoute.propertyId),
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) } onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }
) )
@@ -45,7 +46,9 @@ internal fun renderBookingRoutes(
bookingId = currentRoute.bookingId 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( is AppRoute.BookingPayments -> BookingPaymentsScreen(

View File

@@ -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
}

View File

@@ -18,6 +18,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.Error
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.QrCode 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.core.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.BookingBillingMode import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingCancelRequest 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.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.ui.booking.BookingDatePickerDialog import com.android.trisolarispms.ui.booking.BookingDatePickerDialog
@@ -89,6 +91,8 @@ fun BookingDetailsTabsScreen(
onOpenRazorpayQr: (Long?, String?) -> Unit, onOpenRazorpayQr: (Long?, String?) -> Unit,
onOpenPayments: () -> Unit, onOpenPayments: () -> Unit,
canManageDocuments: Boolean, canManageDocuments: Boolean,
canCheckOutRoomStay: Boolean,
canCheckOutBooking: Boolean,
staysViewModel: BookingRoomStaysViewModel = viewModel(), staysViewModel: BookingRoomStaysViewModel = viewModel(),
detailsViewModel: BookingDetailsViewModel = viewModel() detailsViewModel: BookingDetailsViewModel = viewModel()
) { ) {
@@ -101,11 +105,18 @@ fun BookingDetailsTabsScreen(
val showCancelConfirm = remember { mutableStateOf(false) } val showCancelConfirm = remember { mutableStateOf(false) }
val cancelLoading = remember { mutableStateOf(false) } val cancelLoading = remember { mutableStateOf(false) }
val cancelError = remember { mutableStateOf<String?>(null) } val cancelError = remember { mutableStateOf<String?>(null) }
val showCheckoutConfirm = remember { mutableStateOf(false) }
val checkoutLoading = remember { mutableStateOf(false) }
val checkoutError = remember { mutableStateOf<String?>(null) }
val canModifyDocuments = canManageDocuments && when (detailsState.details?.status) { val canModifyDocuments = canManageDocuments && when (detailsState.details?.status) {
"OPEN", "CHECKED_IN" -> true "OPEN", "CHECKED_IN" -> true
else -> false else -> false
} }
val checkoutBlockedReason = deriveBookingCheckoutBlockedReason(detailsState.details)
val canCancelBooking = detailsState.details?.status?.uppercase() == "OPEN" 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) { LaunchedEffect(propertyId, bookingId, guestId) {
staysViewModel.load(propertyId, bookingId) staysViewModel.load(propertyId, bookingId)
@@ -120,6 +131,20 @@ fun BookingDetailsTabsScreen(
title = "Details", title = "Details",
onBack = onBack, onBack = onBack,
actions = { 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) { if (canCancelBooking) {
IconButton(onClick = { actionsMenuExpanded.value = true }) { IconButton(onClick = { actionsMenuExpanded.value = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More options") Icon(Icons.Default.MoreVert, contentDescription = "More options")
@@ -193,7 +218,13 @@ fun BookingDetailsTabsScreen(
onOpenRazorpayQr = onOpenRazorpayQr, onOpenRazorpayQr = onOpenRazorpayQr,
onOpenPayments = onOpenPayments onOpenPayments = onOpenPayments
) )
1 -> BookingRoomStaysTabContent(staysState, staysViewModel) 1 -> BookingRoomStaysTabContent(
propertyId = propertyId,
bookingId = bookingId,
state = staysState,
viewModel = staysViewModel,
canCheckOutRoomStay = canCheckOutRoomStay
)
2 -> if (canManageDocuments) { 2 -> if (canManageDocuments) {
val resolvedGuestId = detailsState.details?.guestId ?: guestId val resolvedGuestId = detailsState.details?.guestId ?: guestId
if (!resolvedGuestId.isNullOrBlank()) { 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) { if (showCancelConfirm.value && canCancelBooking) {
AlertDialog( AlertDialog(
onDismissRequest = { onDismissRequest = {
@@ -739,8 +840,11 @@ private fun SignaturePreview(
@Composable @Composable
private fun BookingRoomStaysTabContent( private fun BookingRoomStaysTabContent(
propertyId: String,
bookingId: String,
state: BookingRoomStaysState, state: BookingRoomStaysState,
viewModel: BookingRoomStaysViewModel viewModel: BookingRoomStaysViewModel,
canCheckOutRoomStay: Boolean
) { ) {
val displayZone = remember { ZoneId.of("Asia/Kolkata") } val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yy HH:mm") } val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yy HH:mm") }
@@ -749,50 +853,31 @@ private fun BookingRoomStaysTabContent(
.fillMaxSize() .fillMaxSize()
.padding(16.dp) .padding(16.dp)
) { ) {
Row( RoomStayListSection(
modifier = Modifier.fillMaxWidth(), state = state,
horizontalArrangement = Arrangement.SpaceBetween, canCheckOutRoomStay = canCheckOutRoomStay,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically onToggleShowAll = viewModel::toggleShowAll,
) { onCheckoutRoomStay = { roomStayId ->
Text(text = "Show all (including checkout)") viewModel.checkoutRoomStay(
androidx.compose.material3.Switch( propertyId = propertyId,
checked = state.showAll, bookingId = bookingId,
onCheckedChange = viewModel::toggleShowAll roomStayId = roomStayId
) )
} },
Spacer(modifier = Modifier.height(12.dp)) formatTimeLine = { stay ->
if (state.isLoading) { val fromAt = stay.fromAt?.let {
CircularProgressIndicator() runCatching {
Spacer(modifier = Modifier.height(8.dp)) OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
} }.getOrNull()
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))
} }
} val toAt = stay.expectedCheckoutAt?.let {
} runCatching {
OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
}.getOrNull()
}
listOfNotNull(fromAt, toAt).joinToString("").ifBlank { null }
},
showGuestLine = false
)
} }
} }

View File

@@ -1,22 +1,12 @@
package com.android.trisolarispms.ui.roomstay package com.android.trisolarispms.ui.roomstay
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -26,6 +16,7 @@ import com.android.trisolarispms.ui.common.BackTopBarScaffold
fun BookingRoomStaysScreen( fun BookingRoomStaysScreen(
propertyId: String, propertyId: String,
bookingId: String, bookingId: String,
canCheckOutRoomStay: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
viewModel: BookingRoomStaysViewModel = viewModel() viewModel: BookingRoomStaysViewModel = viewModel()
) { ) {
@@ -45,45 +36,24 @@ fun BookingRoomStaysScreen(
.padding(padding) .padding(padding)
.padding(16.dp) .padding(16.dp)
) { ) {
Row( RoomStayListSection(
modifier = Modifier.fillMaxWidth(), state = state,
horizontalArrangement = Arrangement.SpaceBetween, canCheckOutRoomStay = canCheckOutRoomStay,
verticalAlignment = Alignment.CenterVertically onToggleShowAll = viewModel::toggleShowAll,
) { onCheckoutRoomStay = { roomStayId ->
Text(text = "Show all (including checkout)") viewModel.checkoutRoomStay(
Switch( propertyId = propertyId,
checked = state.showAll, bookingId = bookingId,
onCheckedChange = viewModel::toggleShowAll roomStayId = roomStayId
) )
} },
Spacer(modifier = Modifier.height(12.dp)) formatTimeLine = { stay ->
if (state.isLoading) { listOfNotNull(stay.fromAt, stay.expectedCheckoutAt)
CircularProgressIndicator() .joinToString("")
Spacer(modifier = Modifier.height(8.dp)) .ifBlank { null }
} },
state.error?.let { showGuestLine = true
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))
}
}
}
} }
} }
} }

View File

@@ -6,5 +6,9 @@ data class BookingRoomStaysState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val stays: List<ActiveRoomStayDto> = emptyList(), val stays: List<ActiveRoomStayDto> = emptyList(),
val showAll: Boolean = false val showAll: Boolean = false,
val checkingOutRoomStayId: String? = null,
val checkoutError: String? = null,
val checkoutBlockedReason: String? = null,
val conflictRoomStayIds: Set<String> = emptySet()
) )

View File

@@ -1,11 +1,16 @@
package com.android.trisolarispms.ui.roomstay package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest
import com.android.trisolarispms.core.viewmodel.launchRequest import com.android.trisolarispms.core.viewmodel.launchRequest
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookingRoomStaysViewModel : ViewModel() { class BookingRoomStaysViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingRoomStaysState()) private val _state = MutableStateFlow(BookingRoomStaysState())
@@ -24,19 +29,110 @@ class BookingRoomStaysViewModel : ViewModel() {
defaultError = "Load failed" defaultError = "Load failed"
) { ) {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.listActiveRoomStays(propertyId) val (staysResponse, detailsResponse) = coroutineScope {
if (response.isSuccessful) { val staysDeferred = async { api.listActiveRoomStays(propertyId) }
val filtered = response.body().orEmpty().filter { it.bookingId == bookingId } val detailsDeferred = async { api.getBookingDetails(propertyId, bookingId) }
_state.update { staysDeferred.await() to detailsDeferred.await()
it.copy( }
isLoading = false, if (!staysResponse.isSuccessful) {
stays = filtered, _state.update { it.copy(isLoading = false, error = "Load failed: ${staysResponse.code()}") }
error = null 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
)
}
}

View File

@@ -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
}

View File

@@ -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))
}
}
}
}