Add amenities support for room types

This commit is contained in:
androidlover5842
2026-01-27 04:15:52 +05:30
parent 6cebefc91f
commit b4a4b17af6
20 changed files with 813 additions and 9 deletions

View File

@@ -19,7 +19,11 @@ import com.android.trisolarispms.ui.property.AddPropertyScreen
import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomFormScreen
import com.android.trisolarispms.ui.room.RoomsScreen import com.android.trisolarispms.ui.room.RoomsScreen
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen
import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen import com.android.trisolarispms.ui.roomtype.AddRoomTypeScreen
import com.android.trisolarispms.ui.roomtype.AmenitiesScreen
import com.android.trisolarispms.ui.roomtype.EditAmenityScreen
import com.android.trisolarispms.ui.roomtype.EditRoomTypeScreen
import com.android.trisolarispms.ui.roomtype.RoomTypesScreen import com.android.trisolarispms.ui.roomtype.RoomTypesScreen
import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme import com.android.trisolarispms.ui.theme.TrisolarisPMSTheme
@@ -45,8 +49,13 @@ class MainActivity : ComponentActivity() {
val selectedPropertyId = remember { mutableStateOf<String?>(null) } val selectedPropertyId = remember { mutableStateOf<String?>(null) }
val selectedPropertyName = remember { mutableStateOf<String?>(null) } val selectedPropertyName = remember { mutableStateOf<String?>(null) }
val selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) } val selectedRoom = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomDto?>(null) }
val selectedRoomType = remember { mutableStateOf<com.android.trisolarispms.data.api.model.RoomTypeDto?>(null) }
val selectedAmenity = remember { mutableStateOf<com.android.trisolarispms.data.api.model.AmenityDto?>(null) }
val roomFormKey = remember { mutableStateOf(0) } val roomFormKey = remember { mutableStateOf(0) }
val currentRoute = route.value val currentRoute = route.value
val canManageProperty: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || (state.propertyRoles[propertyId]?.contains("ADMIN") == true)
}
when (currentRoute) { when (currentRoute) {
AppRoute.Home -> HomeScreen( AppRoute.Home -> HomeScreen(
@@ -89,7 +98,7 @@ class MainActivity : ComponentActivity() {
route.value = AppRoute.AddRoom(currentRoute.propertyId) route.value = AppRoute.AddRoom(currentRoute.propertyId)
}, },
onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }, onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
canManageRooms = state.isSuperAdmin, canManageRooms = canManageProperty(currentRoute.propertyId),
onEditRoom = { onEditRoom = {
selectedRoom.value = it selectedRoom.value = it
roomFormKey.value++ roomFormKey.value++
@@ -99,13 +108,47 @@ class MainActivity : ComponentActivity() {
is AppRoute.RoomTypes -> RoomTypesScreen( is AppRoute.RoomTypes -> RoomTypesScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, onBack = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onAdd = { route.value = AppRoute.AddRoomType(currentRoute.propertyId) } onAdd = { route.value = AppRoute.AddRoomType(currentRoute.propertyId) },
onAmenities = { route.value = AppRoute.Amenities(currentRoute.propertyId) },
canManageRoomTypes = canManageProperty(currentRoute.propertyId),
onEdit = {
selectedRoomType.value = it
route.value = AppRoute.EditRoomType(currentRoute.propertyId, it.id ?: "")
}
) )
is AppRoute.AddRoomType -> AddRoomTypeScreen( is AppRoute.AddRoomType -> AddRoomTypeScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }, onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onSave = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) } onSave = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }
) )
is AppRoute.EditRoomType -> EditRoomTypeScreen(
propertyId = currentRoute.propertyId,
roomType = selectedRoomType.value
?: com.android.trisolarispms.data.api.model.RoomTypeDto(id = currentRoute.roomTypeId, code = "", name = ""),
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onSave = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }
)
is AppRoute.Amenities -> AmenitiesScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) },
onAdd = { route.value = AppRoute.AddAmenity(currentRoute.propertyId) },
onEdit = {
selectedAmenity.value = it
route.value = AppRoute.EditAmenity(currentRoute.propertyId, it.id ?: "")
}
)
is AppRoute.AddAmenity -> AddAmenityScreen(
propertyId = currentRoute.propertyId,
onBack = { route.value = AppRoute.Amenities(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Amenities(currentRoute.propertyId) }
)
is AppRoute.EditAmenity -> EditAmenityScreen(
propertyId = currentRoute.propertyId,
amenity = selectedAmenity.value
?: com.android.trisolarispms.data.api.model.AmenityDto(id = currentRoute.amenityId, name = ""),
onBack = { route.value = AppRoute.Amenities(currentRoute.propertyId) },
onSave = { route.value = AppRoute.Amenities(currentRoute.propertyId) }
)
is AppRoute.AddRoom -> RoomFormScreen( is AppRoute.AddRoom -> RoomFormScreen(
title = "Add Room", title = "Add Room",
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,

View File

@@ -0,0 +1,36 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.AmenityCreateRequest
import com.android.trisolarispms.data.api.model.AmenityDto
import com.android.trisolarispms.data.api.model.AmenityUpdateRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
interface AmenityApi {
@GET("properties/{propertyId}/amenities")
suspend fun listAmenities(@Path("propertyId") propertyId: String): Response<List<AmenityDto>>
@POST("properties/{propertyId}/amenities")
suspend fun createAmenity(
@Path("propertyId") propertyId: String,
@Body body: AmenityCreateRequest
): Response<AmenityDto>
@PUT("properties/{propertyId}/amenities/{amenityId}")
suspend fun updateAmenity(
@Path("propertyId") propertyId: String,
@Path("amenityId") amenityId: String,
@Body body: AmenityUpdateRequest
): Response<AmenityDto>
@DELETE("properties/{propertyId}/amenities/{amenityId}")
suspend fun deleteAmenity(
@Path("propertyId") propertyId: String,
@Path("amenityId") amenityId: String
): Response<Unit>
}

View File

@@ -12,4 +12,5 @@ interface ApiService :
GuestApi, GuestApi,
GuestDocumentApi, GuestDocumentApi,
TransportApi, TransportApi,
InboundEmailApi InboundEmailApi,
AmenityApi

View File

@@ -0,0 +1,14 @@
package com.android.trisolarispms.data.api.model
data class AmenityDto(
val id: String? = null,
val name: String? = null
)
data class AmenityCreateRequest(
val name: String
)
data class AmenityUpdateRequest(
val name: String
)

View File

@@ -1,10 +1,15 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
import com.android.trisolarispms.data.api.model.AmenityDto
data class RoomTypeCreateRequest( data class RoomTypeCreateRequest(
val code: String, val code: String,
val name: String, val name: String,
val baseOccupancy: Int? = null, val baseOccupancy: Int? = null,
val maxOccupancy: Int? = null, val maxOccupancy: Int? = null,
val sqFeet: Int? = null,
val bathroomSqFeet: Int? = null,
val amenityIds: List<String>? = null,
val otaAliases: List<String>? = null val otaAliases: List<String>? = null
) )
@@ -13,6 +18,9 @@ data class RoomTypeUpdateRequest(
val name: String? = null, val name: String? = null,
val baseOccupancy: Int? = null, val baseOccupancy: Int? = null,
val maxOccupancy: Int? = null, val maxOccupancy: Int? = null,
val sqFeet: Int? = null,
val bathroomSqFeet: Int? = null,
val amenityIds: List<String>? = null,
val otaAliases: List<String>? = null val otaAliases: List<String>? = null
) )
@@ -23,5 +31,8 @@ data class RoomTypeDto(
val name: String? = null, val name: String? = null,
val baseOccupancy: Int? = null, val baseOccupancy: Int? = null,
val maxOccupancy: Int? = null, val maxOccupancy: Int? = null,
val sqFeet: Int? = null,
val bathroomSqFeet: Int? = null,
val amenities: List<AmenityDto>? = null,
val otaAliases: List<String>? = null val otaAliases: List<String>? = null
) )

View File

@@ -9,4 +9,8 @@ sealed interface AppRoute {
data class EditRoom(val propertyId: String, val roomId: String) : AppRoute data class EditRoom(val propertyId: String, val roomId: String) : AppRoute
data class RoomTypes(val propertyId: String) : AppRoute data class RoomTypes(val propertyId: String) : AppRoute
data class AddRoomType(val propertyId: String) : AppRoute data class AddRoomType(val propertyId: String) : AppRoute
data class EditRoomType(val propertyId: String, val roomTypeId: String) : AppRoute
data class Amenities(val propertyId: String) : AppRoute
data class AddAmenity(val propertyId: String) : AppRoute
data class EditAmenity(val propertyId: String, val amenityId: String) : AppRoute
} }

View File

@@ -15,5 +15,6 @@ data class AuthUiState(
val userName: String? = null, val userName: String? = null,
val nameInput: String = "", val nameInput: String = "",
val needsName: Boolean = false, val needsName: Boolean = false,
val unauthorized: Boolean = false val unauthorized: Boolean = false,
val propertyRoles: Map<String, List<String>> = emptyMap()
) )

View File

@@ -161,6 +161,13 @@ class AuthViewModel(
val body = response.body() val body = response.body()
val userName = body?.user?.name val userName = body?.user?.name
val isSuperAdmin = body?.user?.superAdmin == true val isSuperAdmin = body?.user?.superAdmin == true
val propertyRoles = body?.properties
.orEmpty()
.mapNotNull { entry ->
val id = entry.propertyId
id?.let { it to entry.roles.orEmpty() }
}
.toMap()
when { when {
response.isSuccessful && (body?.status == "OK" || body?.status == "SUPER_ADMIN") -> { response.isSuccessful && (body?.status == "OK" || body?.status == "SUPER_ADMIN") -> {
_state.update { _state.update {
@@ -174,6 +181,7 @@ class AuthViewModel(
isSuperAdmin = isSuperAdmin, isSuperAdmin = isSuperAdmin,
noProperties = body?.status == "NO_PROPERTIES", noProperties = body?.status == "NO_PROPERTIES",
unauthorized = false, unauthorized = false,
propertyRoles = propertyRoles,
error = null error = null
) )
} }
@@ -190,6 +198,7 @@ class AuthViewModel(
isSuperAdmin = isSuperAdmin, isSuperAdmin = isSuperAdmin,
noProperties = true, noProperties = true,
unauthorized = false, unauthorized = false,
propertyRoles = propertyRoles,
error = null error = null
) )
} }
@@ -202,6 +211,7 @@ class AuthViewModel(
isSuperAdmin = false, isSuperAdmin = false,
noProperties = false, noProperties = false,
unauthorized = true, unauthorized = true,
propertyRoles = emptyMap(),
error = "Not authorized. Contact admin." error = "Not authorized. Contact admin."
) )
} }
@@ -214,6 +224,7 @@ class AuthViewModel(
isSuperAdmin = false, isSuperAdmin = false,
noProperties = false, noProperties = false,
unauthorized = false, unauthorized = false,
propertyRoles = emptyMap(),
error = "API verify failed: ${response.code()}" error = "API verify failed: ${response.code()}"
) )
} }

View File

@@ -0,0 +1,76 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AddAmenityScreen(
propertyId: String,
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: AmenityFormViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Add Amenity") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { viewModel.submitCreate(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.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -2,15 +2,18 @@ package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -35,9 +38,15 @@ fun AddRoomTypeScreen(
propertyId: String, propertyId: String,
onBack: () -> Unit, onBack: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
viewModel: RoomTypeFormViewModel = viewModel() viewModel: RoomTypeFormViewModel = viewModel(),
amenityViewModel: AmenityListViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val amenityState by amenityViewModel.state.collectAsState()
LaunchedEffect(propertyId) {
amenityViewModel.load(propertyId)
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -98,6 +107,45 @@ fun AddRoomTypeScreen(
) )
Spacer(modifier = Modifier.height(12.dp)) 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( OutlinedTextField(
value = state.otaAliases, value = state.otaAliases,
onValueChange = viewModel::onAliasesChange, onValueChange = viewModel::onAliasesChange,

View File

@@ -0,0 +1,98 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.filled.Add
import androidx.compose.material.icons.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.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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.AmenityDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun AmenitiesScreen(
propertyId: String,
onBack: () -> Unit,
onAdd: () -> Unit,
onEdit: (AmenityDto) -> Unit,
viewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(propertyId) {
viewModel.load(propertyId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Amenities") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Amenity")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
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.items.isEmpty()) {
Text(text = "No amenities")
} else {
state.items.forEach { item ->
Text(
text = item.name ?: "",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = item.id != null) { onEdit(item) }
.padding(vertical = 8.dp)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
package com.android.trisolarispms.ui.roomtype
data class AmenityFormState(
val name: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
)

View File

@@ -0,0 +1,70 @@
package com.android.trisolarispms.ui.roomtype
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.AmenityCreateRequest
import com.android.trisolarispms.data.api.model.AmenityUpdateRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AmenityFormViewModel : ViewModel() {
private val _state = MutableStateFlow(AmenityFormState())
val state: StateFlow<AmenityFormState> = _state
fun setAmenityName(name: String) {
_state.update { it.copy(name = name, error = null) }
}
fun onNameChange(value: String) {
_state.update { it.copy(name = value, error = null) }
}
fun submitCreate(propertyId: String, onDone: () -> Unit) {
val name = state.value.name.trim()
if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.createAmenity(propertyId, AmenityCreateRequest(name))
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Create failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Create failed") }
}
}
}
fun submitUpdate(propertyId: String, amenityId: String, onDone: () -> Unit) {
val name = state.value.name.trim()
if (name.isBlank()) {
_state.update { it.copy(error = "Name is required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.updateAmenity(propertyId, amenityId, AmenityUpdateRequest(name))
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") }
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.android.trisolarispms.ui.roomtype
import com.android.trisolarispms.data.api.model.AmenityDto
data class AmenityListState(
val isLoading: Boolean = false,
val error: String? = null,
val items: List<AmenityDto> = emptyList()
)

View File

@@ -0,0 +1,38 @@
package com.android.trisolarispms.ui.roomtype
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 AmenityListViewModel : ViewModel() {
private val _state = MutableStateFlow(AmenityListState())
val state: StateFlow<AmenityListState> = _state
fun load(propertyId: String) {
if (propertyId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val response = api.listAmenities(propertyId)
if (response.isSuccessful) {
_state.update {
it.copy(
isLoading = false,
items = response.body().orEmpty(),
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Load failed") }
}
}
}
}

View File

@@ -0,0 +1,86 @@
package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.filled.ArrowBack
import androidx.compose.material.icons.filled.Done
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.AmenityDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun EditAmenityScreen(
propertyId: String,
amenity: AmenityDto,
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: AmenityFormViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(amenity.id) {
viewModel.setAmenityName(amenity.name.orEmpty())
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Edit Amenity") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = {
val id = amenity.id.orEmpty()
viewModel.submitUpdate(propertyId, id, 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.name,
onValueChange = viewModel::onNameChange,
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@@ -0,0 +1,168 @@
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.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.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.data.api.model.RoomTypeDto
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun EditRoomTypeScreen(
propertyId: String,
roomType: RoomTypeDto,
onBack: () -> Unit,
onSave: () -> Unit,
viewModel: RoomTypeFormViewModel = viewModel(),
amenityViewModel: AmenityListViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val amenityState by amenityViewModel.state.collectAsState()
LaunchedEffect(roomType.id) {
viewModel.setRoomType(roomType)
}
LaunchedEffect(propertyId) {
amenityViewModel.load(propertyId)
}
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")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
verticalArrangement = Arrangement.Top
) {
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))
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)
}
}
}
}

View File

@@ -5,6 +5,9 @@ data class RoomTypeFormState(
val name: String = "", val name: String = "",
val baseOccupancy: String = "", val baseOccupancy: String = "",
val maxOccupancy: String = "", val maxOccupancy: String = "",
val sqFeet: String = "",
val bathroomSqFeet: String = "",
val amenityIds: Set<String> = emptySet(),
val otaAliases: String = "", val otaAliases: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.RoomTypeCreateRequest import com.android.trisolarispms.data.api.model.RoomTypeCreateRequest
import com.android.trisolarispms.data.api.model.RoomTypeUpdateRequest
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -13,10 +14,33 @@ class RoomTypeFormViewModel : ViewModel() {
private val _state = MutableStateFlow(RoomTypeFormState()) private val _state = MutableStateFlow(RoomTypeFormState())
val state: StateFlow<RoomTypeFormState> = _state val state: StateFlow<RoomTypeFormState> = _state
fun setRoomType(type: com.android.trisolarispms.data.api.model.RoomTypeDto) {
_state.update {
it.copy(
code = type.code ?: "",
name = type.name ?: "",
baseOccupancy = type.baseOccupancy?.toString() ?: "",
maxOccupancy = type.maxOccupancy?.toString() ?: "",
sqFeet = type.sqFeet?.toString() ?: "",
bathroomSqFeet = type.bathroomSqFeet?.toString() ?: "",
amenityIds = type.amenities?.mapNotNull { it.id }?.toSet() ?: emptySet(),
otaAliases = type.otaAliases?.joinToString(",") ?: "",
error = null
)
}
}
fun onCodeChange(value: String) = _state.update { it.copy(code = value.trim().uppercase(), error = null) } fun onCodeChange(value: String) = _state.update { it.copy(code = value.trim().uppercase(), error = null) }
fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null) } fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null) }
fun onBaseOccupancyChange(value: String) = _state.update { it.copy(baseOccupancy = value, error = null) } fun onBaseOccupancyChange(value: String) = _state.update { it.copy(baseOccupancy = value, error = null) }
fun onMaxOccupancyChange(value: String) = _state.update { it.copy(maxOccupancy = value, error = null) } fun onMaxOccupancyChange(value: String) = _state.update { it.copy(maxOccupancy = value, error = null) }
fun onSqFeetChange(value: String) = _state.update { it.copy(sqFeet = value, error = null) }
fun onBathroomSqFeetChange(value: String) = _state.update { it.copy(bathroomSqFeet = value, error = null) }
fun onAmenityToggle(id: String) = _state.update { current ->
val next = current.amenityIds.toMutableSet()
if (next.contains(id)) next.remove(id) else next.add(id)
current.copy(amenityIds = next, error = null)
}
fun onAliasesChange(value: String) = _state.update { it.copy(otaAliases = value, error = null) } fun onAliasesChange(value: String) = _state.update { it.copy(otaAliases = value, error = null) }
fun submit(propertyId: String, onDone: () -> Unit) { fun submit(propertyId: String, onDone: () -> Unit) {
@@ -35,6 +59,9 @@ class RoomTypeFormViewModel : ViewModel() {
name = name, name = name,
baseOccupancy = state.value.baseOccupancy.toIntOrNull(), baseOccupancy = state.value.baseOccupancy.toIntOrNull(),
maxOccupancy = state.value.maxOccupancy.toIntOrNull(), maxOccupancy = state.value.maxOccupancy.toIntOrNull(),
sqFeet = state.value.sqFeet.toIntOrNull(),
bathroomSqFeet = state.value.bathroomSqFeet.toIntOrNull(),
amenityIds = state.value.amenityIds.toList().ifEmpty { null },
otaAliases = state.value.otaAliases.split(',').map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null } otaAliases = state.value.otaAliases.split(',').map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null }
) )
val response = api.createRoomType(propertyId, body) val response = api.createRoomType(propertyId, body)
@@ -49,4 +76,38 @@ class RoomTypeFormViewModel : ViewModel() {
} }
} }
} }
fun submitUpdate(propertyId: String, roomTypeId: String, onDone: () -> Unit) {
val code = state.value.code.trim()
val name = state.value.name.trim()
if (code.isBlank() || name.isBlank()) {
_state.update { it.copy(error = "Code and name are required") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
val body = RoomTypeUpdateRequest(
code = code,
name = name,
baseOccupancy = state.value.baseOccupancy.toIntOrNull(),
maxOccupancy = state.value.maxOccupancy.toIntOrNull(),
sqFeet = state.value.sqFeet.toIntOrNull(),
bathroomSqFeet = state.value.bathroomSqFeet.toIntOrNull(),
amenityIds = state.value.amenityIds.toList().ifEmpty { null },
otaAliases = state.value.otaAliases.split(',').map { it.trim() }.filter { it.isNotBlank() }.ifEmpty { null }
)
val response = api.updateRoomType(propertyId, roomTypeId, body)
if (response.isSuccessful) {
_state.update { it.copy(isLoading = false, success = true) }
onDone()
} else {
_state.update { it.copy(isLoading = false, error = "Update failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Update failed") }
}
}
}
} }

View File

@@ -1,5 +1,6 @@
package com.android.trisolarispms.ui.roomtype package com.android.trisolarispms.ui.roomtype
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.material.icons.filled.Settings
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -33,6 +35,9 @@ fun RoomTypesScreen(
propertyId: String, propertyId: String,
onBack: () -> Unit, onBack: () -> Unit,
onAdd: () -> Unit, onAdd: () -> Unit,
onAmenities: () -> Unit,
canManageRoomTypes: Boolean,
onEdit: (com.android.trisolarispms.data.api.model.RoomTypeDto) -> Unit,
viewModel: RoomTypeListViewModel = viewModel() viewModel: RoomTypeListViewModel = viewModel()
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
@@ -51,9 +56,14 @@ fun RoomTypesScreen(
} }
}, },
actions = { actions = {
if (canManageRoomTypes) {
IconButton(onClick = onAmenities) {
Icon(Icons.Default.Settings, contentDescription = "Amenities")
}
IconButton(onClick = onAdd) { IconButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "Add Room Type") Icon(Icons.Default.Add, contentDescription = "Add Room Type")
} }
}
}, },
colors = TopAppBarDefaults.topAppBarColors() colors = TopAppBarDefaults.topAppBarColors()
) )
@@ -79,8 +89,16 @@ fun RoomTypesScreen(
Text(text = "No room types") Text(text = "No room types")
} else { } else {
state.items.forEach { item -> state.items.forEach { item ->
Text(text = "${item.code}${item.name}", style = MaterialTheme.typography.titleMedium) Text(
Spacer(modifier = Modifier.height(8.dp)) text = "${item.code}${item.name}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = item.id != null) {
onEdit(item)
}
)
Spacer(modifier = Modifier.height(12.dp))
} }
} }
} }