Compare commits

...

3 Commits

Author SHA1 Message Date
androidlover5842
be820391bc booking details: add ability to see details of guest 2026-01-29 22:03:06 +05:30
androidlover5842
9d3ade3d03 active stays: ability to extent checkout time 2026-01-29 20:39:15 +05:30
androidlover5842
f593306c50 improve checked in guest ui 2026-01-29 20:14:14 +05:30
14 changed files with 1029 additions and 17 deletions

View File

@@ -56,7 +56,27 @@ Optional query param:
Behavior: Behavior:
- If status is omitted, returns all bookings for the property (newest first). - If status is omitted, returns all bookings for the property (newest first).
Response: List of BookingListItem with id, status, guestId, source, times, counts, expectedGuestCount, notes. Response: List of BookingListItem with id, status, guestId, guestName, guestPhone, roomNumbers, source, times, counts, expectedGuestCount, notes.
Notes:
- It returns active room stays (toAt = null) for each booking.
---
### Booking details
GET /properties/{propertyId}/bookings/{bookingId}
Includes:
- Guest info (name/phone/nationality/address/signatureUrl)
- Room numbers (active stays if present; otherwise all stays)
- Travel fields (fromCity/toCity/memberRelation)
- Transport mode, expected/actual times
- Counts (male/female/child/total/expected)
- Registered by (createdBy name/phone)
- totalNightlyRate (sum of nightlyRate across shown rooms)
- balance: expectedPay, amountCollected, pending
--- ---
@@ -174,6 +194,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 ## 2) Guests
### Create guest + link to booking ### Create guest + link to booking

View File

@@ -56,6 +56,7 @@ dependencies {
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.lottie.compose) implementation(libs.lottie.compose)
implementation(libs.calendar.compose) implementation(libs.calendar.compose)
implementation(libs.libphonenumber) implementation(libs.libphonenumber)

View File

@@ -18,10 +18,12 @@ import com.android.trisolarispms.ui.auth.UnauthorizedScreen
import com.android.trisolarispms.ui.booking.BookingCreateScreen import com.android.trisolarispms.ui.booking.BookingCreateScreen
import com.android.trisolarispms.ui.guest.GuestInfoScreen import com.android.trisolarispms.ui.guest.GuestInfoScreen
import com.android.trisolarispms.ui.guest.GuestSignatureScreen 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.ManageRoomStayRatesScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen
import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection
import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen
import com.android.trisolarispms.ui.roomstay.BookingDetailsTabsScreen
import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.home.HomeScreen
import com.android.trisolarispms.ui.property.AddPropertyScreen import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomFormScreen
@@ -147,6 +149,14 @@ class MainActivity : ComponentActivity() {
currentRoute.propertyId, currentRoute.propertyId,
selectedPropertyName.value ?: "Property" selectedPropertyName.value ?: "Property"
) )
is AppRoute.BookingExpectedDates -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.BookingDetailsTabs -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
} }
} }
@@ -262,6 +272,22 @@ class MainActivity : ComponentActivity() {
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty() 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
)
},
onOpenBookingDetails = { booking ->
route.value = AppRoute.BookingDetailsTabs(
propertyId = currentRoute.propertyId,
bookingId = booking.id.orEmpty(),
guestId = booking.guestId
)
} }
) )
is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen( is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen(
@@ -359,6 +385,36 @@ 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.BookingDetailsTabs -> BookingDetailsTabsScreen(
propertyId = currentRoute.propertyId,
bookingId = currentRoute.bookingId,
guestId = currentRoute.guestId,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
}
)
is AppRoute.Rooms -> RoomsScreen( is AppRoute.Rooms -> RoomsScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
onBack = { onBack = {

View File

@@ -11,6 +11,8 @@ import com.android.trisolarispms.data.api.model.BookingNoShowRequest
import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest import com.android.trisolarispms.data.api.model.BookingRoomStayCreateRequest
import com.android.trisolarispms.data.api.model.BookingListItem import com.android.trisolarispms.data.api.model.BookingListItem
import com.android.trisolarispms.data.api.model.BookingBulkCheckInRequest 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.RoomStayDto
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
@@ -39,6 +41,19 @@ interface BookingApi {
@Body body: BookingBulkCheckInRequest @Body body: BookingBulkCheckInRequest
): Response<Unit> ): 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>
@GET("properties/{propertyId}/bookings/{bookingId}")
suspend fun getBookingDetails(
@Path("propertyId") propertyId: String,
@Path("bookingId") bookingId: String
): Response<BookingDetailsResponse>
@POST("properties/{propertyId}/bookings/{bookingId}/link-guest") @POST("properties/{propertyId}/bookings/{bookingId}/link-guest")
suspend fun linkGuest( suspend fun linkGuest(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,

View File

@@ -37,7 +37,14 @@ data class BookingListItem(
val id: String? = null, val id: String? = null,
val status: String? = null, val status: String? = null,
val guestId: String? = null, val guestId: String? = null,
val guestName: String? = null,
val guestPhone: String? = null,
val roomNumbers: List<Int> = emptyList(),
val source: String? = null, val source: String? = null,
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
val transportMode: String? = null,
val checkInAt: String? = null, val checkInAt: String? = null,
val checkOutAt: String? = null, val checkOutAt: String? = null,
val expectedCheckInAt: String? = null, val expectedCheckInAt: String? = null,
@@ -65,6 +72,43 @@ data class BookingBulkCheckInStayRequest(
val currency: String? = null val currency: String? = null
) )
data class BookingExpectedDatesRequest(
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null
)
data class BookingDetailsResponse(
val id: String? = null,
val status: String? = null,
val guestId: String? = null,
val guestName: String? = null,
val guestPhone: String? = null,
val guestNationality: String? = null,
val guestAddressText: String? = null,
val guestSignatureUrl: String? = null,
val roomNumbers: List<Int> = emptyList(),
val fromCity: String? = null,
val toCity: String? = null,
val memberRelation: String? = null,
val transportMode: String? = null,
val expectedCheckInAt: String? = null,
val expectedCheckOutAt: String? = null,
val checkInAt: String? = null,
val checkOutAt: String? = null,
val adultCount: Int? = null,
val maleCount: Int? = null,
val femaleCount: Int? = null,
val childCount: Int? = null,
val totalGuestCount: Int? = null,
val expectedGuestCount: Int? = null,
val totalNightlyRate: Long? = null,
val registeredByName: String? = null,
val registeredByPhone: String? = null,
val expectedPay: Long? = null,
val amountCollected: Long? = null,
val pending: Long? = null
)
data class BookingLinkGuestRequest( data class BookingLinkGuestRequest(
val guestId: String val guestId: String
) )

View File

@@ -35,6 +35,18 @@ sealed interface AppRoute {
val propertyId: String, val propertyId: String,
val bookingId: String val bookingId: String
) : AppRoute ) : AppRoute
data class BookingExpectedDates(
val propertyId: String,
val bookingId: String,
val status: String?,
val expectedCheckInAt: String?,
val expectedCheckOutAt: String?
) : AppRoute
data class BookingDetailsTabs(
val propertyId: String,
val bookingId: String,
val guestId: String?
) : AppRoute
data object AddProperty : AppRoute data object AddProperty : AppRoute
data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute data class ActiveRoomStays(val propertyId: String, val propertyName: String) : AppRoute
data class Rooms(val propertyId: 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

@@ -5,6 +5,7 @@ data class GuestInfoState(
val name: String = "", val name: String = "",
val nationality: String = "", val nationality: String = "",
val addressText: String = "", val addressText: String = "",
val vehicleNumbers: List<String> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null val error: String? = null
) )

View File

@@ -41,6 +41,7 @@ class GuestInfoViewModel : ViewModel() {
name = guest?.name.orEmpty(), name = guest?.name.orEmpty(),
nationality = guest?.nationality.orEmpty(), nationality = guest?.nationality.orEmpty(),
addressText = guest?.addressText.orEmpty(), addressText = guest?.addressText.orEmpty(),
vehicleNumbers = guest?.vehicleNumbers ?: emptyList(),
error = null error = null
) )
} }
@@ -61,6 +62,7 @@ class GuestInfoViewModel : ViewModel() {
name = guest.name.orEmpty(), name = guest.name.orEmpty(),
nationality = guest.nationality.orEmpty(), nationality = guest.nationality.orEmpty(),
addressText = guest.addressText.orEmpty(), addressText = guest.addressText.orEmpty(),
vehicleNumbers = guest.vehicleNumbers ?: emptyList(),
isLoading = false, isLoading = false,
error = null error = null
) )

View File

@@ -53,6 +53,8 @@ fun ActiveRoomStaysScreen(
onCreateBooking: () -> Unit, onCreateBooking: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit, onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit, onViewBookingStays: (BookingListItem) -> Unit,
onExtendBooking: (BookingListItem) -> Unit,
onOpenBookingDetails: (BookingListItem) -> Unit,
viewModel: ActiveRoomStaysViewModel = viewModel() viewModel: ActiveRoomStaysViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
@@ -118,7 +120,7 @@ fun ActiveRoomStaysScreen(
CheckedInBookingCard( CheckedInBookingCard(
booking = booking, booking = booking,
onClick = { selectedBooking.value = booking }, onClick = { selectedBooking.value = booking },
onLongClick = { onViewBookingStays(booking) } onLongClick = { onOpenBookingDetails(booking) }
) )
} }
} }
@@ -149,6 +151,14 @@ fun ActiveRoomStaysScreen(
TextButton(onClick = { selectedBooking.value = null }) { TextButton(onClick = { selectedBooking.value = null }) {
Text("Add photos") Text("Add photos")
} }
TextButton(
onClick = {
selectedBooking.value = null
onExtendBooking(booking)
}
) {
Text("Extend checkout")
}
TextButton(onClick = { selectedBooking.value = null }) { TextButton(onClick = { selectedBooking.value = null }) {
Text("Checkout") Text("Checkout")
} }
@@ -158,6 +168,7 @@ fun ActiveRoomStaysScreen(
dismissButton = {} dismissButton = {}
) )
} }
} }
@Composable @Composable
@@ -174,10 +185,23 @@ private fun CheckedInBookingCard(
) )
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
val guestName = booking.guestName?.takeIf { it.isNotBlank() }
if (guestName == null) {
Text( Text(
text = booking.id?.take(8)?.let { "Booking #$it" } ?: "Booking", text = booking.id?.take(8)?.let { "Booking #$it" } ?: "Booking",
style = MaterialTheme.typography.titleSmall style = MaterialTheme.typography.titleSmall
) )
}
if (guestName != null) {
Text(text = guestName, style = MaterialTheme.typography.bodyMedium)
}
if (booking.roomNumbers.isNotEmpty()) {
Text(
text = booking.roomNumbers.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
val source = booking.source?.takeIf { it.isNotBlank() } val source = booking.source?.takeIf { it.isNotBlank() }
if (source != null) { if (source != null) {
Text(text = source, style = MaterialTheme.typography.bodySmall) Text(text = source, style = MaterialTheme.typography.bodySmall)
@@ -207,12 +231,19 @@ private fun CheckedInBookingCard(
val total = Duration.between(start, end).toMinutes().coerceAtLeast(0) val total = Duration.between(start, end).toMinutes().coerceAtLeast(0)
val remaining = Duration.between(now, end).toMinutes().coerceAtLeast(0) val remaining = Duration.between(now, end).toMinutes().coerceAtLeast(0)
val hoursLeft = (remaining / 60).coerceAtLeast(0) val hoursLeft = (remaining / 60).coerceAtLeast(0)
Spacer(modifier = Modifier.height(8.dp))
if (now.isAfter(end)) {
Text(
text = "Checkout Now",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else {
val progress = if (total > 0) { val progress = if (total > 0) {
((total - remaining).toFloat() / total.toFloat()).coerceIn(0f, 1f) ((total - remaining).toFloat() / total.toFloat()).coerceIn(0f, 1f)
} else { } else {
0f 0f
} }
Spacer(modifier = Modifier.height(8.dp))
Text(text = "$hoursLeft hours", style = MaterialTheme.typography.bodySmall) Text(text = "$hoursLeft hours", style = MaterialTheme.typography.bodySmall)
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
LinearProgressIndicator( LinearProgressIndicator(
@@ -223,4 +254,5 @@ private fun CheckedInBookingCard(
} }
} }
} }
}
} }

View File

@@ -0,0 +1,9 @@
package com.android.trisolarispms.ui.roomstay
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
data class BookingDetailsState(
val isLoading: Boolean = false,
val error: String? = null,
val details: BookingDetailsResponse? = null
)

View File

@@ -0,0 +1,370 @@
package com.android.trisolarispms.ui.roomstay
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.verticalScroll
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.ColumnScope
import kotlinx.coroutines.launch
import com.android.trisolarispms.data.api.model.BookingDetailsResponse
import com.android.trisolarispms.data.api.FirebaseAuthTokenProvider
import com.android.trisolarispms.data.api.ApiConstants
import com.google.firebase.auth.FirebaseAuth
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import androidx.compose.ui.platform.LocalContext
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun BookingDetailsTabsScreen(
propertyId: String,
bookingId: String,
guestId: String?,
onBack: () -> Unit,
staysViewModel: BookingRoomStaysViewModel = viewModel(),
detailsViewModel: BookingDetailsViewModel = viewModel()
) {
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
val scope = rememberCoroutineScope()
val staysState by staysViewModel.state.collectAsState()
val detailsState by detailsViewModel.state.collectAsState()
LaunchedEffect(propertyId, bookingId, guestId) {
staysViewModel.load(propertyId, bookingId)
detailsViewModel.load(propertyId, bookingId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Details") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
TabRow(selectedTabIndex = pagerState.currentPage) {
Tab(
selected = pagerState.currentPage == 0,
onClick = {
scope.launch { pagerState.animateScrollToPage(0) }
},
text = { Text("Guest Info") }
)
Tab(
selected = pagerState.currentPage == 1,
onClick = {
scope.launch { pagerState.animateScrollToPage(1) }
},
text = { Text("Room Stays") }
)
}
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
) { page ->
when (page) {
0 -> GuestInfoTabContent(
propertyId = propertyId,
details = detailsState.details,
guestId = guestId,
isLoading = detailsState.isLoading,
error = detailsState.error
)
1 -> BookingRoomStaysTabContent(staysState, staysViewModel)
}
}
}
}
}
@Composable
private fun GuestInfoTabContent(
propertyId: String,
details: BookingDetailsResponse?,
guestId: String?,
isLoading: Boolean,
error: String?
) {
val displayZone = remember { ZoneId.of("Asia/Kolkata") }
val dateFormatter = remember { DateTimeFormatter.ofPattern("dd/MM/yyyy") }
val timeFormatter = remember { DateTimeFormatter.ofPattern("hh:mm a") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
if (isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
SectionCard(title = "Details") {
GuestDetailRow(label = "Name", value = details?.guestName.orEmpty())
GuestDetailRow(label = "Address", value = details?.guestAddressText.orEmpty())
GuestDetailRow(label = "Phone number", value = details?.guestPhone.orEmpty())
GuestDetailRow(label = "Coming From", value = details?.fromCity.orEmpty())
GuestDetailRow(label = "Going To", value = details?.toCity.orEmpty())
GuestDetailRow(label = "Relation", value = details?.memberRelation.orEmpty())
GuestDetailRow(label = "Mode of transport", value = details?.transportMode.orEmpty())
GuestDetailRow(label = "Vehicle numbers", value = "")
}
val checkIn = details?.checkInAt ?: details?.expectedCheckInAt
val checkOut = details?.expectedCheckOutAt ?: details?.checkOutAt
SectionCard(title = "Stay") {
if (!checkIn.isNullOrBlank()) {
val parsed = runCatching { OffsetDateTime.parse(checkIn).atZoneSameInstant(displayZone) }.getOrNull()
GuestDetailRow(
label = "Check In Time",
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }.orEmpty()
)
} else {
GuestDetailRow(label = "Check In Time", value = "")
}
if (!checkOut.isNullOrBlank()) {
val parsed = runCatching { OffsetDateTime.parse(checkOut).atZoneSameInstant(displayZone) }.getOrNull()
GuestDetailRow(
label = "Estimated Check Out Time",
value = parsed?.let { "${it.format(dateFormatter)} ${it.format(timeFormatter)}" }.orEmpty()
)
} else {
GuestDetailRow(label = "Estimated Check Out Time", value = "")
}
GuestDetailRow(
label = "Rooms Booked",
value = details?.roomNumbers?.takeIf { it.isNotEmpty() }?.joinToString(", ").orEmpty()
)
GuestDetailRow(label = "Total Adults", value = details?.adultCount?.toString().orEmpty())
GuestDetailRow(label = "Total Males", value = details?.maleCount?.toString().orEmpty())
GuestDetailRow(label = "Total Females", value = details?.femaleCount?.toString().orEmpty())
GuestDetailRow(label = "Total Children", value = details?.childCount?.toString().orEmpty())
GuestDetailRow(label = "Total Guests", value = details?.totalGuestCount?.toString().orEmpty())
GuestDetailRow(label = "Expected Guests", value = details?.expectedGuestCount?.toString().orEmpty())
}
SectionCard(title = "Calculations") {
GuestDetailRow(label = "Amount Collected", value = details?.amountCollected?.toString().orEmpty())
GuestDetailRow(label = "Rent per day", value = details?.totalNightlyRate?.toString().orEmpty())
GuestDetailRow(label = "Expected pay", value = details?.expectedPay?.toString().orEmpty())
GuestDetailRow(label = "Pending", value = details?.pending?.toString().orEmpty())
}
SectionCard(title = "Registered By") {
GuestDetailRow(label = "Name", value = details?.registeredByName.orEmpty())
GuestDetailRow(label = "Phone number", value = details?.registeredByPhone.orEmpty())
}
Spacer(modifier = Modifier.height(16.dp))
SignaturePreview(
propertyId = propertyId,
guestId = details?.guestId ?: guestId,
signatureUrl = details?.guestSignatureUrl
)
}
}
@Composable
private fun GuestDetailRow(label: String, value: String) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value.ifBlank { "-" },
style = MaterialTheme.typography.bodyLarge
)
}
}
@Composable
private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
content()
}
}
Spacer(modifier = Modifier.height(12.dp))
}
@Composable
private fun SignaturePreview(propertyId: String, guestId: String?, signatureUrl: String?) {
val resolvedGuestId = guestId
if (resolvedGuestId.isNullOrBlank() && signatureUrl.isNullOrBlank()) {
Text(text = "Signature", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
Text(text = "Not available", style = MaterialTheme.typography.bodySmall)
return
}
val auth = remember { FirebaseAuth.getInstance() }
val tokenProvider = remember { FirebaseAuthTokenProvider(auth) }
val context = LocalContext.current
val imageLoader = remember {
ImageLoader.Builder(context)
.components { add(SvgDecoder.Factory()) }
.okHttpClient(
OkHttpClient.Builder()
.addInterceptor(Interceptor { chain ->
val original = chain.request()
val token = runCatching {
kotlinx.coroutines.runBlocking { tokenProvider.token(forceRefresh = false) }
}.getOrNull()
if (token.isNullOrBlank()) {
chain.proceed(original)
} else {
val request = original.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
})
.build()
)
.build()
}
val url = signatureUrl?.let { if (it.startsWith("http")) it else "${ApiConstants.BASE_URL.trimEnd('/')}$it" }
?: "${ApiConstants.BASE_URL}properties/$propertyId/guests/$resolvedGuestId/signature/file"
Column {
Text(text = "Signature", style = MaterialTheme.typography.titleSmall)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(url)
.build(),
imageLoader = imageLoader,
contentDescription = "Signature",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.padding(8.dp)
)
}
}
}
@Composable
private fun BookingRoomStaysTabContent(
state: BookingRoomStaysState,
viewModel: BookingRoomStaysViewModel
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(text = "Show all (including checkout)")
androidx.compose.material3.Switch(
checked = state.showAll,
onCheckedChange = viewModel::toggleShowAll
)
}
Spacer(modifier = Modifier.height(12.dp))
if (state.isLoading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
}
state.error?.let {
Text(text = it, color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(8.dp))
}
if (!state.isLoading && state.error == null) {
if (state.stays.isEmpty()) {
Text(text = "No stays found")
} else {
state.stays.forEach { stay ->
val roomLine = "Room ${stay.roomNumber ?: "-"}${stay.roomTypeName ?: ""}".trim()
Text(text = roomLine, style = MaterialTheme.typography.titleMedium)
val guestLine = listOfNotNull(stay.guestName, stay.guestPhone).joinToString("")
if (guestLine.isNotBlank()) {
Text(text = guestLine, style = MaterialTheme.typography.bodyMedium)
}
val timeLine = listOfNotNull(stay.fromAt, stay.expectedCheckoutAt).joinToString("")
if (timeLine.isNotBlank()) {
Text(text = timeLine, style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
}

View File

@@ -0,0 +1,38 @@
package com.android.trisolarispms.ui.roomstay
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class BookingDetailsViewModel : ViewModel() {
private val _state = MutableStateFlow(BookingDetailsState())
val state: StateFlow<BookingDetailsState> = _state
fun load(propertyId: String, bookingId: String) {
if (propertyId.isBlank() || bookingId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.getBookingDetails(propertyId, bookingId)
if (response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
details = response.body(),
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
}

View File

@@ -18,6 +18,7 @@ lifecycleViewModelCompose = "2.10.0"
firebaseAuthKtx = "24.0.1" firebaseAuthKtx = "24.0.1"
vectordrawable = "1.2.0" vectordrawable = "1.2.0"
coilCompose = "2.7.0" coilCompose = "2.7.0"
coilSvg = "2.7.0"
lottieCompose = "6.7.1" lottieCompose = "6.7.1"
calendarCompose = "2.6.0" calendarCompose = "2.6.0"
libphonenumber = "8.13.34" libphonenumber = "8.13.34"
@@ -49,6 +50,7 @@ kotlinx-coroutines-play-services = { group = "org.jetbrains.kotlinx", name = "ko
androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" } androidx-vectordrawable = { group = "androidx.vectordrawable", name = "vectordrawable", version.ref = "vectordrawable" }
androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" } androidx-vectordrawable-animated = { group = "androidx.vectordrawable", name = "vectordrawable-animated", version.ref = "vectordrawable" }
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" }
coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coilSvg" }
lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottieCompose" }
calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" } calendar-compose = { group = "com.kizitonwose.calendar", name = "compose", version.ref = "calendarCompose" }
libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" } libphonenumber = { group = "com.googlecode.libphonenumber", name = "libphonenumber", version.ref = "libphonenumber" }