booking create: manage booking rates flow

This commit is contained in:
androidlover5842
2026-01-29 12:09:43 +05:30
parent 29065cee22
commit 5f522ca3ab
17 changed files with 1033 additions and 18 deletions

121
AGENTS.md
View File

@@ -63,7 +63,7 @@ Response: List of BookingListItem with id, status, guestId, source, times, count
### Check-in (creates RoomStay)
POST /properties/{propertyId}/bookings/{bookingId}/check-in
Auth: ADMIN/MANAGER/STAFF
Auth: ADMIN/MANAGER
Body
Required:
@@ -75,7 +75,7 @@ Optional:
- checkInAt (String)
- transportMode (String enum)
- nightlyRate (Long)
- rateSource (PRESET|NEGOTIATED|OTA)
- rateSource (MANUAL|RATE_PLAN|OTA)
- ratePlanCode (String)
- currency (String)
- notes (String)
@@ -84,8 +84,8 @@ Optional:
"roomIds": ["uuid1","uuid2"],
"checkInAt": "2026-01-28T12:00:00+05:30",
"nightlyRate": 2500,
"rateSource": "NEGOTIATED",
"ratePlanCode": "WEEKEND",
"rateSource": "MANUAL",
"ratePlanCode": "EP",
"currency": "INR",
"notes": "Late arrival"
}
@@ -95,7 +95,7 @@ Optional:
### Pre-assign room stay
POST /properties/{propertyId}/bookings/{bookingId}/room-stays
Auth: ADMIN/MANAGER/STAFF
Auth: ADMIN/MANAGER
Body
Required:
@@ -107,7 +107,7 @@ Required:
Optional:
- nightlyRate (Long)
- rateSource (PRESET|NEGOTIATED|OTA)
- rateSource (MANUAL|RATE_PLAN|OTA)
- ratePlanCode (String)
- currency (String)
- notes (String)
@@ -117,13 +117,63 @@ Optional:
"fromAt": "2026-01-29T12:00:00+05:30",
"toAt": "2026-01-30T10:00:00+05:30",
"nightlyRate": 2800,
"rateSource": "PRESET",
"ratePlanCode": "WEEKEND",
"rateSource": "RATE_PLAN",
"ratePlanCode": "EP",
"currency": "INR"
}
---
### Active room stays
GET /properties/{propertyId}/room-stays/active
Auth: any member except AGENT-only
Response: list of ActiveRoomStayResponse
[
{
"roomStayId":"uuid",
"bookingId":"uuid",
"guestId":"uuid-or-null",
"guestName":"Name",
"guestPhone":"+9111...",
"roomId":"uuid",
"roomNumber":"101",
"roomTypeName":"DELUXE",
"fromAt":"2026-01-29T12:00:00+05:30",
"checkinAt":"2026-01-29T12:05:00+05:30",
"expectedCheckoutAt":"2026-01-30T10:00:00+05:30"
}
]
---
### Change room (move guest)
POST /properties/{propertyId}/room-stays/{roomStayId}/change-room
Auth: ADMIN/MANAGER/STAFF
Body
{
"newRoomId":"uuid",
"movedAt":"2026-01-30T15:00:00+05:30",
"idempotencyKey":"any-unique-string"
}
Response
{
"oldRoomStayId":"uuid",
"newRoomStayId":"uuid",
"oldRoomId":"uuid",
"newRoomId":"uuid",
"movedAt":"2026-01-30T15:00:00+05:30"
}
---
## 2) Guests
### Create guest + link to booking
@@ -310,7 +360,7 @@ Required:
- effectiveAt (String, ISO-8601)
- nightlyRate (Long)
- rateSource (PRESET|NEGOTIATED|OTA)
- rateSource (MANUAL|RATE_PLAN|OTA)
Optional:
@@ -320,7 +370,7 @@ Optional:
{
"effectiveAt": "2026-01-30T12:00:00+05:30",
"nightlyRate": 2000,
"rateSource": "NEGOTIATED",
"rateSource": "MANUAL",
"currency": "INR"
}
@@ -330,6 +380,57 @@ Response
---
### Check-out (closes all active stays on booking)
POST /properties/{propertyId}/bookings/{bookingId}/check-out
Auth: ADMIN/MANAGER
Body
{ "checkOutAt":"2026-01-30T10:00:00+05:30", "notes":"optional" }
Response: 204 No Content
---
### Bulk check-in (creates multiple room stays)
POST /properties/{propertyId}/bookings/{bookingId}/check-in/bulk
Body:
{
"stays": [
{
"roomId": "uuid",
"checkInAt": "2026-01-29T12:00:00+05:30",
"checkOutAt": "2026-01-30T10:00:00+05:30",
"nightlyRate": 6000,
"rateSource": "MANUAL",
"ratePlanCode": "EP",
"currency": "INR"
},
{
"roomId": "uuid",
"checkInAt": "2026-01-29T12:00:00+05:30",
"checkOutAt": "2026-01-30T10:00:00+05:30",
"nightlyRate": 8000,
"rateSource": "MANUAL",
"ratePlanCode": "EP",
"currency": "INR"
}
]
}
Behavior
- Creates one RoomStay per stay with its own rate.
- Sets booking CHECKED_IN, checkinAt = earliest stay check-in.
- If any checkOutAt provided, booking expectedCheckoutAt = latest of those.
- Rejects duplicate room IDs.
- Rejects invalid stay date range (checkOutAt <= checkInAt).
- Blocks occupied rooms.
## 6) Payments + Balance
### Add payment

View File

@@ -18,6 +18,9 @@ import com.android.trisolarispms.ui.auth.UnauthorizedScreen
import com.android.trisolarispms.ui.booking.BookingCreateScreen
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.guest.GuestSignatureScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen
@@ -65,6 +68,7 @@ class MainActivity : ComponentActivity() {
val selectedGuest = remember { mutableStateOf<com.android.trisolarispms.data.api.model.GuestDto?>(null) }
val selectedGuestPhone = remember { mutableStateOf<String?>(null) }
val selectedImageTag = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomImageTagDto?>(null) }
val selectedManageRooms = remember { mutableStateOf<List<ManageRoomStaySelection>>(emptyList()) }
val roomFormKey = remember { mutableStateOf(0) }
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
val currentRoute = route.value
@@ -117,6 +121,27 @@ class MainActivity : ComponentActivity() {
currentRoute.bookingId,
currentRoute.guestId
)
is AppRoute.ManageRoomStaySelect -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.ManageRoomStayRates -> route.value = AppRoute.ManageRoomStaySelect(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.fromAt,
currentRoute.toAt
)
is AppRoute.ManageRoomStaySelectFromBooking -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.ManageRoomStayRatesFromBooking -> route.value = AppRoute.ManageRoomStaySelectFromBooking(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId,
currentRoute.fromAt,
currentRoute.toAt
)
}
}
@@ -160,8 +185,17 @@ class MainActivity : ComponentActivity() {
val guestId = (guest?.id ?: response.guestId).orEmpty()
selectedGuest.value = guest
selectedGuestPhone.value = phone
if (bookingId.isNotBlank()) {
route.value = AppRoute.GuestInfo(currentRoute.propertyId, bookingId, guestId)
if (bookingId.isNotBlank() && guestId.isNotBlank()) {
val fromAt = response.checkInAt?.takeIf { it.isNotBlank() }
?: response.expectedCheckInAt.orEmpty()
val toAt = response.expectedCheckOutAt?.takeIf { it.isNotBlank() }
route.value = AppRoute.ManageRoomStaySelectFromBooking(
propertyId = currentRoute.propertyId,
bookingId = bookingId,
guestId = guestId,
fromAt = fromAt,
toAt = toAt
)
} else {
route.value = AppRoute.Home
}
@@ -203,7 +237,106 @@ class MainActivity : ComponentActivity() {
propertyName = currentRoute.propertyName,
onBack = { route.value = AppRoute.Home },
onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) }
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty()
val toAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() }
?: booking.checkOutAt?.takeIf { it.isNotBlank() }
if (fromAt.isNotBlank()) {
route.value = AppRoute.ManageRoomStaySelect(
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty(),
fromAt = fromAt,
toAt = toAt
)
}
}
)
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen(
propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt,
bookingToAt = currentRoute.toAt,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onNext = { rooms ->
selectedManageRooms.value = rooms
route.value = AppRoute.ManageRoomStayRates(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt
)
}
)
is AppRoute.ManageRoomStaySelectFromBooking -> ManageRoomStaySelectScreen(
propertyId = currentRoute.propertyId,
bookingFromAt = currentRoute.fromAt,
bookingToAt = currentRoute.toAt,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onNext = { rooms ->
selectedManageRooms.value = rooms
route.value = AppRoute.ManageRoomStayRatesFromBooking(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
fromAt = currentRoute.fromAt,
toAt = currentRoute.toAt
)
}
)
is AppRoute.ManageRoomStayRates -> ManageRoomStayRatesScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
checkInAt = currentRoute.fromAt,
checkOutAt = currentRoute.toAt,
selectedRooms = selectedManageRooms.value,
onBack = {
route.value = AppRoute.ManageRoomStaySelect(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.fromAt,
currentRoute.toAt
)
},
onDone = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.ManageRoomStayRatesFromBooking -> ManageRoomStayRatesScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
checkInAt = currentRoute.fromAt,
checkOutAt = currentRoute.toAt,
selectedRooms = selectedManageRooms.value,
onBack = {
route.value = AppRoute.ManageRoomStaySelectFromBooking(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId,
currentRoute.fromAt,
currentRoute.toAt
)
},
onDone = {
route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
}
)
is AppRoute.Rooms -> RoomsScreen(
propertyId = currentRoute.propertyId,

View File

@@ -9,11 +9,15 @@ import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.BookingNoShowRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest
import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.RoomStayDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface BookingApi {
@POST("properties/{propertyId}/bookings")
@@ -22,6 +26,19 @@ interface BookingApi {
@Body body: BookingCreateRequest
): Response<BookingCreateResponse>
@GET("properties/{propertyId}/bookings")
suspend fun listBookings(
@Path("propertyId") propertyId: String,
@Query("status") status: String? = null
): Response<List<BookingListItem>>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in/bulk")
suspend fun bulkCheckIn(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBulkCheckInRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/link-guest")
suspend fun linkGuest(
@Path("propertyId") propertyId: String,

View File

@@ -2,6 +2,7 @@ package com.android.trisolarispms.data.api
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
@@ -62,6 +63,14 @@ 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,

View File

@@ -30,6 +30,38 @@ data class BookingCreateResponse(
val expectedCheckOutAt: String? = null
)
data class BookingListItem(
val id: String? = null,
val status: String? = null,
val guestId: String? = null,
val source: String? = null,
val checkInAt: String? = null,
val checkOutAt: String? = null,
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null,
val adultCount: Int? = null,
val childCount: Int? = null,
val maleCount: Int? = null,
val femaleCount: Int? = null,
val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null,
val notes: String? = null
)
data class BookingBulkCheckInRequest(
val stays: List<BookingBulkCheckInStayRequest>
)
data class BookingBulkCheckInStayRequest(
val roomId: String,
val checkInAt: String,
val checkOutAt: String? = null,
val nightlyRate: Long? = null,
val rateSource: String? = null,
val ratePlanCode: String? = null,
val currency: String? = null
)
data class BookingLinkGuestRequest(
val guestId: String
)

View File

@@ -52,6 +52,16 @@ data class RoomAvailabilityRangeResponse(
val freeCount: Int? = null
)
data class RoomAvailableRateResponse(
val roomId: String? = null,
val roomNumber: Int? = null,
val roomTypeCode: String? = null,
val roomTypeName: String? = null,
val averageRate: Double? = null,
val currency: String? = null,
val ratePlanCode: String? = null
)
// Images
data class ImageDto(

View File

@@ -5,6 +5,32 @@ sealed interface AppRoute {
data class CreateBooking(val propertyId: String) : AppRoute
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data class GuestSignature(val propertyId: String, val bookingId: String, val guestId: String) : AppRoute
data class ManageRoomStaySelect(
val propertyId: String,
val bookingId: String,
val fromAt: String,
val toAt: String?
) : AppRoute
data class ManageRoomStayRates(
val propertyId: String,
val bookingId: String,
val fromAt: String,
val toAt: String?
) : AppRoute
data class ManageRoomStaySelectFromBooking(
val propertyId: String,
val bookingId: String,
val guestId: String,
val fromAt: String,
val toAt: String?
) : AppRoute
data class ManageRoomStayRatesFromBooking(
val propertyId: String,
val bookingId: String,
val guestId: String,
val fromAt: String,
val toAt: String?
) : AppRoute
data object AddProperty : AppRoute
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
data class Rooms(val propertyId: String) : AppRoute

View File

@@ -7,10 +7,17 @@ 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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -19,15 +26,19 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.BookingListItem
@Composable
@OptIn(ExperimentalMaterial3Api::class)
@@ -37,9 +48,11 @@ fun ActiveRoomStaysScreen(
onBack: () -> Unit,
onViewRooms: () -> Unit,
onCreateBooking: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit,
viewModel: ActiveRoomStaysViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
LaunchedEffect(propertyId) {
viewModel.load(propertyId)
@@ -88,6 +101,25 @@ fun ActiveRoomStaysScreen(
}
if (!state.isLoading && state.error == null) {
if (state.checkedInBookings.isNotEmpty()) {
Text(text = "Checked-in bookings", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.checkedInBookings) { booking ->
CheckedInBookingCard(
booking = booking,
onClick = { selectedBooking.value = booking }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
if (state.items.isEmpty()) {
Text(text = "No active room stays")
} else {
@@ -108,4 +140,70 @@ fun ActiveRoomStaysScreen(
}
}
}
selectedBooking.value?.let { booking ->
AlertDialog(
onDismissRequest = { selectedBooking.value = null },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
selectedBooking.value = null
onManageRoomStay(booking)
}
) {
Text("Manage room stay")
}
TextButton(onClick = { selectedBooking.value = null }) {
Text("Balance")
}
TextButton(onClick = { selectedBooking.value = null }) {
Text("Add photos")
}
TextButton(onClick = { selectedBooking.value = null }) {
Text("Checkout")
}
}
},
confirmButton = {},
dismissButton = {}
)
}
}
@Composable
private fun CheckedInBookingCard(
booking: BookingListItem,
onClick: () -> Unit
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.clickable(onClick = onClick)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = booking.id?.take(8)?.let { "Booking #$it" } ?: "Booking",
style = MaterialTheme.typography.titleSmall
)
val source = booking.source?.takeIf { it.isNotBlank() }
if (source != null) {
Text(text = source, style = MaterialTheme.typography.bodySmall)
}
val expectedCount = booking.expectedGuestCount
val totalCount = booking.totalGuestCount
val countLine = when {
expectedCount != null -> "Expected guests: $expectedCount"
totalCount != null -> "Guests: $totalCount"
else -> null
}
if (countLine != null) {
Text(text = countLine, style = MaterialTheme.typography.bodySmall)
}
val notes = booking.notes?.takeIf { it.isNotBlank() }
if (notes != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(text = notes, style = MaterialTheme.typography.bodySmall)
}
}
}
}

View File

@@ -1,9 +1,11 @@
package com.android.trisolarispms.ui.roomstay
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
import com.android.trisolarispms.data.api.model.BookingListItem
data class ActiveRoomStaysState(
val isLoading: Boolean = false,
val error: String? = null,
val items: List<ActiveRoomStayDto> = emptyList()
val items: List<ActiveRoomStayDto> = emptyList(),
val checkedInBookings: List<BookingListItem> = emptyList()
)

View File

@@ -18,17 +18,19 @@ class ActiveRoomStaysViewModel : ViewModel() {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listActiveRoomStays(propertyId)
if (response.isSuccessful) {
val activeResponse = api.listActiveRoomStays(propertyId)
val bookingsResponse = api.listBookings(propertyId, status = "CHECKED_IN")
if (activeResponse.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
items = response.body().orEmpty(),
items = activeResponse.body().orEmpty(),
checkedInBookings = bookingsResponse.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: ${activeResponse.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }

View File

@@ -0,0 +1,19 @@
package com.android.trisolarispms.ui.roomstay
data class ManageRoomStaySelection(
val roomId: String,
val roomNumber: Int,
val roomTypeName: String,
val averageRate: Double?,
val currency: String?,
val ratePlanCode: String?
)
data class ManageRoomStayRateItem(
val roomId: String,
val roomNumber: Int,
val roomTypeName: String,
val nightlyRate: Long,
val currency: String?,
val ratePlanCode: String?
)

View File

@@ -0,0 +1,172 @@
package com.android.trisolarispms.ui.roomstay
import androidx.compose.foundation.clickable
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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 androidx.compose.foundation.text.KeyboardOptions
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ManageRoomStayRatesScreen(
propertyId: String,
bookingId: String,
checkInAt: String,
checkOutAt: String?,
selectedRooms: List<ManageRoomStaySelection>,
onBack: () -> Unit,
onDone: () -> Unit,
viewModel: ManageRoomStayRatesViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val showTotalDialog = remember { mutableStateOf(false) }
val totalInput = remember { mutableStateOf("") }
LaunchedEffect(selectedRooms) {
viewModel.setItems(selectedRooms)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Selected Rooms") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
},
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val total = state.items.sumOf { it.nightlyRate }
Text(
text = "${total}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.clickable {
totalInput.value = total.takeIf { it > 0 }?.toString().orEmpty()
showTotalDialog.value = true
}
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
viewModel.submit(propertyId, bookingId, checkInAt, checkOutAt, onDone)
},
enabled = state.items.isNotEmpty() && !state.isLoading,
modifier = Modifier.fillMaxWidth(0.5f)
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.height(16.dp), strokeWidth = 2.dp)
} else {
Text("Update")
}
}
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.Top
) {
if (state.items.isEmpty()) {
Text(text = "No rooms selected.")
return@Column
}
state.items.forEach { item ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = "${item.roomNumber}", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = item.nightlyRate.toString(),
onValueChange = { viewModel.updateRate(item.roomId, it) },
label = { Text("Rate") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(0.5f)
)
}
Spacer(modifier = Modifier.height(12.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
if (showTotalDialog.value) {
AlertDialog(
onDismissRequest = { showTotalDialog.value = false },
title = { Text("Amount") },
text = {
OutlinedTextField(
value = totalInput.value,
onValueChange = { totalInput.value = it.filter { ch -> ch.isDigit() } },
label = { Text("Amount") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = {
val total = totalInput.value.toLongOrNull()
if (total != null) {
viewModel.applyTotal(total)
}
showTotalDialog.value = false
}
) {
Text("Update")
}
},
dismissButton = {
TextButton(onClick = { showTotalDialog.value = false }) {
Text("Cancel")
}
}
)
}
}

View File

@@ -0,0 +1,8 @@
package com.android.trisolarispms.ui.roomstay
data class ManageRoomStayRatesState(
val isLoading: Boolean = false,
val error: String? = null,
val items: List<ManageRoomStayRateItem> = emptyList(),
val lastTotal: Long? = null
)

View File

@@ -0,0 +1,113 @@
package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingBulkCheckInStayRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ManageRoomStayRatesViewModel : ViewModel() {
private val _state = MutableStateFlow(ManageRoomStayRatesState())
val state: StateFlow<ManageRoomStayRatesState> = _state
fun setItems(items: List<ManageRoomStaySelection>) {
val mapped = items.map { item ->
ManageRoomStayRateItem(
roomId = item.roomId,
roomNumber = item.roomNumber,
roomTypeName = item.roomTypeName,
nightlyRate = item.averageRate?.toLong() ?: 0L,
currency = item.currency,
ratePlanCode = item.ratePlanCode
)
}
_state.update { it.copy(items = mapped, error = null) }
}
fun updateRate(roomId: String, value: String) {
val rate = value.filter { it.isDigit() }.toLongOrNull() ?: 0L
_state.update { current ->
val updated = current.items.map {
if (it.roomId == roomId) it.copy(nightlyRate = rate) else it
}
current.copy(items = updated)
}
}
fun applyTotal(total: Long) {
if (total <= 0) return
_state.update { current ->
val items = current.items
if (items.isEmpty()) return@update current
val currentTotal = items.sumOf { it.nightlyRate }
val updated = if (currentTotal <= 0L) {
val base = total / items.size
val remainder = total - base * items.size
items.mapIndexed { index, item ->
val extra = if (index == items.lastIndex) remainder else 0L
item.copy(nightlyRate = base + extra)
}
} else {
var remaining = total
items.mapIndexed { index, item ->
val share = if (index == items.lastIndex) {
remaining
} else {
val portion = (item.nightlyRate.toDouble() / currentTotal.toDouble()) * total.toDouble()
val rounded = portion.toLong()
remaining -= rounded
rounded
}
item.copy(nightlyRate = share)
}
}
current.copy(items = updated, lastTotal = total)
}
}
fun submit(
propertyId: String,
bookingId: String,
checkInAt: String,
checkOutAt: String?,
onDone: () -> Unit
) {
if (propertyId.isBlank() || bookingId.isBlank() || checkInAt.isBlank()) return
val items = _state.value.items
if (items.isEmpty()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val stays = items.map { item ->
BookingBulkCheckInStayRequest(
roomId = item.roomId,
checkInAt = checkInAt,
checkOutAt = checkOutAt,
nightlyRate = item.nightlyRate,
rateSource = "MANUAL",
ratePlanCode = item.ratePlanCode,
currency = item.currency
)
}
val response = api.bulkCheckIn(
propertyId = propertyId,
bookingId = bookingId,
body = BookingBulkCheckInRequest(stays = stays)
)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") }
}
}
}
}

View File

@@ -0,0 +1,226 @@
package com.android.trisolarispms.ui.roomstay
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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 java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ManageRoomStaySelectScreen(
propertyId: String,
bookingFromAt: String,
bookingToAt: String?,
onBack: () -> Unit,
onNext: (List<ManageRoomStaySelection>) -> Unit,
viewModel: ManageRoomStaySelectViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val selectedRooms = remember { mutableStateListOf<ManageRoomStaySelection>() }
val fromDate = remember(bookingFromAt) { bookingFromAt.toDateOnly() }
val toDate = remember(bookingToAt) { bookingToAt?.toDateOnly() }
val fallbackToDate = remember(fromDate, toDate) {
if (toDate != null || fromDate == null) {
toDate
} else {
runCatching {
java.time.LocalDate.parse(fromDate)
.plusDays(1)
.format(DateTimeFormatter.ISO_LOCAL_DATE)
}.getOrNull()
}
}
LaunchedEffect(propertyId, fromDate, fallbackToDate) {
if (fromDate != null && fallbackToDate != null) {
viewModel.load(propertyId, from = fromDate, to = fallbackToDate)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Select Rooms") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
},
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
if (selectedRooms.isNotEmpty()) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(selectedRooms) { item ->
RoomChip(text = item.roomNumber.toString())
}
}
Spacer(modifier = Modifier.height(12.dp))
}
Button(
onClick = { onNext(selectedRooms.toList()) },
enabled = selectedRooms.isNotEmpty(),
modifier = Modifier.fillMaxWidth()
) {
Text("Proceed")
}
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(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 (fromDate == null || fallbackToDate == null) {
Text(text = "Booking dates not available.", color = MaterialTheme.colorScheme.error)
return@Column
}
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(state.rooms) { room ->
val selection = room.toSelection() ?: return@items
val isSelected = selectedRooms.any { it.roomId == selection.roomId }
RoomSelectCard(
item = selection,
isSelected = isSelected,
onToggle = {
if (isSelected) {
selectedRooms.removeAll { it.roomId == selection.roomId }
} else {
selectedRooms.add(selection)
}
}
)
}
}
}
}
}
@Composable
private fun RoomSelectCard(
item: ManageRoomStaySelection,
isSelected: Boolean,
onToggle: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.border(
width = 1.dp,
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
shape = MaterialTheme.shapes.medium
)
.background(
color = if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.08f) else Color.Transparent,
shape = MaterialTheme.shapes.medium
)
.clickable(onClick = onToggle)
.padding(12.dp)
) {
Text(
text = item.roomNumber.toString(),
style = MaterialTheme.typography.titleLarge
)
Text(
text = item.roomTypeName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = item.averageRate?.toLong()?.let { "${item.currency ?: ""} $it" } ?: "--",
style = MaterialTheme.typography.bodyMedium
)
}
}
@Composable
private fun RoomChip(text: String) {
Row(
modifier = Modifier
.border(1.dp, MaterialTheme.colorScheme.outline, MaterialTheme.shapes.large)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = text, style = MaterialTheme.typography.bodySmall)
}
}
private fun String.toDateOnly(): String? {
return runCatching {
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
)
}

View File

@@ -0,0 +1,9 @@
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()
)

View File

@@ -0,0 +1,38 @@
package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ManageRoomStaySelectViewModel : ViewModel() {
private val _state = MutableStateFlow(ManageRoomStaySelectState())
val state: StateFlow<ManageRoomStaySelectState> = _state
fun load(propertyId: String, from: String, to: String) {
if (propertyId.isBlank() || from.isBlank() || to.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
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 {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
}