booking create: manage booking rates flow
This commit is contained in:
121
AGENTS.md
121
AGENTS.md
@@ -63,7 +63,7 @@ Response: List of BookingListItem with id, status, guestId, source, times, count
|
|||||||
### Check-in (creates RoomStay)
|
### Check-in (creates RoomStay)
|
||||||
|
|
||||||
POST /properties/{propertyId}/bookings/{bookingId}/check-in
|
POST /properties/{propertyId}/bookings/{bookingId}/check-in
|
||||||
Auth: ADMIN/MANAGER/STAFF
|
Auth: ADMIN/MANAGER
|
||||||
|
|
||||||
Body
|
Body
|
||||||
Required:
|
Required:
|
||||||
@@ -75,7 +75,7 @@ Optional:
|
|||||||
- checkInAt (String)
|
- checkInAt (String)
|
||||||
- transportMode (String enum)
|
- transportMode (String enum)
|
||||||
- nightlyRate (Long)
|
- nightlyRate (Long)
|
||||||
- rateSource (PRESET|NEGOTIATED|OTA)
|
- rateSource (MANUAL|RATE_PLAN|OTA)
|
||||||
- ratePlanCode (String)
|
- ratePlanCode (String)
|
||||||
- currency (String)
|
- currency (String)
|
||||||
- notes (String)
|
- notes (String)
|
||||||
@@ -84,8 +84,8 @@ Optional:
|
|||||||
"roomIds": ["uuid1","uuid2"],
|
"roomIds": ["uuid1","uuid2"],
|
||||||
"checkInAt": "2026-01-28T12:00:00+05:30",
|
"checkInAt": "2026-01-28T12:00:00+05:30",
|
||||||
"nightlyRate": 2500,
|
"nightlyRate": 2500,
|
||||||
"rateSource": "NEGOTIATED",
|
"rateSource": "MANUAL",
|
||||||
"ratePlanCode": "WEEKEND",
|
"ratePlanCode": "EP",
|
||||||
"currency": "INR",
|
"currency": "INR",
|
||||||
"notes": "Late arrival"
|
"notes": "Late arrival"
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ Optional:
|
|||||||
### Pre-assign room stay
|
### Pre-assign room stay
|
||||||
|
|
||||||
POST /properties/{propertyId}/bookings/{bookingId}/room-stays
|
POST /properties/{propertyId}/bookings/{bookingId}/room-stays
|
||||||
Auth: ADMIN/MANAGER/STAFF
|
Auth: ADMIN/MANAGER
|
||||||
|
|
||||||
Body
|
Body
|
||||||
Required:
|
Required:
|
||||||
@@ -107,7 +107,7 @@ Required:
|
|||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- nightlyRate (Long)
|
- nightlyRate (Long)
|
||||||
- rateSource (PRESET|NEGOTIATED|OTA)
|
- rateSource (MANUAL|RATE_PLAN|OTA)
|
||||||
- ratePlanCode (String)
|
- ratePlanCode (String)
|
||||||
- currency (String)
|
- currency (String)
|
||||||
- notes (String)
|
- notes (String)
|
||||||
@@ -117,13 +117,63 @@ Optional:
|
|||||||
"fromAt": "2026-01-29T12:00:00+05:30",
|
"fromAt": "2026-01-29T12:00:00+05:30",
|
||||||
"toAt": "2026-01-30T10:00:00+05:30",
|
"toAt": "2026-01-30T10:00:00+05:30",
|
||||||
"nightlyRate": 2800,
|
"nightlyRate": 2800,
|
||||||
"rateSource": "PRESET",
|
"rateSource": "RATE_PLAN",
|
||||||
"ratePlanCode": "WEEKEND",
|
"ratePlanCode": "EP",
|
||||||
"currency": "INR"
|
"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
|
## 2) Guests
|
||||||
|
|
||||||
### Create guest + link to booking
|
### Create guest + link to booking
|
||||||
@@ -310,7 +360,7 @@ Required:
|
|||||||
|
|
||||||
- effectiveAt (String, ISO-8601)
|
- effectiveAt (String, ISO-8601)
|
||||||
- nightlyRate (Long)
|
- nightlyRate (Long)
|
||||||
- rateSource (PRESET|NEGOTIATED|OTA)
|
- rateSource (MANUAL|RATE_PLAN|OTA)
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
@@ -320,7 +370,7 @@ Optional:
|
|||||||
{
|
{
|
||||||
"effectiveAt": "2026-01-30T12:00:00+05:30",
|
"effectiveAt": "2026-01-30T12:00:00+05:30",
|
||||||
"nightlyRate": 2000,
|
"nightlyRate": 2000,
|
||||||
"rateSource": "NEGOTIATED",
|
"rateSource": "MANUAL",
|
||||||
"currency": "INR"
|
"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
|
## 6) Payments + Balance
|
||||||
|
|
||||||
### Add payment
|
### Add payment
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import com.android.trisolarispms.ui.auth.UnauthorizedScreen
|
|||||||
import com.android.trisolarispms.ui.booking.BookingCreateScreen
|
import com.android.trisolarispms.ui.booking.BookingCreateScreen
|
||||||
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.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.home.HomeScreen
|
||||||
import com.android.trisolarispms.ui.property.AddPropertyScreen
|
import com.android.trisolarispms.ui.property.AddPropertyScreen
|
||||||
import com.android.trisolarispms.ui.room.RoomFormScreen
|
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 selectedGuest = remember { mutableStateOf<com.android.trisolarispms.data.api.model.GuestDto?>(null) }
|
||||||
val selectedGuestPhone = remember { mutableStateOf<String?>(null) }
|
val selectedGuestPhone = remember { mutableStateOf<String?>(null) }
|
||||||
val selectedImageTag = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomImageTagDto?>(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 roomFormKey = remember { mutableStateOf(0) }
|
||||||
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
|
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
|
||||||
val currentRoute = route.value
|
val currentRoute = route.value
|
||||||
@@ -117,6 +121,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
currentRoute.bookingId,
|
currentRoute.bookingId,
|
||||||
currentRoute.guestId
|
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()
|
val guestId = (guest?.id ?: response.guestId).orEmpty()
|
||||||
selectedGuest.value = guest
|
selectedGuest.value = guest
|
||||||
selectedGuestPhone.value = phone
|
selectedGuestPhone.value = phone
|
||||||
if (bookingId.isNotBlank()) {
|
if (bookingId.isNotBlank() && guestId.isNotBlank()) {
|
||||||
route.value = AppRoute.GuestInfo(currentRoute.propertyId, bookingId, guestId)
|
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 {
|
} else {
|
||||||
route.value = AppRoute.Home
|
route.value = AppRoute.Home
|
||||||
}
|
}
|
||||||
@@ -203,7 +237,106 @@ class MainActivity : ComponentActivity() {
|
|||||||
propertyName = currentRoute.propertyName,
|
propertyName = currentRoute.propertyName,
|
||||||
onBack = { route.value = AppRoute.Home },
|
onBack = { route.value = AppRoute.Home },
|
||||||
onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
|
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(
|
is AppRoute.Rooms -> RoomsScreen(
|
||||||
propertyId = currentRoute.propertyId,
|
propertyId = currentRoute.propertyId,
|
||||||
|
|||||||
@@ -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.BookingLinkGuestRequest
|
||||||
import com.android.trisolarispms.data.api.model.BookingNoShowRequest
|
import com.android.trisolarispms.data.api.model.BookingNoShowRequest
|
||||||
import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest
|
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 com.android.trisolarispms.data.api.model.RoomStayDto
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface BookingApi {
|
interface BookingApi {
|
||||||
@POST("properties/{propertyId}/bookings")
|
@POST("properties/{propertyId}/bookings")
|
||||||
@@ -22,6 +26,19 @@ interface BookingApi {
|
|||||||
@Body body: BookingCreateRequest
|
@Body body: BookingCreateRequest
|
||||||
): Response<BookingCreateResponse>
|
): 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")
|
@POST("properties/{propertyId}/bookings/{bookingId}/link-guest")
|
||||||
suspend fun linkGuest(
|
suspend fun linkGuest(
|
||||||
@Path("propertyId") propertyId: String,
|
@Path("propertyId") propertyId: String,
|
||||||
|
|||||||
@@ -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.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
|
||||||
@@ -62,6 +63,14 @@ 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,
|
||||||
|
|||||||
@@ -30,6 +30,38 @@ data class BookingCreateResponse(
|
|||||||
val expectedCheckOutAt: String? = null
|
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(
|
data class BookingLinkGuestRequest(
|
||||||
val guestId: String
|
val guestId: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ data class RoomAvailabilityRangeResponse(
|
|||||||
val freeCount: Int? = null
|
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
|
// Images
|
||||||
|
|
||||||
data class ImageDto(
|
data class ImageDto(
|
||||||
|
|||||||
@@ -5,6 +5,32 @@ sealed interface AppRoute {
|
|||||||
data class CreateBooking(val propertyId: String) : AppRoute
|
data class CreateBooking(val propertyId: String) : AppRoute
|
||||||
data class GuestInfo(val propertyId: String, val bookingId: String, val guestId: 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 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 object AddProperty : AppRoute
|
||||||
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
|
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
|
||||||
data class Rooms(val propertyId: String) : AppRoute
|
data class Rooms(val propertyId: String) : AppRoute
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.MeetingRoom
|
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.FloatingActionButton
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -19,15 +26,19 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.android.trisolarispms.data.api.model.BookingListItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -37,9 +48,11 @@ fun ActiveRoomStaysScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onViewRooms: () -> Unit,
|
onViewRooms: () -> Unit,
|
||||||
onCreateBooking: () -> Unit,
|
onCreateBooking: () -> Unit,
|
||||||
|
onManageRoomStay: (BookingListItem) -> Unit,
|
||||||
viewModel: ActiveRoomStaysViewModel = viewModel()
|
viewModel: ActiveRoomStaysViewModel = viewModel()
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
|
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(propertyId) {
|
LaunchedEffect(propertyId) {
|
||||||
viewModel.load(propertyId)
|
viewModel.load(propertyId)
|
||||||
@@ -88,6 +101,25 @@ fun ActiveRoomStaysScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!state.isLoading && state.error == null) {
|
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()) {
|
if (state.items.isEmpty()) {
|
||||||
Text(text = "No active room stays")
|
Text(text = "No active room stays")
|
||||||
} else {
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.android.trisolarispms.ui.roomstay
|
package com.android.trisolarispms.ui.roomstay
|
||||||
|
|
||||||
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
|
||||||
|
import com.android.trisolarispms.data.api.model.BookingListItem
|
||||||
|
|
||||||
data class ActiveRoomStaysState(
|
data class ActiveRoomStaysState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val items: List<ActiveRoomStayDto> = emptyList()
|
val items: List<ActiveRoomStayDto> = emptyList(),
|
||||||
|
val checkedInBookings: List<BookingListItem> = emptyList()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,17 +18,19 @@ class ActiveRoomStaysViewModel : ViewModel() {
|
|||||||
_state.update { it.copy(isLoading = true, error = null) }
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
try {
|
||||||
val api = ApiClient.create()
|
val api = ApiClient.create()
|
||||||
val response = api.listActiveRoomStays(propertyId)
|
val activeResponse = api.listActiveRoomStays(propertyId)
|
||||||
if (response.isSuccessful) {
|
val bookingsResponse = api.listBookings(propertyId, status = "CHECKED_IN")
|
||||||
|
if (activeResponse.isSuccessful) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items = response.body().orEmpty(),
|
items = activeResponse.body().orEmpty(),
|
||||||
|
checkedInBookings = bookingsResponse.body().orEmpty(),
|
||||||
error = null
|
error = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
} catch (e: Exception) {
|
||||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
|
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
|
||||||
|
|||||||
@@ -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?
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user