diff --git a/app/src/main/java/com/android/trisolarispms/MainActivity.kt b/app/src/main/java/com/android/trisolarispms/MainActivity.kt index 7ce3124..248b18c 100644 --- a/app/src/main/java/com/android/trisolarispms/MainActivity.kt +++ b/app/src/main/java/com/android/trisolarispms/MainActivity.kt @@ -19,7 +19,11 @@ import com.android.trisolarispms.ui.property.AddPropertyScreen import com.android.trisolarispms.ui.room.RoomFormScreen import com.android.trisolarispms.ui.room.RoomsScreen 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.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.theme.TrisolarisPMSTheme @@ -45,8 +49,13 @@ class MainActivity : ComponentActivity() { val selectedPropertyId = remember { mutableStateOf(null) } val selectedPropertyName = remember { mutableStateOf(null) } val selectedRoom = remember { mutableStateOf(null) } + val selectedRoomType = remember { mutableStateOf(null) } + val selectedAmenity = remember { mutableStateOf(null) } val roomFormKey = remember { mutableStateOf(0) } val currentRoute = route.value + val canManageProperty: (String) -> Boolean = { propertyId -> + state.isSuperAdmin || (state.propertyRoles[propertyId]?.contains("ADMIN") == true) + } when (currentRoute) { AppRoute.Home -> HomeScreen( @@ -89,7 +98,7 @@ class MainActivity : ComponentActivity() { route.value = AppRoute.AddRoom(currentRoute.propertyId) }, onViewRoomTypes = { route.value = AppRoute.RoomTypes(currentRoute.propertyId) }, - canManageRooms = state.isSuperAdmin, + canManageRooms = canManageProperty(currentRoute.propertyId), onEditRoom = { selectedRoom.value = it roomFormKey.value++ @@ -99,13 +108,47 @@ class MainActivity : ComponentActivity() { is AppRoute.RoomTypes -> RoomTypesScreen( propertyId = 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( propertyId = currentRoute.propertyId, onBack = { 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( title = "Add Room", propertyId = currentRoute.propertyId, diff --git a/app/src/main/java/com/android/trisolarispms/data/api/AmenityApi.kt b/app/src/main/java/com/android/trisolarispms/data/api/AmenityApi.kt new file mode 100644 index 0000000..9376fa5 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/AmenityApi.kt @@ -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> + + @POST("properties/{propertyId}/amenities") + suspend fun createAmenity( + @Path("propertyId") propertyId: String, + @Body body: AmenityCreateRequest + ): Response + + @PUT("properties/{propertyId}/amenities/{amenityId}") + suspend fun updateAmenity( + @Path("propertyId") propertyId: String, + @Path("amenityId") amenityId: String, + @Body body: AmenityUpdateRequest + ): Response + + @DELETE("properties/{propertyId}/amenities/{amenityId}") + suspend fun deleteAmenity( + @Path("propertyId") propertyId: String, + @Path("amenityId") amenityId: String + ): Response +} diff --git a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt index e9f0076..2265253 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/ApiService.kt @@ -12,4 +12,5 @@ interface ApiService : GuestApi, GuestDocumentApi, TransportApi, - InboundEmailApi + InboundEmailApi, + AmenityApi diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/AmenityModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/AmenityModels.kt new file mode 100644 index 0000000..dd76f9b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/AmenityModels.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt index 66e444f..4497cad 100644 --- a/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt +++ b/app/src/main/java/com/android/trisolarispms/data/api/model/RoomTypeModels.kt @@ -1,10 +1,15 @@ package com.android.trisolarispms.data.api.model +import com.android.trisolarispms.data.api.model.AmenityDto + data class RoomTypeCreateRequest( val code: String, val name: String, val baseOccupancy: Int? = null, val maxOccupancy: Int? = null, + val sqFeet: Int? = null, + val bathroomSqFeet: Int? = null, + val amenityIds: List? = null, val otaAliases: List? = null ) @@ -13,6 +18,9 @@ data class RoomTypeUpdateRequest( val name: String? = null, val baseOccupancy: Int? = null, val maxOccupancy: Int? = null, + val sqFeet: Int? = null, + val bathroomSqFeet: Int? = null, + val amenityIds: List? = null, val otaAliases: List? = null ) @@ -23,5 +31,8 @@ data class RoomTypeDto( val name: String? = null, val baseOccupancy: Int? = null, val maxOccupancy: Int? = null, + val sqFeet: Int? = null, + val bathroomSqFeet: Int? = null, + val amenities: List? = null, val otaAliases: List? = null ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt index deb8db9..7a23e1e 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/AppRoute.kt @@ -9,4 +9,8 @@ sealed interface AppRoute { data class EditRoom(val propertyId: String, val roomId: String) : AppRoute data class RoomTypes(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 } diff --git a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthUiState.kt b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthUiState.kt index 4a3d84b..5add7fe 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthUiState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthUiState.kt @@ -15,5 +15,6 @@ data class AuthUiState( val userName: String? = null, val nameInput: String = "", val needsName: Boolean = false, - val unauthorized: Boolean = false + val unauthorized: Boolean = false, + val propertyRoles: Map> = emptyMap() ) diff --git a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt index 08aef09..76a146b 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/auth/AuthViewModel.kt @@ -161,6 +161,13 @@ class AuthViewModel( val body = response.body() val userName = body?.user?.name 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 { response.isSuccessful && (body?.status == "OK" || body?.status == "SUPER_ADMIN") -> { _state.update { @@ -174,6 +181,7 @@ class AuthViewModel( isSuperAdmin = isSuperAdmin, noProperties = body?.status == "NO_PROPERTIES", unauthorized = false, + propertyRoles = propertyRoles, error = null ) } @@ -190,6 +198,7 @@ class AuthViewModel( isSuperAdmin = isSuperAdmin, noProperties = true, unauthorized = false, + propertyRoles = propertyRoles, error = null ) } @@ -202,6 +211,7 @@ class AuthViewModel( isSuperAdmin = false, noProperties = false, unauthorized = true, + propertyRoles = emptyMap(), error = "Not authorized. Contact admin." ) } @@ -214,6 +224,7 @@ class AuthViewModel( isSuperAdmin = false, noProperties = false, unauthorized = false, + propertyRoles = emptyMap(), error = "API verify failed: ${response.code()}" ) } diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt new file mode 100644 index 0000000..6c85102 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AddAmenityScreen.kt @@ -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) + } + } + } +} 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 91b61ef..44b37e3 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 @@ -2,15 +2,18 @@ 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 @@ -35,9 +38,15 @@ fun AddRoomTypeScreen( propertyId: String, onBack: () -> Unit, onSave: () -> Unit, - viewModel: RoomTypeFormViewModel = viewModel() + viewModel: RoomTypeFormViewModel = viewModel(), + amenityViewModel: AmenityListViewModel = viewModel() ) { val state by viewModel.state.collectAsState() + val amenityState by amenityViewModel.state.collectAsState() + + LaunchedEffect(propertyId) { + amenityViewModel.load(propertyId) + } Scaffold( topBar = { @@ -98,6 +107,45 @@ fun AddRoomTypeScreen( ) 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, diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenitiesScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenitiesScreen.kt new file mode 100644 index 0000000..2c585f3 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenitiesScreen.kt @@ -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) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormState.kt new file mode 100644 index 0000000..85595ec --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormState.kt @@ -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 +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt new file mode 100644 index 0000000..5e81c72 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityFormViewModel.kt @@ -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 = _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") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListState.kt new file mode 100644 index 0000000..c285250 --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListState.kt @@ -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 = emptyList() +) diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListViewModel.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListViewModel.kt new file mode 100644 index 0000000..11b7fec --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/AmenityListViewModel.kt @@ -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 = _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") } + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt new file mode 100644 index 0000000..d442fcd --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditAmenityScreen.kt @@ -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) + } + } + } +} 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 new file mode 100644 index 0000000..29da60b --- /dev/null +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/EditRoomTypeScreen.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormState.kt b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormState.kt index cab0f00..9e3d15f 100644 --- a/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormState.kt +++ b/app/src/main/java/com/android/trisolarispms/ui/roomtype/RoomTypeFormState.kt @@ -5,6 +5,9 @@ data class RoomTypeFormState( val name: String = "", val baseOccupancy: String = "", val maxOccupancy: String = "", + val sqFeet: String = "", + val bathroomSqFeet: String = "", + val amenityIds: Set = emptySet(), val otaAliases: String = "", val isLoading: Boolean = false, val error: String? = null, 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 4516d99..1455461 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.trisolarispms.data.api.ApiClient 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.StateFlow import kotlinx.coroutines.flow.update @@ -13,10 +14,33 @@ class RoomTypeFormViewModel : ViewModel() { private val _state = MutableStateFlow(RoomTypeFormState()) val state: StateFlow = _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 onNameChange(value: String) = _state.update { it.copy(name = 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 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 submit(propertyId: String, onDone: () -> Unit) { @@ -35,6 +59,9 @@ class RoomTypeFormViewModel : ViewModel() { 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.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") } + } + } + } } 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 99313dd..fec1ca3 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 @@ -1,5 +1,6 @@ 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 @@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.material.icons.filled.Settings @Composable @OptIn(ExperimentalMaterial3Api::class) @@ -33,6 +35,9 @@ fun RoomTypesScreen( propertyId: String, onBack: () -> Unit, onAdd: () -> Unit, + onAmenities: () -> Unit, + canManageRoomTypes: Boolean, + onEdit: (com.android.trisolarispms.data.api.model.RoomTypeDto) -> Unit, viewModel: RoomTypeListViewModel = viewModel() ) { val state by viewModel.state.collectAsState() @@ -51,8 +56,13 @@ fun RoomTypesScreen( } }, actions = { - IconButton(onClick = onAdd) { - Icon(Icons.Default.Add, contentDescription = "Add Room Type") + if (canManageRoomTypes) { + IconButton(onClick = onAmenities) { + Icon(Icons.Default.Settings, contentDescription = "Amenities") + } + IconButton(onClick = onAdd) { + Icon(Icons.Default.Add, contentDescription = "Add Room Type") + } } }, colors = TopAppBarDefaults.topAppBarColors() @@ -79,8 +89,16 @@ fun RoomTypesScreen( Text(text = "No room types") } else { state.items.forEach { item -> - Text(text = "${item.code} • ${item.name}", style = MaterialTheme.typography.titleMedium) - Spacer(modifier = Modifier.height(8.dp)) + Text( + 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)) } } }