Add rate plan calendar screen

This commit is contained in:
androidlover5842
2026-01-29 06:13:05 +05:30
parent 3fe0730f4c
commit 726f07bff4
15 changed files with 1321 additions and 22 deletions

369
AGENTS.md Normal file
View 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.

View File

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

View File

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

View File

@@ -14,4 +14,5 @@ interface ApiService :
GuestDocumentApi,
TransportApi,
InboundEmailApi,
AmenityApi
AmenityApi,
RatePlanApi

View File

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

View File

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

View File

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

View File

@@ -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,9 +92,11 @@ fun RoomsScreen(
IconButton(onClick = onViewRoomTypes) {
Icon(Icons.Default.Category, contentDescription = "Room Types")
}
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)
}
},

View File

@@ -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,7 +83,8 @@ fun ImageTagsScreen(
if (state.tags.isEmpty()) {
Text(text = "No tags")
} else {
state.tags.forEach { tag ->
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.tags) { tag ->
androidx.compose.foundation.layout.Row(
modifier = Modifier
.fillMaxWidth()
@@ -106,4 +109,5 @@ fun ImageTagsScreen(
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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