From 726f07bff4d590e19c05b82ac4cec92d4cb72be4 Mon Sep 17 00:00:00 2001 From: androidlover5842 Date: Thu, 29 Jan 2026 06:13:05 +0530 Subject: [PATCH] Add rate plan calendar screen --- AGENTS.md | 369 ++++++++++++++++++ app/build.gradle.kts | 3 +- .../com/android/trisolarispms/MainActivity.kt | 27 +- .../trisolarispms/data/api/ApiService.kt | 3 +- .../trisolarispms/data/api/RatePlanApi.kt | 63 +++ .../data/api/model/RatePlanModels.kt | 30 ++ .../com/android/trisolarispms/ui/AppRoute.kt | 6 + .../trisolarispms/ui/room/RoomsScreen.kt | 17 +- .../ui/roomimage/ImageTagsScreen.kt | 36 +- .../ui/roomtype/EditRoomTypeScreen.kt | 13 + .../ui/roomtype/RatePlanCalendarScreen.kt | 295 ++++++++++++++ .../ui/roomtype/RatePlanSection.kt | 257 ++++++++++++ .../ui/roomtype/RatePlanState.kt | 13 + .../ui/roomtype/RatePlanViewModel.kt | 209 ++++++++++ gradle/libs.versions.toml | 2 + 15 files changed, 1321 insertions(+), 22 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/RatePlanApi.kt create mode 100644 app/src/main/java/com/android/trisolarispms/data/api/model/RatePlanModels.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanCalendarScreen.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanSection.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanState.kt create mode 100644 app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanViewModel.kt diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2258bd1 --- /dev/null +++ b/AGENTS.md @@ -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) + +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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index df8f07d..24360ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index 2494724..43cb714 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt index 1884514..ebb574d 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt @@ -14,4 +14,5 @@ interface ApiService : GuestDocumentApi, TransportApi, InboundEmailApi, - AmenityApi + AmenityApi, + RatePlanApi diff --git a/app/src/main/java/com/android/trisolarispms/data/api/RatePlanApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/RatePlanApi.kt new file mode 100644 index 0000000..a330435 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/RatePlanApi.kt @@ -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 + + @GET("properties/{propertyId}/rate-plans") + suspend fun listRatePlans( + @Path("propertyId") propertyId: String, + @Query("roomTypeCode") roomTypeCode: String? = null + ): Response> + + @PUT("properties/{propertyId}/rate-plans/{ratePlanId}") + suspend fun updateRatePlan( + @Path("propertyId") propertyId: String, + @Path("ratePlanId") ratePlanId: String, + @Body body: RatePlanRequest + ): Response + + @DELETE("properties/{propertyId}/rate-plans/{ratePlanId}") + suspend fun deleteRatePlan( + @Path("propertyId") propertyId: String, + @Path("ratePlanId") ratePlanId: String + ): Response + + @POST("properties/{propertyId}/rate-plans/{ratePlanId}/calendar") + suspend fun upsertRatePlanCalendar( + @Path("propertyId") propertyId: String, + @Path("ratePlanId") ratePlanId: String, + @Body body: RatePlanCalendarUpsertRequest + ): Response + + @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> + + @DELETE("properties/{propertyId}/rate-plans/{ratePlanId}/calendar/{rateDate}") + suspend fun deleteRatePlanCalendarEntry( + @Path("propertyId") propertyId: String, + @Path("ratePlanId") ratePlanId: String, + @Path("rateDate") rateDate: String + ): Response +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RatePlanModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RatePlanModels.kt new file mode 100644 index 0000000..d25c80b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RatePlanModels.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt index b12b884..950d817 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt index 006d3dc..e2f4104 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt @@ -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) } }, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagsScreen.kt index 87f5118..fb42799 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomimage/ImageTagsScreen.kt @@ -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") + } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt index e104937..551becb 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanCalendarScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanCalendarScreen.kt new file mode 100644 index 0000000..ccabe62 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanCalendarScreen.kt @@ -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(null) } + val selectionEnd = remember { mutableStateOf(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) { + 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) + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanSection.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanSection.kt new file mode 100644 index 0000000..caeae3e --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanSection.kt @@ -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(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") + } + } + ) +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanState.kt new file mode 100644 index 0000000..de6f334 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanState.kt @@ -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 = emptyList(), + val calendarByPlanId: Map> = emptyMap(), + val calendarLoading: Boolean = false, + val calendarError: String? = null +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanViewModel.kt new file mode 100644 index 0000000..01269c3 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RatePlanViewModel.kt @@ -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 = _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") } + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a259e5..1185d2b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }