diff --git a/AGENTS.md b/AGENTS.md index 440e9d8..378263e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,25 @@ Response --- +### Update expected dates + +POST /properties/{propertyId}/bookings/{bookingId}/expected-dates + +Rules: + +- OPEN → can update expectedCheckInAt and/or expectedCheckOutAt +- CHECKED_IN → can update only expectedCheckOutAt +- CHECKED_OUT / CANCELLED / NO_SHOW → forbidden + +Body + +{ + "expectedCheckInAt": "2026-01-29T12:00:00+05:30", + "expectedCheckOutAt": "2026-01-30T10:00:00+05:30" +} + +--- + ## 2) Guests ### Create guest + link to booking diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index 073842a..98243ff 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -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 = { diff --git a/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt index d157bfa..3fec178 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/BookingApi.kt @@ -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 + @POST("properties/{propertyId}/bookings/{bookingId}/expected-dates") + suspend fun updateExpectedDates( + @Path("propertyId") propertyId: String, + @Path("bookingId") bookingId: String, + @Body body: BookingExpectedDatesRequest + ): Response + @POST("properties/{propertyId}/bookings/{bookingId}/link-guest") suspend fun linkGuest( @Path("propertyId") propertyId: String, 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 2a69730..c7fcf22 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 @@ -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 ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt index bbc8d0a..f666293 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -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 diff --git a/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt new file mode 100644 index 0000000..0fa4a63 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingExpectedDatesScreen.kt @@ -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(null) } + val checkOutDate = remember { mutableStateOf(null) } + val checkInTime = remember { mutableStateOf("12:00") } + val checkOutTime = remember { mutableStateOf("11:00") } + val isLoading = remember { mutableStateOf(false) } + val error = remember { mutableStateOf(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) { + 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) +} 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 9332abd..bbf498b 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 @@ -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() + ) + } } } }