Room lists filters and room type availability

This commit is contained in:
androidlover5842
2026-01-28 05:42:23 +05:30
parent d2d60b5074
commit 6d7edc3022
12 changed files with 636 additions and 448 deletions

View File

@@ -56,4 +56,16 @@ interface RoomApi {
@Query("from") from: String,
@Query("to") to: String
): Response<List<RoomAvailabilityRangeResponse>>
@GET("properties/{propertyId}/rooms/available")
suspend fun listAvailableRooms(
@Path("propertyId") propertyId: String
): Response<List<RoomDto>>
@GET("properties/{propertyId}/rooms/by-type/{roomTypeCode}")
suspend fun listRoomsByType(
@Path("propertyId") propertyId: String,
@Path("roomTypeCode") roomTypeCode: String,
@Query("availableOnly") availableOnly: Boolean? = null
): Response<List<RoomDto>>
}

View File

@@ -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,

View File

@@ -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<RoomDto> = emptyList()
val rooms: List<RoomDto> = emptyList(),
val showAll: Boolean = false,
val roomTypeCode: String? = null
)

View File

@@ -12,13 +12,24 @@ class RoomListViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomListState())
val state: StateFlow<RoomListState> = _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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
)
}

View File

@@ -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<List<com.android.trisolarispms.data.api.model.ImageDto>>(emptyList()) }
val originalOrderIds = remember { mutableStateOf<List<String>>(emptyList()) }
val previewUrl = remember { mutableStateOf<String?>(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 }
)

View File

@@ -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")
}
}
)
}
}

View File

@@ -14,6 +14,10 @@ class RoomTypeFormViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomTypeFormState())
val state: StateFlow<RoomTypeFormState> = _state
fun resetForm() {
_state.update { RoomTypeFormState() }
}
fun setRoomType(type: com.android.trisolarispms.data.api.model.RoomTypeDto) {
_state.update {
it.copy(

View File

@@ -6,5 +6,6 @@ data class RoomTypeListState(
val isLoading: Boolean = false,
val error: String? = null,
val items: List<RoomTypeDto> = emptyList(),
val imageByTypeCode: Map<String, com.android.trisolarispms.data.api.model.ImageDto?> = emptyMap()
val imageByTypeCode: Map<String, com.android.trisolarispms.data.api.model.ImageDto?> = emptyMap(),
val availableCountByTypeCode: Map<String, Int> = emptyMap()
)

View File

@@ -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<com.android.trisolarispms.data.api.model.RoomTypeDto>) {
viewModelScope.launch {
val api = ApiClient.create()
val updates = mutableMapOf<String, Int>()
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) }
}
}
}
}

View File

@@ -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))
}