active stays: ability to extent checkout time

This commit is contained in:
androidlover5842
2026-01-29 20:39:15 +05:30
parent f593306c50
commit 9d3ade3d03
7 changed files with 491 additions and 11 deletions

View File

@@ -18,6 +18,7 @@ import com.android.trisolarispms.ui.auth.UnauthorizedScreen
import com.android.trisolarispms.ui.booking.BookingCreateScreen
import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.guest.GuestSignatureScreen
import com.android.trisolarispms.ui.booking.BookingExpectedDatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
@@ -147,6 +148,10 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.BookingExpectedDates -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
}
@@ -262,6 +267,15 @@ class MainActivity : ComponentActivity() {
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty()
)
},
onExtendBooking = { booking ->
route.value = AppRoute.BookingExpectedDates(
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty(),
status = booking.status,
expectedCheckInAt = booking.expectedCheckInAt,
expectedCheckOutAt = booking.expectedCheckOutAt
)
}
)
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen(
@@ -359,6 +373,25 @@ class MainActivity : ComponentActivity() {
)
}
)
is AppRoute.BookingExpectedDates -> BookingExpectedDatesScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
status = currentRoute.status,
expectedCheckInAt = currentRoute.expectedCheckInAt,
expectedCheckOutAt = currentRoute.expectedCheckOutAt,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onDone = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.Rooms -> RoomsScreen(
propertyId = currentRoute.propertyId,
onBack = {

View File

@@ -11,6 +11,7 @@ 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.RoomStayDto
import retrofit2.Response
import retrofit2.http.Body
@@ -39,6 +40,13 @@ interface BookingApi {
@Body body: BookingBulkCheckInRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/expected-dates")
suspend fun updateExpectedDates(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String,
@Body body: BookingExpectedDatesRequest
): Response<Unit>
@POST("properties/{propertyId}/bookings/{bookingId}/link-guest")
suspend fun linkGuest(
@Path("propertyId") propertyId: String,

View File

@@ -68,6 +68,11 @@ data class BookingBulkCheckInStayRequest(
val currency: String? = null
)
data class BookingExpectedDatesRequest(
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingLinkGuestRequest(
val guestId: String
)

View File

@@ -35,6 +35,13 @@ sealed interface AppRoute {
val propertyId: String,
val bookingId: String
) : AppRoute
data class BookingExpectedDates(
val propertyId: String,
val bookingId: String,
val status: String?,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?
) : AppRoute
data object AddProperty : AppRoute
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
data class Rooms(val propertyId: String) : AppRoute

View File

@@ -0,0 +1,391 @@
package com.android.trisolarispms.ui.booking
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.BookingExpectedDatesRequest
import com.kizitonwose.calendar.compose.HorizontalCalendar
import com.kizitonwose.calendar.compose.rememberCalendarState
import com.kizitonwose.calendar.core.CalendarDay
import com.kizitonwose.calendar.core.CalendarMonth
import com.kizitonwose.calendar.core.DayPosition
import com.kizitonwose.calendar.core.daysOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.YearMonth
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingExpectedDatesScreen(
propertyId: String,
bookingId: String,
status: String?,
expectedCheckInAt: String?,
expectedCheckOutAt: String?,
onBack: () -> Unit,
onDone: () -> Unit
) {
val showCheckInPicker = remember { mutableStateOf(false) }
val showCheckOutPicker = remember { mutableStateOf(false) }
val checkInDate = remember { mutableStateOf<LocalDate?>(null) }
val checkOutDate = remember { mutableStateOf<LocalDate?>(null) }
val checkInTime = remember { mutableStateOf("12:00") }
val checkOutTime = remember { mutableStateOf("11:00") }
val isLoading = remember { mutableStateOf(false) }
val error = remember { mutableStateOf<String?>(null) }
val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") }
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val editableCheckIn = status?.uppercase() == "OPEN"
val scope = rememberCoroutineScope()
LaunchedEffect(bookingId) {
val now = OffsetDateTime.now()
expectedCheckInAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()?.let { parsed ->
val zoned = parsed.atZoneSameInstant(displayZone)
checkInDate.value = zoned.toLocalDate()
checkInTime.value = zoned.format(DateTimeFormatter.ofPattern("HH:mm"))
}
} ?: run {
checkInDate.value = now.toLocalDate()
checkInTime.value = now.format(DateTimeFormatter.ofPattern("HH:mm"))
}
expectedCheckOutAt?.let {
runCatching { OffsetDateTime.parse(it) }.getOrNull()?.let { parsed ->
val zoned = parsed.atZoneSameInstant(displayZone)
checkOutDate.value = zoned.toLocalDate()
checkOutTime.value = zoned.format(DateTimeFormatter.ofPattern("HH:mm"))
}
} ?: run {
checkOutDate.value = (checkInDate.value ?: now.toLocalDate()).plusDays(1)
checkOutTime.value = "11:00"
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Update Expected Dates") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(
onClick = {
isLoading.value = true
error.value = null
val inAt = if (editableCheckIn) {
checkInDate.value?.let { formatIso(it, checkInTime.value) }
} else {
null
}
val outAt = checkOutDate.value?.let { formatIso(it, checkOutTime.value) }
scope.launch {
try {
val api = ApiClient.create()
val response = api.updateExpectedDates(
propertyId = propertyId,
bookingId = bookingId,
body = BookingExpectedDatesRequest(
expectedCheckInAt = inAt,
expectedCheckOutAt = outAt
)
)
if (response.isSuccessful) {
onDone()
} else {
error.value = "Update failed: ${response.code()}"
}
} catch (e: Exception) {
error.value = e.localizedMessage ?: "Update failed"
} finally {
isLoading.value = false
}
}
},
enabled = !isLoading.value
) {
Icon(Icons.Default.Done, contentDescription = "Save")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
if (editableCheckIn) {
OutlinedTextField(
value = checkInDate.value?.let {
formatIso(it, checkInTime.value)
}?.let { iso ->
runCatching {
OffsetDateTime.parse(iso).atZoneSameInstant(displayZone).format(displayFormatter)
}.getOrDefault(iso)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-in") },
trailingIcon = {
IconButton(onClick = { showCheckInPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
}
OutlinedTextField(
value = checkOutDate.value?.let {
formatIso(it, checkOutTime.value)
}?.let { iso ->
runCatching {
OffsetDateTime.parse(iso).atZoneSameInstant(displayZone).format(displayFormatter)
}.getOrDefault(iso)
}.orEmpty(),
onValueChange = {},
readOnly = true,
label = { Text("Expected Check-out") },
trailingIcon = {
IconButton(onClick = { showCheckOutPicker.value = true }) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Pick date")
}
},
modifier = Modifier.fillMaxWidth()
)
if (isLoading.value) {
Spacer(modifier = Modifier.height(12.dp))
CircularProgressIndicator()
}
error.value?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
if (showCheckInPicker.value && editableCheckIn) {
DateTimePickerDialog(
title = "Select check-in",
initialDate = checkInDate.value,
initialTime = checkInTime.value,
minDate = LocalDate.now(),
onDismiss = { showCheckInPicker.value = false },
onConfirm = { date, time ->
checkInDate.value = date
checkInTime.value = time
showCheckInPicker.value = false
}
)
}
if (showCheckOutPicker.value) {
DateTimePickerDialog(
title = "Select check-out",
initialDate = checkOutDate.value,
initialTime = checkOutTime.value,
minDate = checkInDate.value ?: LocalDate.now(),
onDismiss = { showCheckOutPicker.value = false },
onConfirm = { date, time ->
checkOutDate.value = date
checkOutTime.value = time
showCheckOutPicker.value = false
}
)
}
}
@Composable
private fun DateTimePickerDialog(
title: String,
initialDate: LocalDate?,
initialTime: String,
minDate: LocalDate,
onDismiss: () -> Unit,
onConfirm: (LocalDate, String) -> Unit
) {
val today = remember { LocalDate.now() }
val currentMonth = remember { YearMonth.from(today) }
val startMonth = remember { currentMonth }
val endMonth = remember { currentMonth.plusMonths(24) }
val daysOfWeek = remember { daysOfWeek() }
val calendarState = rememberCalendarState(
startMonth = startMonth,
endMonth = endMonth,
firstVisibleMonth = currentMonth,
firstDayOfWeek = daysOfWeek.first()
)
val selectedDate = remember { mutableStateOf(initialDate ?: today) }
val timeValue = remember { mutableStateOf(initialTime) }
val dateFormatter = remember { DateTimeFormatter.ISO_LOCAL_DATE }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
DaysOfWeekHeader(daysOfWeek)
HorizontalCalendar(
state = calendarState,
dayContent = { day ->
val selectable = day.position == DayPosition.MonthDate && !day.date.isBefore(minDate)
DayCell(
day = day,
isSelectedStart = selectedDate.value == day.date,
isSelectedEnd = false,
isInRange = false,
hasRate = false,
isSelectable = selectable,
onClick = { selectedDate.value = day.date }
)
},
monthHeader = { month ->
MonthHeader(month)
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Selected: ${selectedDate.value.format(dateFormatter)}",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = timeValue.value,
onValueChange = { timeValue.value = it },
label = { Text("Time (HH:MM)") },
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
TextButton(
onClick = {
val time = timeValue.value.ifBlank { initialTime }
onConfirm(selectedDate.value, time)
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun DaysOfWeekHeader(daysOfWeek: List<java.time.DayOfWeek>) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
daysOfWeek.forEach { day ->
Text(
text = day.name.take(3),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
@Composable
private fun MonthHeader(month: CalendarMonth) {
Text(
text = "${month.yearMonth.month.name.lowercase().replaceFirstChar { it.titlecase() }} ${month.yearMonth.year}",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
@Composable
private fun DayCell(
day: CalendarDay,
isSelectedStart: Boolean,
isSelectedEnd: Boolean,
isInRange: Boolean,
hasRate: Boolean,
isSelectable: Boolean,
onClick: () -> Unit
) {
val isInMonth = day.position == DayPosition.MonthDate
val background = when {
isSelectedStart || isSelectedEnd -> MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
isInRange -> MaterialTheme.colorScheme.primary.copy(alpha = 0.18f)
hasRate -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f)
else -> Color.Transparent
}
val textColor = when {
!isInMonth -> MaterialTheme.colorScheme.onSurfaceVariant
!isSelectable -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurface
}
Column(
modifier = Modifier
.size(40.dp)
.padding(2.dp)
.background(background, shape = MaterialTheme.shapes.small)
.clickable(enabled = isSelectable) { onClick() },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = day.date.dayOfMonth.toString(), color = textColor, style = MaterialTheme.typography.bodySmall)
}
}
private fun formatIso(date: LocalDate, time: String): String {
val parts = time.split(":")
val hour = parts.getOrNull(0)?.toIntOrNull() ?: 0
val minute = parts.getOrNull(1)?.toIntOrNull() ?: 0
val zone = ZoneId.of("Asia/Kolkata")
val localDateTime = LocalDateTime.of(date.year, date.monthValue, date.dayOfMonth, hour, minute)
val offset = zone.rules.getOffset(localDateTime)
return OffsetDateTime.of(localDateTime, offset)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
}

View File

@@ -53,6 +53,7 @@ fun ActiveRoomStaysScreen(
onCreateBooking: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit,
onExtendBooking: (BookingListItem) -> Unit,
viewModel: ActiveRoomStaysViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
@@ -149,6 +150,14 @@ fun ActiveRoomStaysScreen(
TextButton(onClick = { selectedBooking.value = null }) {
Text("Add photos")
}
TextButton(
onClick = {
selectedBooking.value = null
onExtendBooking(booking)
}
) {
Text("Extend checkout")
}
TextButton(onClick = { selectedBooking.value = null }) {
Text("Checkout")
}
@@ -220,18 +229,26 @@ private fun CheckedInBookingCard(
val total = Duration.between(start, end).toMinutes().coerceAtLeast(0)
val remaining = Duration.between(now, end).toMinutes().coerceAtLeast(0)
val hoursLeft = (remaining / 60).coerceAtLeast(0)
val progress = if (total > 0) {
((total - remaining).toFloat() / total.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = "$hoursLeft hours", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(6.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth()
)
if (now.isAfter(end)) {
Text(
text = "Checkout Now",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else {
val progress = if (total > 0) {
((total - remaining).toFloat() / total.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
Text(text = "$hoursLeft hours", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(6.dp))
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth()
)
}
}
}
}