diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index d0ddb90..073842a 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -21,6 +21,7 @@ import com.android.trisolarispms.ui.guest.GuestSignatureScreen import com.android.trisolarispms.ui.roomstay.ManageRoomStayRatesScreen import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelectScreen import com.android.trisolarispms.ui.roomstay.ManageRoomStaySelection +import com.android.trisolarispms.ui.roomstay.BookingRoomStaysScreen import com.android.trisolarispms.ui.home.HomeScreen import com.android.trisolarispms.ui.property.AddPropertyScreen import com.android.trisolarispms.ui.room.RoomFormScreen @@ -142,6 +143,10 @@ class MainActivity : ComponentActivity() { currentRoute.fromAt, currentRoute.toAt ) + is AppRoute.BookingRoomStays -> route.value = AppRoute.ActiveRoomStays( + currentRoute.propertyId, + selectedPropertyName.value ?: "Property" + ) } } @@ -251,6 +256,12 @@ class MainActivity : ComponentActivity() { toAt = toAt ) } + }, + onViewBookingStays = { booking -> + route.value = AppRoute.BookingRoomStays( + propertyId = currentRoute.propertyId, + bookingId = booking.id.orEmpty() + ) } ) is AppRoute.ManageRoomStaySelect -> ManageRoomStaySelectScreen( @@ -338,6 +349,16 @@ class MainActivity : ComponentActivity() { ) } ) + is AppRoute.BookingRoomStays -> BookingRoomStaysScreen( + propertyId = currentRoute.propertyId, + bookingId = currentRoute.bookingId, + onBack = { + 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/model/BookingModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/BookingModels.kt index f520a94..47ee274 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 @@ -13,6 +13,9 @@ data class BookingCreateRequest( val expectedCheckOutAt: String, val source: String? = null, val guestPhoneE164: String? = null, + val fromCity: String? = null, + val toCity: String? = null, + val memberRelation: String? = null, val transportMode: String? = null, val childCount: Int? = null, val maleCount: Int? = null, 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 4062811..bbc8d0a 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -31,6 +31,10 @@ sealed interface AppRoute { val fromAt: String, val toAt: String? ) : AppRoute + data class BookingRoomStays( + val propertyId: String, + val bookingId: 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/BookingCreateScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/booking/BookingCreateScreen.kt index 9d7a223..20c8ad7 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 @@ -11,7 +11,9 @@ 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.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.material.icons.filled.CalendarMonth @@ -75,6 +77,8 @@ fun BookingCreateScreen( val checkInNow = remember { mutableStateOf(true) } val sourceMenuExpanded = remember { mutableStateOf(false) } val sourceOptions = listOf("WALKIN", "OTA", "AGENT") + val relationMenuExpanded = remember { mutableStateOf(false) } + val relationOptions = listOf("FRIENDS", "FAMILY", "GROUP", "ALONE") val transportMenuExpanded = remember { mutableStateOf(false) } val transportOptions = listOf("CAR", "BIKE", "TRAIN", "PLANE", "BUS", "FOOT", "CYCLE", "OTHER") val displayFormatter = remember { DateTimeFormatter.ofPattern("dd-MM-yy HH:mm") } @@ -117,7 +121,8 @@ fun BookingCreateScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(24.dp), + .padding(24.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.Top ) { Row( @@ -316,6 +321,52 @@ fun BookingCreateScreen( } } Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = state.fromCity, + onValueChange = viewModel::onFromCityChange, + label = { Text("From City (optional)") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = state.toCity, + onValueChange = viewModel::onToCityChange, + label = { Text("To City (optional)") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + ExposedDropdownMenuBox( + expanded = relationMenuExpanded.value, + onExpandedChange = { relationMenuExpanded.value = !relationMenuExpanded.value } + ) { + OutlinedTextField( + value = state.memberRelation, + onValueChange = {}, + readOnly = true, + label = { Text("Member Relation (optional)") }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = relationMenuExpanded.value) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + ExposedDropdownMenu( + expanded = relationMenuExpanded.value, + onDismissRequest = { relationMenuExpanded.value = false } + ) { + relationOptions.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + relationMenuExpanded.value = false + viewModel.onMemberRelationChange(option) + } + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) ExposedDropdownMenuBox( expanded = transportMenuExpanded.value, onExpandedChange = { transportMenuExpanded.value = !transportMenuExpanded.value } 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 075de30..35d7f21 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 @@ -9,6 +9,9 @@ data class BookingCreateState( val expectedCheckInAt: String = "", val expectedCheckOutAt: String = "", val source: String = "WALKIN", + val fromCity: String = "", + val toCity: String = "", + val memberRelation: String = "", val transportMode: String = "CAR", val childCount: String = "", val maleCount: 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 0aa72b0..a883420 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 @@ -88,6 +88,18 @@ class BookingCreateViewModel : ViewModel() { _state.update { it.copy(source = value, error = null) } } + fun onFromCityChange(value: String) { + _state.update { it.copy(fromCity = value, error = null) } + } + + fun onToCityChange(value: String) { + _state.update { it.copy(toCity = value, error = null) } + } + + fun onMemberRelationChange(value: String) { + _state.update { it.copy(memberRelation = value, error = null) } + } + fun onTransportModeChange(value: String) { _state.update { it.copy(transportMode = value, error = null) } } @@ -142,6 +154,9 @@ class BookingCreateViewModel : ViewModel() { expectedCheckOutAt = checkOut, source = current.source.trim().ifBlank { null }, guestPhoneE164 = phone, + fromCity = current.fromCity.trim().ifBlank { null }, + toCity = current.toCity.trim().ifBlank { null }, + memberRelation = current.memberRelation.trim().ifBlank { null }, transportMode = current.transportMode.trim().ifBlank { null }, childCount = childCount, maleCount = maleCount, 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 d78d7af..8348ff1 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 @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack @@ -29,6 +29,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -39,6 +40,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.android.trisolarispms.data.api.model.BookingListItem +import java.time.Duration +import java.time.OffsetDateTime @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -49,6 +52,7 @@ fun ActiveRoomStaysScreen( onViewRooms: () -> Unit, onCreateBooking: () -> Unit, onManageRoomStay: (BookingListItem) -> Unit, + onViewBookingStays: (BookingListItem) -> Unit, viewModel: ActiveRoomStaysViewModel = viewModel() ) { val state by viewModel.state.collectAsState() @@ -113,29 +117,14 @@ fun ActiveRoomStaysScreen( items(state.checkedInBookings) { booking -> CheckedInBookingCard( booking = booking, - onClick = { selectedBooking.value = booking } + onClick = { selectedBooking.value = booking }, + onLongClick = { onViewBookingStays(booking) } ) } } Spacer(modifier = Modifier.height(16.dp)) - } - - if (state.items.isEmpty()) { - Text(text = "No active room stays") } else { - state.items.forEach { item -> - val roomLine = "Room ${item.roomNumber ?: "-"} • ${item.roomTypeName ?: ""}".trim() - Text(text = roomLine, style = MaterialTheme.typography.titleMedium) - val guestLine = listOfNotNull(item.guestName, item.guestPhone).joinToString(" • ") - if (guestLine.isNotBlank()) { - Text(text = guestLine, style = MaterialTheme.typography.bodyMedium) - } - val timeLine = listOfNotNull(item.fromAt, item.expectedCheckoutAt).joinToString(" → ") - if (timeLine.isNotBlank()) { - Text(text = timeLine, style = MaterialTheme.typography.bodySmall) - } - Spacer(modifier = Modifier.height(12.dp)) - } + Text(text = "No checked-in bookings") } } } @@ -174,11 +163,15 @@ fun ActiveRoomStaysScreen( @Composable private fun CheckedInBookingCard( booking: BookingListItem, - onClick: () -> Unit + onClick: () -> Unit, + onLongClick: () -> Unit ) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - modifier = Modifier.clickable(onClick = onClick) + modifier = Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick + ) ) { Column(modifier = Modifier.padding(12.dp)) { Text( @@ -204,6 +197,30 @@ private fun CheckedInBookingCard( Spacer(modifier = Modifier.height(6.dp)) Text(text = notes, style = MaterialTheme.typography.bodySmall) } + val checkInAt = booking.checkInAt?.takeIf { it.isNotBlank() } + val checkOutAt = booking.expectedCheckOutAt?.takeIf { it.isNotBlank() } + if (checkInAt != null && checkOutAt != null) { + val now = OffsetDateTime.now() + val start = runCatching { OffsetDateTime.parse(checkInAt) }.getOrNull() + val end = runCatching { OffsetDateTime.parse(checkOutAt) }.getOrNull() + if (start != null && end != null) { + 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() + ) + } + } } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysScreen.kt new file mode 100644 index 0000000..db6d206 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysScreen.kt @@ -0,0 +1,106 @@ +package com.android.trisolarispms.ui.roomstay + +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.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.Scaffold +import androidx.compose.material3.Switch +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun BookingRoomStaysScreen( + propertyId: String, + bookingId: String, + onBack: () -> Unit, + viewModel: BookingRoomStaysViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + + LaunchedEffect(propertyId, bookingId) { + viewModel.load(propertyId, bookingId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Room Stays") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Show all (including checkout)") + 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)) + } + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysState.kt new file mode 100644 index 0000000..d232b1d --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysState.kt @@ -0,0 +1,10 @@ +package com.android.trisolarispms.ui.roomstay + +import com.android.trisolarispms.data.api.model.ActiveRoomStayDto + +data class BookingRoomStaysState( + val isLoading: Boolean = false, + val error: String? = null, + val stays: List = emptyList(), + val showAll: Boolean = false +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt new file mode 100644 index 0000000..a15c5b8 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomstay/BookingRoomStaysViewModel.kt @@ -0,0 +1,43 @@ +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 BookingRoomStaysViewModel : ViewModel() { + private val _state = MutableStateFlow(BookingRoomStaysState()) + val state: StateFlow = _state + + fun toggleShowAll(value: Boolean) { + _state.update { it.copy(showAll = value) } + } + + 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.listActiveRoomStays(propertyId) + if (response.isSuccessful) { + val filtered = response.body().orEmpty().filter { it.bookingId == bookingId } + _state.update { + it.copy( + isLoading = false, + stays = filtered, + 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") } + } + } + } +}