diff --git a/app/src/main/java/com/android/trisolarispms/data/api/RoomApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/RoomApi.kt index 26229c8..8c005fb 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/RoomApi.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/RoomApi.kt @@ -56,4 +56,16 @@ interface RoomApi { @Query("from") from: String, @Query("to") to: String ): Response> + + @GET("properties/{propertyId}/rooms/available") + suspend fun listAvailableRooms( + @Path("propertyId") propertyId: String + ): Response> + + @GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}") + suspend fun listRoomsByType( + @Path("propertyId") propertyId: String, + @Path("roomTypeCode") roomTypeCode: String, + @Query("availableOnly") availableOnly: Boolean? = null + ): Response> } diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt index 2c55b75..ee518cf 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomModels.kt @@ -25,6 +25,7 @@ data class RoomDto( val roomNumber: Int? = null, val roomTypeCode: String? = null, val roomTypeName: String? = null, + val maxOccupancy: Int? = null, val floor: Int? = null, val hasNfc: Boolean? = null, val active: Boolean? = null, diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomListState.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListState.kt index bff5d55..c5f5bde 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomListState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListState.kt @@ -5,5 +5,7 @@ import com.android.trisolarispms.data.api.model.RoomDto data class RoomListState( val isLoading: Boolean = false, val error: String? = null, - val rooms: List = emptyList() + val rooms: List = emptyList(), + val showAll: Boolean = false, + val roomTypeCode: String? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt index b481081..1079624 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomListViewModel.kt @@ -12,13 +12,24 @@ class RoomListViewModel : ViewModel() { private val _state = MutableStateFlow(RoomListState()) val state: StateFlow = _state - fun load(propertyId: String) { + fun load(propertyId: String, showAll: Boolean = false, roomTypeCode: String? = null) { if (propertyId.isBlank()) return viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } + _state.update { it.copy(isLoading = true, error = null, showAll = showAll, roomTypeCode = roomTypeCode) } try { val api = ApiClient.create() - val response = api.listRooms(propertyId) + val trimmedCode = roomTypeCode?.trim().orEmpty() + val response = if (trimmedCode.isNotBlank()) { + api.listRoomsByType( + propertyId = propertyId, + roomTypeCode = trimmedCode, + availableOnly = if (showAll) false else true + ) + } else if (showAll) { + api.listRooms(propertyId) + } else { + api.listAvailableRooms(propertyId) + } if (response.isSuccessful) { _state.update { it.copy( @@ -35,4 +46,12 @@ class RoomListViewModel : ViewModel() { } } } + + fun setShowAll(propertyId: String, showAll: Boolean) { + load(propertyId, showAll, _state.value.roomTypeCode) + } + + fun setRoomTypeFilter(propertyId: String, roomTypeCode: String?) { + load(propertyId, _state.value.showAll, roomTypeCode) + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt index 69b994b..6e469fb 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/room/RoomsScreen.kt @@ -1,18 +1,30 @@ package com.android.trisolarispms.ui.room import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable 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.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.Hotel +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.Groups import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -21,6 +33,9 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -29,6 +44,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.trisolarispms.ui.roomtype.RoomTypeListViewModel +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.layout.Box @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -39,12 +58,16 @@ fun RoomsScreen( onViewRoomTypes: () -> Unit, canManageRooms: Boolean, onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, - viewModel: RoomListViewModel = viewModel() + viewModel: RoomListViewModel = viewModel(), + roomTypeListViewModel: RoomTypeListViewModel = viewModel() ) { val state by viewModel.state.collectAsState() + val roomTypeState by roomTypeListViewModel.state.collectAsState() + val showTypeMenu = remember { mutableStateOf(false) } LaunchedEffect(propertyId) { - viewModel.load(propertyId) + viewModel.load(propertyId, showAll = false) + roomTypeListViewModel.load(propertyId) } Scaffold( @@ -86,29 +109,153 @@ fun RoomsScreen( Spacer(modifier = Modifier.height(8.dp)) } if (!state.isLoading && state.error == null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + FilterChip( + selected = state.showAll, + onClick = { viewModel.setShowAll(propertyId, !state.showAll) }, + label = { Text(if (state.showAll) "Showing all" else "Showing available") } + ) + Box { + val selectedType = state.roomTypeCode + FilterChip( + selected = !selectedType.isNullOrBlank(), + onClick = { showTypeMenu.value = true }, + label = { Text("Type: ${selectedType ?: "All"}") } + ) + DropdownMenu( + expanded = showTypeMenu.value, + onDismissRequest = { showTypeMenu.value = false } + ) { + DropdownMenuItem( + text = { Text("All types") }, + onClick = { + showTypeMenu.value = false + viewModel.setRoomTypeFilter(propertyId, null) + } + ) + roomTypeState.items.forEach { type -> + val code = type.code ?: return@forEach + val label = type.name?.let { "$code • $it" } ?: code + DropdownMenuItem( + text = { Text(label) }, + onClick = { + showTypeMenu.value = false + viewModel.setRoomTypeFilter(propertyId, code) + } + ) + } + } + } + } + val availableCount = state.rooms.size + Text( + text = "Available rooms: $availableCount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(12.dp)) if (state.rooms.isEmpty()) { Text(text = "No rooms found") } else { - state.rooms.forEach { room -> - val label = room.roomNumber?.toString() ?: "-" - val details = listOfNotNull( - room.floor?.let { "Floor $it" }, - room.roomTypeName ?: room.roomTypeCode - ).joinToString(" • ") - val isDimmed = (room.active == false) || (room.maintenance == true) - val alpha = if (isDimmed) 0.5f else 1f - Column( - modifier = Modifier - .fillMaxWidth() - .alpha(alpha) - .clickable(enabled = room.id != null) { - onEditRoom(room) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + items(state.rooms) { room -> + val label = room.roomNumber?.toString() ?: "-" + val category = room.roomTypeName ?: room.roomTypeCode ?: "Unassigned" + val floorLabel = room.floor?.let { "Floor $it" }.orEmpty() + val isDimmed = (room.active == false) || (room.maintenance == true) + val alpha = if (isDimmed) 0.5f else 1f + Card( + modifier = Modifier + .fillMaxWidth() + .alpha(alpha) + .combinedClickable( + enabled = room.id != null, + onClick = {}, + onLongClick = { onEditRoom(room) } + ), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Hotel, + contentDescription = "Room", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = label, style = MaterialTheme.typography.titleMedium) + } + Spacer(modifier = Modifier.height(6.dp)) + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Category, + contentDescription = "Category", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = category, + style = MaterialTheme.typography.bodySmall + ) + } + val maxOcc = room.maxOccupancy + if (maxOcc != null && maxOcc > 0) { + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Groups, + contentDescription = "Max occupancy", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Max $maxOcc", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (floorLabel.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Layers, + contentDescription = "Floor", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = floorLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + if (room.maintenance == true) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Maintenance", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } } - .padding(vertical = 10.dp) - ) { - Text(text = label, style = MaterialTheme.typography.titleMedium) - if (details.isNotBlank()) { - Text(text = details, style = MaterialTheme.typography.bodySmall) } } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddRoomTypeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddRoomTypeScreen.kt index d7f61eb..78d7c21 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddRoomTypeScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddRoomTypeScreen.kt @@ -1,35 +1,7 @@ package com.android.trisolarispms.ui.roomtype -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.wrapContentHeight -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Done -import androidx.compose.material3.Checkbox 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.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.Modifier -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @Composable @@ -41,122 +13,16 @@ fun AddRoomTypeScreen( viewModel: RoomTypeFormViewModel = viewModel(), amenityViewModel: AmenityListViewModel = viewModel() ) { - val state by viewModel.state.collectAsState() - val amenityState by amenityViewModel.state.collectAsState() - - LaunchedEffect(Unit) { - amenityViewModel.load() - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Add Room Type") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { viewModel.submit(propertyId, onSave) }) { - Icon(Icons.Default.Done, contentDescription = "Save") - } - }, - colors = TopAppBarDefaults.topAppBarColors() - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(24.dp), - verticalArrangement = Arrangement.Top - ) { - OutlinedTextField( - value = state.code, - onValueChange = viewModel::onCodeChange, - label = { Text("Code") }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.name, - onValueChange = viewModel::onNameChange, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.baseOccupancy, - onValueChange = viewModel::onBaseOccupancyChange, - label = { Text("Base Occupancy") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.maxOccupancy, - onValueChange = viewModel::onMaxOccupancyChange, - label = { Text("Max Occupancy") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.sqFeet, - onValueChange = viewModel::onSqFeetChange, - label = { Text("Sq Ft") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.bathroomSqFeet, - onValueChange = viewModel::onBathroomSqFeetChange, - label = { Text("Bathroom Sq Ft") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - Text(text = "Amenities", style = MaterialTheme.typography.titleSmall) - if (amenityState.items.isEmpty()) { - Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall) - } else { - amenityState.items.forEach { amenity -> - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(vertical = 4.dp) - ) { - Checkbox( - checked = state.amenityIds.contains(amenity.id), - onCheckedChange = { amenity.id?.let { viewModel.onAmenityToggle(it) } } - ) - Text(text = amenity.name ?: "", modifier = Modifier.padding(start = 8.dp)) - } - } - } - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.otaAliases, - onValueChange = viewModel::onAliasesChange, - label = { Text("OTA Aliases (comma separated)") }, - modifier = Modifier.fillMaxWidth() - ) - - state.error?.let { - Spacer(modifier = Modifier.height(12.dp)) - Text(text = it, color = MaterialTheme.colorScheme.error) - } - } + androidx.compose.runtime.LaunchedEffect(Unit) { + viewModel.resetForm() } + RoomTypeFormScreen( + title = "Add Room Type", + propertyId = propertyId, + onBack = onBack, + onSave = { viewModel.submit(propertyId, onSave) }, + codeEditable = true, + viewModel = viewModel, + amenityViewModel = amenityViewModel + ) } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt index ebbb31c..e104937 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt @@ -1,58 +1,29 @@ package com.android.trisolarispms.ui.roomtype 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.aspectRatio -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.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.filled.DragIndicator -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Done import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage -import com.android.trisolarispms.data.api.ApiConstants import com.android.trisolarispms.data.api.model.RoomTypeDto -import com.android.trisolarispms.ui.roomimage.RoomImageGridItem +import com.android.trisolarispms.ui.roomimage.ImagePreviewDialog import com.android.trisolarispms.ui.roomimage.ReorderableImageGrid +import com.android.trisolarispms.ui.roomimage.RoomImageGridItem import com.android.trisolarispms.ui.roomimage.RoomImageViewModel @Composable @@ -66,16 +37,14 @@ fun EditRoomTypeScreen( amenityViewModel: AmenityListViewModel = viewModel(), roomImageViewModel: RoomImageViewModel = viewModel() ) { - val state by viewModel.state.collectAsState() - val amenityState by amenityViewModel.state.collectAsState() val roomImageState by roomImageViewModel.state.collectAsState() val showDeleteConfirm = remember { mutableStateOf(false) } val orderedImages = remember { mutableStateOf>(emptyList()) } val originalOrderIds = remember { mutableStateOf>(emptyList()) } val previewUrl = remember { mutableStateOf(null) } - val showAmenityDialog = remember { mutableStateOf(false) } - val amenitySearch = remember { mutableStateOf("") } - val gridState = rememberLazyGridState() + + val gridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState() + val saveRoomTypeImageOrder = remember(roomType.code, orderedImages.value) { { val code = roomType.code.orEmpty() @@ -101,9 +70,6 @@ fun EditRoomTypeScreen( LaunchedEffect(roomType.id) { viewModel.setRoomType(roomType) } - LaunchedEffect(Unit) { - amenityViewModel.load() - } LaunchedEffect(roomType.code) { val code = roomType.code.orEmpty() if (code.isNotBlank()) { @@ -115,159 +81,55 @@ fun EditRoomTypeScreen( originalOrderIds.value = roomImageState.images.mapNotNull { it.id } } - Scaffold( - topBar = { - TopAppBar( - title = { Text("Modify Room Type") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") - } - }, - actions = { - IconButton(onClick = { viewModel.submitUpdate(propertyId, roomType.id.orEmpty(), onSave) }) { - Icon(Icons.Default.Done, contentDescription = "Save") - } - if (!roomType.id.isNullOrBlank()) { - IconButton(onClick = { showDeleteConfirm.value = true }) { - Icon(Icons.Default.Delete, contentDescription = "Delete Room Type") - } - } - }, - colors = TopAppBarDefaults.topAppBarColors() - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Top + RoomTypeFormScreen( + title = "Modify Room Type", + propertyId = propertyId, + onBack = onBack, + onSave = { viewModel.submitUpdate(propertyId, roomType.id.orEmpty(), onSave) }, + codeEditable = false, + onDelete = { if (!roomType.id.isNullOrBlank()) showDeleteConfirm.value = true }, + viewModel = viewModel, + amenityViewModel = amenityViewModel + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - OutlinedTextField( - value = state.code, - onValueChange = {}, - readOnly = true, - label = { Text("Code") }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.name, - onValueChange = viewModel::onNameChange, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.baseOccupancy, - onValueChange = viewModel::onBaseOccupancyChange, - label = { Text("Base Occupancy") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.maxOccupancy, - onValueChange = viewModel::onMaxOccupancyChange, - label = { Text("Max Occupancy") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.sqFeet, - onValueChange = viewModel::onSqFeetChange, - label = { Text("Sq Ft") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.bathroomSqFeet, - onValueChange = viewModel::onBathroomSqFeetChange, - label = { Text("Bathroom Sq Ft") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - OutlinedTextField( - value = state.otaAliases, - onValueChange = viewModel::onAliasesChange, - label = { Text("OTA Aliases (comma separated)") }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "Amenities", style = MaterialTheme.typography.titleSmall) - Button(onClick = { showAmenityDialog.value = true }) { - Text("Edit") + Text(text = "Room Type Images", style = androidx.compose.material3.MaterialTheme.typography.titleSmall) + val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value + if (orderedImages.value.isNotEmpty() && hasOrderChanged) { + Button(onClick = saveRoomTypeImageOrder) { + Text("Save order") } } - val selectedCount = state.amenityIds.size - Text( - text = if (selectedCount == 0) "No amenities selected" else "$selectedCount selected", - style = MaterialTheme.typography.bodySmall - ) - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(text = "Room Type Images", style = MaterialTheme.typography.titleSmall) - val hasOrderChanged = orderedImages.value.mapNotNull { it.id } != originalOrderIds.value - if (orderedImages.value.isNotEmpty() && hasOrderChanged) { - Button(onClick = saveRoomTypeImageOrder) { - Text("Save order") - } - } - } - if (roomImageState.isLoading) { - Spacer(modifier = Modifier.height(8.dp)) - androidx.compose.material3.CircularProgressIndicator() - } else if (orderedImages.value.isEmpty()) { - Text(text = "No images yet", style = MaterialTheme.typography.bodySmall) - } else { - Spacer(modifier = Modifier.height(8.dp)) - ReorderableImageGrid( - images = orderedImages.value, - gridState = gridState, - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .fillMaxWidth() - .height(320.dp), - onOrderChange = { list -> - orderedImages.value = list - }, - onDragEnd = {} - ) { image, dragHandleModifier, _ -> - RoomImageGridItem( - image = image, - modifier = dragHandleModifier, - onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl }, - showTags = true - ) - } - } - state.error?.let { - Spacer(modifier = Modifier.height(12.dp)) - Text(text = it, color = MaterialTheme.colorScheme.error) + } + if (roomImageState.isLoading) { + Spacer(modifier = Modifier.height(8.dp)) + androidx.compose.material3.CircularProgressIndicator() + } else if (orderedImages.value.isEmpty()) { + Text(text = "No images yet", style = androidx.compose.material3.MaterialTheme.typography.bodySmall) + } else { + Spacer(modifier = Modifier.height(8.dp)) + ReorderableImageGrid( + images = orderedImages.value, + gridState = gridState, + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + onOrderChange = { list -> + orderedImages.value = list + }, + onDragEnd = {} + ) { image, dragHandleModifier, _ -> + RoomImageGridItem( + image = image, + modifier = dragHandleModifier, + onPreview = { previewUrl.value = image.url ?: image.thumbnailUrl }, + showTags = true + ) } } } @@ -293,90 +155,9 @@ fun EditRoomTypeScreen( ) } - if (showAmenityDialog.value) { - val query = amenitySearch.value.trim() - val filtered = if (query.isBlank()) { - amenityState.items - } else { - amenityState.items.filter { - val name = it.name.orEmpty() - val category = it.category.orEmpty() - name.contains(query, ignoreCase = true) || category.contains(query, ignoreCase = true) - } - } - AlertDialog( - onDismissRequest = { showAmenityDialog.value = false }, - title = { Text("Select Amenities") }, - text = { - Column { - OutlinedTextField( - value = amenitySearch.value, - onValueChange = { amenitySearch.value = it }, - label = { Text("Search") }, - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - if (amenityState.items.isEmpty()) { - Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall) - } else if (filtered.isEmpty()) { - Text(text = "No matches", style = MaterialTheme.typography.bodySmall) - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .height(320.dp) - ) { - items(filtered) { amenity -> - val id = amenity.id ?: return@items - val iconKey = amenity.iconKey.orEmpty().removeSuffix(".png") - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = state.amenityIds.contains(id), - onCheckedChange = { viewModel.onAmenityToggle(id) } - ) - if (iconKey.isNotBlank()) { - AsyncImage( - model = "${ApiConstants.BASE_URL}icons/png/$iconKey.png", - contentDescription = amenity.name ?: iconKey, - modifier = Modifier - .size(24.dp) - .padding(end = 8.dp) - ) - } else { - Spacer(modifier = Modifier.width(8.dp)) - } - Column(modifier = Modifier.weight(1f)) { - Text(text = amenity.name.orEmpty()) - if (!amenity.category.isNullOrBlank()) { - Text( - text = amenity.category.orEmpty(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } - } - } - }, - confirmButton = { - TextButton(onClick = { showAmenityDialog.value = false }) { - Text("Done") - } - } - ) - } - val preview = previewUrl.value if (!preview.isNullOrBlank()) { - com.android.trisolarispms.ui.roomimage.ImagePreviewDialog( + ImagePreviewDialog( imageUrl = preview, onDismiss = { previewUrl.value = null } ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormScreen.kt new file mode 100644 index 0000000..e88565f --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormScreen.kt @@ -0,0 +1,275 @@ +package com.android.trisolarispms.ui.roomtype + +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.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.android.trisolarispms.data.api.ApiConstants + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun RoomTypeFormScreen( + title: String, + propertyId: String, + onBack: () -> Unit, + onSave: () -> Unit, + codeEditable: Boolean, + onDelete: (() -> Unit)? = null, + viewModel: RoomTypeFormViewModel = viewModel(), + amenityViewModel: AmenityListViewModel = viewModel(), + extraContent: @Composable (() -> Unit)? = null +) { + val state by viewModel.state.collectAsState() + val amenityState by amenityViewModel.state.collectAsState() + val showAmenityDialog = remember { mutableStateOf(false) } + val amenitySearch = remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + amenityViewModel.load() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = onSave) { + Icon(Icons.Default.Done, contentDescription = "Save") + } + if (onDelete != null) { + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Delete Room Type") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors() + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top + ) { + OutlinedTextField( + value = state.code, + onValueChange = { value -> + if (codeEditable) { + viewModel.onCodeChange(value) + } + }, + readOnly = !codeEditable, + label = { Text("Code") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.name, + onValueChange = viewModel::onNameChange, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.baseOccupancy, + onValueChange = viewModel::onBaseOccupancyChange, + label = { Text("Base Occupancy") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.maxOccupancy, + onValueChange = viewModel::onMaxOccupancyChange, + label = { Text("Max Occupancy") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.sqFeet, + onValueChange = viewModel::onSqFeetChange, + label = { Text("Sq Ft") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.bathroomSqFeet, + onValueChange = viewModel::onBathroomSqFeetChange, + label = { Text("Bathroom Sq Ft") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = "Amenities", style = MaterialTheme.typography.titleSmall) + Button(onClick = { showAmenityDialog.value = true }) { + Text("Edit") + } + } + val selectedCount = state.amenityIds.size + Text( + text = if (selectedCount == 0) "No amenities selected" else "$selectedCount selected", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = state.otaAliases, + onValueChange = viewModel::onAliasesChange, + label = { Text("OTA Aliases (comma separated)") }, + modifier = Modifier.fillMaxWidth() + ) + + extraContent?.let { + Spacer(modifier = Modifier.height(16.dp)) + it() + } + + state.error?.let { + Spacer(modifier = Modifier.height(12.dp)) + Text(text = it, color = MaterialTheme.colorScheme.error) + } + } + } + + if (showAmenityDialog.value) { + val query = amenitySearch.value.trim() + val filtered = if (query.isBlank()) { + amenityState.items + } else { + amenityState.items.filter { + val name = it.name.orEmpty() + val category = it.category.orEmpty() + name.contains(query, ignoreCase = true) || category.contains(query, ignoreCase = true) + } + } + AlertDialog( + onDismissRequest = { showAmenityDialog.value = false }, + title = { Text("Select Amenities") }, + text = { + Column { + OutlinedTextField( + value = amenitySearch.value, + onValueChange = { amenitySearch.value = it }, + label = { Text("Search") }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + if (amenityState.items.isEmpty()) { + Text(text = "No amenities available", style = MaterialTheme.typography.bodySmall) + } else if (filtered.isEmpty()) { + Text(text = "No matches", style = MaterialTheme.typography.bodySmall) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(320.dp) + ) { + items(filtered) { amenity -> + val id = amenity.id ?: return@items + val iconKey = amenity.iconKey.orEmpty().removeSuffix(".png") + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = state.amenityIds.contains(id), + onCheckedChange = { viewModel.onAmenityToggle(id) } + ) + if (iconKey.isNotBlank()) { + AsyncImage( + model = "${ApiConstants.BASE_URL}icons/png/$iconKey.png", + contentDescription = amenity.name ?: iconKey, + modifier = Modifier + .size(24.dp) + .padding(end = 8.dp) + ) + } else { + Spacer(modifier = Modifier.width(8.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text(text = amenity.name.orEmpty()) + if (!amenity.category.isNullOrBlank()) { + Text( + text = amenity.category.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { showAmenityDialog.value = false }) { + Text("Done") + } + } + ) + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt index 42a653e..9aa3a31 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormViewModel.kt @@ -14,6 +14,10 @@ class RoomTypeFormViewModel : ViewModel() { private val _state = MutableStateFlow(RoomTypeFormState()) val state: StateFlow = _state + fun resetForm() { + _state.update { RoomTypeFormState() } + } + fun setRoomType(type: com.android.trisolarispms.data.api.model.RoomTypeDto) { _state.update { it.copy( diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt index b489a59..9debd61 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListState.kt @@ -6,5 +6,6 @@ data class RoomTypeListState( val isLoading: Boolean = false, val error: String? = null, val items: List = emptyList(), - val imageByTypeCode: Map = emptyMap() + val imageByTypeCode: Map = emptyMap(), + val availableCountByTypeCode: Map = emptyMap() ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt index a248d10..f152ce7 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeListViewModel.kt @@ -29,6 +29,7 @@ class RoomTypeListViewModel : ViewModel() { ) } loadRoomTypeImages(propertyId, items) + loadRoomTypeAvailableCounts(propertyId, items) } else { _state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") } } @@ -59,4 +60,30 @@ class RoomTypeListViewModel : ViewModel() { } } } + + private fun loadRoomTypeAvailableCounts(propertyId: String, items: List) { + viewModelScope.launch { + val api = ApiClient.create() + val updates = mutableMapOf() + for (item in items) { + val code = item.code?.trim().orEmpty() + if (code.isBlank()) continue + try { + val response = api.listRoomsByType( + propertyId = propertyId, + roomTypeCode = code, + availableOnly = true + ) + if (response.isSuccessful) { + updates[code] = response.body().orEmpty().size + } + } catch (_: Exception) { + // Ignore per-item failures. + } + } + if (updates.isNotEmpty()) { + _state.update { it.copy(availableCountByTypeCode = it.availableCountByTypeCode + updates) } + } + } + } } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt index 3d235aa..5331587 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypesScreen.kt @@ -13,6 +13,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AspectRatio +import androidx.compose.material.icons.filled.Bed +import androidx.compose.material.icons.filled.Groups import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -105,11 +108,61 @@ fun RoomTypesScreen( .clip(MaterialTheme.shapes.medium) ) } - Text( - text = "${item.code} • ${item.name}", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.fillMaxWidth() - ) + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "${item.code} • ${item.name}", + style = MaterialTheme.typography.titleMedium + ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + item.maxOccupancy?.let { max -> + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.Groups, + contentDescription = "Max occupancy", + modifier = Modifier.size(16.dp) + ) + Text( + text = max.toString(), + style = MaterialTheme.typography.bodySmall + ) + } + } + state.availableCountByTypeCode[item.code.orEmpty()]?.let { count -> + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.Bed, + contentDescription = "Available rooms", + modifier = Modifier.size(16.dp) + ) + Text( + text = count.toString(), + style = MaterialTheme.typography.bodySmall + ) + } + } + item.sqFeet?.let { sq -> + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.AspectRatio, + contentDescription = "Room size", + modifier = Modifier.size(16.dp) + ) + Text( + text = "${sq} sq ft", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } } Spacer(modifier = Modifier.height(12.dp)) }