Add rate plan calendar screen
This commit is contained in:
369
AGENTS.md
Normal file
369
AGENTS.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# TrisolarisPMS API Usage
|
||||
|
||||
## 1) Booking
|
||||
|
||||
### Create booking
|
||||
|
||||
POST /properties/{propertyId}/bookings
|
||||
Auth: ADMIN/MANAGER/STAFF
|
||||
|
||||
Body (JSON)
|
||||
Required:
|
||||
|
||||
- expectedCheckInAt (String, ISO-8601, required)
|
||||
- expectedCheckOutAt (String, ISO-8601, required)
|
||||
|
||||
Optional:
|
||||
|
||||
- source (String, default "WALKIN")
|
||||
- transportMode (String enum)
|
||||
- adultCount (Int)
|
||||
- totalGuestCount (Int)
|
||||
- notes (String)
|
||||
|
||||
{
|
||||
"source": "WALKIN",
|
||||
"expectedCheckInAt": "2026-01-28T12:00:00+05:30",
|
||||
"expectedCheckOutAt": "2026-01-29T10:00:00+05:30",
|
||||
"transportMode": "CAR",
|
||||
"adultCount": 2,
|
||||
"totalGuestCount": 3,
|
||||
"notes": "Late arrival"
|
||||
}
|
||||
|
||||
Behavior
|
||||
If expectedCheckInAt >= now(property timezone) -> booking becomes CHECKED_IN, and checkinAt is set, expected fields are null.
|
||||
|
||||
Response
|
||||
|
||||
{
|
||||
"id": "uuid",
|
||||
"status": "OPEN|CHECKED_IN",
|
||||
"checkInAt": "2026-01-28T12:00:00+05:30" | null,
|
||||
"expectedCheckInAt": "..." | null,
|
||||
"expectedCheckOutAt": "..." | null
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
### Check-in (creates RoomStay)
|
||||
|
||||
POST /properties/{propertyId}/bookings/{bookingId}/check-in
|
||||
Auth: ADMIN/MANAGER/STAFF
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- roomIds (List<UUID>)
|
||||
|
||||
Optional:
|
||||
|
||||
- checkInAt (String)
|
||||
- transportMode (String enum)
|
||||
- nightlyRate (Long)
|
||||
- rateSource (PRESET|NEGOTIATED|OTA)
|
||||
- ratePlanCode (String)
|
||||
- currency (String)
|
||||
- notes (String)
|
||||
|
||||
{
|
||||
"roomIds": ["uuid1","uuid2"],
|
||||
"checkInAt": "2026-01-28T12:00:00+05:30",
|
||||
"nightlyRate": 2500,
|
||||
"rateSource": "NEGOTIATED",
|
||||
"ratePlanCode": "WEEKEND",
|
||||
"currency": "INR",
|
||||
"notes": "Late arrival"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
### Pre-assign room stay
|
||||
|
||||
POST /properties/{propertyId}/bookings/{bookingId}/room-stays
|
||||
Auth: ADMIN/MANAGER/STAFF
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- roomId (UUID)
|
||||
- fromAt (String)
|
||||
- toAt (String)
|
||||
|
||||
Optional:
|
||||
|
||||
- nightlyRate (Long)
|
||||
- rateSource (PRESET|NEGOTIATED|OTA)
|
||||
- ratePlanCode (String)
|
||||
- currency (String)
|
||||
- notes (String)
|
||||
|
||||
{
|
||||
"roomId": "uuid",
|
||||
"fromAt": "2026-01-29T12:00:00+05:30",
|
||||
"toAt": "2026-01-30T10:00:00+05:30",
|
||||
"nightlyRate": 2800,
|
||||
"rateSource": "PRESET",
|
||||
"ratePlanCode": "WEEKEND",
|
||||
"currency": "INR"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
## 2) Guests
|
||||
|
||||
### Create guest + link to booking
|
||||
|
||||
POST /properties/{propertyId}/guests
|
||||
Auth: property member
|
||||
Body (required):
|
||||
|
||||
- phoneE164 (String)
|
||||
- bookingId (UUID)
|
||||
|
||||
Optional:
|
||||
|
||||
- name (String)
|
||||
- nationality (String)
|
||||
- addressText (String)
|
||||
|
||||
{
|
||||
"phoneE164": "+911111111111",
|
||||
"bookingId": "uuid",
|
||||
"name": "John",
|
||||
"nationality": "IN",
|
||||
"addressText": "Varanasi"
|
||||
}
|
||||
|
||||
Behavior:
|
||||
|
||||
- If phone already exists -> links existing guest to booking and returns it.
|
||||
- If booking already has a guest -> 409.
|
||||
|
||||
Response (GuestResponse)
|
||||
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "John",
|
||||
"phoneE164": "+911111111111",
|
||||
"nationality": "IN",
|
||||
"addressText": "Varanasi",
|
||||
"signatureUrl": "/properties/{propertyId}/guests/{guestId}/signature/file",
|
||||
"vehicleNumbers": [],
|
||||
"averageScore": null
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
### Add guest vehicle + link to booking
|
||||
|
||||
POST /properties/{propertyId}/guests/{guestId}/vehicles
|
||||
Auth: property member
|
||||
Body:
|
||||
|
||||
{ "vehicleNumber": "UP32AB1234", "bookingId": "uuid" }
|
||||
|
||||
---
|
||||
|
||||
### Upload signature (SVG only)
|
||||
|
||||
POST /properties/{propertyId}/guests/{guestId}/signature
|
||||
Auth: ADMIN/MANAGER
|
||||
Multipart:
|
||||
|
||||
- file (SVG)
|
||||
|
||||
---
|
||||
|
||||
### Download signature
|
||||
|
||||
GET /properties/{propertyId}/guests/{guestId}/signature/file
|
||||
Auth: property member
|
||||
Returns image/svg+xml.
|
||||
|
||||
---
|
||||
|
||||
## 3) Room Types (default rate + rate resolve)
|
||||
|
||||
### Room type create/update
|
||||
|
||||
Fields now include defaultRate:
|
||||
|
||||
RoomTypeUpsertRequest
|
||||
|
||||
{
|
||||
"code": "DELUX",
|
||||
"name": "Deluxe",
|
||||
"baseOccupancy": 2,
|
||||
"maxOccupancy": 3,
|
||||
"sqFeet": 150,
|
||||
"bathroomSqFeet": 30,
|
||||
"defaultRate": 2500,
|
||||
"active": true,
|
||||
"otaAliases": [],
|
||||
"amenityIds": []
|
||||
}
|
||||
|
||||
### Resolve preset rate for date
|
||||
|
||||
GET /properties/{propertyId}/room-types/{roomTypeCode}/rate?date=YYYY-MM-DD&ratePlanCode=optional
|
||||
Auth: public if no auth, or member
|
||||
|
||||
Response
|
||||
|
||||
{
|
||||
"roomTypeCode": "DELUX",
|
||||
"rateDate": "2026-02-01",
|
||||
"rate": 2800,
|
||||
"currency": "INR",
|
||||
"ratePlanCode": "WEEKEND"
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
## 4) Rate Plans + Calendar
|
||||
|
||||
### Create rate plan
|
||||
|
||||
POST /properties/{propertyId}/rate-plans
|
||||
Auth: ADMIN/MANAGER
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- code (String)
|
||||
- name (String)
|
||||
- roomTypeCode (String)
|
||||
- baseRate (Long)
|
||||
|
||||
Optional:
|
||||
|
||||
- currency (String, default property currency)
|
||||
|
||||
{ "code":"WEEKEND", "name":"Weekend", "roomTypeCode":"DELUX", "baseRate":2800, "currency":"INR" }
|
||||
|
||||
Response RatePlanResponse
|
||||
|
||||
### List plans
|
||||
|
||||
GET /properties/{propertyId}/rate-plans?roomTypeCode=optional
|
||||
Auth: member
|
||||
|
||||
### Update
|
||||
|
||||
PUT /properties/{propertyId}/rate-plans/{ratePlanId}
|
||||
Body:
|
||||
|
||||
{ "name":"Weekend", "baseRate":3000, "currency":"INR" }
|
||||
|
||||
### Delete
|
||||
|
||||
DELETE /properties/{propertyId}/rate-plans/{ratePlanId}
|
||||
|
||||
### Calendar upsert (batch)
|
||||
|
||||
POST /properties/{propertyId}/rate-plans/{ratePlanId}/calendar
|
||||
Body: Array
|
||||
|
||||
[
|
||||
{ "rateDate":"2026-02-01", "rate":3200 },
|
||||
{ "rateDate":"2026-02-02", "rate":3500 }
|
||||
]
|
||||
|
||||
### Calendar list
|
||||
|
||||
GET /properties/{propertyId}/rate-plans/{ratePlanId}/calendar?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||
|
||||
### Calendar delete
|
||||
|
||||
DELETE /properties/{propertyId}/rate-plans/{ratePlanId}/calendar/{rateDate}
|
||||
|
||||
---
|
||||
|
||||
## 5) RoomStay rate change (mid-stay renegotiation)
|
||||
|
||||
POST /properties/{propertyId}/room-stays/{roomStayId}/change-rate
|
||||
Auth: ADMIN/MANAGER
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- effectiveAt (String, ISO-8601)
|
||||
- nightlyRate (Long)
|
||||
- rateSource (PRESET|NEGOTIATED|OTA)
|
||||
|
||||
Optional:
|
||||
|
||||
- ratePlanCode (String)
|
||||
- currency (String)
|
||||
|
||||
{
|
||||
"effectiveAt": "2026-01-30T12:00:00+05:30",
|
||||
"nightlyRate": 2000,
|
||||
"rateSource": "NEGOTIATED",
|
||||
"currency": "INR"
|
||||
}
|
||||
|
||||
Response
|
||||
|
||||
{ "oldRoomStayId":"uuid", "newRoomStayId":"uuid", "effectiveAt":"..." }
|
||||
|
||||
---
|
||||
|
||||
## 6) Payments + Balance
|
||||
|
||||
### Add payment
|
||||
|
||||
POST /properties/{propertyId}/bookings/{bookingId}/payments
|
||||
Auth: ADMIN/MANAGER/STAFF
|
||||
|
||||
Body
|
||||
Required:
|
||||
|
||||
- amount (Long)
|
||||
- method (CASH|CARD|UPI|BANK|ONLINE)
|
||||
|
||||
Optional:
|
||||
|
||||
- currency (String, default property currency)
|
||||
- reference (String)
|
||||
- notes (String)
|
||||
- receivedAt (String)
|
||||
|
||||
{
|
||||
"amount": 1200,
|
||||
"method": "CASH",
|
||||
"currency": "INR",
|
||||
"reference": "RCP-123",
|
||||
"notes": "Advance"
|
||||
}
|
||||
|
||||
Response
|
||||
|
||||
{
|
||||
"id":"uuid",
|
||||
"bookingId":"uuid",
|
||||
"amount":1200,
|
||||
"currency":"INR",
|
||||
"method":"CASH",
|
||||
"reference":"RCP-123",
|
||||
"notes":"Advance",
|
||||
"receivedAt":"2026-01-28T12:00:00+05:30",
|
||||
"receivedByUserId":"uuid"
|
||||
}
|
||||
|
||||
### List payments
|
||||
|
||||
GET /properties/{propertyId}/bookings/{bookingId}/payments
|
||||
|
||||
### Booking balance
|
||||
|
||||
GET /properties/{propertyId}/bookings/{bookingId}/balance
|
||||
|
||||
{ "expectedPay": 2745, "amountCollected": 1200, "pending": 1545 }
|
||||
|
||||
---
|
||||
|
||||
## 7) Compose Notes
|
||||
|
||||
- Use `androidx.compose.foundation.text.KeyboardOptions` for keyboard options imports.
|
||||
@@ -12,7 +12,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.android.trisolarispms"
|
||||
minSdk = 23
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
@@ -57,6 +57,7 @@ dependencies {
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.lottie.compose)
|
||||
implementation(libs.calendar.compose)
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.auth.ktx)
|
||||
implementation(libs.kotlinx.coroutines.play.services)
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
|
||||
import com.android.trisolarispms.ui.roomtype.AmenitiesScreen
|
||||
import com.android.trisolarispms.ui.roomtype.EditAmenityScreen
|
||||
import com.android.trisolarispms.ui.roomtype.EditRoomTypeScreen
|
||||
import com.android.trisolarispms.ui.roomtype.RatePlanCalendarScreen
|
||||
import com.android.trisolarispms.ui.roomtype.RoomTypesScreen
|
||||
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
|
||||
|
||||
@@ -65,6 +66,11 @@ class MainActivity : ComponentActivity() {
|
||||
val canManageProperty: (String) -> Boolean = { propertyId ->
|
||||
state.isSuperAdmin || (state.propertyRoles[propertyId]?.contains("ADMIN") == true)
|
||||
}
|
||||
val canViewCardInfo: (String) -> Boolean = { propertyId ->
|
||||
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
|
||||
it == "ADMIN" || it == "MANAGER" || it == "STAFF"
|
||||
} == true
|
||||
}
|
||||
|
||||
BackHandler(enabled = currentRoute != AppRoute.Home) {
|
||||
when (currentRoute) {
|
||||
@@ -92,6 +98,10 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
is AppRoute.IssueTemporaryCard -> route.value = AppRoute.Rooms(currentRoute.propertyId)
|
||||
is AppRoute.CardInfo -> route.value = AppRoute.Rooms(currentRoute.propertyId)
|
||||
is AppRoute.RatePlanCalendar -> route.value = AppRoute.EditRoomType(
|
||||
currentRoute.propertyId,
|
||||
currentRoute.roomTypeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +153,7 @@ class MainActivity : ComponentActivity() {
|
||||
onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
|
||||
onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) },
|
||||
canManageRooms = canManageProperty(currentRoute.propertyId),
|
||||
canViewCardInfo = canViewCardInfo(currentRoute.propertyId),
|
||||
onEditRoom = {
|
||||
selectedRoom.value = it
|
||||
roomFormKey.value++
|
||||
@@ -175,7 +186,15 @@ class MainActivity : ComponentActivity() {
|
||||
roomType = selectedRoomType.value
|
||||
?: com.android.trisolarispms.data.api.model.RoomTypeDto(id = currentRoute.roomTypeId, code = "", name = ""),
|
||||
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
|
||||
onSave = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }
|
||||
onSave = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
|
||||
onOpenRatePlanCalendar = { ratePlanId, ratePlanCode ->
|
||||
route.value = AppRoute.RatePlanCalendar(
|
||||
currentRoute.propertyId,
|
||||
currentRoute.roomTypeId,
|
||||
ratePlanId,
|
||||
ratePlanCode
|
||||
)
|
||||
}
|
||||
)
|
||||
AppRoute.Amenities -> AmenitiesScreen(
|
||||
onBack = { route.value = amenitiesReturnRoute.value },
|
||||
@@ -246,6 +265,12 @@ class MainActivity : ComponentActivity() {
|
||||
propertyId = currentRoute.propertyId,
|
||||
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }
|
||||
)
|
||||
is AppRoute.RatePlanCalendar -> RatePlanCalendarScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
ratePlanId = currentRoute.ratePlanId,
|
||||
ratePlanCode = currentRoute.ratePlanCode,
|
||||
onBack = { route.value = AppRoute.EditRoomType(currentRoute.propertyId, currentRoute.roomTypeId) }
|
||||
)
|
||||
is AppRoute.RoomImages -> RoomImagesScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
roomId = currentRoute.roomId,
|
||||
|
||||
@@ -14,4 +14,5 @@ interface ApiService :
|
||||
GuestDocumentApi,
|
||||
TransportApi,
|
||||
InboundEmailApi,
|
||||
AmenityApi
|
||||
AmenityApi,
|
||||
RatePlanApi
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.android.trisolarispms.data.api
|
||||
|
||||
import com.android.trisolarispms.data.api.model.RatePlanCalendarEntry
|
||||
import com.android.trisolarispms.data.api.model.RatePlanCalendarUpsertRequest
|
||||
import com.android.trisolarispms.data.api.model.RatePlanRequest
|
||||
import com.android.trisolarispms.data.api.model.RatePlanResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface RatePlanApi {
|
||||
@POST("properties/{propertyId}/rate-plans")
|
||||
suspend fun createRatePlan(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Body body: RatePlanRequest
|
||||
): Response<RatePlanResponse>
|
||||
|
||||
@GET("properties/{propertyId}/rate-plans")
|
||||
suspend fun listRatePlans(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Query("roomTypeCode") roomTypeCode: String? = null
|
||||
): Response<List<RatePlanResponse>>
|
||||
|
||||
@PUT("properties/{propertyId}/rate-plans/{ratePlanId}")
|
||||
suspend fun updateRatePlan(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String,
|
||||
@Body body: RatePlanRequest
|
||||
): Response<RatePlanResponse>
|
||||
|
||||
@DELETE("properties/{propertyId}/rate-plans/{ratePlanId}")
|
||||
suspend fun deleteRatePlan(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String
|
||||
): Response<Unit>
|
||||
|
||||
@POST("properties/{propertyId}/rate-plans/{ratePlanId}/calendar")
|
||||
suspend fun upsertRatePlanCalendar(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String,
|
||||
@Body body: RatePlanCalendarUpsertRequest
|
||||
): Response<Unit>
|
||||
|
||||
@GET("properties/{propertyId}/rate-plans/{ratePlanId}/calendar")
|
||||
suspend fun listRatePlanCalendar(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String,
|
||||
@Query("from") from: String,
|
||||
@Query("to") to: String
|
||||
): Response<List<RatePlanCalendarEntry>>
|
||||
|
||||
@DELETE("properties/{propertyId}/rate-plans/{ratePlanId}/calendar/{rateDate}")
|
||||
suspend fun deleteRatePlanCalendarEntry(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("ratePlanId") ratePlanId: String,
|
||||
@Path("rateDate") rateDate: String
|
||||
): Response<Unit>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class RatePlanRequest(
|
||||
val code: String? = null,
|
||||
val name: String? = null,
|
||||
val roomTypeCode: String? = null,
|
||||
val baseRate: Long? = null,
|
||||
val currency: String? = null
|
||||
)
|
||||
|
||||
data class RatePlanResponse(
|
||||
val id: String? = null,
|
||||
val propertyId: String? = null,
|
||||
val code: String? = null,
|
||||
val name: String? = null,
|
||||
val roomTypeCode: String? = null,
|
||||
val baseRate: Long? = null,
|
||||
val currency: String? = null
|
||||
)
|
||||
|
||||
data class RatePlanCalendarEntry(
|
||||
val rateDate: String,
|
||||
val rate: Long
|
||||
)
|
||||
|
||||
data class RatePlanCalendarUpsertRequest(
|
||||
val from: String,
|
||||
val to: String,
|
||||
val rate: Long
|
||||
)
|
||||
@@ -12,6 +12,12 @@ sealed interface AppRoute {
|
||||
data class RoomTypes(val propertyId: String) : AppRoute
|
||||
data class AddRoomType(val propertyId: String) : AppRoute
|
||||
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute
|
||||
data class RatePlanCalendar(
|
||||
val propertyId: String,
|
||||
val roomTypeId: String,
|
||||
val ratePlanId: String,
|
||||
val ratePlanCode: String
|
||||
) : AppRoute
|
||||
data object Amenities : AppRoute
|
||||
data object AddAmenity : AppRoute
|
||||
data class EditAmenity(val amenityId: String) : AppRoute
|
||||
|
||||
@@ -40,6 +40,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import android.nfc.NfcAdapter
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,6 +60,7 @@ fun RoomsScreen(
|
||||
onViewRoomTypes: () -> Unit,
|
||||
onViewCardInfo: () -> Unit,
|
||||
canManageRooms: Boolean,
|
||||
canViewCardInfo: Boolean,
|
||||
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
||||
onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
||||
viewModel: RoomListViewModel = viewModel(),
|
||||
@@ -66,6 +69,9 @@ fun RoomsScreen(
|
||||
val state by viewModel.state.collectAsState()
|
||||
val roomTypeState by roomTypeListViewModel.state.collectAsState()
|
||||
val showTypeMenu = remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val nfcAdapter = NfcAdapter.getDefaultAdapter(context)
|
||||
val nfcSupported = nfcAdapter != null
|
||||
|
||||
LaunchedEffect(propertyId) {
|
||||
viewModel.load(propertyId, showAll = false)
|
||||
@@ -86,8 +92,10 @@ fun RoomsScreen(
|
||||
IconButton(onClick = onViewRoomTypes) {
|
||||
Icon(Icons.Default.Category, contentDescription = "Room Types")
|
||||
}
|
||||
IconButton(onClick = onViewCardInfo) {
|
||||
Icon(Icons.Default.CreditCard, contentDescription = "Card Info")
|
||||
if (nfcSupported && canViewCardInfo) {
|
||||
IconButton(onClick = onViewCardInfo) {
|
||||
Icon(Icons.Default.CreditCard, contentDescription = "Card Info")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onAddRoom) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add Room")
|
||||
@@ -185,7 +193,10 @@ fun RoomsScreen(
|
||||
.combinedClickable(
|
||||
enabled = room.id != null,
|
||||
onClick = {
|
||||
if (room.hasNfc != false && room.tempCardActive != true) {
|
||||
if (nfcSupported &&
|
||||
room.hasNfc != false &&
|
||||
room.tempCardActive != true
|
||||
) {
|
||||
onIssueTemporaryCard(room)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
@@ -81,23 +83,25 @@ fun ImageTagsScreen(
|
||||
if (state.tags.isEmpty()) {
|
||||
Text(text = "No tags")
|
||||
} else {
|
||||
state.tags.forEach { tag ->
|
||||
androidx.compose.foundation.layout.Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = tag.name ?: "",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(state.tags) { tag ->
|
||||
androidx.compose.foundation.layout.Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable(enabled = tag.id != null) { onEdit(tag) }
|
||||
)
|
||||
if (!tag.id.isNullOrBlank()) {
|
||||
IconButton(onClick = { viewModel.delete(tag.id) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete Tag")
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = tag.name ?: "",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clickable(enabled = tag.id != null) { onEdit(tag) }
|
||||
)
|
||||
if (!tag.id.isNullOrBlank()) {
|
||||
IconButton(onClick = { viewModel.delete(tag.id) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete Tag")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ fun EditRoomTypeScreen(
|
||||
roomType: RoomTypeDto,
|
||||
onBack: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onOpenRatePlanCalendar: (String, String) -> Unit,
|
||||
viewModel: RoomTypeFormViewModel = viewModel(),
|
||||
amenityViewModel: AmenityListViewModel = viewModel(),
|
||||
roomImageViewModel: RoomImageViewModel = viewModel()
|
||||
@@ -91,6 +92,18 @@ fun EditRoomTypeScreen(
|
||||
viewModel = viewModel,
|
||||
amenityViewModel = amenityViewModel
|
||||
) {
|
||||
RatePlanSection(
|
||||
propertyId = propertyId,
|
||||
roomTypeCode = roomType.code.orEmpty(),
|
||||
onOpenCalendar = { plan ->
|
||||
val id = plan.id.orEmpty()
|
||||
val code = plan.code.orEmpty()
|
||||
if (id.isNotBlank() && code.isNotBlank()) {
|
||||
onOpenRatePlanCalendar(id, code)
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.android.trisolarispms.ui.roomtype
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.kizitonwose.calendar.compose.HorizontalCalendar
|
||||
import com.kizitonwose.calendar.compose.rememberCalendarState
|
||||
import com.kizitonwose.calendar.core.CalendarDay
|
||||
import com.kizitonwose.calendar.core.CalendarMonth
|
||||
import com.kizitonwose.calendar.core.DayPosition
|
||||
import com.kizitonwose.calendar.core.daysOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun RatePlanCalendarScreen(
|
||||
propertyId: String,
|
||||
ratePlanId: String,
|
||||
ratePlanCode: String,
|
||||
onBack: () -> Unit,
|
||||
viewModel: RatePlanViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val today = remember { LocalDate.now() }
|
||||
val currentMonth = remember { YearMonth.from(today) }
|
||||
val startMonth = remember { currentMonth }
|
||||
val endMonth = remember { currentMonth.plusMonths(60) }
|
||||
val daysOfWeek = remember { daysOfWeek() }
|
||||
val calendarState = rememberCalendarState(
|
||||
startMonth = startMonth,
|
||||
endMonth = endMonth,
|
||||
firstVisibleMonth = currentMonth,
|
||||
firstDayOfWeek = daysOfWeek.first()
|
||||
)
|
||||
val selectionStart = remember { mutableStateOf<LocalDate?>(null) }
|
||||
val selectionEnd = remember { mutableStateOf<LocalDate?>(null) }
|
||||
val rateInput = remember { mutableStateOf("") }
|
||||
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
|
||||
|
||||
val calendarEntries = remember(state.calendarByPlanId, ratePlanId) {
|
||||
state.calendarByPlanId[ratePlanId].orEmpty()
|
||||
}
|
||||
val rateByDate = remember(calendarEntries) {
|
||||
calendarEntries.associateBy { it.rateDate }
|
||||
}
|
||||
|
||||
val visibleMonth = calendarState.firstVisibleMonth.yearMonth
|
||||
LaunchedEffect(propertyId, ratePlanId, visibleMonth) {
|
||||
val from = visibleMonth.atDay(1).format(dateFormatter)
|
||||
val to = visibleMonth.atEndOfMonth().format(dateFormatter)
|
||||
viewModel.loadCalendar(propertyId, ratePlanId, from, to)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Calendar: $ratePlanCode") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors()
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
if (state.calendarLoading) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
state.calendarError?.let {
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
DaysOfWeekHeader(daysOfWeek)
|
||||
Text(
|
||||
text = "Past dates disabled",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
HorizontalCalendar(
|
||||
state = calendarState,
|
||||
dayContent = { day ->
|
||||
val dateKey = day.date.format(dateFormatter)
|
||||
val entry = rateByDate[dateKey]
|
||||
val start = selectionStart.value
|
||||
val end = selectionEnd.value
|
||||
val inRange = start != null && end != null &&
|
||||
(day.date == start || day.date == end ||
|
||||
(day.date.isAfter(start) && day.date.isBefore(end)))
|
||||
val selectable = day.position == DayPosition.MonthDate && !day.date.isBefore(today)
|
||||
DayCell(
|
||||
day = day,
|
||||
isSelectedStart = start == day.date,
|
||||
isSelectedEnd = end == day.date,
|
||||
isInRange = inRange,
|
||||
hasRate = entry != null,
|
||||
isSelectable = selectable,
|
||||
onClick = {
|
||||
if (selectable) {
|
||||
val currentStart = selectionStart.value
|
||||
val currentEnd = selectionEnd.value
|
||||
when {
|
||||
currentStart == null || currentEnd != null -> {
|
||||
selectionStart.value = day.date
|
||||
selectionEnd.value = null
|
||||
}
|
||||
day.date.isBefore(currentStart) -> {
|
||||
selectionStart.value = day.date
|
||||
selectionEnd.value = null
|
||||
}
|
||||
else -> {
|
||||
selectionEnd.value = day.date
|
||||
}
|
||||
}
|
||||
val rateEntry = rateByDate[day.date.format(dateFormatter)]
|
||||
rateInput.value = rateEntry?.rate?.toString().orEmpty()
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
monthHeader = { month ->
|
||||
MonthHeader(month)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
val start = selectionStart.value
|
||||
val end = selectionEnd.value ?: selectionStart.value
|
||||
Text(
|
||||
text = if (start != null && end != null) {
|
||||
"${start.format(dateFormatter)} -> ${end.format(dateFormatter)}"
|
||||
} else {
|
||||
"Select a date range"
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = rateInput.value,
|
||||
onValueChange = { rateInput.value = it },
|
||||
label = { Text("Rate") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val startDate = selectionStart.value ?: return@TextButton
|
||||
val endDate = selectionEnd.value ?: selectionStart.value ?: return@TextButton
|
||||
val rate = rateInput.value.toLongOrNull() ?: return@TextButton
|
||||
val from = startDate.format(dateFormatter)
|
||||
val to = endDate.format(dateFormatter)
|
||||
viewModel.upsertCalendar(propertyId, ratePlanId, from, to, rate) {
|
||||
val month = YearMonth.from(startDate)
|
||||
val from = month.atDay(1).format(dateFormatter)
|
||||
val to = month.atEndOfMonth().format(dateFormatter)
|
||||
viewModel.loadCalendar(propertyId, ratePlanId, from, to)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
val date = selectionStart.value ?: return@TextButton
|
||||
val key = date.format(dateFormatter)
|
||||
viewModel.deleteCalendarEntry(propertyId, ratePlanId, key) {
|
||||
val month = YearMonth.from(date)
|
||||
val from = month.atDay(1).format(dateFormatter)
|
||||
val to = month.atEndOfMonth().format(dateFormatter)
|
||||
viewModel.loadCalendar(propertyId, ratePlanId, from, to)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
if (calendarEntries.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Rates loaded: ${calendarEntries.size}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DaysOfWeekHeader(daysOfWeek: List<java.time.DayOfWeek>) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
daysOfWeek.forEach { day ->
|
||||
Text(
|
||||
text = day.name.take(3),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthHeader(month: CalendarMonth) {
|
||||
Text(
|
||||
text = "${month.yearMonth.month.name.lowercase().replaceFirstChar { it.titlecase() }} ${month.yearMonth.year}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayCell(
|
||||
day: CalendarDay,
|
||||
isSelectedStart: Boolean,
|
||||
isSelectedEnd: Boolean,
|
||||
isInRange: Boolean,
|
||||
hasRate: Boolean,
|
||||
isSelectable: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val isInMonth = day.position == DayPosition.MonthDate
|
||||
val background = when {
|
||||
isSelectedStart || isSelectedEnd -> MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
|
||||
isInRange -> MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
|
||||
hasRate -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val textColor = when {
|
||||
!isInMonth -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
!isSelectable -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.padding(2.dp)
|
||||
.background(background, shape = MaterialTheme.shapes.small)
|
||||
.clickable(enabled = isSelectable) { onClick() },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = day.date.dayOfMonth.toString(), color = textColor, style = MaterialTheme.typography.bodySmall)
|
||||
if (hasRate && isInMonth) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(text = "₹", style = MaterialTheme.typography.labelSmall, color = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.android.trisolarispms.ui.roomtype
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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 com.android.trisolarispms.data.api.model.RatePlanResponse
|
||||
|
||||
@Composable
|
||||
fun RatePlanSection(
|
||||
propertyId: String,
|
||||
roomTypeCode: String,
|
||||
onOpenCalendar: (RatePlanResponse) -> Unit,
|
||||
viewModel: RatePlanViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val showCreateDialog = remember { mutableStateOf(false) }
|
||||
val showEditDialog = remember { mutableStateOf<RatePlanResponse?>(null) }
|
||||
|
||||
LaunchedEffect(propertyId, roomTypeCode) {
|
||||
viewModel.load(propertyId, roomTypeCode)
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(text = "Rate Plans", style = MaterialTheme.typography.titleSmall)
|
||||
Button(onClick = { showCreateDialog.value = true }) {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
if (state.isLoading) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
state.error?.let {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
if (!state.isLoading) {
|
||||
if (state.items.isEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(text = "No rate plans", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
state.items.forEach { plan ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "${plan.code.orEmpty()} • ${plan.name.orEmpty()}",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "${plan.baseRate ?: 0} ${plan.currency.orEmpty()}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Row {
|
||||
TextButton(onClick = { onOpenCalendar(plan) }) {
|
||||
Text("Calendar")
|
||||
}
|
||||
TextButton(onClick = { showEditDialog.value = plan }) {
|
||||
Text("Edit")
|
||||
}
|
||||
if (!plan.id.isNullOrBlank()) {
|
||||
IconButton(onClick = { viewModel.delete(propertyId, roomTypeCode, plan.id) }) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete Rate Plan")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog.value) {
|
||||
RatePlanCreateDialog(
|
||||
onDismiss = { showCreateDialog.value = false },
|
||||
onSave = { code, name, baseRate, currency ->
|
||||
viewModel.create(propertyId, roomTypeCode, code, name, baseRate, currency) {
|
||||
showCreateDialog.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
showEditDialog.value?.let { plan ->
|
||||
RatePlanEditDialog(
|
||||
plan = plan,
|
||||
onDismiss = { showEditDialog.value = null },
|
||||
onSave = { name, baseRate, currency ->
|
||||
val id = plan.id.orEmpty()
|
||||
if (id.isNotBlank()) {
|
||||
viewModel.update(propertyId, roomTypeCode, id, name, baseRate, currency) {
|
||||
showEditDialog.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RatePlanCreateDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, String, Long, String?) -> Unit
|
||||
) {
|
||||
val code = remember { mutableStateOf("") }
|
||||
val name = remember { mutableStateOf("") }
|
||||
val baseRate = remember { mutableStateOf("") }
|
||||
val currency = remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Add Rate Plan") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = code.value,
|
||||
onValueChange = { code.value = it },
|
||||
label = { Text("Code") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = baseRate.value,
|
||||
onValueChange = { baseRate.value = it },
|
||||
label = { Text("Base rate") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = currency.value,
|
||||
onValueChange = { currency.value = it },
|
||||
label = { Text("Currency (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val rate = baseRate.value.toLongOrNull() ?: 0L
|
||||
if (code.value.isNotBlank() && name.value.isNotBlank() && rate > 0) {
|
||||
onSave(code.value.trim(), name.value.trim(), rate, currency.value.trim().ifBlank { null })
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RatePlanEditDialog(
|
||||
plan: RatePlanResponse,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, Long, String?) -> Unit
|
||||
) {
|
||||
val name = remember { mutableStateOf(plan.name.orEmpty()) }
|
||||
val baseRate = remember { mutableStateOf(plan.baseRate?.toString().orEmpty()) }
|
||||
val currency = remember { mutableStateOf(plan.currency.orEmpty()) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit Rate Plan") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = "Code: ${plan.code.orEmpty()}", style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
label = { Text("Name") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = baseRate.value,
|
||||
onValueChange = { baseRate.value = it },
|
||||
label = { Text("Base rate") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = currency.value,
|
||||
onValueChange = { currency.value = it },
|
||||
label = { Text("Currency (optional)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val rate = baseRate.value.toLongOrNull() ?: 0L
|
||||
if (name.value.isNotBlank() && rate > 0) {
|
||||
onSave(name.value.trim(), rate, currency.value.trim().ifBlank { null })
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.android.trisolarispms.ui.roomtype
|
||||
|
||||
import com.android.trisolarispms.data.api.model.RatePlanCalendarEntry
|
||||
import com.android.trisolarispms.data.api.model.RatePlanResponse
|
||||
|
||||
data class RatePlanState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val items: List<RatePlanResponse> = emptyList(),
|
||||
val calendarByPlanId: Map<String, List<RatePlanCalendarEntry>> = emptyMap(),
|
||||
val calendarLoading: Boolean = false,
|
||||
val calendarError: String? = null
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.android.trisolarispms.ui.roomtype
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.android.trisolarispms.data.api.ApiClient
|
||||
import com.android.trisolarispms.data.api.model.RatePlanCalendarEntry
|
||||
import com.android.trisolarispms.data.api.model.RatePlanCalendarUpsertRequest
|
||||
import com.android.trisolarispms.data.api.model.RatePlanRequest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class RatePlanViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(RatePlanState())
|
||||
val state: StateFlow<RatePlanState> = _state
|
||||
|
||||
fun load(propertyId: String, roomTypeCode: String) {
|
||||
if (propertyId.isBlank() || roomTypeCode.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.listRatePlans(propertyId, roomTypeCode)
|
||||
if (response.isSuccessful) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
items = 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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun create(
|
||||
propertyId: String,
|
||||
roomTypeCode: String,
|
||||
code: String,
|
||||
name: String,
|
||||
baseRate: Long,
|
||||
currency: String?,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
if (propertyId.isBlank() || roomTypeCode.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.createRatePlan(
|
||||
propertyId = propertyId,
|
||||
body = RatePlanRequest(
|
||||
code = code,
|
||||
name = name,
|
||||
roomTypeCode = roomTypeCode,
|
||||
baseRate = baseRate,
|
||||
currency = currency
|
||||
)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
load(propertyId, roomTypeCode)
|
||||
onDone()
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun update(
|
||||
propertyId: String,
|
||||
roomTypeCode: String,
|
||||
ratePlanId: String,
|
||||
name: String,
|
||||
baseRate: Long,
|
||||
currency: String?,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
if (propertyId.isBlank() || ratePlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.updateRatePlan(
|
||||
propertyId = propertyId,
|
||||
ratePlanId = ratePlanId,
|
||||
body = RatePlanRequest(
|
||||
name = name,
|
||||
baseRate = baseRate,
|
||||
currency = currency
|
||||
)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
load(propertyId, roomTypeCode)
|
||||
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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(propertyId: String, roomTypeCode: String, ratePlanId: String) {
|
||||
if (propertyId.isBlank() || ratePlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.deleteRatePlan(propertyId, ratePlanId)
|
||||
if (response.isSuccessful) {
|
||||
load(propertyId, roomTypeCode)
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = false, error = "Delete failed: ${response.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Delete failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCalendar(propertyId: String, ratePlanId: String, from: String, to: String) {
|
||||
if (propertyId.isBlank() || ratePlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(calendarLoading = true, calendarError = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.listRatePlanCalendar(propertyId, ratePlanId, from, to)
|
||||
if (response.isSuccessful) {
|
||||
val items = response.body().orEmpty()
|
||||
_state.update {
|
||||
it.copy(
|
||||
calendarLoading = false,
|
||||
calendarByPlanId = it.calendarByPlanId + (ratePlanId to items),
|
||||
calendarError = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(calendarLoading = false, calendarError = "Calendar load failed: ${response.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(calendarLoading = false, calendarError = e.localizedMessage ?: "Calendar load failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertCalendar(
|
||||
propertyId: String,
|
||||
ratePlanId: String,
|
||||
from: String,
|
||||
to: String,
|
||||
rate: Long,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
if (propertyId.isBlank() || ratePlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(calendarLoading = true, calendarError = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.upsertRatePlanCalendar(
|
||||
propertyId,
|
||||
ratePlanId,
|
||||
RatePlanCalendarUpsertRequest(from = from, to = to, rate = rate)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { it.copy(calendarLoading = false, calendarError = null) }
|
||||
onDone()
|
||||
} else {
|
||||
_state.update { it.copy(calendarLoading = false, calendarError = "Calendar update failed: ${response.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(calendarLoading = false, calendarError = e.localizedMessage ?: "Calendar update failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCalendarEntry(
|
||||
propertyId: String,
|
||||
ratePlanId: String,
|
||||
rateDate: String,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
if (propertyId.isBlank() || ratePlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(calendarLoading = true, calendarError = null) }
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val response = api.deleteRatePlanCalendarEntry(propertyId, ratePlanId, rateDate)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { it.copy(calendarLoading = false, calendarError = null) }
|
||||
onDone()
|
||||
} else {
|
||||
_state.update { it.copy(calendarLoading = false, calendarError = "Calendar delete failed: ${response.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(calendarLoading = false, calendarError = e.localizedMessage ?: "Calendar delete failed") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ firebaseAuthKtx = "24.0.1"
|
||||
vectordrawable = "1.2.0"
|
||||
coilCompose = "2.7.0"
|
||||
lottieCompose = "6.7.1"
|
||||
calendarCompose = "2.6.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -48,6 +49,7 @@ androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordra
|
||||
androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
|
||||
lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
|
||||
calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Reference in New Issue
Block a user