createbooking: improve logic for future bookings
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package com.android.trisolarispms.core.booking
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
private val defaultPropertyZone: ZoneId = ZoneId.of("Asia/Kolkata")
|
||||
|
||||
fun isFutureBookingCheckIn(expectedCheckInAt: String?, zoneId: ZoneId = defaultPropertyZone): Boolean {
|
||||
if (expectedCheckInAt.isNullOrBlank()) return false
|
||||
val checkInDate = runCatching { OffsetDateTime.parse(expectedCheckInAt).toLocalDate() }.getOrNull() ?: return false
|
||||
val today = LocalDate.now(zoneId)
|
||||
return checkInDate.isAfter(today)
|
||||
}
|
||||
@@ -102,6 +102,13 @@ data class BookingExpectedDatesRequest(
|
||||
val expectedCheckOutAt: String? = null
|
||||
)
|
||||
|
||||
data class BookingRoomRequestCreateRequest(
|
||||
val roomTypeCode: String,
|
||||
val quantity: Int,
|
||||
val fromAt: String,
|
||||
val toAt: String
|
||||
)
|
||||
|
||||
data class BookingBillableNightsRequest(
|
||||
val expectedCheckInAt: String? = null,
|
||||
val expectedCheckOutAt: String? = null
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class RoomCreateRequest(
|
||||
val roomNumber: Int,
|
||||
val floor: Int? = null,
|
||||
@@ -47,16 +49,11 @@ data class RoomAvailabilityResponse(
|
||||
)
|
||||
|
||||
data class RoomAvailabilityRangeResponse(
|
||||
val roomTypeName: String? = null,
|
||||
val freeRoomNumbers: List<Int> = emptyList(),
|
||||
val freeCount: Int? = null
|
||||
)
|
||||
|
||||
data class RoomAvailableRateResponse(
|
||||
val roomId: String? = null,
|
||||
val roomNumber: Int? = null,
|
||||
@SerializedName(value = "roomTypeCode", alternate = ["code"])
|
||||
val roomTypeCode: String? = null,
|
||||
val roomTypeName: String? = null,
|
||||
val freeRoomNumbers: List<Int> = emptyList(),
|
||||
val freeCount: Int? = null,
|
||||
val averageRate: Double? = null,
|
||||
val currency: String? = null,
|
||||
val ratePlanCode: String? = null
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.android.trisolarispms.data.api.model.BookingBillableNightsResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
|
||||
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingBalanceResponse
|
||||
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
|
||||
@@ -64,6 +65,13 @@ interface BookingApi {
|
||||
@Body body: BookingExpectedDatesRequest
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/{bookingId}/room-requests")
|
||||
suspend fun createRoomRequest(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("bookingId") bookingId: String,
|
||||
@Body body: BookingRoomRequestCreateRequest
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/bookings/{bookingId}/billable-nights")
|
||||
suspend fun previewBillableNights(
|
||||
@Path("propertyId") propertyId: String,
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.android.trisolarispms.data.api.service
|
||||
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailabilityRangeResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
|
||||
import com.android.trisolarispms.data.api.model.RoomBoardDto
|
||||
import com.android.trisolarispms.data.api.model.RoomCreateRequest
|
||||
import com.android.trisolarispms.data.api.model.RoomDto
|
||||
@@ -55,7 +54,8 @@ interface RoomApi {
|
||||
suspend fun getRoomAvailabilityRange(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Query("from") from: String,
|
||||
@Query("to") to: String
|
||||
@Query("to") to: String,
|
||||
@Query("ratePlanCode") ratePlanCode: String? = null
|
||||
): Response<List<RoomAvailabilityRangeResponse>>
|
||||
|
||||
@GET("properties/{propertyId}/rooms/available")
|
||||
@@ -63,14 +63,6 @@ interface RoomApi {
|
||||
@Path("propertyId") propertyId: String
|
||||
): Response<List<RoomDto>>
|
||||
|
||||
@GET("properties/{propertyId}/rooms/available-range-with-rate")
|
||||
suspend fun listAvailableRoomsWithRate(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Query("from") from: String,
|
||||
@Query("to") to: String,
|
||||
@Query("ratePlanCode") ratePlanCode: String? = null
|
||||
): Response<List<RoomAvailableRateResponse>>
|
||||
|
||||
@GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
|
||||
suspend fun listRoomsByType(
|
||||
@Path("propertyId") propertyId: String,
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -55,13 +54,10 @@ fun BookingCreateScreen(
|
||||
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
|
||||
val checkInTime = remember { mutableStateOf("12:00") }
|
||||
val checkOutTime = remember { mutableStateOf("11:00") }
|
||||
val checkInNow = remember { mutableStateOf(true) }
|
||||
val sourceMenuExpanded = remember { mutableStateOf(false) }
|
||||
val sourceOptions = listOf("DIRECT", "AGENT")
|
||||
val relationMenuExpanded = remember { mutableStateOf(false) }
|
||||
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
|
||||
val transportMenuExpanded = remember { mutableStateOf(false) }
|
||||
val transportOptions = listOf("CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
|
||||
val transportOptions = listOf("", "CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
|
||||
val billingModeMenuExpanded = remember { mutableStateOf(false) }
|
||||
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
|
||||
val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
|
||||
@@ -88,7 +84,6 @@ fun BookingCreateScreen(
|
||||
viewModel.reset()
|
||||
viewModel.loadBillingPolicy(propertyId)
|
||||
val now = OffsetDateTime.now()
|
||||
checkInNow.value = true
|
||||
val defaultCheckoutDate = now.toLocalDate().plusDays(1)
|
||||
checkOutDate.value = defaultCheckoutDate
|
||||
checkOutTime.value = "11:00"
|
||||
@@ -115,24 +110,6 @@ fun BookingCreateScreen(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "Check in now")
|
||||
Switch(
|
||||
checked = checkInNow.value,
|
||||
onCheckedChange = { enabled ->
|
||||
checkInNow.value = enabled
|
||||
if (enabled) {
|
||||
val now = OffsetDateTime.now()
|
||||
applyCheckInSelection(now.toLocalDate(), now.format(timeFormatter))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
|
||||
runCatching { OffsetDateTime.parse(it) }.getOrNull()
|
||||
}
|
||||
@@ -152,7 +129,7 @@ fun BookingCreateScreen(
|
||||
checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----",
|
||||
checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--",
|
||||
totalTimeText = totalTimeText,
|
||||
checkInEditable = !checkInNow.value,
|
||||
checkInEditable = true,
|
||||
onCheckInDateClick = { showCheckInDatePicker.value = true },
|
||||
onCheckInTimeClick = { showCheckInTimePicker.value = true },
|
||||
onCheckOutDateClick = { showCheckOutDatePicker.value = true },
|
||||
@@ -316,36 +293,6 @@ fun BookingCreateScreen(
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = sourceMenuExpanded.value,
|
||||
onExpandedChange = { sourceMenuExpanded.value = !sourceMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.source,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Source") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = sourceMenuExpanded.value) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = sourceMenuExpanded.value,
|
||||
onDismissRequest = { sourceMenuExpanded.value = false }
|
||||
) {
|
||||
sourceOptions.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
sourceMenuExpanded.value = false
|
||||
viewModel.onSourceChange(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = state.fromCity,
|
||||
onValueChange = viewModel::onFromCityChange,
|
||||
@@ -397,7 +344,7 @@ fun BookingCreateScreen(
|
||||
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.transportMode,
|
||||
value = state.transportMode.ifBlank { "Not set" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Transport Mode") },
|
||||
@@ -413,8 +360,9 @@ fun BookingCreateScreen(
|
||||
onDismissRequest = { transportMenuExpanded.value = false }
|
||||
) {
|
||||
transportOptions.forEach { option ->
|
||||
val optionLabel = option.ifBlank { "Not set" }
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
text = { Text(optionLabel) },
|
||||
onClick = {
|
||||
transportMenuExpanded.value = false
|
||||
viewModel.onTransportModeChange(option)
|
||||
|
||||
@@ -12,11 +12,12 @@ data class BookingCreateState(
|
||||
val expectedCheckOutAt: String = "",
|
||||
val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY,
|
||||
val billingCheckoutTime: String = "",
|
||||
val source: String = "DIRECT",
|
||||
val source: String = "",
|
||||
val fromCity: String = "",
|
||||
val toCity: String = "",
|
||||
val memberRelation: String = "",
|
||||
val transportMode: String = "CAR",
|
||||
val isTransportModeAuto: Boolean = true,
|
||||
val childCount: String = "",
|
||||
val maleCount: String = "",
|
||||
val femaleCount: String = "",
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.android.trisolarispms.ui.booking
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.model.BookingBillingMode
|
||||
import com.android.trisolarispms.data.api.model.BookingCreateRequest
|
||||
@@ -29,7 +30,10 @@ class BookingCreateViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun onExpectedCheckInAtChange(value: String) {
|
||||
_state.update { it.copy(expectedCheckInAt = value, error = null) }
|
||||
_state.update { current ->
|
||||
val withCheckIn = current.copy(expectedCheckInAt = value, error = null)
|
||||
withCheckIn.withDefaultTransportModeForCheckIn(value)
|
||||
}
|
||||
}
|
||||
|
||||
fun onExpectedCheckOutAtChange(value: String) {
|
||||
@@ -201,19 +205,34 @@ class BookingCreateViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun onTransportModeChange(value: String) {
|
||||
_state.update { it.copy(transportMode = value, error = null) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
transportMode = value,
|
||||
isTransportModeAuto = false,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onChildCountChange(value: String) {
|
||||
_state.update { it.copy(childCount = value.filter { it.isDigit() }, error = null) }
|
||||
_state.update { current ->
|
||||
current.copy(childCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onMaleCountChange(value: String) {
|
||||
_state.update { it.copy(maleCount = value.filter { it.isDigit() }, error = null) }
|
||||
_state.update { current ->
|
||||
current.copy(maleCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onFemaleCountChange(value: String) {
|
||||
_state.update { it.copy(femaleCount = value.filter { it.isDigit() }, error = null) }
|
||||
_state.update { current ->
|
||||
current.copy(femaleCount = value.filter { it.isDigit() }, error = null)
|
||||
.withDefaultMemberRelationForFamily()
|
||||
}
|
||||
}
|
||||
|
||||
fun onExpectedGuestCountChange(value: String) {
|
||||
@@ -292,3 +311,20 @@ class BookingCreateViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun BookingCreateState.withDefaultTransportModeForCheckIn(expectedCheckInAt: String): BookingCreateState {
|
||||
if (!isTransportModeAuto) return this
|
||||
val defaultMode = if (isFutureBookingCheckIn(expectedCheckInAt)) "" else "CAR"
|
||||
if (transportMode == defaultMode) return this
|
||||
return copy(transportMode = defaultMode)
|
||||
}
|
||||
|
||||
private fun BookingCreateState.withDefaultMemberRelationForFamily(): BookingCreateState {
|
||||
if (memberRelation.isNotBlank()) return this
|
||||
val child = childCount.toIntOrNull() ?: 0
|
||||
val male = maleCount.toIntOrNull() ?: 0
|
||||
val female = femaleCount.toIntOrNull() ?: 0
|
||||
val shouldDefaultFamily = child >= 1 || (male >= 1 && female >= 1)
|
||||
if (!shouldDefaultFamily) return this
|
||||
return copy(memberRelation = "FAMILY")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
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.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.ui.common.BackTopBarScaffold
|
||||
import com.android.trisolarispms.ui.common.PaddedScreenColumn
|
||||
|
||||
@Composable
|
||||
fun BookingRoomRequestScreen(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
fromAt: String,
|
||||
toAt: String,
|
||||
onBack: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
viewModel: BookingRoomRequestViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
LaunchedEffect(propertyId, fromAt, toAt) {
|
||||
viewModel.load(propertyId, fromAt, toAt)
|
||||
}
|
||||
|
||||
BackTopBarScaffold(
|
||||
title = "Select Room Types",
|
||||
onBack = onBack,
|
||||
bottomBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val hasSelection = state.roomTypes.any { it.quantity > 0 }
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.submit(propertyId, bookingId, fromAt, toAt, onDone)
|
||||
},
|
||||
enabled = hasSelection && !state.isSubmitting,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (state.isSubmitting) "Saving..." else "Proceed")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
PaddedScreenColumn(padding = padding, contentPadding = 16.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) {
|
||||
if (state.roomTypes.isEmpty()) {
|
||||
Text(text = "No room types found")
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
items(state.roomTypes) { item ->
|
||||
RoomTypeQuantityCard(
|
||||
item = item,
|
||||
onIncrease = { viewModel.increaseQuantity(item.roomTypeCode) },
|
||||
onDecrease = { viewModel.decreaseQuantity(item.roomTypeCode) },
|
||||
onRateChange = { viewModel.updateRate(item.roomTypeCode, it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomTypeQuantityCard(
|
||||
item: BookingRoomTypeQuantityItem,
|
||||
onIncrease: () -> Unit,
|
||||
onDecrease: () -> Unit,
|
||||
onRateChange: (String) -> Unit
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = item.roomTypeName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "${item.roomTypeCode} • Available: ${item.maxQuantity}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onDecrease, enabled = item.quantity > 0) {
|
||||
Icon(Icons.Default.Remove, contentDescription = "Decrease")
|
||||
}
|
||||
Text(
|
||||
text = item.quantity.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
IconButton(onClick = onIncrease, enabled = item.quantity < item.maxQuantity) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Increase")
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = item.rateInput,
|
||||
onValueChange = onRateChange,
|
||||
label = { Text("Rate / night") },
|
||||
placeholder = { Text("Enter rate") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
prefix = { item.currency?.takeIf { it.isNotBlank() }?.let { Text("$it ") } },
|
||||
supportingText = {
|
||||
item.ratePlanCode?.takeIf { it.isNotBlank() }?.let { Text("Plan: $it") }
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
data class BookingRoomTypeQuantityItem(
|
||||
val roomTypeCode: String,
|
||||
val roomTypeName: String,
|
||||
val maxQuantity: Int,
|
||||
val quantity: Int = 0,
|
||||
val rateInput: String = "",
|
||||
val currency: String? = null,
|
||||
val ratePlanCode: String? = null
|
||||
)
|
||||
|
||||
data class BookingRoomRequestState(
|
||||
val isLoading: Boolean = false,
|
||||
val isSubmitting: Boolean = false,
|
||||
val error: String? = null,
|
||||
val roomTypes: List<BookingRoomTypeQuantityItem> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.android.trisolarispms.ui.booking
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.data.api.model.BookingRoomRequestCreateRequest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class BookingRoomRequestViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(BookingRoomRequestState())
|
||||
val state: StateFlow<BookingRoomRequestState> = _state
|
||||
|
||||
fun load(propertyId: String, fromAt: String, toAt: String) {
|
||||
if (propertyId.isBlank()) return
|
||||
val fromDate = fromAt.toDateOnly() ?: run {
|
||||
_state.update { it.copy(error = "Invalid check-in date") }
|
||||
return
|
||||
}
|
||||
val toDate = toAt.toDateOnly() ?: run {
|
||||
_state.update { it.copy(error = "Invalid check-out date") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val availabilityResponse = api.getRoomAvailabilityRange(propertyId, from = fromDate, to = toDate)
|
||||
if (!availabilityResponse.isSuccessful) {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${availabilityResponse.code()}") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val currentByType = _state.value.roomTypes.associateBy { it.roomTypeCode }
|
||||
val items = availabilityResponse.body().orEmpty()
|
||||
.mapNotNull { entry ->
|
||||
val maxQuantity = (entry.freeCount ?: entry.freeRoomNumbers.size).coerceAtLeast(0)
|
||||
if (maxQuantity <= 0) return@mapNotNull null
|
||||
val code = entry.roomTypeCode?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: entry.roomTypeName?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return@mapNotNull null
|
||||
val name = entry.roomTypeName?.trim()?.takeIf { it.isNotBlank() } ?: code
|
||||
val previous = currentByType[code]
|
||||
val defaultRateInput = entry.averageRate?.toLong()?.toString().orEmpty()
|
||||
BookingRoomTypeQuantityItem(
|
||||
roomTypeCode = code,
|
||||
roomTypeName = name,
|
||||
maxQuantity = maxQuantity,
|
||||
quantity = previous?.quantity?.coerceAtMost(maxQuantity) ?: 0,
|
||||
rateInput = previous?.rateInput ?: defaultRateInput,
|
||||
currency = entry.currency,
|
||||
ratePlanCode = entry.ratePlanCode
|
||||
)
|
||||
}
|
||||
.sortedBy { it.roomTypeName }
|
||||
|
||||
_state.update { it.copy(isLoading = false, roomTypes = items, error = null) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun increaseQuantity(roomTypeCode: String) {
|
||||
updateQuantity(roomTypeCode, delta = 1)
|
||||
}
|
||||
|
||||
fun decreaseQuantity(roomTypeCode: String) {
|
||||
updateQuantity(roomTypeCode, delta = -1)
|
||||
}
|
||||
|
||||
fun updateRate(roomTypeCode: String, value: String) {
|
||||
val digitsOnly = value.filter { it.isDigit() }
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
roomTypes = current.roomTypes.map { item ->
|
||||
if (item.roomTypeCode == roomTypeCode) {
|
||||
item.copy(rateInput = digitsOnly)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
},
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun submit(
|
||||
propertyId: String,
|
||||
bookingId: String,
|
||||
fromAt: String,
|
||||
toAt: String,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
if (propertyId.isBlank() || bookingId.isBlank() || fromAt.isBlank() || toAt.isBlank()) {
|
||||
_state.update { it.copy(error = "Booking dates are missing") }
|
||||
return
|
||||
}
|
||||
val selected = _state.value.roomTypes.filter { it.quantity > 0 }
|
||||
if (selected.isEmpty()) {
|
||||
_state.update { it.copy(error = "Select at least one room type") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSubmitting = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
for (item in selected) {
|
||||
val response = api.createRoomRequest(
|
||||
propertyId = propertyId,
|
||||
bookingId = bookingId,
|
||||
body = BookingRoomRequestCreateRequest(
|
||||
roomTypeCode = item.roomTypeCode,
|
||||
quantity = item.quantity,
|
||||
fromAt = fromAt,
|
||||
toAt = toAt
|
||||
)
|
||||
)
|
||||
if (!response.isSuccessful) {
|
||||
_state.update { it.copy(isSubmitting = false, error = "Create failed: ${response.code()}") }
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(isSubmitting = false, error = null) }
|
||||
onDone()
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isSubmitting = false, error = e.localizedMessage ?: "Create failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateQuantity(roomTypeCode: String, delta: Int) {
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
roomTypes = current.roomTypes.map { item ->
|
||||
if (item.roomTypeCode != roomTypeCode) {
|
||||
item
|
||||
} else {
|
||||
val updated = (item.quantity + delta).coerceIn(0, item.maxQuantity)
|
||||
item.copy(quantity = updated)
|
||||
}
|
||||
},
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toDateOnly(): String? =
|
||||
runCatching {
|
||||
OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
}.getOrNull()
|
||||
@@ -31,6 +31,13 @@ sealed interface AppRoute {
|
||||
val fromAt: String,
|
||||
val toAt: String?
|
||||
) : AppRoute
|
||||
data class BookingRoomRequestFromBooking(
|
||||
val propertyId: String,
|
||||
val bookingId: String,
|
||||
val guestId: String,
|
||||
val fromAt: String,
|
||||
val toAt: String
|
||||
) : AppRoute
|
||||
data class BookingRoomStays(
|
||||
val propertyId: String,
|
||||
val bookingId: String
|
||||
|
||||
@@ -50,6 +50,7 @@ internal fun handleBackNavigation(
|
||||
null
|
||||
)
|
||||
is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||
is AppRoute.BookingRoomRequestFromBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
|
||||
is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home
|
||||
is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo(
|
||||
currentRoute.propertyId,
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.android.trisolarispms.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.android.trisolarispms.core.auth.Role
|
||||
import com.android.trisolarispms.core.booking.isFutureBookingCheckIn
|
||||
import com.android.trisolarispms.ui.navigation.AppRoute
|
||||
import com.android.trisolarispms.ui.auth.AuthUiState
|
||||
import com.android.trisolarispms.ui.auth.AuthViewModel
|
||||
import com.android.trisolarispms.ui.booking.BookingCreateScreen
|
||||
import com.android.trisolarispms.ui.booking.BookingRoomRequestScreen
|
||||
import com.android.trisolarispms.ui.guest.GuestInfoScreen
|
||||
import com.android.trisolarispms.ui.guest.GuestSignatureScreen
|
||||
import com.android.trisolarispms.ui.home.HomeScreen
|
||||
@@ -70,19 +72,52 @@ internal fun renderHomeGuestRoutes(
|
||||
val fromAt = response.checkInAt?.takeIf { it.isNotBlank() }
|
||||
?: response.expectedCheckInAt.orEmpty()
|
||||
val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() }
|
||||
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking(
|
||||
if (isFutureBookingCheckIn(response.expectedCheckInAt)) {
|
||||
if (fromAt.isNotBlank() && !toAt.isNullOrBlank()) {
|
||||
refs.route.value = AppRoute.BookingRoomRequestFromBooking(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
fromAt = fromAt,
|
||||
toAt = toAt
|
||||
)
|
||||
} else {
|
||||
refs.route.value = AppRoute.GuestInfo(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId
|
||||
)
|
||||
}
|
||||
} else {
|
||||
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = bookingId,
|
||||
guestId = guestId,
|
||||
fromAt = fromAt,
|
||||
toAt = toAt
|
||||
)
|
||||
}
|
||||
} else {
|
||||
refs.route.value = AppRoute.Home
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
is AppRoute.BookingRoomRequestFromBooking -> BookingRoomRequestScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
fromAt = currentRoute.fromAt,
|
||||
toAt = currentRoute.toAt,
|
||||
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
|
||||
onDone = {
|
||||
refs.route.value = AppRoute.GuestInfo(
|
||||
propertyId = currentRoute.propertyId,
|
||||
bookingId = currentRoute.bookingId,
|
||||
guestId = currentRoute.guestId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
is AppRoute.GuestInfo -> GuestInfoScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
guestId = currentRoute.guestId,
|
||||
|
||||
@@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
|
||||
import com.android.trisolarispms.ui.common.BackTopBarScaffold
|
||||
import com.android.trisolarispms.ui.common.LoadingAndError
|
||||
import com.android.trisolarispms.ui.common.PaddedScreenColumn
|
||||
@@ -113,16 +112,15 @@ fun ManageRoomStaySelectScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(state.rooms) { room ->
|
||||
val selection = room.toSelection() ?: return@items
|
||||
val isSelected = selectedRooms.any { it.roomId == selection.roomId }
|
||||
val isSelected = selectedRooms.any { it.roomId == room.roomId }
|
||||
RoomSelectCard(
|
||||
item = selection,
|
||||
item = room,
|
||||
isSelected = isSelected,
|
||||
onToggle = {
|
||||
if (isSelected) {
|
||||
selectedRooms.removeAll { it.roomId == selection.roomId }
|
||||
selectedRooms.removeAll { it.roomId == room.roomId }
|
||||
} else {
|
||||
selectedRooms.add(selection)
|
||||
selectedRooms.add(room)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -187,16 +185,3 @@ private fun String.toDateOnly(): String? {
|
||||
OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun RoomAvailableRateResponse.toSelection(): ManageRoomStaySelection? {
|
||||
val id = roomId ?: return null
|
||||
val number = roomNumber ?: return null
|
||||
return ManageRoomStaySelection(
|
||||
roomId = id,
|
||||
roomNumber = number,
|
||||
roomTypeName = roomTypeName ?: roomTypeCode ?: "Room",
|
||||
averageRate = averageRate,
|
||||
currency = currency,
|
||||
ratePlanCode = ratePlanCode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package com.android.trisolarispms.ui.roomstay
|
||||
|
||||
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
|
||||
|
||||
data class ManageRoomStaySelectState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val rooms: List<RoomAvailableRateResponse> = emptyList()
|
||||
val rooms: List<ManageRoomStaySelection> = emptyList()
|
||||
)
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.android.trisolarispms.ui.roomstay
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.android.trisolarispms.data.api.core.ApiClient
|
||||
import com.android.trisolarispms.core.viewmodel.launchRequest
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -20,18 +23,58 @@ class ManageRoomStaySelectViewModel : ViewModel() {
|
||||
defaultError = "Load failed"
|
||||
) {
|
||||
val api = ApiClient.create()
|
||||
val response = api.listAvailableRoomsWithRate(propertyId, from = from, to = to)
|
||||
if (response.isSuccessful) {
|
||||
val response = api.getRoomAvailabilityRange(propertyId, from = from, to = to)
|
||||
if (!response.isSuccessful) {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
||||
return@launchRequest
|
||||
}
|
||||
|
||||
val rooms = coroutineScope {
|
||||
response.body().orEmpty()
|
||||
.mapNotNull { entry ->
|
||||
val roomTypeCode = entry.roomTypeCode?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
val hasFreeRooms = (entry.freeCount ?: entry.freeRoomNumbers.size) > 0
|
||||
if (!hasFreeRooms) return@mapNotNull null
|
||||
async {
|
||||
val byTypeResponse = api.listRoomsByType(
|
||||
propertyId = propertyId,
|
||||
roomTypeCode = roomTypeCode,
|
||||
availableOnly = true
|
||||
)
|
||||
if (!byTypeResponse.isSuccessful) {
|
||||
throw IllegalStateException("Load failed: ${byTypeResponse.code()}")
|
||||
}
|
||||
byTypeResponse.body().orEmpty()
|
||||
.mapNotNull { room ->
|
||||
val roomId = room.id ?: return@mapNotNull null
|
||||
val roomNumber = room.roomNumber ?: return@mapNotNull null
|
||||
ManageRoomStaySelection(
|
||||
roomId = roomId,
|
||||
roomNumber = roomNumber,
|
||||
roomTypeName = room.roomTypeName
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: entry.roomTypeName
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: roomTypeCode,
|
||||
averageRate = entry.averageRate,
|
||||
currency = entry.currency,
|
||||
ratePlanCode = entry.ratePlanCode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
.flatten()
|
||||
.sortedBy { it.roomNumber }
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
rooms = response.body().orEmpty(),
|
||||
rooms = rooms,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user