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
- 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.

View File

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

View File

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

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.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<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) {
"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,33 +853,18 @@ 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
RoomStayListSection(
state = state,
canCheckOutRoomStay = canCheckOutRoomStay,
onToggleShowAll = viewModel::toggleShowAll,
onCheckoutRoomStay = { roomStayId ->
viewModel.checkoutRoomStay(
propertyId = propertyId,
bookingId = bookingId,
roomStayId = roomStayId
)
}
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)
},
formatTimeLine = { stay ->
val fromAt = stay.fromAt?.let {
runCatching {
OffsetDateTime.parse(it).atZoneSameInstant(displayZone).format(dateFormatter)
@@ -786,13 +875,9 @@ private fun BookingRoomStaysTabContent(
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))
}
}
}
listOfNotNull(fromAt, toAt).joinToString("").ifBlank { null }
},
showGuestLine = false
)
}
}

View File

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

View File

@@ -6,5 +6,9 @@ data class BookingRoomStaysState(
val isLoading: Boolean = false,
val error: String? = null,
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
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(
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
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"
)
}
}
}
}
}
private fun handleCheckoutConflict(
current: BookingRoomStaysState,
roomStayId: String,
message: String
): BookingRoomStaysState {
return if (isBookingLevelCheckoutConflict(message)) {
current.copy(
checkingOutRoomStayId = null,
checkoutError = message,
checkoutBlockedReason = message
)
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
}
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))
}
}
}
}