ablity to checkout stay
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user