From a67eacd77fd56ce461ed86db005fb272edb62058 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Wed, 4 Feb 2026 17:55:35 +0530 Subject: [PATCH] createbooking: improve logic for future bookings --- .../core/booking/BookingDatePolicy.kt | 14 ++ .../data/api/model/BookingModels.kt | 7 + .../data/api/model/RoomModels.kt | 13 +- .../data/api/service/BookingApi.kt | 8 + .../trisolarispms/data/api/service/RoomApi.kt | 12 +- .../ui/booking/BookingCreateScreen.kt | 62 +------ .../ui/booking/BookingCreateState.kt | 3 +- .../ui/booking/BookingCreateViewModel.kt | 46 ++++- .../ui/booking/BookingRoomRequestScreen.kt | 163 ++++++++++++++++++ .../ui/booking/BookingRoomRequestState.kt | 18 ++ .../ui/booking/BookingRoomRequestViewModel.kt | 156 +++++++++++++++++ .../trisolarispms/ui/navigation/AppRoute.kt | 7 + .../ui/navigation/MainBackNavigation.kt | 1 + .../ui/navigation/MainRoutesHomeGuest.kt | 49 +++++- .../ui/roomstay/ManageRoomStaySelectScreen.kt | 23 +-- .../ui/roomstay/ManageRoomStaySelectState.kt | 4 +- .../roomstay/ManageRoomStaySelectViewModel.kt | 63 +++++-- 17 files changed, 529 insertions(+), 120 deletions(-) create mode 100644 app/src/main/java/com/android/trisolarispms/core/booking/BookingDatePolicy.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestViewModel.kt diff --git a/app/src/main/java/com/android/trisolarispms/core/booking/BookingDatePolicy.kt b/app/src/main/java/com/android/trisolarispms/core/booking/BookingDatePolicy.kt new file mode 100644 index 0000000..038c90a --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/core/booking/BookingDatePolicy.kt @@ -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) +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt index 72c6430..e4ea481 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt index be9bb09..418597f 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt @@ -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 = 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 = emptyList(), + val freeCount: Int? = null, val averageRate: Double? = null, val currency: String? = null, val ratePlanCode: String? = null diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt index ab588dd..b85178d 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt @@ -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 + @POST("properties/{propertyId}/bookings/{bookingId}/room-requests") + suspend fun createRoomRequest( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: BookingRoomRequestCreateRequest + ): Response + @POST("properties/{propertyId}/bookings/{bookingId}/billable-nights") suspend fun previewBillableNights( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/RoomApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/RoomApi.kt index ed550f8..4930285 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/service/RoomApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/RoomApi.kt @@ -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> @GET("properties/{propertyId}/rooms/available") @@ -63,14 +63,6 @@ interface RoomApi { @Path("propertyId") propertyId: String ): Response> - @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> - @GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}") suspend fun listRoomsByType( @Path("propertyId") propertyId: String, diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt index d04c37a..3ca8d3f 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt @@ -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(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) diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt index dfb5529..d24ac5a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt @@ -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 = "", diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt index d401bde..ec5af36 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt @@ -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") +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestScreen.kt new file mode 100644 index 0000000..4ac7b71 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestScreen.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestState.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestState.kt new file mode 100644 index 0000000..1fdd960 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestState.kt @@ -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 = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestViewModel.kt new file mode 100644 index 0000000..5001bbc --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingRoomRequestViewModel.kt @@ -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 = _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() diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt index d6930e4..655dff8 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt index fa69641..e52b4a7 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt index c822cb0..78a3ac1 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesHomeGuest.kt @@ -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( - propertyId = currentRoute.propertyId, - bookingId = bookingId, - guestId = guestId, - fromAt = fromAt, - toAt = toAt - ) + 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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectScreen.kt index dc2c9a2..533f960 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectScreen.kt @@ -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 - ) -} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectState.kt index 3f6787d..97423aa 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectState.kt @@ -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 = emptyList() + val rooms: List = emptyList() ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectViewModel.kt index eb91041..089dd3d 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ManageRoomStaySelectViewModel.kt @@ -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,17 +23,57 @@ class ManageRoomStaySelectViewModel : ViewModel() { defaultError = "Load failed" ) { val api = ApiClient.create() - val response = api.listAvailableRoomsWithRate(propertyId, from = from, to = to) - if (response.isSuccessful) { - _state.update { - it.copy( - isLoading = false, - rooms = response.body().orEmpty(), - error = null - ) - } - } else { + 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 = rooms, + error = null + ) } } }