policy:time related

This commit is contained in:
androidlover5842
2026-02-02 11:35:30 +05:30
parent a691e84fd8
commit 18c5cb814d
22 changed files with 850 additions and 81 deletions

View File

@@ -0,0 +1,35 @@
kotlin version: 2.3.0
error message: Incremental compilation failed: /home/androidlover5842/AndroidStudioProjects/TrisolarisPMS/app/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin (No such file or directory)
java.io.FileNotFoundException: /home/androidlover5842/AndroidStudioProjects/TrisolarisPMS/app/build/kotlin/compileDebugKotlin/classpath-snapshot/shrunk-classpath-snapshot.bin (No such file or directory)
at java.base/java.io.FileInputStream.open0(Native Method)
at java.base/java.io.FileInputStream.open(Unknown Source)
at java.base/java.io.FileInputStream.<init>(Unknown Source)
at org.jetbrains.kotlin.incremental.storage.ExternalizersKt.loadFromFile(externalizers.kt:184)
at org.jetbrains.kotlin.incremental.snapshots.LazyClasspathSnapshot.getSavedShrunkClasspathAgainstPreviousLookups(LazyClasspathSnapshot.kt:86)
at org.jetbrains.kotlin.incremental.classpathDiff.ClasspathSnapshotShrinkerKt.shrinkAndSaveClasspathSnapshot(ClasspathSnapshotShrinker.kt:267)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.performWorkAfterCompilation(IncrementalJvmCompilerRunner.kt:76)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.performWorkAfterCompilation(IncrementalJvmCompilerRunner.kt:23)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:420)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally$lambda$0$compile(IncrementalCompilerRunner.kt:249)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:267)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:119)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:684)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:94)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1810)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)

View File

@@ -543,6 +543,7 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
### Required project structure (current baseline) ### Required project structure (current baseline)
- `core/` -> cross-cutting business primitives/policies (e.g., auth policy, role enum). - `core/` -> cross-cutting business primitives/policies (e.g., auth policy, role enum).
- `core/viewmodel/` -> shared ViewModel execution helpers (loading/error wrappers, common request runners).
- `data/api/core/` -> API client, constants, token providers, aggregated API service. - `data/api/core/` -> API client, constants, token providers, aggregated API service.
- `data/api/service/` -> Retrofit endpoint interfaces only. - `data/api/service/` -> Retrofit endpoint interfaces only.
- `data/api/model/` -> DTO/request/response models. - `data/api/model/` -> DTO/request/response models.
@@ -558,6 +559,9 @@ GET /properties/{propertyId}/bookings/{bookingId}/balance
5. Keep UI dumb: consume state and callbacks; avoid business rules in composables. 5. Keep UI dumb: consume state and callbacks; avoid business rules in composables.
6. If navigation changes, update `ui/navigation` only (single source of truth). 6. If navigation changes, update `ui/navigation` only (single source of truth).
7. Before finishing, remove any newly introduced duplication and compile-check. 7. Before finishing, remove any newly introduced duplication and compile-check.
8. If 2+ ViewModels repeat loading/error coroutine flow, extract/use shared helper in `core/viewmodel`.
9. If Add/Edit screens differ only by initialization + submit callback, extract a feature-local shared form screen.
10. Prefer dedupe/organization improvements even if net LOC does not decrease, as long as behavior remains unchanged.
### PR/refactor acceptance checklist ### PR/refactor acceptance checklist

View File

@@ -18,6 +18,10 @@ class AuthzPolicy(
fun canManageRazorpaySettings(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN) fun canManageRazorpaySettings(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canManageCancellationPolicy(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canManageBillingPolicy(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canDeleteCashPayment(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN) fun canDeleteCashPayment(propertyId: String): Boolean = hasRole(propertyId, Role.ADMIN)
fun canAddBookingPayment(propertyId: String): Boolean = fun canAddBookingPayment(propertyId: String): Boolean =

View File

@@ -2,7 +2,9 @@ package com.android.trisolarispms.data.api.core
import com.android.trisolarispms.data.api.service.AmenityApi import com.android.trisolarispms.data.api.service.AmenityApi
import com.android.trisolarispms.data.api.service.AuthApi import com.android.trisolarispms.data.api.service.AuthApi
import com.android.trisolarispms.data.api.service.BillingPolicyApi
import com.android.trisolarispms.data.api.service.BookingApi import com.android.trisolarispms.data.api.service.BookingApi
import com.android.trisolarispms.data.api.service.CancellationPolicyApi
import com.android.trisolarispms.data.api.service.CardApi import com.android.trisolarispms.data.api.service.CardApi
import com.android.trisolarispms.data.api.service.GuestApi import com.android.trisolarispms.data.api.service.GuestApi
import com.android.trisolarispms.data.api.service.GuestDocumentApi import com.android.trisolarispms.data.api.service.GuestDocumentApi
@@ -20,6 +22,7 @@ import com.android.trisolarispms.data.api.service.UserAdminApi
interface ApiService : interface ApiService :
AuthApi, AuthApi,
BillingPolicyApi,
PropertyApi, PropertyApi,
RoomTypeApi, RoomTypeApi,
RoomApi, RoomApi,
@@ -35,4 +38,5 @@ interface ApiService :
AmenityApi, AmenityApi,
RatePlanApi, RatePlanApi,
RazorpaySettingsApi, RazorpaySettingsApi,
CancellationPolicyApi,
UserAdminApi UserAdminApi

View File

@@ -0,0 +1,12 @@
package com.android.trisolarispms.data.api.model
data class BillingPolicyRequest(
val billingCheckinTime: String,
val billingCheckoutTime: String
)
data class BillingPolicyResponse(
val propertyId: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)

View File

@@ -1,5 +1,20 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import java.util.Locale
enum class BookingBillingMode {
PROPERTY_POLICY,
CUSTOM_WINDOW,
FULL_24H;
companion object {
fun from(value: String?): BookingBillingMode? {
val normalized = value?.trim()?.uppercase(Locale.US) ?: return null
return entries.firstOrNull { it.name == normalized }
}
}
}
data class BookingCheckInRequest( data class BookingCheckInRequest(
val roomIds: List<String>, val roomIds: List<String>,
val checkInAt: String? = null, val checkInAt: String? = null,
@@ -11,6 +26,8 @@ data class BookingCheckInRequest(
data class BookingCreateRequest( data class BookingCreateRequest(
val expectedCheckInAt: String, val expectedCheckInAt: String,
val expectedCheckOutAt: String, val expectedCheckOutAt: String,
val billingMode: BookingBillingMode? = null,
val billingCheckoutTime: String? = null,
val source: String? = null, val source: String? = null,
val guestPhoneE164: String? = null, val guestPhoneE164: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
@@ -57,11 +74,16 @@ data class BookingListItem(
val expectedGuestCount: Int? = null, val expectedGuestCount: Int? = null,
val notes: String? = null val notes: String? = null
, ,
val pending: Long? = null val pending: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
) )
data class BookingBulkCheckInRequest( data class BookingBulkCheckInRequest(
val stays: List<BookingBulkCheckInStayRequest> val stays: List<BookingBulkCheckInStayRequest>,
val transportMode: String? = null,
val notes: String? = null
) )
data class BookingBulkCheckInStayRequest( data class BookingBulkCheckInStayRequest(
@@ -95,6 +117,7 @@ data class BookingDetailsResponse(
val guestSignatureUrl: String? = null, val guestSignatureUrl: String? = null,
val vehicleNumbers: List<String> = emptyList(), val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(), val roomNumbers: List<Int> = emptyList(),
val source: String? = null,
val fromCity: String? = null, val fromCity: String? = null,
val toCity: String? = null, val toCity: String? = null,
val memberRelation: String? = null, val memberRelation: String? = null,
@@ -110,11 +133,20 @@ data class BookingDetailsResponse(
val totalGuestCount: Int? = null, val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null, val expectedGuestCount: Int? = null,
val totalNightlyRate: Long? = null, val totalNightlyRate: Long? = null,
val notes: String? = null,
val registeredByName: String? = null, val registeredByName: String? = null,
val registeredByPhone: String? = null, val registeredByPhone: String? = null,
val expectedPay: Long? = null, val expectedPay: Long? = null,
val amountCollected: Long? = null, val amountCollected: Long? = null,
val pending: Long? = null val pending: Long? = null,
val billingMode: String? = null,
val billingCheckinTime: String? = null,
val billingCheckoutTime: String? = null
)
data class BookingBillingPolicyUpdateRequest(
val billingMode: BookingBillingMode,
val billingCheckoutTime: String? = null
) )
data class BookingLinkGuestRequest( data class BookingLinkGuestRequest(
@@ -126,6 +158,11 @@ data class BookingCheckOutRequest(
val notes: String? = null val notes: String? = null
) )
data class BookingRoomStayCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null
)
data class BookingCancelRequest( data class BookingCancelRequest(
val cancelledAt: String? = null, val cancelledAt: String? = null,
val reason: String? = null val reason: String? = null
@@ -136,39 +173,12 @@ data class BookingNoShowRequest(
val reason: String? = null val reason: String? = null
) )
data class BookingRoomStayCreateRequest( data class BookingBalanceResponse(
val roomId: String, val expectedPay: Long? = null,
val fromAt: String, val amountCollected: Long? = null,
val toAt: String, val pending: Long? = null
val notes: String? = null
) )
// Room Stays data class RoomStayVoidRequest(
val reason: String
data class RoomStayCreateRequest(
val roomId: String,
val guestId: String? = null,
val checkIn: String? = null,
val checkOut: String? = null
)
data class RoomStayDto(
val id: String? = null,
val bookingId: String? = null,
val roomId: String? = null,
val status: String? = null
)
data class RoomChangeRequest(
val newRoomId: String,
val movedAt: String? = null,
val idempotencyKey: String
)
data class RoomChangeResponse(
val oldRoomStayId: String? = null,
val newRoomStayId: String? = null,
val oldRoomId: String? = null,
val newRoomId: String? = null,
val movedAt: String? = null
) )

View File

@@ -0,0 +1,26 @@
package com.android.trisolarispms.data.api.model
import java.util.Locale
enum class CancellationPenaltyMode {
NO_CHARGE,
ONE_NIGHT,
FULL_STAY;
companion object {
fun from(value: String?): CancellationPenaltyMode? {
val normalized = value?.trim()?.uppercase(Locale.US) ?: return null
return entries.firstOrNull { it.name == normalized }
}
}
}
data class CancellationPolicyRequest(
val freeDaysBeforeCheckin: Int,
val penaltyMode: CancellationPenaltyMode
)
data class CancellationPolicyResponse(
val freeDaysBeforeCheckin: Int? = null,
val penaltyMode: String? = null
)

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.BillingPolicyRequest
import com.android.trisolarispms.data.api.model.BillingPolicyResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface BillingPolicyApi {
@GET("properties/{propertyId}/billing-policy")
suspend fun getBillingPolicy(
@Path("propertyId") propertyId: String
): Response<BillingPolicyResponse>
@PUT("properties/{propertyId}/billing-policy")
suspend fun updateBillingPolicy(
@Path("propertyId") propertyId: String,
@Body body: BillingPolicyRequest
): Response<BillingPolicyResponse>
}

View File

@@ -4,16 +4,17 @@ import com.android.trisolarispms.data.api.model.ActionResponse
import com.android.trisolarispms.data.api.model.BookingCancelRequest import com.android.trisolarispms.data.api.model.BookingCancelRequest
import com.android.trisolarispms.data.api.model.BookingCheckInRequest import com.android.trisolarispms.data.api.model.BookingCheckInRequest
import com.android.trisolarispms.data.api.model.BookingCheckOutRequest import com.android.trisolarispms.data.api.model.BookingCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCheckOutRequest
import com.android.trisolarispms.data.api.model.BookingCreateRequest import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
import com.android.trisolarispms.data.api.model.BookingNoShowRequest import com.android.trisolarispms.data.api.model.BookingNoShowRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest
import com.android.trisolarispms.data.api.model.BookingListItem import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.model.RoomStayDto import com.android.trisolarispms.data.api.model.BookingBalanceResponse
import com.android.trisolarispms.data.api.model.BookingBillingPolicyUpdateRequest
import com.android.trisolarispms.data.api.model.RazorpayQrEventDto import com.android.trisolarispms.data.api.model.RazorpayQrEventDto
import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto import com.android.trisolarispms.data.api.model.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayQrRequest import com.android.trisolarispms.data.api.model.RazorpayQrRequest
@@ -59,6 +60,13 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest @Body body: BookingExpectedDatesRequest
): Response<Unit> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/billing-policy")
suspend fun updateBookingBillingPolicy(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingBillingPolicyUpdateRequest
): Response<Unit>
@GET("properties/{propertyId}/bookings/{bookingId}") @GET("properties/{propertyId}/bookings/{bookingId}")
suspend fun getBookingDetails( suspend fun getBookingDetails(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@@ -70,7 +78,7 @@ interface BookingApi {
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingLinkGuestRequest @Body body: BookingLinkGuestRequest
): Response<BookingCreateResponse> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in") @POST("properties/{propertyId}/bookings/{bookingId}/check-in")
suspend fun checkIn( suspend fun checkIn(
@@ -84,28 +92,35 @@ interface BookingApi {
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingCheckOutRequest @Body body: BookingCheckOutRequest
): Response<ActionResponse> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-stays/{roomStayId}/check-out")
suspend fun checkOutRoomStay(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Path("roomStayId") roomStayId: String,
@Body body: BookingRoomStayCheckOutRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/cancel") @POST("properties/{propertyId}/bookings/{bookingId}/cancel")
suspend fun cancelBooking( suspend fun cancelBooking(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingCancelRequest @Body body: BookingCancelRequest
): Response<ActionResponse> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/no-show") @POST("properties/{propertyId}/bookings/{bookingId}/no-show")
suspend fun noShow( suspend fun noShow(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String,
@Body body: BookingNoShowRequest @Body body: BookingNoShowRequest
): Response<ActionResponse> ): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-stays") @GET("properties/{propertyId}/bookings/{bookingId}/balance")
suspend fun preAssignRoomStay( suspend fun getBookingBalance(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String, @Path("bookingId") bookingId: String
@Body body: BookingRoomStayCreateRequest ): Response<BookingBalanceResponse>
): Response<RoomStayDto>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr") @POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr")
suspend fun generateRazorpayQr( suspend fun generateRazorpayQr(

View File

@@ -0,0 +1,22 @@
package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.CancellationPolicyRequest
import com.android.trisolarispms.data.api.model.CancellationPolicyResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PUT
import retrofit2.http.Path
interface CancellationPolicyApi {
@GET("properties/{propertyId}/cancellation-policy")
suspend fun getCancellationPolicy(
@Path("propertyId") propertyId: String
): Response<CancellationPolicyResponse>
@PUT("properties/{propertyId}/cancellation-policy")
suspend fun updateCancellationPolicy(
@Path("propertyId") propertyId: String,
@Body body: CancellationPolicyRequest
): Response<CancellationPolicyResponse>
}

View File

@@ -1,8 +1,7 @@
package com.android.trisolarispms.data.api.service package com.android.trisolarispms.data.api.service
import com.android.trisolarispms.data.api.model.RoomChangeRequest
import com.android.trisolarispms.data.api.model.RoomChangeResponse
import com.android.trisolarispms.data.api.model.ActiveRoomStayDto import com.android.trisolarispms.data.api.model.ActiveRoomStayDto
import com.android.trisolarispms.data.api.model.RoomStayVoidRequest
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
@@ -10,13 +9,13 @@ import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
interface RoomStayApi { interface RoomStayApi {
@POST("properties/{propertyId}/room-stays/{roomStayId}/change-room")
suspend fun changeRoom(
@Path("propertyId") propertyId: String,
@Path("roomStayId") roomStayId: String,
@Body body: RoomChangeRequest
): Response<RoomChangeResponse>
@GET("properties/{propertyId}/room-stays/active") @GET("properties/{propertyId}/room-stays/active")
suspend fun listActiveRoomStays(@Path("propertyId") propertyId: String): Response<List<ActiveRoomStayDto>> suspend fun listActiveRoomStays(@Path("propertyId") propertyId: String): Response<List<ActiveRoomStayDto>>
@POST("properties/{propertyId}/room-stays/{roomStayId}/void")
suspend fun voidRoomStay(
@Path("propertyId") propertyId: String,
@Path("roomStayId") roomStayId: String,
@Body body: RoomStayVoidRequest
): Response<Unit>
} }

View File

@@ -1,5 +1,6 @@
package com.android.trisolarispms.ui.booking package com.android.trisolarispms.ui.booking
import android.app.TimePickerDialog
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -17,6 +18,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -43,9 +45,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.kizitonwose.calendar.compose.HorizontalCalendar import com.kizitonwose.calendar.compose.HorizontalCalendar
import com.kizitonwose.calendar.compose.rememberCalendarState import com.kizitonwose.calendar.compose.rememberCalendarState
import com.kizitonwose.calendar.core.CalendarDay import com.kizitonwose.calendar.core.CalendarDay
@@ -81,6 +85,7 @@ fun BookingCreateScreen(
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE") val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
val transportMenuExpanded = remember { mutableStateOf(false) } val transportMenuExpanded = remember { mutableStateOf(false) }
val transportOptions = listOf("CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER") val transportOptions = listOf("CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER")
val billingModeMenuExpanded = remember { mutableStateOf(false) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") } val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val phoneCountryMenuExpanded = remember { mutableStateOf(false) } val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountries = remember { phoneCountryOptions() } val phoneCountries = remember { phoneCountryOptions() }
@@ -88,10 +93,13 @@ fun BookingCreateScreen(
LaunchedEffect(propertyId) { LaunchedEffect(propertyId) {
viewModel.reset() viewModel.reset()
viewModel.loadBillingPolicy(propertyId)
val now = OffsetDateTime.now() val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate() checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm")) checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
viewModel.onExpectedCheckInAtChange(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) val nowIso = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
viewModel.onExpectedCheckInAtChange(nowIso)
viewModel.autoSetBillingFromCheckIn(nowIso)
checkInNow.value = true checkInNow.value = true
val defaultCheckoutDate = now.toLocalDate().plusDays(1) val defaultCheckoutDate = now.toLocalDate().plusDays(1)
checkOutDate.value = defaultCheckoutDate checkOutDate.value = defaultCheckoutDate
@@ -139,7 +147,9 @@ fun BookingCreateScreen(
val now = OffsetDateTime.now() val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate() checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm")) checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
viewModel.onExpectedCheckInAtChange(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) val nowIso = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
viewModel.onExpectedCheckInAtChange(nowIso)
viewModel.autoSetBillingFromCheckIn(nowIso)
} else { } else {
viewModel.onExpectedCheckInAtChange("") viewModel.onExpectedCheckInAtChange("")
} }
@@ -179,6 +189,47 @@ fun BookingCreateScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox(
expanded = billingModeMenuExpanded.value,
onExpandedChange = { billingModeMenuExpanded.value = !billingModeMenuExpanded.value }
) {
OutlinedTextField(
value = state.billingMode.name,
onValueChange = {},
readOnly = true,
label = { Text("Billing Mode") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = billingModeMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = billingModeMenuExpanded.value,
onDismissRequest = { billingModeMenuExpanded.value = false }
) {
BookingBillingMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.name) },
onClick = {
billingModeMenuExpanded.value = false
viewModel.onBillingModeChange(mode)
}
)
}
}
}
if (state.billingMode == BookingBillingMode.CUSTOM_WINDOW) {
Spacer(modifier = Modifier.height(12.dp))
TimePickerTextField(
value = state.billingCheckoutTime,
label = { Text("Billing check-out (HH:mm)") },
onTimeSelected = viewModel::onBillingCheckoutTimeChange,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(12.dp))
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode) val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
val phoneDigitsLength = state.phoneNationalNumber.length val phoneDigitsLength = state.phoneNationalNumber.length
val phoneIsComplete = phoneDigitsLength == selectedCountry.maxLength val phoneIsComplete = phoneDigitsLength == selectedCountry.maxLength
@@ -491,6 +542,58 @@ fun BookingCreateScreen(
} }
} }
@Composable
private fun TimePickerTextField(
value: String,
label: @Composable () -> Unit,
onTimeSelected: (String) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val parsed = runCatching {
val parts = value.split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 12
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
(hour.coerceIn(0, 23)) to (minute.coerceIn(0, 59))
}.getOrDefault(12 to 0)
OutlinedTextField(
value = value,
onValueChange = {},
readOnly = true,
label = label,
trailingIcon = {
IconButton(
onClick = {
TimePickerDialog(
context,
{ _, hourOfDay, minute ->
onTimeSelected("%02d:%02d".format(hourOfDay, minute))
},
parsed.first,
parsed.second,
true
).show()
}
) {
Icon(Icons.Default.Schedule, contentDescription = "Pick time")
}
},
modifier = modifier
.clickable {
TimePickerDialog(
context,
{ _, hourOfDay, minute ->
onTimeSelected("%02d:%02d".format(hourOfDay, minute))
},
parsed.first,
parsed.second,
true
).show()
}
)
}
@Composable @Composable
private fun DateTimePickerDialog( private fun DateTimePickerDialog(
title: String, title: String,

View File

@@ -1,5 +1,7 @@
package com.android.trisolarispms.ui.booking package com.android.trisolarispms.ui.booking
import com.android.trisolarispms.data.api.model.BookingBillingMode
data class BookingCreateState( data class BookingCreateState(
val phoneCountryCode: String = "IN", val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "", val phoneNationalNumber: String = "",
@@ -8,6 +10,8 @@ data class BookingCreateState(
val phoneVisitCountPhone: String? = null, val phoneVisitCountPhone: String? = null,
val expectedCheckInAt: String = "", val expectedCheckInAt: String = "",
val expectedCheckOutAt: String = "", val expectedCheckOutAt: String = "",
val billingMode: BookingBillingMode = BookingBillingMode.PROPERTY_POLICY,
val billingCheckoutTime: String = "",
val source: String = "WALKIN", val source: String = "WALKIN",
val fromCity: String = "", val fromCity: String = "",
val toCity: String = "", val toCity: String = "",

View File

@@ -3,6 +3,7 @@ package com.android.trisolarispms.ui.booking
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingCreateRequest import com.android.trisolarispms.data.api.model.BookingCreateRequest
import com.android.trisolarispms.data.api.model.BookingCreateResponse import com.android.trisolarispms.data.api.model.BookingCreateResponse
import com.android.trisolarispms.data.api.model.GuestDto import com.android.trisolarispms.data.api.model.GuestDto
@@ -10,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class BookingCreateViewModel : ViewModel() { class BookingCreateViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingCreateState()) private val _state = MutableStateFlow(BookingCreateState())
@@ -27,6 +29,53 @@ class BookingCreateViewModel : ViewModel() {
_state.update { it.copy(expectedCheckOutAt = value, error = null) } _state.update { it.copy(expectedCheckOutAt = value, error = null) }
} }
fun autoSetBillingFromCheckIn(checkInAtIso: String) {
val checkIn = runCatching { OffsetDateTime.parse(checkInAtIso) }.getOrNull() ?: return
val hour = checkIn.hour
if (hour in 0..4) {
_state.update {
it.copy(
billingMode = BookingBillingMode.CUSTOM_WINDOW,
billingCheckoutTime = "17:00",
error = null
)
}
return
}
if (hour in 5..11) {
_state.update { it.copy(billingMode = BookingBillingMode.FULL_24H, error = null) }
}
}
fun loadBillingPolicy(propertyId: String) {
if (propertyId.isBlank()) return
viewModelScope.launch {
try {
val api = ApiClient.create()
val response = api.getBillingPolicy(propertyId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
billingCheckoutTime = body.billingCheckoutTime.orEmpty(),
error = null
)
}
}
} catch (_: Exception) {
// Keep defaults; billing policy can still be set manually.
}
}
}
fun onBillingModeChange(value: BookingBillingMode) {
_state.update { it.copy(billingMode = value, error = null) }
}
fun onBillingCheckoutTimeChange(value: String) {
_state.update { it.copy(billingCheckoutTime = value, error = null) }
}
fun onPhoneCountryChange(value: String) { fun onPhoneCountryChange(value: String) {
val option = findPhoneCountryOption(value) val option = findPhoneCountryOption(value)
_state.update { current -> _state.update { current ->
@@ -132,6 +181,14 @@ class BookingCreateViewModel : ViewModel() {
_state.update { it.copy(error = "Check-in and check-out are required") } _state.update { it.copy(error = "Check-in and check-out are required") }
return return
} }
val hhmmRegex = Regex("^([01]\\d|2[0-3]):[0-5]\\d$")
val billingCheckout = current.billingCheckoutTime.trim()
if (current.billingMode == BookingBillingMode.CUSTOM_WINDOW) {
if (!hhmmRegex.matches(billingCheckout)) {
_state.update { it.copy(error = "Billing checkout time must be HH:mm") }
return
}
}
val childCount = current.childCount.toIntOrNull() val childCount = current.childCount.toIntOrNull()
val maleCount = current.maleCount.toIntOrNull() val maleCount = current.maleCount.toIntOrNull()
val femaleCount = current.femaleCount.toIntOrNull() val femaleCount = current.femaleCount.toIntOrNull()
@@ -152,6 +209,12 @@ class BookingCreateViewModel : ViewModel() {
body = BookingCreateRequest( body = BookingCreateRequest(
expectedCheckInAt = checkIn, expectedCheckInAt = checkIn,
expectedCheckOutAt = checkOut, expectedCheckOutAt = checkOut,
billingMode = current.billingMode,
billingCheckoutTime = when (current.billingMode) {
BookingBillingMode.CUSTOM_WINDOW -> billingCheckout
BookingBillingMode.PROPERTY_POLICY -> null
BookingBillingMode.FULL_24H -> null
},
source = current.source.trim().ifBlank { null }, source = current.source.trim().ifBlank { null },
guestPhoneE164 = phone, guestPhoneE164 = phone,
fromCity = current.fromCity.trim().ifBlank { null }, fromCity = current.fromCity.trim().ifBlank { null },

View File

@@ -68,6 +68,7 @@ sealed interface AppRoute {
val ratePlanCode: String val ratePlanCode: String
) : AppRoute ) : AppRoute
data class RazorpaySettings(val propertyId: String) : AppRoute data class RazorpaySettings(val propertyId: String) : AppRoute
data class PropertySettings(val propertyId: String) : AppRoute
data class RazorpayQr( data class RazorpayQr(
val propertyId: String, val propertyId: String,
val bookingId: String, val bookingId: String,

View File

@@ -43,6 +43,7 @@ internal fun handleBackNavigation(
currentRoute.roomTypeId currentRoute.roomTypeId
) )
is AppRoute.RazorpaySettings -> refs.openActiveRoomStays(currentRoute.propertyId) is AppRoute.RazorpaySettings -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.PropertySettings -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.RazorpayQr -> refs.route.value = AppRoute.BookingDetailsTabs( is AppRoute.RazorpayQr -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId, currentRoute.propertyId,
currentRoute.bookingId, currentRoute.bookingId,

View File

@@ -11,6 +11,7 @@ import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.settings.PropertySettingsScreen
@Composable @Composable
internal fun renderStayFlowRoutes( internal fun renderStayFlowRoutes(
@@ -70,13 +71,13 @@ internal fun renderStayFlowRoutes(
}, },
showBack = !shouldBlockHomeBack(authz, state, currentRoute.propertyId), showBack = !shouldBlockHomeBack(authz, state, currentRoute.propertyId),
onViewRooms = { refs.route.value = AppRoute.Rooms(currentRoute.propertyId) }, onViewRooms = { refs.route.value = AppRoute.Rooms(currentRoute.propertyId) },
onOpenSettings = { refs.route.value = AppRoute.PropertySettings(currentRoute.propertyId) },
onCreateBooking = { refs.route.value = AppRoute.CreateBooking(currentRoute.propertyId) }, onCreateBooking = { refs.route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
canCreateBooking = authz.canCreateBookingFor(currentRoute.propertyId), canCreateBooking = authz.canCreateBookingFor(currentRoute.propertyId),
showRazorpaySettings = authz.canManageRazorpaySettings(currentRoute.propertyId), showRazorpaySettings = authz.canManageRazorpaySettings(currentRoute.propertyId),
onRazorpaySettings = { refs.route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) }, onRazorpaySettings = { refs.route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) },
showUserAdmin = authz.canManagePropertyUsers(currentRoute.propertyId), showUserAdmin = authz.canManagePropertyUsers(currentRoute.propertyId),
onUserAdmin = { refs.route.value = AppRoute.PropertyUsers(currentRoute.propertyId) }, onUserAdmin = { refs.route.value = AppRoute.PropertyUsers(currentRoute.propertyId) },
onLogout = authViewModel::signOut,
onManageRoomStay = { booking -> onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() } val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty() ?: booking.expectedCheckInAt.orEmpty()
@@ -111,6 +112,14 @@ internal fun renderStayFlowRoutes(
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) } onBack = { refs.openActiveRoomStays(currentRoute.propertyId) }
) )
is AppRoute.PropertySettings -> PropertySettingsScreen(
propertyId = currentRoute.propertyId,
canManageCancellationPolicy = authz.canManageCancellationPolicy(currentRoute.propertyId),
canManageBillingPolicy = authz.canManageBillingPolicy(currentRoute.propertyId),
onBack = { refs.openActiveRoomStays(currentRoute.propertyId) },
onLogout = authViewModel::signOut
)
is AppRoute.RazorpayQr -> RazorpayQrScreen( is AppRoute.RazorpayQr -> RazorpayQrScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId, bookingId = currentRoute.bookingId,

View File

@@ -17,7 +17,7 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.MeetingRoom import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material.icons.filled.Payment import androidx.compose.material.icons.filled.Payment
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -32,8 +32,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -41,8 +39,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -58,13 +54,13 @@ fun ActiveRoomStaysScreen(
onBack: () -> Unit, onBack: () -> Unit,
showBack: Boolean, showBack: Boolean,
onViewRooms: () -> Unit, onViewRooms: () -> Unit,
onOpenSettings: () -> Unit,
onCreateBooking: () -> Unit, onCreateBooking: () -> Unit,
canCreateBooking: Boolean, canCreateBooking: Boolean,
showRazorpaySettings: Boolean, showRazorpaySettings: Boolean,
onRazorpaySettings: () -> Unit, onRazorpaySettings: () -> Unit,
showUserAdmin: Boolean, showUserAdmin: Boolean,
onUserAdmin: () -> Unit, onUserAdmin: () -> Unit,
onLogout: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit, onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit, onViewBookingStays: (BookingListItem) -> Unit,
onOpenBookingDetails: (BookingListItem) -> Unit, onOpenBookingDetails: (BookingListItem) -> Unit,
@@ -72,7 +68,6 @@ fun ActiveRoomStaysScreen(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) } val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
val menuExpanded = remember { mutableStateOf(false) }
LaunchedEffect(propertyId) { LaunchedEffect(propertyId) {
viewModel.load(propertyId) viewModel.load(propertyId)
@@ -93,6 +88,9 @@ fun ActiveRoomStaysScreen(
IconButton(onClick = onViewRooms) { IconButton(onClick = onViewRooms) {
Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms") Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms")
} }
IconButton(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
if (showRazorpaySettings) { if (showRazorpaySettings) {
IconButton(onClick = onRazorpaySettings) { IconButton(onClick = onRazorpaySettings) {
Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings") Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings")
@@ -103,21 +101,6 @@ fun ActiveRoomStaysScreen(
Icon(Icons.Default.People, contentDescription = "Property Users") Icon(Icons.Default.People, contentDescription = "Property Users")
} }
} }
IconButton(onClick = { menuExpanded.value = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
}
DropdownMenu(
expanded = menuExpanded.value,
onDismissRequest = { menuExpanded.value = false }
) {
DropdownMenuItem(
text = { Text("Logout") },
onClick = {
menuExpanded.value = false
onLogout()
}
)
}
}, },
colors = TopAppBarDefaults.topAppBarColors() colors = TopAppBarDefaults.topAppBarColors()
) )

View File

@@ -55,6 +55,7 @@ import coil.decode.SvgDecoder
import coil.request.ImageRequest import coil.request.ImageRequest
import com.android.trisolarispms.data.api.core.ApiConstants import com.android.trisolarispms.data.api.core.ApiConstants
import com.android.trisolarispms.data.api.core.FirebaseAuthTokenProvider import com.android.trisolarispms.data.api.core.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.model.BookingBillingMode
import com.android.trisolarispms.data.api.model.BookingDetailsResponse import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab import com.android.trisolarispms.ui.guestdocs.GuestDocumentsTab
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
@@ -264,6 +265,14 @@ private fun GuestInfoTabContent(
} }
} }
} }
val billingMode = BookingBillingMode.from(details?.billingMode)
GuestDetailRow(label = "Billing Mode", value = details?.billingMode)
if (billingMode == BookingBillingMode.FULL_24H) {
GuestDetailRow(label = "Billing Window", value = "24-hour block")
} else {
GuestDetailRow(label = "Billing Check-in Time", value = details?.billingCheckinTime)
GuestDetailRow(label = "Billing Checkout Time", value = details?.billingCheckoutTime)
}
GuestDetailRow( GuestDetailRow(
label = "Rooms Booked", label = "Rooms Booked",
value = details?.roomNumbers?.takeIf { it.isNotEmpty() }?.joinToString(", ") value = details?.roomNumbers?.takeIf { it.isNotEmpty() }?.joinToString(", ")

View File

@@ -0,0 +1,240 @@
package com.android.trisolarispms.ui.settings
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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.DropdownMenuItem
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.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.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.CancellationPenaltyMode
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PropertySettingsScreen(
propertyId: String,
canManageCancellationPolicy: Boolean,
canManageBillingPolicy: Boolean,
onBack: () -> Unit,
onLogout: () -> Unit,
viewModel: PropertySettingsViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val canLoadPolicies = canManageCancellationPolicy || canManageBillingPolicy
LaunchedEffect(propertyId, canLoadPolicies) {
if (canLoadPolicies) {
viewModel.load(propertyId)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
if (canManageCancellationPolicy) {
Text(
text = "Cancellation Policy",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Configure how much to charge when booking is cancelled close to check-in.",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(12.dp))
}
state.message?.let {
Text(text = it, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
OutlinedTextField(
value = state.freeDaysBeforeCheckinInput,
onValueChange = viewModel::onFreeDaysBeforeCheckinChange,
label = { Text("Free days before check-in") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
PenaltyModeDropdown(
value = state.penaltyMode,
enabled = true,
onSelect = viewModel::onPenaltyModeChange
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.save(propertyId) },
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth()
) {
if (state.isSaving) {
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
Text("Saving...")
} else {
Text("Save policy")
}
}
Spacer(modifier = Modifier.height(24.dp))
}
if (canManageBillingPolicy) {
Text(
text = "Billing Policy",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Manage default billing check-in and checkout times in HH:mm format.",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.billingCheckinTimeInput,
onValueChange = viewModel::onBillingCheckinTimeChange,
label = { Text("Billing check-in time (HH:mm)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = state.billingCheckoutTimeInput,
onValueChange = viewModel::onBillingCheckoutTimeChange,
label = { Text("Billing checkout time (HH:mm)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.saveBillingPolicy(propertyId) },
enabled = !state.isSavingBilling,
modifier = Modifier.fillMaxWidth()
) {
if (state.isSavingBilling) {
CircularProgressIndicator(
modifier = Modifier
.height(16.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
Text("Saving...")
} else {
Text("Save billing policy")
}
}
Spacer(modifier = Modifier.height(24.dp))
}
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth()
) {
Text("Logout")
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun PenaltyModeDropdown(
value: CancellationPenaltyMode,
enabled: Boolean,
onSelect: (CancellationPenaltyMode) -> Unit
) {
val expanded = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = { if (enabled) expanded.value = !expanded.value }
) {
OutlinedTextField(
value = value.name,
onValueChange = {},
readOnly = true,
label = { Text("Penalty mode") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded.value) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded.value,
onDismissRequest = { expanded.value = false }
) {
CancellationPenaltyMode.entries.forEach { mode ->
DropdownMenuItem(
text = { Text(mode.name) },
onClick = {
expanded.value = false
onSelect(mode)
}
)
}
}
}
}

View File

@@ -0,0 +1,15 @@
package com.android.trisolarispms.ui.settings
import com.android.trisolarispms.data.api.model.CancellationPenaltyMode
data class PropertySettingsState(
val freeDaysBeforeCheckinInput: String = "",
val penaltyMode: CancellationPenaltyMode = CancellationPenaltyMode.ONE_NIGHT,
val billingCheckinTimeInput: String = "",
val billingCheckoutTimeInput: String = "",
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isSavingBilling: Boolean = false,
val error: String? = null,
val message: String? = null
)

View File

@@ -0,0 +1,188 @@
package com.android.trisolarispms.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.core.ApiClient
import com.android.trisolarispms.data.api.model.BillingPolicyRequest
import com.android.trisolarispms.data.api.model.CancellationPenaltyMode
import com.android.trisolarispms.data.api.model.CancellationPolicyRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class PropertySettingsViewModel : ViewModel() {
private val _state = MutableStateFlow(PropertySettingsState())
val state: StateFlow<PropertySettingsState> = _state
fun load(propertyId: String) {
if (propertyId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val api = ApiClient.create()
val cancellationResponse = api.getCancellationPolicy(propertyId)
val billingResponse = api.getBillingPolicy(propertyId)
val cancellationBody = cancellationResponse.body()
val billingBody = billingResponse.body()
if (
cancellationResponse.isSuccessful &&
cancellationBody != null &&
billingResponse.isSuccessful &&
billingBody != null
) {
val mode = CancellationPenaltyMode.from(cancellationBody.penaltyMode) ?: CancellationPenaltyMode.ONE_NIGHT
val freeDays = cancellationBody.freeDaysBeforeCheckin ?: 0
_state.update {
it.copy(
freeDaysBeforeCheckinInput = freeDays.toString(),
penaltyMode = mode,
billingCheckinTimeInput = billingBody.billingCheckinTime.orEmpty(),
billingCheckoutTimeInput = billingBody.billingCheckoutTime.orEmpty(),
isLoading = false,
isSaving = false,
isSavingBilling = false,
error = null
)
}
} else {
_state.update {
it.copy(
isLoading = false,
isSaving = false,
isSavingBilling = false,
error = "Load failed: ${cancellationResponse.code()}/${billingResponse.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isLoading = false,
isSaving = false,
isSavingBilling = false,
error = e.localizedMessage ?: "Load failed",
message = null
)
}
}
}
}
fun onFreeDaysBeforeCheckinChange(value: String) {
_state.update {
it.copy(
freeDaysBeforeCheckinInput = value.filter { ch -> ch.isDigit() },
error = null,
message = null
)
}
}
fun onPenaltyModeChange(value: CancellationPenaltyMode) {
_state.update { it.copy(penaltyMode = value, error = null, message = null) }
}
fun onBillingCheckinTimeChange(value: String) {
_state.update { it.copy(billingCheckinTimeInput = value, error = null, message = null) }
}
fun onBillingCheckoutTimeChange(value: String) {
_state.update { it.copy(billingCheckoutTimeInput = value, error = null, message = null) }
}
fun save(propertyId: String) {
if (propertyId.isBlank()) return
val current = state.value
val freeDays = current.freeDaysBeforeCheckinInput.toIntOrNull()
if (freeDays == null) {
_state.update { it.copy(error = "Free days before check-in is required", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isSaving = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.updateCancellationPolicy(
propertyId = propertyId,
body = CancellationPolicyRequest(
freeDaysBeforeCheckin = freeDays,
penaltyMode = current.penaltyMode
)
)
if (response.isSuccessful) {
_state.update { it.copy(isSaving = false, error = null, message = "Saved") }
} else {
_state.update {
it.copy(
isSaving = false,
error = "Save failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isSaving = false,
error = e.localizedMessage ?: "Save failed",
message = null
)
}
}
}
}
fun saveBillingPolicy(propertyId: String) {
if (propertyId.isBlank()) return
val current = state.value
val checkin = current.billingCheckinTimeInput.trim()
val checkout = current.billingCheckoutTimeInput.trim()
if (!isValidHhMm(checkin)) {
_state.update { it.copy(error = "billingCheckinTime must be HH:mm", message = null) }
return
}
if (!isValidHhMm(checkout)) {
_state.update { it.copy(error = "billingCheckoutTime must be HH:mm", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isSavingBilling = true, error = null, message = null) }
try {
val api = ApiClient.create()
val response = api.updateBillingPolicy(
propertyId = propertyId,
body = BillingPolicyRequest(
billingCheckinTime = checkin,
billingCheckoutTime = checkout
)
)
if (response.isSuccessful) {
_state.update { it.copy(isSavingBilling = false, error = null, message = "Saved") }
} else {
_state.update {
it.copy(
isSavingBilling = false,
error = "Save failed: ${response.code()}",
message = null
)
}
}
} catch (e: Exception) {
_state.update {
it.copy(
isSavingBilling = false,
error = e.localizedMessage ?: "Save failed",
message = null
)
}
}
}
}
private fun isValidHhMm(value: String): Boolean {
val regex = Regex("^([01]\\d|2[0-3]):[0-5]\\d$")
return regex.matches(value)
}
}