Compare commits

...

2 Commits

Author SHA1 Message Date
androidlover5842
5f522ca3ab booking create: manage booking rates flow 2026-01-29 12:09:43 +05:30
androidlover5842
29065cee22 booking: ability to take signature 2026-01-29 10:36:49 +05:30
21 changed files with 1340 additions and 20 deletions

135
AGENTS.md
View File

@@ -46,10 +46,24 @@ Response
--- ---
### List bookings
GET /properties/{propertyId}/bookings
Optional query param:
- status (comma-separated), e.g. status=OPEN,CHECKED_IN
Behavior:
- If status is omitted, returns all bookings for the property (newest first).
Response: List of BookingListItem with id, status, guestId, source, times, counts, expectedGuestCount, notes.
---
### 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:
@@ -61,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)
@@ -70,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"
} }
@@ -81,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:
@@ -93,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)
@@ -103,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
@@ -296,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:
@@ -306,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"
} }
@@ -316,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

View File

@@ -17,6 +17,10 @@ import com.android.trisolarispms.ui.auth.NameScreen
import com.android.trisolarispms.ui.auth.UnauthorizedScreen 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.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
@@ -64,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
@@ -106,8 +111,37 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId, currentRoute.propertyId,
currentRoute.roomTypeId currentRoute.roomTypeId
) )
is AppRoute.CreateBooking -> route.value = AppRoute.Home is AppRoute.CreateBooking -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.GuestInfo -> route.value = AppRoute.Home is AppRoute.GuestInfo -> route.value = AppRoute.Home
is AppRoute.GuestSignature -> route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
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
)
} }
} }
@@ -151,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
} }
@@ -164,14 +207,136 @@ class MainActivity : ComponentActivity() {
initialGuest = selectedGuest.value, initialGuest = selectedGuest.value,
initialPhone = selectedGuestPhone.value, initialPhone = selectedGuestPhone.value,
onBack = { route.value = AppRoute.Home }, onBack = { route.value = AppRoute.Home },
onSave = { route.value = AppRoute.Home } onSave = {
route.value = AppRoute.GuestSignature(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
}
)
is AppRoute.GuestSignature -> GuestSignatureScreen(
propertyId = currentRoute.propertyId,
guestId = currentRoute.guestId,
onBack = {
route.value = AppRoute.GuestInfo(
currentRoute.propertyId,
currentRoute.bookingId,
currentRoute.guestId
)
},
onDone = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
) )
is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen( is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
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,

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.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,

View File

@@ -8,11 +8,14 @@ import com.android.trisolarispms.data.api.model.GuestUpdateRequest
import com.android.trisolarispms.data.api.model.GuestVisitCountResponse import com.android.trisolarispms.data.api.model.GuestVisitCountResponse
import com.android.trisolarispms.data.api.model.GuestVehicleDto import com.android.trisolarispms.data.api.model.GuestVehicleDto
import com.android.trisolarispms.data.api.model.GuestVehicleRequest import com.android.trisolarispms.data.api.model.GuestVehicleRequest
import okhttp3.MultipartBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
@@ -49,6 +52,14 @@ interface GuestApi {
@Path("guestId") guestId: String @Path("guestId") guestId: String
): Response<GuestDto> ): Response<GuestDto>
@Multipart
@POST("properties/{propertyId}/guests/{guestId}/signature")
suspend fun uploadSignature(
@Path("propertyId") propertyId: String,
@Path("guestId") guestId: String,
@Part file: MultipartBody.Part
): Response<Unit>
@POST("properties/{propertyId}/guests/{guestId}/vehicles") @POST("properties/{propertyId}/guests/{guestId}/vehicles")
suspend fun addGuestVehicle( suspend fun addGuestVehicle(
@Path("propertyId") propertyId: String, @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.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,

View File

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

View File

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

View File

@@ -4,6 +4,33 @@ sealed interface AppRoute {
data object Home : AppRoute data object Home : 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 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

View File

@@ -0,0 +1,195 @@
package com.android.trisolarispms.ui.guest
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
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.OutlinedButton
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import java.util.Locale
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun GuestSignatureScreen(
propertyId: String,
guestId: String,
onBack: () -> Unit,
onDone: () -> Unit,
viewModel: GuestSignatureViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val strokes = remember { mutableStateListOf<MutableList<Offset>>() }
val canvasSize = remember { mutableStateOf(IntSize.Zero) }
LaunchedEffect(guestId) {
viewModel.reset()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Guest Signature") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(
onClick = {
val svg = buildSignatureSvg(strokes, canvasSize.value)
if (!svg.isNullOrBlank()) {
viewModel.uploadSignature(propertyId, guestId, svg, onDone)
}
},
enabled = strokes.isNotEmpty() && !state.isLoading
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.Done, contentDescription = "Upload")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
Text(
text = "Please draw the guest signature below.",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(12.dp))
Canvas(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.border(1.dp, MaterialTheme.colorScheme.outline)
.clipToBounds()
.onSizeChanged { canvasSize.value = it }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val stroke = mutableStateListOf(down.position)
strokes.add(stroke)
drag(down.id) { change ->
stroke.add(change.position)
change.consume()
}
}
}
) {
val strokeColor = Color.Black
val strokeWidth = 3.dp.toPx()
strokes.forEach { stroke ->
if (stroke.size == 1) {
drawCircle(
color = strokeColor,
radius = strokeWidth / 2f,
center = stroke.first()
)
} else {
val path = Path()
path.moveTo(stroke.first().x, stroke.first().y)
for (i in 1 until stroke.size) {
val point = stroke[i]
path.lineTo(point.x, point.y)
}
drawPath(
path = path,
color = strokeColor,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round)
)
}
}
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { strokes.clear() },
enabled = strokes.isNotEmpty() && !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Clear")
}
}
}
}
private fun buildSignatureSvg(strokes: List<List<Offset>>, canvasSize: IntSize): String? {
if (strokes.isEmpty() || canvasSize.width <= 0 || canvasSize.height <= 0) return null
val width = canvasSize.width
val height = canvasSize.height
val sb = StringBuilder()
sb.append("""<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="$height" viewBox="0 0 $width $height">""")
strokes.forEach { stroke ->
if (stroke.isNotEmpty()) {
sb.append("<path d=\"")
stroke.forEachIndexed { index, point ->
val x = String.format(Locale.US, "%.2f", point.x)
val y = String.format(Locale.US, "%.2f", point.y)
if (index == 0) {
sb.append("M $x $y ")
} else {
sb.append("L $x $y ")
}
}
sb.append("\" fill=\"none\" stroke=\"#000000\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>")
}
}
sb.append("</svg>")
return sb.toString()
}

View File

@@ -0,0 +1,6 @@
package com.android.trisolarispms.ui.guest
data class GuestSignatureState(
val isLoading: Boolean = false,
val error: String? = null
)

View File

@@ -0,0 +1,46 @@
package com.android.trisolarispms.ui.guest
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
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
class GuestSignatureViewModel : ViewModel() {
private val _state = MutableStateFlow(GuestSignatureState())
val state: StateFlow<GuestSignatureState> = _state
fun reset() {
_state.value = GuestSignatureState()
}
fun uploadSignature(propertyId: String, guestId: String, svg: String, onDone: () -> Unit) {
if (propertyId.isBlank() || guestId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val requestBody = svg.toRequestBody("image/svg+xml".toMediaType())
val part = MultipartBody.Part.createFormData(
name = "file",
filename = "signature.svg",
body = requestBody
)
val response = api.uploadSignature(propertyId = propertyId, guestId = guestId, file = part)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, error = null) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Upload failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Upload failed") }
}
}
}
}

View File

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

View File

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

View File

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

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