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)
- `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/service/` -> Retrofit endpoint interfaces only.
- `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.
6. If navigation changes, update `ui/navigation` only (single source of truth).
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

View File

@@ -18,6 +18,10 @@ class AuthzPolicy(
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 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.AuthApi
import com.android.trisolarispms.data.api.service.BillingPolicyApi
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.GuestApi
import com.android.trisolarispms.data.api.service.GuestDocumentApi
@@ -20,6 +22,7 @@ import com.android.trisolarispms.data.api.service.UserAdminApi
interface ApiService :
AuthApi,
BillingPolicyApi,
PropertyApi,
RoomTypeApi,
RoomApi,
@@ -35,4 +38,5 @@ interface ApiService :
AmenityApi,
RatePlanApi,
RazorpaySettingsApi,
CancellationPolicyApi,
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
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(
val roomIds: List<String>,
val checkInAt: String? = null,
@@ -11,6 +26,8 @@ data class BookingCheckInRequest(
data class BookingCreateRequest(
val expectedCheckInAt: String,
val expectedCheckOutAt: String,
val billingMode: BookingBillingMode? = null,
val billingCheckoutTime: String? = null,
val source: String? = null,
val guestPhoneE164: String? = null,
val fromCity: String? = null,
@@ -57,11 +74,16 @@ data class BookingListItem(
val expectedGuestCount: Int? = 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(
val stays: List<BookingBulkCheckInStayRequest>
val stays: List<BookingBulkCheckInStayRequest>,
val transportMode: String? = null,
val notes: String? = null
)
data class BookingBulkCheckInStayRequest(
@@ -95,6 +117,7 @@ data class BookingDetailsResponse(
val guestSignatureUrl: String? = null,
val vehicleNumbers: List<String> = emptyList(),
val roomNumbers: List<Int> = emptyList(),
val source: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
@@ -110,11 +133,20 @@ data class BookingDetailsResponse(
val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null,
val totalNightlyRate: Long? = null,
val notes: String? = null,
val registeredByName: String? = null,
val registeredByPhone: String? = null,
val expectedPay: 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(
@@ -126,6 +158,11 @@ data class BookingCheckOutRequest(
val notes: String? = null
)
data class BookingRoomStayCheckOutRequest(
val checkOutAt: String? = null,
val notes: String? = null
)
data class BookingCancelRequest(
val cancelledAt: String? = null,
val reason: String? = null
@@ -136,39 +173,12 @@ data class BookingNoShowRequest(
val reason: String? = null
)
data class BookingRoomStayCreateRequest(
val roomId: String,
val fromAt: String,
val toAt: String,
val notes: String? = null
data class BookingBalanceResponse(
val expectedPay: Long? = null,
val amountCollected: Long? = null,
val pending: Long? = null
)
// Room Stays
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
data class RoomStayVoidRequest(
val reason: String
)

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.BookingCheckInRequest
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.BookingCreateResponse
import com.android.trisolarispms.data.api.model.BookingLinkGuestRequest
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.BookingBulkCheckInRequest
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
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.RazorpayRequestListItemDto
import com.android.trisolarispms.data.api.model.RazorpayQrRequest
@@ -59,6 +60,13 @@ interface BookingApi {
@Body body: BookingExpectedDatesRequest
): 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}")
suspend fun getBookingDetails(
@Path("propertyId") propertyId: String,
@@ -70,7 +78,7 @@ interface BookingApi {
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingLinkGuestRequest
): Response<BookingCreateResponse>
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/check-in")
suspend fun checkIn(
@@ -84,28 +92,35 @@ interface BookingApi {
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@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")
suspend fun cancelBooking(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingCancelRequest
): Response<ActionResponse>
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/no-show")
suspend fun noShow(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingNoShowRequest
): Response<ActionResponse>
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/room-stays")
suspend fun preAssignRoomStay(
@GET("properties/{propertyId}/bookings/{bookingId}/balance")
suspend fun getBookingBalance(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingRoomStayCreateRequest
): Response<RoomStayDto>
@Path("bookingId") bookingId: String
): Response<BookingBalanceResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr")
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
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.RoomStayVoidRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
@@ -10,13 +9,13 @@ import retrofit2.http.POST
import retrofit2.http.Path
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")
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
import android.app.TimePickerDialog
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
@@ -43,9 +45,11 @@ 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.platform.LocalContext
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.BookingBillingMode
import com.kizitonwose.calendar.compose.HorizontalCalendar
import com.kizitonwose.calendar.compose.rememberCalendarState
import com.kizitonwose.calendar.core.CalendarDay
@@ -81,6 +85,7 @@ fun BookingCreateScreen(
val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE")
val transportMenuExpanded = remember { mutableStateOf(false) }
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 phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountries = remember { phoneCountryOptions() }
@@ -88,10 +93,13 @@ fun BookingCreateScreen(
LaunchedEffect(propertyId) {
viewModel.reset()
viewModel.loadBillingPolicy(propertyId)
val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate()
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
val defaultCheckoutDate = now.toLocalDate().plusDays(1)
checkOutDate.value = defaultCheckoutDate
@@ -139,7 +147,9 @@ fun BookingCreateScreen(
val now = OffsetDateTime.now()
checkInDate.value = now.toLocalDate()
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 {
viewModel.onExpectedCheckInAtChange("")
}
@@ -179,6 +189,47 @@ fun BookingCreateScreen(
modifier = Modifier.fillMaxWidth()
)
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 phoneDigitsLength = state.phoneNationalNumber.length
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
private fun DateTimePickerDialog(
title: String,

View File

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

View File

@@ -3,6 +3,7 @@ package com.android.trisolarispms.ui.booking
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.BookingCreateResponse
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.update
import kotlinx.coroutines.launch
import java.time.OffsetDateTime
class BookingCreateViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingCreateState())
@@ -27,6 +29,53 @@ class BookingCreateViewModel : ViewModel() {
_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) {
val option = findPhoneCountryOption(value)
_state.update { current ->
@@ -132,6 +181,14 @@ class BookingCreateViewModel : ViewModel() {
_state.update { it.copy(error = "Check-in and check-out are required") }
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 maleCount = current.maleCount.toIntOrNull()
val femaleCount = current.femaleCount.toIntOrNull()
@@ -152,6 +209,12 @@ class BookingCreateViewModel : ViewModel() {
body = BookingCreateRequest(
expectedCheckInAt = checkIn,
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 },
guestPhoneE164 = phone,
fromCity = current.fromCity.trim().ifBlank { null },

View File

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

View File

@@ -43,6 +43,7 @@ internal fun handleBackNavigation(
currentRoute.roomTypeId
)
is AppRoute.RazorpaySettings -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.PropertySettings -> refs.openActiveRoomStays(currentRoute.propertyId)
is AppRoute.RazorpayQr -> refs.route.value = AppRoute.BookingDetailsTabs(
currentRoute.propertyId,
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.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.settings.PropertySettingsScreen
@Composable
internal fun renderStayFlowRoutes(
@@ -70,13 +71,13 @@ internal fun renderStayFlowRoutes(
},
showBack = !shouldBlockHomeBack(authz, state, 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) },
canCreateBooking = authz.canCreateBookingFor(currentRoute.propertyId),
showRazorpaySettings = authz.canManageRazorpaySettings(currentRoute.propertyId),
onRazorpaySettings = { refs.route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) },
showUserAdmin = authz.canManagePropertyUsers(currentRoute.propertyId),
onUserAdmin = { refs.route.value = AppRoute.PropertyUsers(currentRoute.propertyId) },
onLogout = authViewModel::signOut,
onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty()
@@ -111,6 +112,14 @@ internal fun renderStayFlowRoutes(
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(
propertyId = currentRoute.propertyId,
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.MeetingRoom
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.Card
import androidx.compose.material3.CardDefaults
@@ -32,8 +32,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.material3.LinearProgressIndicator
@@ -41,8 +39,6 @@ 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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -58,13 +54,13 @@ fun ActiveRoomStaysScreen(
onBack: () -> Unit,
showBack: Boolean,
onViewRooms: () -> Unit,
onOpenSettings: () -> Unit,
onCreateBooking: () -> Unit,
canCreateBooking: Boolean,
showRazorpaySettings: Boolean,
onRazorpaySettings: () -> Unit,
showUserAdmin: Boolean,
onUserAdmin: () -> Unit,
onLogout: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit,
onOpenBookingDetails: (BookingListItem) -> Unit,
@@ -72,7 +68,6 @@ fun ActiveRoomStaysScreen(
) {
val state by viewModel.state.collectAsState()
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
val menuExpanded = remember { mutableStateOf(false) }
LaunchedEffect(propertyId) {
viewModel.load(propertyId)
@@ -93,6 +88,9 @@ fun ActiveRoomStaysScreen(
IconButton(onClick = onViewRooms) {
Icon(Icons.Default.MeetingRoom, contentDescription = "Available Rooms")
}
IconButton(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
if (showRazorpaySettings) {
IconButton(onClick = onRazorpaySettings) {
Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings")
@@ -103,21 +101,6 @@ fun ActiveRoomStaysScreen(
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()
)

View File

@@ -55,6 +55,7 @@ import coil.decode.SvgDecoder
import coil.request.ImageRequest
import com.android.trisolarispms.data.api.core.ApiConstants
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.ui.guestdocs.GuestDocumentsTab
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(
label = "Rooms Booked",
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)
}
}