diff --git a/.kotlin/errors/errors-1770004718940.log b/.kotlin/errors/errors-1770004718940.log new file mode 100644 index 0000000..44b23f1 --- /dev/null +++ b/.kotlin/errors/errors-1770004718940.log @@ -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.(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) + + diff --git a/AGENTS.md b/AGENTS.md index cfda38e..865b3dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/core/auth/AuthzPolicy.kt b/app/src/main/java/com/android/trisolarispms/core/auth/AuthzPolicy.kt index 8633e24..63baf3e 100644 --- a/app/src/main/java/com/android/trisolarispms/core/auth/AuthzPolicy.kt +++ b/app/src/main/java/com/android/trisolarispms/core/auth/AuthzPolicy.kt @@ -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 = diff --git a/app/src/main/java/com/android/trisolarispms/data/api/core/ApiService.kt b/app/src/main/java/com/android/trisolarispms/data/api/core/ApiService.kt index c684fb7..4fe5581 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/core/ApiService.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/core/ApiService.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/BillingPolicyModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/BillingPolicyModels.kt new file mode 100644 index 0000000..525ebbc --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/BillingPolicyModels.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt index 24c155b..5c22d13 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt @@ -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, 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 + val stays: List, + 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 = emptyList(), val roomNumbers: List = 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 ) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/CancellationPolicyModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/CancellationPolicyModels.kt new file mode 100644 index 0000000..f10d599 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/CancellationPolicyModels.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/BillingPolicyApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/BillingPolicyApi.kt new file mode 100644 index 0000000..8b0ff27 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/BillingPolicyApi.kt @@ -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 + + @PUT("properties/{propertyId}/billing-policy") + suspend fun updateBillingPolicy( + @Path("propertyId") propertyId: String, + @Body body: BillingPolicyRequest + ): Response +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt index 50d23ba..3ef7d33 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/BookingApi.kt @@ -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 + @POST("properties/{propertyId}/bookings/{bookingId}/billing-policy") + suspend fun updateBookingBillingPolicy( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: BookingBillingPolicyUpdateRequest + ): Response + @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 + ): Response @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 + ): Response + + @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 @POST("properties/{propertyId}/bookings/{bookingId}/cancel") suspend fun cancelBooking( @Path("propertyId") propertyId: String, @Path("bookingId") bookingId: String, @Body body: BookingCancelRequest - ): Response + ): Response @POST("properties/{propertyId}/bookings/{bookingId}/no-show") suspend fun noShow( @Path("propertyId") propertyId: String, @Path("bookingId") bookingId: String, @Body body: BookingNoShowRequest - ): Response + ): Response - @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 + @Path("bookingId") bookingId: String + ): Response @POST("properties/{propertyId}/bookings/{bookingId}/payments/razorpay/qr") suspend fun generateRazorpayQr( diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/CancellationPolicyApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/CancellationPolicyApi.kt new file mode 100644 index 0000000..86a6f74 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/CancellationPolicyApi.kt @@ -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 + + @PUT("properties/{propertyId}/cancellation-policy") + suspend fun updateCancellationPolicy( + @Path("propertyId") propertyId: String, + @Body body: CancellationPolicyRequest + ): Response +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/service/RoomStayApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/service/RoomStayApi.kt index 4083cbc..2f63426 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/service/RoomStayApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/service/RoomStayApi.kt @@ -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 - @GET("properties/{propertyId}/room-stays/active") suspend fun listActiveRoomStays(@Path("propertyId") propertyId: String): Response> + + @POST("properties/{propertyId}/room-stays/{roomStayId}/void") + suspend fun voidRoomStay( + @Path("propertyId") propertyId: String, + @Path("roomStayId") roomStayId: String, + @Body body: RoomStayVoidRequest + ): Response } diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt index 20c8ad7..90d48ea 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt index 35d7f21..4d05b24 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateState.kt @@ -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 = "", diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt index 3ae3b83..0f9a8a0 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateViewModel.kt @@ -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 }, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt index acd8178..974c850 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/AppRoute.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt index 81f3ab4..13f1429 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainBackNavigation.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesStayFlow.kt b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesStayFlow.kt index afdbc41..1d55f75 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesStayFlow.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/navigation/MainRoutesStayFlow.kt @@ -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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt index 070b498..3f0c633 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/ActiveRoomStaysScreen.kt @@ -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(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() ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt index 69ebc41..48f48fa 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingDetailsTabsScreen.kt @@ -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(", ") diff --git a/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsScreen.kt new file mode 100644 index 0000000..d2d5385 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsScreen.kt @@ -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) + } + ) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsState.kt b/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsState.kt new file mode 100644 index 0000000..8a78f99 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsState.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsViewModel.kt new file mode 100644 index 0000000..2832ad6 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/settings/PropertySettingsViewModel.kt @@ -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 = _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) + } +}