policy:time related
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = "",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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(", ")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user