createbooking: improve logic for future bookings

This commit is contained in:
androidlover5842
2026-02-04 17:55:35 +05:30
parent f9b09e2376
commit a67eacd77f
17 changed files with 529 additions and 120 deletions

View File

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

View File

@@ -102,6 +102,13 @@ data class BookingExpectedDatesRequest(
val expectedCheckOutAt: String? = null val expectedCheckOutAt: String? = null
) )
data class BookingRoomRequestCreateRequest(
val roomTypeCode: String,
val quantity: Int,
val fromAt: String,
val toAt: String
)
data class BookingBillableNightsRequest( data class BookingBillableNightsRequest(
val expectedCheckInAt: String? = null, val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null val expectedCheckOutAt: String? = null

View File

@@ -1,5 +1,7 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import com.google.gson.annotations.SerializedName
data class RoomCreateRequest( data class RoomCreateRequest(
val roomNumber: Int, val roomNumber: Int,
val floor: Int? = null, val floor: Int? = null,
@@ -47,16 +49,11 @@ data class RoomAvailabilityResponse(
) )
data class RoomAvailabilityRangeResponse( data class RoomAvailabilityRangeResponse(
val roomTypeName: String? = null, @SerializedName(value = "roomTypeCode", alternate = ["code"])
val freeRoomNumbers: List<Int> = emptyList(),
val freeCount: Int? = null
)
data class RoomAvailableRateResponse(
val roomId: String? = null,
val roomNumber: Int? = null,
val roomTypeCode: String? = null, val roomTypeCode: String? = null,
val roomTypeName: String? = null, val roomTypeName: String? = null,
val freeRoomNumbers: List<Int> = emptyList(),
val freeCount: Int? = null,
val averageRate: Double? = null, val averageRate: Double? = null,
val currency: String? = null, val currency: String? = null,
val ratePlanCode: String? = null val ratePlanCode: String? = null

View File

@@ -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.BookingExpectedCheckoutPreviewRequest
import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewResponse import com.android.trisolarispms.data.api.model.BookingExpectedCheckoutPreviewResponse
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest 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.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.BookingBalanceResponse import com.android.trisolarispms.data.api.model.BookingBalanceResponse
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
@@ -64,6 +65,13 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest @Body body: BookingExpectedDatesRequest
): Response<Unit> ): 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") @POST("properties/{propertyId}/bookings/{bookingId}/billable-nights")
suspend fun previewBillableNights( suspend fun previewBillableNights(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

@@ -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.RoomAvailabilityRangeResponse
import com.android.trisolarispms.data.api.model.RoomAvailabilityResponse 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.RoomBoardDto
import com.android.trisolarispms.data.api.model.RoomCreateRequest import com.android.trisolarispms.data.api.model.RoomCreateRequest
import com.android.trisolarispms.data.api.model.RoomDto import com.android.trisolarispms.data.api.model.RoomDto
@@ -55,7 +54,8 @@ interface RoomApi {
suspend fun getRoomAvailabilityRange( suspend fun getRoomAvailabilityRange(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Query("from") from: String, @Query("from") from: String,
@Query("to") to: String @Query("to") to: String,
@Query("ratePlanCode") ratePlanCode: String? = null
): Response<List<RoomAvailabilityRangeResponse>> ): Response<List<RoomAvailabilityRangeResponse>>
@GET("properties/{propertyId}/rooms/available") @GET("properties/{propertyId}/rooms/available")
@@ -63,14 +63,6 @@ interface RoomApi {
@Path("propertyId") propertyId: String @Path("propertyId") propertyId: String
): Response<List<RoomDto>> ): 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}") @GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
suspend fun listRoomsByType( suspend fun listRoomsByType(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

@@ -18,7 +18,6 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text 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
@@ -55,13 +54,10 @@ fun BookingCreateScreen(
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) } val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") } val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11: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 relationMenuExpanded = remember { mutableStateOf(false) }
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE") val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
val transportMenuExpanded = remember { mutableStateOf(false) } 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 billingModeMenuExpanded = remember { mutableStateOf(false) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") } val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") } val timeFormatter = remember { DateTimeFormatter.ofPattern("HH:mm") }
@@ -88,7 +84,6 @@ fun BookingCreateScreen(
viewModel.reset() viewModel.reset()
viewModel.loadBillingPolicy(propertyId) viewModel.loadBillingPolicy(propertyId)
val now = OffsetDateTime.now() val now = OffsetDateTime.now()
checkInNow.value = true
val defaultCheckoutDate = now.toLocalDate().plusDays(1) val defaultCheckoutDate = now.toLocalDate().plusDays(1)
checkOutDate.value = defaultCheckoutDate checkOutDate.value = defaultCheckoutDate
checkOutTime.value = "11:00" checkOutTime.value = "11:00"
@@ -115,24 +110,6 @@ fun BookingCreateScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top 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 { val checkInDisplay = state.expectedCheckInAt.takeIf { it.isNotBlank() }?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull() runCatching { OffsetDateTime.parse(it) }.getOrNull()
} }
@@ -152,7 +129,7 @@ fun BookingCreateScreen(
checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----", checkOutDateText = checkOutDisplay?.format(cardDateFormatter) ?: "--/--/----",
checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--", checkOutTimeText = checkOutDisplay?.format(cardTimeFormatter) ?: "--:--",
totalTimeText = totalTimeText, totalTimeText = totalTimeText,
checkInEditable = !checkInNow.value, checkInEditable = true,
onCheckInDateClick = { showCheckInDatePicker.value = true }, onCheckInDateClick = { showCheckInDatePicker.value = true },
onCheckInTimeClick = { showCheckInTimePicker.value = true }, onCheckInTimeClick = { showCheckInTimePicker.value = true },
onCheckOutDateClick = { showCheckOutDatePicker.value = true }, onCheckOutDateClick = { showCheckOutDatePicker.value = true },
@@ -316,36 +293,6 @@ fun BookingCreateScreen(
) )
} }
Spacer(modifier = Modifier.height(12.dp)) 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( OutlinedTextField(
value = state.fromCity, value = state.fromCity,
onValueChange = viewModel::onFromCityChange, onValueChange = viewModel::onFromCityChange,
@@ -397,7 +344,7 @@ fun BookingCreateScreen(
onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value } onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value }
) { ) {
OutlinedTextField( OutlinedTextField(
value = state.transportMode, value = state.transportMode.ifBlank { "Not set" },
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Transport Mode") }, label = { Text("Transport Mode") },
@@ -413,8 +360,9 @@ fun BookingCreateScreen(
onDismissRequest = { transportMenuExpanded.value = false } onDismissRequest = { transportMenuExpanded.value = false }
) { ) {
transportOptions.forEach { option -> transportOptions.forEach { option ->
val optionLabel = option.ifBlank { "Not set" }
DropdownMenuItem( DropdownMenuItem(
text = { Text(option) }, text = { Text(optionLabel) },
onClick = { onClick = {
transportMenuExpanded.value = false transportMenuExpanded.value = false
viewModel.onTransportModeChange(option) viewModel.onTransportModeChange(option)

View File

@@ -12,11 +12,12 @@ data class BookingCreateState(
val expectedCheckOutAt: String = "", val expectedCheckOutAt: String = "",
val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY, val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY,
val billingCheckoutTime: String = "", val billingCheckoutTime: String = "",
val source: String = "DIRECT", val source: String = "",
val fromCity: String = "", val fromCity: String = "",
val toCity: String = "", val toCity: String = "",
val memberRelation: String = "", val memberRelation: String = "",
val transportMode: String = "CAR", val transportMode: String = "CAR",
val isTransportModeAuto: Boolean = true,
val childCount: String = "", val childCount: String = "",
val maleCount: String = "", val maleCount: String = "",
val femaleCount: String = "", val femaleCount: String = "",

View File

@@ -2,6 +2,7 @@ package com.android.trisolarispms.ui.booking
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingBillingMode import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingCreateRequest import com.android.trisolarispms.data.api.model.BookingCreateRequest
@@ -29,7 +30,10 @@ class BookingCreateViewModel : ViewModel() {
} }
fun onExpectedCheckInAtChange(value: String) { 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) { fun onExpectedCheckOutAtChange(value: String) {
@@ -201,19 +205,34 @@ class BookingCreateViewModel : ViewModel() {
} }
fun onTransportModeChange(value: String) { 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) { 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) { 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) { 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) { 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")
}

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,13 @@ sealed interface AppRoute {
val fromAt: String, val fromAt: String,
val toAt: String? val toAt: String?
) : AppRoute ) : AppRoute
data class BookingRoomRequestFromBooking(
val propertyId: String,
val bookingId: String,
val guestId: String,
val fromAt: String,
val toAt: String
) : AppRoute
data class BookingRoomStays( data class BookingRoomStays(
val propertyId: String, val propertyId: String,
val bookingId: String val bookingId: String

View File

@@ -50,6 +50,7 @@ internal fun handleBackNavigation(
null null
) )
is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId) is AppRoute.CreateBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.BookingRoomRequestFromBooking -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home is AppRoute.GuestInfo -> refs.route.value = AppRoute.Home
is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo( is AppRoute.GuestSignature -> refs.route.value = AppRoute.GuestInfo(
currentRoute.propertyId, currentRoute.propertyId,

View File

@@ -2,10 +2,12 @@ package com.android.trisolarispms.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.android.trisolarispms.core.auth.Role 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.navigation.AppRoute
import com.android.trisolarispms.ui.auth.AuthUiState import com.android.trisolarispms.ui.auth.AuthUiState
import com.android.trisolarispms.ui.auth.AuthViewModel import com.android.trisolarispms.ui.auth.AuthViewModel
import com.android.trisolarispms.ui.booking.BookingCreateScreen 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.GuestInfoScreen
import com.android.trisolarispms.ui.guest.GuestSignatureScreen import com.android.trisolarispms.ui.guest.GuestSignatureScreen
import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.home.HomeScreen
@@ -70,19 +72,52 @@ internal fun renderHomeGuestRoutes(
val fromAt = response.checkInAt?.takeIf { it.isNotBlank() } val fromAt = response.checkInAt?.takeIf { it.isNotBlank() }
?: response.expectedCheckInAt.orEmpty() ?: response.expectedCheckInAt.orEmpty()
val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() } val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() }
refs.route.value = AppRoute.ManageRoomStaySelectFromBooking( if (isFutureBookingCheckIn(response.expectedCheckInAt)) {
propertyId = currentRoute.propertyId, if (fromAt.isNotBlank() && !toAt.isNullOrBlank()) {
bookingId = bookingId, refs.route.value = AppRoute.BookingRoomRequestFromBooking(
guestId = guestId, propertyId = currentRoute.propertyId,
fromAt = fromAt, bookingId = bookingId,
toAt = toAt 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 { } else {
refs.route.value = AppRoute.Home 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( is AppRoute.GuestInfo -> GuestInfoScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
guestId = currentRoute.guestId, guestId = currentRoute.guestId,

View File

@@ -32,7 +32,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.BackTopBarScaffold
import com.android.trisolarispms.ui.common.LoadingAndError import com.android.trisolarispms.ui.common.LoadingAndError
import com.android.trisolarispms.ui.common.PaddedScreenColumn import com.android.trisolarispms.ui.common.PaddedScreenColumn
@@ -113,16 +112,15 @@ fun ManageRoomStaySelectScreen(
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(state.rooms) { room -> items(state.rooms) { room ->
val selection = room.toSelection() ?: return@items val isSelected = selectedRooms.any { it.roomId == room.roomId }
val isSelected = selectedRooms.any { it.roomId == selection.roomId }
RoomSelectCard( RoomSelectCard(
item = selection, item = room,
isSelected = isSelected, isSelected = isSelected,
onToggle = { onToggle = {
if (isSelected) { if (isSelected) {
selectedRooms.removeAll { it.roomId == selection.roomId } selectedRooms.removeAll { it.roomId == room.roomId }
} else { } 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) OffsetDateTime.parse(this).toLocalDate().format(DateTimeFormatter.ISO_LOCAL_DATE)
}.getOrNull() }.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
)
}

View File

@@ -1,9 +1,7 @@
package com.android.trisolarispms.ui.roomstay package com.android.trisolarispms.ui.roomstay
import com.android.trisolarispms.data.api.model.RoomAvailableRateResponse
data class ManageRoomStaySelectState( data class ManageRoomStaySelectState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
val rooms: List<RoomAvailableRateResponse> = emptyList() val rooms: List<ManageRoomStaySelection> = emptyList()
) )

View File

@@ -3,6 +3,9 @@ package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.core.viewmodel.launchRequest 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -20,17 +23,57 @@ class ManageRoomStaySelectViewModel : ViewModel() {
defaultError = "Load failed" defaultError = "Load failed"
) { ) {
val api = ApiClient.create() val api = ApiClient.create()
val response = api.listAvailableRoomsWithRate(propertyId, from = from, to = to) val response = api.getRoomAvailabilityRange(propertyId, from = from, to = to)
if (response.isSuccessful) { if (!response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
rooms = response.body().orEmpty(),
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } _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 = rooms,
error = null
)
} }
} }
} }