users and permission manage

This commit is contained in:
androidlover5842
2026-02-01 23:33:15 +05:30
parent 3219e40a02
commit 86307a66c8
26 changed files with 1624 additions and 100 deletions

View File

@@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.trisolarispms.ui.AppRoute import com.android.trisolarispms.ui.AppRoute
import com.android.trisolarispms.ui.auth.AuthScreen import com.android.trisolarispms.ui.auth.AuthScreen
@@ -38,6 +39,9 @@ import com.android.trisolarispms.ui.roomimage.EditImageTagScreen
import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen import com.android.trisolarispms.ui.roomstay.ActiveRoomStaysScreen
import com.android.trisolarispms.ui.razorpay.RazorpaySettingsScreen import com.android.trisolarispms.ui.razorpay.RazorpaySettingsScreen
import com.android.trisolarispms.ui.razorpay.RazorpayQrScreen import com.android.trisolarispms.ui.razorpay.RazorpayQrScreen
import com.android.trisolarispms.ui.users.PropertyUsersScreen
import com.android.trisolarispms.ui.users.PropertyAccessCodeScreen
import com.android.trisolarispms.ui.users.SuperAdminUserDirectoryScreen
import com.android.trisolarispms.ui.roomtype.AddAmenityScreen 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.AmenitiesScreen
@@ -78,28 +82,69 @@ class MainActivity : ComponentActivity() {
val roomFormKey = remember { mutableStateOf(0) } val roomFormKey = remember { mutableStateOf(0) }
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) } val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
val currentRoute = route.value val currentRoute = route.value
val singlePropertyId = state.propertyRoles.keys.firstOrNull()
val singlePropertyIsAdmin = singlePropertyId?.let {
state.propertyRoles[it]?.contains("ADMIN") == true
} ?: false
val canManageProperty: (String) -> Boolean = { propertyId -> val canManageProperty: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || (state.propertyRoles[propertyId]?.contains("ADMIN") == true) state.isSuperAdmin || (state.propertyRoles[propertyId]?.contains("ADMIN") == true)
} }
val canIssueTemporaryCard: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
it == "ADMIN" || it == "MANAGER"
} == true
}
val canViewCardInfo: (String) -> Boolean = { propertyId -> val canViewCardInfo: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.any { state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
it == "ADMIN" || it == "MANAGER" || it == "STAFF" it == "ADMIN" || it == "MANAGER" || it == "STAFF"
} == true } == true
} }
val canManageRazorpaySettings: (String) -> Boolean = { propertyId -> val canManageRazorpaySettings: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.any { state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true
it == "ADMIN" || it == "MANAGER"
} == true
} }
val canDeleteCashPayment: (String) -> Boolean = { propertyId -> val canDeleteCashPayment: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true
} }
val canManagePropertyUsers: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true
}
val canCreateBookingFor: (String) -> Boolean = { propertyId ->
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
it == "ADMIN" || it == "MANAGER" || it == "STAFF"
} == true
}
val allowedAccessCodeRoles: (String) -> List<String> = { propertyId ->
if (state.isSuperAdmin) {
listOf("MANAGER", "STAFF", "AGENT")
} else {
val roles = state.propertyRoles[propertyId].orEmpty()
when {
roles.contains("ADMIN") -> listOf("MANAGER", "STAFF", "AGENT")
roles.contains("MANAGER") -> listOf("STAFF", "AGENT")
else -> emptyList()
}
}
}
BackHandler(enabled = currentRoute != AppRoute.Home) { BackHandler(enabled = currentRoute != AppRoute.Home) {
when (currentRoute) { when (currentRoute) {
AppRoute.Home -> Unit AppRoute.Home -> Unit
AppRoute.AddProperty -> route.value = AppRoute.Home AppRoute.AddProperty -> route.value = AppRoute.Home
is AppRoute.ActiveRoomStays -> route.value = AppRoute.Home AppRoute.SuperAdminUsers -> route.value = AppRoute.Home
is AppRoute.ActiveRoomStays -> {
val isAdmin = state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true
val blockBack = !state.isSuperAdmin && !isAdmin && state.propertyRoles.size == 1
if (!blockBack) {
route.value = AppRoute.Home
}
}
is AppRoute.PropertyUsers -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
is AppRoute.PropertyAccessCode -> route.value = AppRoute.PropertyUsers(
currentRoute.propertyId
)
is AppRoute.Rooms -> route.value = AppRoute.ActiveRoomStays( is AppRoute.Rooms -> route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId, currentRoute.propertyId,
selectedPropertyName.value ?: "Property" selectedPropertyName.value ?: "Property"
@@ -185,11 +230,28 @@ class MainActivity : ComponentActivity() {
} }
} }
if (currentRoute == AppRoute.Home &&
!state.isSuperAdmin &&
!singlePropertyIsAdmin &&
state.propertyRoles.size == 1 &&
singlePropertyId != null
) {
LaunchedEffect(singlePropertyId) {
selectedPropertyId.value = singlePropertyId
route.value = AppRoute.ActiveRoomStays(
singlePropertyId,
selectedPropertyName.value ?: "Property"
)
}
}
when (currentRoute) { when (currentRoute) {
AppRoute.Home -> HomeScreen( AppRoute.Home -> HomeScreen(
userId = state.userId, userId = state.userId,
userName = state.userName, userName = state.userName,
isSuperAdmin = state.isSuperAdmin, isSuperAdmin = state.isSuperAdmin,
onUserDirectory = { route.value = AppRoute.SuperAdminUsers },
onLogout = authViewModel::signOut,
onAddProperty = { route.value = AppRoute.AddProperty }, onAddProperty = { route.value = AppRoute.AddProperty },
onAmenities = { onAmenities = {
amenitiesReturnRoute.value = AppRoute.Home amenitiesReturnRoute.value = AppRoute.Home
@@ -203,7 +265,20 @@ class MainActivity : ComponentActivity() {
selectedPropertyName.value = name selectedPropertyName.value = name
route.value = AppRoute.ActiveRoomStays(id, name) route.value = AppRoute.ActiveRoomStays(id, name)
}, },
onRefreshProfile = authViewModel::refreshMe onRefreshProfile = authViewModel::refreshMe,
showJoinProperty = state.propertyRoles.isEmpty() && !state.isSuperAdmin,
onJoinPropertySuccess = { joinedPropertyId, joinedRoles ->
refreshKey.value++
authViewModel.refreshMe()
val isAdmin = joinedRoles.contains("ADMIN")
val shouldAutoOpen = !state.isSuperAdmin && !isAdmin && state.propertyRoles.size <= 1
if (shouldAutoOpen) {
route.value = AppRoute.ActiveRoomStays(
joinedPropertyId,
selectedPropertyName.value ?: "Property"
)
}
}
) )
AppRoute.AddProperty -> AddPropertyScreen( AppRoute.AddProperty -> AddPropertyScreen(
onBack = { route.value = AppRoute.Home }, onBack = { route.value = AppRoute.Home },
@@ -275,11 +350,26 @@ class MainActivity : ComponentActivity() {
is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen( is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
propertyName = currentRoute.propertyName, propertyName = currentRoute.propertyName,
onBack = { route.value = AppRoute.Home }, onBack = {
val isAdmin = state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true
val blockBack = !state.isSuperAdmin && !isAdmin && state.propertyRoles.size == 1
if (!blockBack) {
route.value = AppRoute.Home
}
},
showBack = run {
val isAdmin = state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true
val blockBack = !state.isSuperAdmin && !isAdmin && state.propertyRoles.size == 1
!blockBack
},
onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) }, onViewRooms = { route.value = AppRoute.Rooms(currentRoute.propertyId) },
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) }, onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
canCreateBooking = canCreateBookingFor(currentRoute.propertyId),
showRazorpaySettings = canManageRazorpaySettings(currentRoute.propertyId), showRazorpaySettings = canManageRazorpaySettings(currentRoute.propertyId),
onRazorpaySettings = { route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) }, onRazorpaySettings = { route.value = AppRoute.RazorpaySettings(currentRoute.propertyId) },
showUserAdmin = canManagePropertyUsers(currentRoute.propertyId),
onUserAdmin = { route.value = AppRoute.PropertyUsers(currentRoute.propertyId) },
onLogout = authViewModel::signOut,
onManageRoomStay = { booking -> onManageRoomStay = { booking ->
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() } val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
?: booking.expectedCheckInAt.orEmpty() ?: booking.expectedCheckInAt.orEmpty()
@@ -500,6 +590,37 @@ class MainActivity : ComponentActivity() {
) )
} }
) )
AppRoute.SuperAdminUsers -> SuperAdminUserDirectoryScreen(
onBack = { route.value = AppRoute.Home }
)
is AppRoute.PropertyUsers -> PropertyUsersScreen(
propertyId = currentRoute.propertyId,
allowedRoleAssignments = when {
state.isSuperAdmin -> listOf("ADMIN", "MANAGER", "STAFF", "AGENT")
state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true ->
listOf("ADMIN", "MANAGER", "STAFF", "AGENT")
state.propertyRoles[currentRoute.propertyId]?.contains("MANAGER") == true ->
listOf("STAFF", "AGENT")
else -> emptyList()
},
canDisableAdmin = state.isSuperAdmin ||
state.propertyRoles[currentRoute.propertyId]?.contains("ADMIN") == true,
canDisableManager = state.propertyRoles[currentRoute.propertyId]?.contains("MANAGER") == true,
onBack = {
route.value = AppRoute.ActiveRoomStays(
currentRoute.propertyId,
selectedPropertyName.value ?: "Property"
)
},
onOpenAccessCode = {
route.value = AppRoute.PropertyAccessCode(currentRoute.propertyId)
}
)
is AppRoute.PropertyAccessCode -> PropertyAccessCodeScreen(
propertyId = currentRoute.propertyId,
allowedRoles = allowedAccessCodeRoles(currentRoute.propertyId),
onBack = { route.value = AppRoute.PropertyUsers(currentRoute.propertyId) }
)
is AppRoute.Rooms -> RoomsScreen( is AppRoute.Rooms -> RoomsScreen(
propertyId = currentRoute.propertyId, propertyId = currentRoute.propertyId,
onBack = { onBack = {
@@ -516,13 +637,14 @@ class MainActivity : ComponentActivity() {
onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) }, onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) },
canManageRooms = canManageProperty(currentRoute.propertyId), canManageRooms = canManageProperty(currentRoute.propertyId),
canViewCardInfo = canViewCardInfo(currentRoute.propertyId), canViewCardInfo = canViewCardInfo(currentRoute.propertyId),
canIssueTemporaryCard = canIssueTemporaryCard(currentRoute.propertyId),
onEditRoom = { onEditRoom = {
selectedRoom.value = it selectedRoom.value = it
roomFormKey.value++ roomFormKey.value++
route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "") route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "")
}, },
onIssueTemporaryCard = { onIssueTemporaryCard = {
if (it.id != null) { if (it.id != null && canIssueTemporaryCard(currentRoute.propertyId)) {
selectedRoom.value = it selectedRoom.value = it
route.value = AppRoute.IssueTemporaryCard(currentRoute.propertyId, it.id) route.value = AppRoute.IssueTemporaryCard(currentRoute.propertyId, it.id)
} }

View File

@@ -16,4 +16,5 @@ interface ApiService :
InboundEmailApi, InboundEmailApi,
AmenityApi, AmenityApi,
RatePlanApi, RatePlanApi,
RazorpaySettingsApi RazorpaySettingsApi,
UserAdminApi

View File

@@ -4,7 +4,9 @@ import com.android.trisolarispms.data.api.model.ActionResponse
import com.android.trisolarispms.data.api.model.PropertyCreateRequest import com.android.trisolarispms.data.api.model.PropertyCreateRequest
import com.android.trisolarispms.data.api.model.PropertyDto import com.android.trisolarispms.data.api.model.PropertyDto
import com.android.trisolarispms.data.api.model.PropertyUpdateRequest import com.android.trisolarispms.data.api.model.PropertyUpdateRequest
import com.android.trisolarispms.data.api.model.UserDto import com.android.trisolarispms.data.api.model.PropertyUserResponse
import com.android.trisolarispms.data.api.model.PropertyUserDisabledRequest
import com.android.trisolarispms.data.api.model.PropertyCodeResponse
import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
@@ -28,18 +30,30 @@ interface PropertyApi {
): Response<PropertyDto> ): Response<PropertyDto>
@GET("properties/{propertyId}/users") @GET("properties/{propertyId}/users")
suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response<List<UserDto>> suspend fun listPropertyUsers(@Path("propertyId") propertyId: String): Response<List<PropertyUserResponse>>
@PUT("properties/{propertyId}/users/{userId}/roles") @PUT("properties/{propertyId}/users/{userId}/roles")
suspend fun updateUserRoles( suspend fun updateUserRoles(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("userId") userId: String, @Path("userId") userId: String,
@Body body: UserRolesUpdateRequest @Body body: UserRolesUpdateRequest
): Response<ActionResponse> ): Response<PropertyUserResponse>
@DELETE("properties/{propertyId}/users/{userId}") @DELETE("properties/{propertyId}/users/{userId}")
suspend fun deletePropertyUser( suspend fun deletePropertyUser(
@Path("propertyId") propertyId: String, @Path("propertyId") propertyId: String,
@Path("userId") userId: String @Path("userId") userId: String
): Response<ActionResponse> ): Response<ActionResponse>
@GET("properties/{propertyId}/code")
suspend fun getPropertyCode(
@Path("propertyId") propertyId: String
): Response<PropertyCodeResponse>
@PUT("properties/{propertyId}/users/{userId}/disabled")
suspend fun updateUserDisabled(
@Path("propertyId") propertyId: String,
@Path("userId") userId: String,
@Body body: PropertyUserDisabledRequest
): Response<PropertyUserResponse>
} }

View File

@@ -0,0 +1,38 @@
package com.android.trisolarispms.data.api
import com.android.trisolarispms.data.api.model.AppUserSummaryResponse
import com.android.trisolarispms.data.api.model.PropertyAccessCodeCreateRequest
import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest
import com.android.trisolarispms.data.api.model.PropertyAccessCodeResponse
import com.android.trisolarispms.data.api.model.PropertyUserDetailsResponse
import com.android.trisolarispms.data.api.model.PropertyUserResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface UserAdminApi {
@GET("users")
suspend fun listUsers(
@Query("phone") phone: String? = null
): Response<List<AppUserSummaryResponse>>
@GET("properties/{propertyId}/users/search")
suspend fun searchPropertyUsers(
@Path("propertyId") propertyId: String,
@Query("phone") phone: String? = null
): Response<List<PropertyUserDetailsResponse>>
@POST("properties/{propertyId}/access-codes")
suspend fun createAccessCode(
@Path("propertyId") propertyId: String,
@Body body: PropertyAccessCodeCreateRequest
): Response<PropertyAccessCodeResponse>
@POST("properties/access-codes/join")
suspend fun joinAccessCode(
@Body body: PropertyAccessCodeJoinRequest
): Response<PropertyUserResponse>
}

View File

@@ -1,7 +1,7 @@
package com.android.trisolarispms.data.api.model package com.android.trisolarispms.data.api.model
data class PropertyCreateRequest( data class PropertyCreateRequest(
val code: String, val code: String? = null,
val name: String, val name: String,
val addressText: String? = null, val addressText: String? = null,
val timezone: String? = null, val timezone: String? = null,
@@ -36,3 +36,7 @@ data class PropertyDto(
val emailAddresses: List<String>? = null, val emailAddresses: List<String>? = null,
val allowedTransportModes: List<String>? = null val allowedTransportModes: List<String>? = null
) )
data class PropertyCodeResponse(
val code: String? = null
)

View File

@@ -0,0 +1,45 @@
package com.android.trisolarispms.data.api.model
data class AppUserSummaryResponse(
val id: String? = null,
val phoneE164: String? = null,
val name: String? = null,
val disabled: Boolean = false,
val superAdmin: Boolean = false
)
data class PropertyUserDetailsResponse(
val userId: String? = null,
val propertyId: String? = null,
val roles: List<String> = emptyList(),
val name: String? = null,
val phoneE164: String? = null,
val disabled: Boolean = false,
val superAdmin: Boolean = false
)
data class PropertyAccessCodeCreateRequest(
val roles: List<String>
)
data class PropertyAccessCodeResponse(
val propertyId: String? = null,
val code: String? = null,
val expiresAt: String? = null,
val roles: List<String> = emptyList()
)
data class PropertyAccessCodeJoinRequest(
val propertyId: String,
val code: String
)
data class PropertyUserResponse(
val userId: String? = null,
val propertyId: String? = null,
val roles: List<String> = emptyList()
)
data class PropertyUserDisabledRequest(
val disabled: Boolean
)

View File

@@ -74,6 +74,9 @@ sealed interface AppRoute {
val pendingAmount: Long?, val pendingAmount: Long?,
val guestPhone: String? val guestPhone: String?
) : AppRoute ) : AppRoute
data object SuperAdminUsers : AppRoute
data class PropertyUsers(val propertyId: String) : AppRoute
data class PropertyAccessCode(val propertyId: String) : AppRoute
data object Amenities : AppRoute data object Amenities : AppRoute
data object AddAmenity : AppRoute data object AddAmenity : AppRoute
data class EditAmenity(val amenityId: String) : AppRoute data class EditAmenity(val amenityId: String) : AppRoute

View File

@@ -17,47 +17,129 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
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.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import com.android.trisolarispms.ui.booking.findPhoneCountryOption
import com.android.trisolarispms.ui.booking.phoneCountryOptions
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AuthScreen(viewModel: AuthViewModel = viewModel()) { fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val activity = context as? ComponentActivity val activity = context as? ComponentActivity
val phoneCountryMenuExpanded = remember { mutableStateOf(false) }
val phoneCountries = remember { phoneCountryOptions() }
val phoneCountrySearch = remember { mutableStateOf("") }
val now = remember { mutableStateOf(System.currentTimeMillis()) }
LaunchedEffect(state.resendAvailableAt) {
while (state.resendAvailableAt != null) {
now.value = System.currentTimeMillis()
delay(1000)
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp), .padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium) Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField( val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
value = state.phone, Row(
onValueChange = viewModel::onPhoneChange, modifier = Modifier.fillMaxWidth(),
label = { Text("Phone number") }, horizontalArrangement = Arrangement.spacedBy(8.dp)
placeholder = { Text("10-digit mobile") }, ) {
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), ExposedDropdownMenuBox(
prefix = { Text(state.countryCode) }, expanded = phoneCountryMenuExpanded.value,
modifier = Modifier.fillMaxWidth() onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
) modifier = Modifier.weight(0.35f)
Spacer(modifier = Modifier.height(12.dp)) ) {
OutlinedTextField(
Text( value = "${selectedCountry.code} +${selectedCountry.dialCode}",
text = "Default country: India (+91)", onValueChange = {},
style = MaterialTheme.typography.bodySmall, readOnly = true,
color = MaterialTheme.colorScheme.onSurfaceVariant label = { Text("Country") },
) trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = phoneCountryMenuExpanded.value)
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = phoneCountryMenuExpanded.value,
onDismissRequest = { phoneCountryMenuExpanded.value = false }
) {
OutlinedTextField(
value = phoneCountrySearch.value,
onValueChange = { phoneCountrySearch.value = it },
label = { Text("Search") },
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
val filtered = phoneCountries.filter { option ->
val query = phoneCountrySearch.value.trim()
if (query.isBlank()) true
else option.name.contains(query, ignoreCase = true) ||
option.code.contains(query, ignoreCase = true) ||
option.dialCode.contains(query)
}
filtered.forEach { option ->
DropdownMenuItem(
text = { Text("${option.name} (+${option.dialCode})") },
onClick = {
phoneCountryMenuExpanded.value = false
phoneCountrySearch.value = ""
viewModel.onPhoneCountryChange(option.code)
}
)
}
}
}
OutlinedTextField(
value = state.phoneNationalNumber,
onValueChange = viewModel::onPhoneNationalNumberChange,
label = { Text("Number") },
prefix = { Text("+${selectedCountry.dialCode}") },
supportingText = { Text("Max ${selectedCountry.maxLength} digits") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.weight(0.65f)
)
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
val canResend = state.resendAvailableAt?.let { now.value >= it } ?: true
val resendText = if (!state.isCodeSent) {
"Send code"
} else if (!canResend) {
val remaining = ((state.resendAvailableAt ?: 0L) - now.value) / 1000
"Resend in ${remaining.coerceAtLeast(0)}s"
} else {
"Resend code"
}
Button( Button(
onClick = { onClick = {
if (activity != null) { if (activity != null) {
@@ -66,9 +148,9 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
viewModel.reportError("Unable to access activity for phone auth") viewModel.reportError("Unable to access activity for phone auth")
} }
}, },
enabled = !state.isLoading enabled = !state.isLoading && (!state.isCodeSent || canResend)
) { ) {
Text(if (state.isCodeSent) "Resend code" else "Send code") Text(resendText)
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))

View File

@@ -2,10 +2,13 @@ package com.android.trisolarispms.ui.auth
data class AuthUiState( data class AuthUiState(
val countryCode: String = "+91", val countryCode: String = "+91",
val phoneCountryCode: String = "IN",
val phoneNationalNumber: String = "",
val phone: String = "", val phone: String = "",
val code: String = "", val code: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isCodeSent: Boolean = false, val isCodeSent: Boolean = false,
val resendAvailableAt: Long? = null,
val verificationId: String? = null, val verificationId: String? = null,
val error: String? = null, val error: String? = null,
val userId: String? = null, val userId: String? = null,

View File

@@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AuthViewModel( class AuthViewModel(
@@ -23,6 +25,7 @@ class AuthViewModel(
val state: StateFlow<AuthUiState> = _state val state: StateFlow<AuthUiState> = _state
private var resendToken: PhoneAuthProvider.ForceResendingToken? = null private var resendToken: PhoneAuthProvider.ForceResendingToken? = null
private var resendCooldownJob: Job? = null
init { init {
val user = auth.currentUser val user = auth.currentUser
@@ -31,13 +34,28 @@ class AuthViewModel(
} }
} }
fun onPhoneChange(value: String) { fun onPhoneCountryChange(value: String) {
val digits = value.filter { it.isDigit() }.take(10) val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(value)
_state.update { it.copy(phone = digits, error = null) } val trimmed = state.value.phoneNationalNumber.take(option.maxLength)
_state.update {
it.copy(
phoneCountryCode = option.code,
countryCode = "+${option.dialCode}",
phoneNationalNumber = trimmed,
error = null
)
}
}
fun onPhoneNationalNumberChange(value: String) {
val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(state.value.phoneCountryCode)
val digits = value.filter { it.isDigit() }.take(option.maxLength)
_state.update { it.copy(phoneNationalNumber = digits, error = null) }
} }
fun onCodeChange(value: String) { fun onCodeChange(value: String) {
_state.update { it.copy(code = value, error = null) } val digits = value.filter { it.isDigit() }.take(6)
_state.update { it.copy(code = digits, error = null) }
} }
fun onNameChange(value: String) { fun onNameChange(value: String) {
@@ -57,9 +75,14 @@ class AuthViewModel(
} }
fun sendCode(activity: ComponentActivity) { fun sendCode(activity: ComponentActivity) {
val now = System.currentTimeMillis()
val resendAt = state.value.resendAvailableAt
if (resendAt != null && now < resendAt) {
return
}
val phone = buildE164Phone() val phone = buildE164Phone()
if (phone == null) { if (phone == null) {
setError("Enter a valid 10-digit phone number") setError("Enter a valid phone number")
return return
} }
@@ -83,10 +106,12 @@ class AuthViewModel(
it.copy( it.copy(
isLoading = false, isLoading = false,
isCodeSent = true, isCodeSent = true,
resendAvailableAt = System.currentTimeMillis() + 60_000,
verificationId = verificationId, verificationId = verificationId,
error = null error = null
) )
} }
startResendCooldown()
} }
} }
@@ -104,9 +129,10 @@ class AuthViewModel(
} }
private fun buildE164Phone(): String? { private fun buildE164Phone(): String? {
val digits = state.value.phone.trim() val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(state.value.phoneCountryCode)
if (digits.length != 10) return null val digits = state.value.phoneNationalNumber.trim()
return "${state.value.countryCode}$digits" if (digits.length != option.maxLength) return null
return "+${option.dialCode}$digits"
} }
fun verifyCode() { fun verifyCode() {
@@ -121,6 +147,10 @@ class AuthViewModel(
setError("Enter the code") setError("Enter the code")
return return
} }
if (code.length != 6) {
setError("Enter the 6-digit code")
return
}
val credential = PhoneAuthProvider.getCredential(verificationId, code) val credential = PhoneAuthProvider.getCredential(verificationId, code)
signInWithCredential(credential) signInWithCredential(credential)
@@ -141,6 +171,25 @@ class AuthViewModel(
} }
} }
private fun startResendCooldown() {
resendCooldownJob?.cancel()
resendCooldownJob = viewModelScope.launch {
while (true) {
val remaining = (state.value.resendAvailableAt ?: 0L) - System.currentTimeMillis()
if (remaining <= 0) {
_state.update { it.copy(resendAvailableAt = null) }
break
}
delay(1000)
}
}
}
override fun onCleared() {
super.onCleared()
resendCooldownJob?.cancel()
}
private fun verifyExistingSession(userId: String) { private fun verifyExistingSession(userId: String) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, userId = userId) } _state.update { it.copy(isLoading = true, error = null, userId = userId) }

View File

@@ -0,0 +1,64 @@
package com.android.trisolarispms.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class HomeJoinPropertyState(
val isLoading: Boolean = false,
val error: String? = null,
val message: String? = null,
val joinedPropertyId: String? = null,
val joinedRoles: List<String> = emptyList()
)
class HomeJoinPropertyViewModel : ViewModel() {
private val _state = MutableStateFlow(HomeJoinPropertyState())
val state: StateFlow<HomeJoinPropertyState> = _state
fun join(propertyId: String, code: String) {
val trimmedPropertyId = propertyId.trim()
val digits = code.filter { it.isDigit() }
if (trimmedPropertyId.isBlank()) {
_state.update { it.copy(error = "Property ID is required", message = null) }
return
}
if (digits.length != 6) {
_state.update { it.copy(error = "Code must be 6 digits", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val response = ApiClient.create().joinAccessCode(
PropertyAccessCodeJoinRequest(
propertyId = trimmedPropertyId,
code = digits
)
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
isLoading = false,
message = "Joined property",
error = null,
joinedPropertyId = body.propertyId ?: trimmedPropertyId,
joinedRoles = body.roles
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Join failed") }
}
}
}
}

View File

@@ -9,6 +9,7 @@ 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.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@@ -29,9 +30,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
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 com.android.trisolarispms.ui.property.PropertyListViewModel import com.android.trisolarispms.ui.property.PropertyListViewModel
import androidx.compose.foundation.text.KeyboardOptions
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -39,6 +42,8 @@ fun HomeScreen(
userId: String?, userId: String?,
userName: String?, userName: String?,
isSuperAdmin: Boolean, isSuperAdmin: Boolean,
onUserDirectory: () -> Unit,
onLogout: () -> Unit,
onAddProperty: () -> Unit, onAddProperty: () -> Unit,
onAmenities: () -> Unit, onAmenities: () -> Unit,
onImageTags: () -> Unit, onImageTags: () -> Unit,
@@ -46,10 +51,16 @@ fun HomeScreen(
selectedPropertyId: String?, selectedPropertyId: String?,
onSelectProperty: (String, String) -> Unit, onSelectProperty: (String, String) -> Unit,
onRefreshProfile: () -> Unit, onRefreshProfile: () -> Unit,
viewModel: PropertyListViewModel = viewModel() showJoinProperty: Boolean,
onJoinPropertySuccess: (String, List<String>) -> Unit,
viewModel: PropertyListViewModel = viewModel(),
joinViewModel: HomeJoinPropertyViewModel = viewModel()
) { ) {
var menuExpanded by remember { mutableStateOf(false) } var menuExpanded by remember { mutableStateOf(false) }
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val joinState by joinViewModel.state.collectAsState()
val joinPropertyId = remember { mutableStateOf("") }
val joinCode = remember { mutableStateOf("") }
LaunchedEffect(refreshKey) { LaunchedEffect(refreshKey) {
viewModel.refresh() viewModel.refresh()
@@ -58,6 +69,13 @@ fun HomeScreen(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
onRefreshProfile() onRefreshProfile()
} }
LaunchedEffect(joinState.joinedPropertyId, joinState.joinedRoles) {
val joinedPropertyId = joinState.joinedPropertyId
if (!joinedPropertyId.isNullOrBlank()) {
onRefreshProfile()
onJoinPropertySuccess(joinedPropertyId, joinState.joinedRoles)
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@@ -65,6 +83,11 @@ fun HomeScreen(
title = { Text("Trisolaris PMS") }, title = { Text("Trisolaris PMS") },
colors = TopAppBarDefaults.topAppBarColors(), colors = TopAppBarDefaults.topAppBarColors(),
actions = { actions = {
if (isSuperAdmin) {
IconButton(onClick = onUserDirectory) {
Icon(Icons.Default.People, contentDescription = "User Directory")
}
}
IconButton(onClick = { menuExpanded = true }) { IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Menu") Icon(Icons.Default.MoreVert, contentDescription = "Menu")
} }
@@ -72,14 +95,21 @@ fun HomeScreen(
expanded = menuExpanded, expanded = menuExpanded,
onDismissRequest = { menuExpanded = false } onDismissRequest = { menuExpanded = false }
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Add Property") }, text = { Text("Add Property") },
onClick = { onClick = {
menuExpanded = false menuExpanded = false
onAddProperty() onAddProperty()
} }
) )
if (isSuperAdmin) { DropdownMenuItem(
text = { Text("Logout") },
onClick = {
menuExpanded = false
onLogout()
}
)
if (isSuperAdmin) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Update Tags") }, text = { Text("Update Tags") },
onClick = { onClick = {
@@ -115,6 +145,41 @@ fun HomeScreen(
if (userId != null) { if (userId != null) {
Text(text = "User: $userId", style = MaterialTheme.typography.bodySmall) Text(text = "User: $userId", style = MaterialTheme.typography.bodySmall)
} }
if (showJoinProperty) {
Spacer(modifier = Modifier.height(12.dp))
Text(text = "Join property", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(6.dp))
androidx.compose.material3.OutlinedTextField(
value = joinPropertyId.value,
onValueChange = { joinPropertyId.value = it },
label = { Text("Property ID") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.OutlinedTextField(
value = joinCode.value,
onValueChange = { joinCode.value = it.filter { ch -> ch.isDigit() } },
label = { Text("6-digit code") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
androidx.compose.material3.Button(
onClick = { joinViewModel.join(joinPropertyId.value, joinCode.value) },
modifier = Modifier.fillMaxWidth(),
enabled = !joinState.isLoading
) {
Text("Join property")
}
joinState.error?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
joinState.message?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(text = it, color = MaterialTheme.colorScheme.primary)
}
}
if (state.isLoading) { if (state.isLoading) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))

View File

@@ -92,20 +92,6 @@ fun AddPropertyScreen(
) { ) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = state.code,
onValueChange = viewModel::onCodeChange,
label = { Text("Code") },
isError = state.codeError != null,
modifier = Modifier.fillMaxWidth()
)
state.codeError?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(12.dp))
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
onExpandedChange = { expanded = it } onExpandedChange = { expanded = it }

View File

@@ -1,13 +1,11 @@
package com.android.trisolarispms.ui.property package com.android.trisolarispms.ui.property
data class AddPropertyState( data class AddPropertyState(
val code: String = "",
val name: String = "", val name: String = "",
val addressText: String = "", val addressText: String = "",
val timezone: String = "Asia/Kolkata", val timezone: String = "Asia/Kolkata",
val currency: String = "INR", val currency: String = "INR",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val codeError: String? = null,
val nameError: String? = null, val nameError: String? = null,
val error: String? = null, val error: String? = null,
val createdPropertyId: String? = null, val createdPropertyId: String? = null,

View File

@@ -13,15 +13,9 @@ class AddPropertyViewModel : ViewModel() {
private val _state = MutableStateFlow(AddPropertyState()) private val _state = MutableStateFlow(AddPropertyState())
val state: StateFlow<AddPropertyState> = _state val state: StateFlow<AddPropertyState> = _state
fun onCodeChange(value: String) {
val normalized = value.trim().uppercase()
_state.update { it.copy(code = normalized, codeError = null, error = null, codeAuto = false) }
}
fun onNameChange(value: String) { fun onNameChange(value: String) {
_state.update { current -> _state.update { current ->
val nextCode = if (current.codeAuto) generateCode(value) else current.code current.copy(name = value, nameError = null, error = null)
current.copy(name = value, nameError = null, error = null, code = nextCode)
} }
} }
@@ -40,27 +34,21 @@ class AddPropertyViewModel : ViewModel() {
fun applyPlace(name: String?, address: String?) { fun applyPlace(name: String?, address: String?) {
_state.update { current -> _state.update { current ->
val nextName = if (current.name.isBlank() && !name.isNullOrBlank()) name else current.name val nextName = if (current.name.isBlank() && !name.isNullOrBlank()) name else current.name
val nextCode = if (current.codeAuto && nextName.isNotBlank()) generateCode(nextName) else current.code
current.copy( current.copy(
name = nextName, name = nextName,
addressText = address ?: current.addressText, addressText = address ?: current.addressText,
code = nextCode,
error = null, error = null,
nameError = null, nameError = null
codeError = null
) )
} }
} }
fun submit() { fun submit() {
val code = state.value.code.trim()
val name = state.value.name.trim() val name = state.value.name.trim()
val codeError = validateCode(code)
val nameError = validateName(name) val nameError = validateName(name)
if (codeError != null || nameError != null) { if (nameError != null) {
_state.update { _state.update {
it.copy( it.copy(
codeError = codeError,
nameError = nameError, nameError = nameError,
error = "Please fix the highlighted fields" error = "Please fix the highlighted fields"
) )
@@ -73,7 +61,7 @@ class AddPropertyViewModel : ViewModel() {
try { try {
val api = ApiClient.create() val api = ApiClient.create()
val body = PropertyCreateRequest( val body = PropertyCreateRequest(
code = code, code = null,
name = name, name = name,
addressText = state.value.addressText.takeIf { it.isNotBlank() }, addressText = state.value.addressText.takeIf { it.isNotBlank() },
timezone = state.value.timezone.takeIf { it.isNotBlank() }, timezone = state.value.timezone.takeIf { it.isNotBlank() },
@@ -101,19 +89,6 @@ class AddPropertyViewModel : ViewModel() {
_state.update { AddPropertyState() } _state.update { AddPropertyState() }
} }
private fun generateCode(name: String): String {
val base = name.filter { it.isLetterOrDigit() }.uppercase().take(4).padEnd(4, 'X')
val suffix = kotlin.random.Random.nextInt(100, 999)
return "$base$suffix"
}
private fun validateCode(code: String): String? {
if (code.isBlank()) return "Code is required"
if (code.length < 2) return "Code is too short"
if (!code.matches(Regex("^[A-Z0-9_-]+$"))) return "Only A-Z, 0-9, _ and -"
return null
}
private fun validateName(name: String): String? { private fun validateName(name: String): String? {
if (name.isBlank()) return "Name is required" if (name.isBlank()) return "Name is required"
if (name.length < 2) return "Name is too short" if (name.length < 2) return "Name is too short"

View File

@@ -96,7 +96,7 @@ fun RazorpayQrScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Razorpay QR") }, title = { Text("Generate Payment Links | QR") },
navigationIcon = { navigationIcon = {
IconButton(onClick = { IconButton(onClick = {
if (isViewingQr) { if (isViewingQr) {

View File

@@ -61,6 +61,7 @@ fun RoomsScreen(
onViewCardInfo: () -> Unit, onViewCardInfo: () -> Unit,
canManageRooms: Boolean, canManageRooms: Boolean,
canViewCardInfo: Boolean, canViewCardInfo: Boolean,
canIssueTemporaryCard: Boolean,
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit, onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
viewModel: RoomListViewModel = viewModel(), viewModel: RoomListViewModel = viewModel(),
@@ -195,12 +196,17 @@ fun RoomsScreen(
onClick = { onClick = {
if (nfcSupported && if (nfcSupported &&
room.hasNfc != false && room.hasNfc != false &&
room.tempCardActive != true room.tempCardActive != true &&
canIssueTemporaryCard
) { ) {
onIssueTemporaryCard(room) onIssueTemporaryCard(room)
} }
}, },
onLongClick = { onEditRoom(room) } onLongClick = {
if (canManageRooms) {
onEditRoom(room)
}
}
), ),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant containerColor = MaterialTheme.colorScheme.surfaceVariant

View File

@@ -14,8 +14,10 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.MeetingRoom import androidx.compose.material.icons.filled.MeetingRoom
import androidx.compose.material.icons.filled.Payment import androidx.compose.material.icons.filled.Payment
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -30,6 +32,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -50,10 +56,15 @@ fun ActiveRoomStaysScreen(
propertyId: String, propertyId: String,
propertyName: String, propertyName: String,
onBack: () -> Unit, onBack: () -> Unit,
showBack: Boolean,
onViewRooms: () -> Unit, onViewRooms: () -> Unit,
onCreateBooking: () -> Unit, onCreateBooking: () -> Unit,
canCreateBooking: Boolean,
showRazorpaySettings: Boolean, showRazorpaySettings: Boolean,
onRazorpaySettings: () -> Unit, onRazorpaySettings: () -> Unit,
showUserAdmin: Boolean,
onUserAdmin: () -> Unit,
onLogout: () -> Unit,
onManageRoomStay: (BookingListItem) -> Unit, onManageRoomStay: (BookingListItem) -> Unit,
onViewBookingStays: (BookingListItem) -> Unit, onViewBookingStays: (BookingListItem) -> Unit,
onOpenBookingDetails: (BookingListItem) -> Unit, onOpenBookingDetails: (BookingListItem) -> Unit,
@@ -61,6 +72,7 @@ fun ActiveRoomStaysScreen(
) { ) {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) } val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
val menuExpanded = remember { mutableStateOf(false) }
LaunchedEffect(propertyId) { LaunchedEffect(propertyId) {
viewModel.load(propertyId) viewModel.load(propertyId)
@@ -71,8 +83,10 @@ fun ActiveRoomStaysScreen(
TopAppBar( TopAppBar(
title = { Text(propertyName) }, title = { Text(propertyName) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { if (showBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
} }
}, },
actions = { actions = {
@@ -84,13 +98,35 @@ fun ActiveRoomStaysScreen(
Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings") Icon(Icons.Default.Payment, contentDescription = "Razorpay Settings")
} }
} }
if (showUserAdmin) {
IconButton(onClick = onUserAdmin) {
Icon(Icons.Default.People, contentDescription = "Property Users")
}
}
IconButton(onClick = { menuExpanded.value = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
}
DropdownMenu(
expanded = menuExpanded.value,
onDismissRequest = { menuExpanded.value = false }
) {
DropdownMenuItem(
text = { Text("Logout") },
onClick = {
menuExpanded.value = false
onLogout()
}
)
}
}, },
colors = TopAppBarDefaults.topAppBarColors() colors = TopAppBarDefaults.topAppBarColors()
) )
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton(onClick = onCreateBooking) { if (canCreateBooking) {
Icon(Icons.Default.Add, contentDescription = "Create Booking") FloatingActionButton(onClick = onCreateBooking) {
Icon(Icons.Default.Add, contentDescription = "Create Booking")
}
} }
} }
) { padding -> ) { padding ->

View File

@@ -0,0 +1,182 @@
package com.android.trisolarispms.ui.users
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.material3.Checkbox
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun PropertyAccessCodeScreen(
propertyId: String,
allowedRoles: List<String>,
onBack: () -> Unit,
viewModel: PropertyUsersViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val clipboard = LocalClipboardManager.current
val roleSelections = remember {
mutableStateOf(
allowedRoles.associateWith { false }
)
}
LaunchedEffect(propertyId) {
viewModel.loadPropertyCode(propertyId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Generate Access Code") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
Text(text = "Select roles", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
if (allowedRoles.isEmpty()) {
Text(
text = "You don't have permission to generate access codes.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else {
roleSelections.value.forEach { (role, selected) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = selected,
onCheckedChange = { checked ->
roleSelections.value = roleSelections.value.mapValues { entry ->
if (entry.key == role) checked else false
}
}
)
Text(text = role)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
val roles = roleSelections.value.filterValues { it }.keys.toList()
viewModel.createAccessCode(propertyId, roles)
},
modifier = Modifier.fillMaxWidth(),
enabled = allowedRoles.isNotEmpty()
) {
Text("Generate code")
}
if (state.isLoading) {
Spacer(modifier = Modifier.height(12.dp))
CircularProgressIndicator()
}
state.error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
state.message?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.primary)
}
state.accessCode?.let { code ->
val isExpired = code.expiresAt?.let {
runCatching { OffsetDateTime.parse(it).isBefore(OffsetDateTime.now()) }
.getOrDefault(false)
} ?: false
if (isExpired) {
return@let
}
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
state.propertyCode?.let { propertyCode ->
Text(text = "Property Code: $propertyCode", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(4.dp))
Button(onClick = { clipboard.setText(AnnotatedString(propertyCode)) }) {
Text("Copy property code")
}
Spacer(modifier = Modifier.height(12.dp))
}
Text(text = "Code: ${code.code}", style = MaterialTheme.typography.titleLarge)
code.expiresAt?.let {
val formatted = runCatching {
OffsetDateTime.parse(it)
.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"))
}.getOrDefault(it)
Text(text = "Expires: $formatted", style = MaterialTheme.typography.bodySmall)
}
if (code.roles.isNotEmpty()) {
Text(
text = "Roles: ${code.roles.joinToString()}",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(8.dp))
code.code?.let { accessCode ->
Button(onClick = { clipboard.setText(AnnotatedString(accessCode)) }) {
Text("Copy access code")
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
package com.android.trisolarispms.ui.users
data class PropertyUserUi(
val userId: String? = null,
val roles: List<String> = emptyList(),
val name: String? = null,
val phoneE164: String? = null,
val disabled: Boolean = false,
val superAdmin: Boolean = false
)

View File

@@ -0,0 +1,189 @@
package com.android.trisolarispms.ui.users
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun PropertyUsersScreen(
propertyId: String,
allowedRoleAssignments: List<String>,
canDisableAdmin: Boolean,
canDisableManager: Boolean,
onBack: () -> Unit,
onOpenAccessCode: () -> Unit,
viewModel: PropertyUsersViewModel = viewModel(),
directoryViewModel: UserDirectoryViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val directoryState by directoryViewModel.state.collectAsState()
val showSearchDialog = remember { mutableStateOf(false) }
val searchQuery = remember { mutableStateOf("") }
val editTarget = remember { mutableStateOf<PropertyUserUi?>(null) }
val editRoles = remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
val disableTarget = remember { mutableStateOf<PropertyUserUi?>(null) }
LaunchedEffect(propertyId) {
directoryViewModel.loadAll(UserDirectoryMode.Property(propertyId))
}
UserDirectoryScaffold(
title = "Property Users",
onBack = onBack,
showSearchIcon = true,
onSearchClick = { showSearchDialog.value = true },
showAccessCodeIcon = true,
onAccessCodeClick = onOpenAccessCode,
isLoading = directoryState.isLoading,
error = directoryState.error,
users = directoryState.users,
emptyText = "No users found",
beforeListContent = {
state.message?.let {
Text(text = it, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(8.dp))
}
}
) { user ->
PropertyUserCard(
user = user,
canEditRoles = allowedRoleAssignments.isNotEmpty() && !user.userId.isNullOrBlank(),
canDisable = canDisableUser(
user = user,
canDisableAdmin = canDisableAdmin,
canDisableManager = canDisableManager
),
onEditRoles = {
editTarget.value = user
editRoles.value = allowedRoleAssignments.associateWith { role ->
user.roles.contains(role)
}
},
onToggleDisabled = {
disableTarget.value = user
}
)
}
UserSearchDialog(
visible = showSearchDialog.value,
title = "Search users",
queryLabel = "Phone (min 6 digits)",
queryValue = searchQuery.value,
onQueryChange = { searchQuery.value = it },
onSearch = {
showSearchDialog.value = false
directoryViewModel.search(UserDirectoryMode.Property(propertyId), it)
},
onClear = {
showSearchDialog.value = false
searchQuery.value = ""
directoryViewModel.loadAll(UserDirectoryMode.Property(propertyId))
}
)
editTarget.value?.let { user ->
AlertDialog(
onDismissRequest = { editTarget.value = null },
title = { Text("Update roles") },
text = {
Column {
Text(
text = "This replaces the user's role set.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
allowedRoleAssignments.forEach { role ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = editRoles.value[role] == true,
onCheckedChange = { checked ->
editRoles.value = editRoles.value.toMutableMap().apply {
put(role, checked)
}
}
)
Text(text = role)
}
}
}
},
confirmButton = {
Button(
onClick = {
val roles = editRoles.value.filterValues { it }.keys.toList()
val userId = user.userId
if (!userId.isNullOrBlank()) {
viewModel.updateRoles(propertyId, userId, roles)
}
editTarget.value = null
}
) {
Text("Save")
}
},
dismissButton = {
Button(onClick = { editTarget.value = null }) {
Text("Cancel")
}
}
)
}
disableTarget.value?.let { user ->
val newDisabled = !user.disabled
AlertDialog(
onDismissRequest = { disableTarget.value = null },
title = { Text(if (newDisabled) "Disable user" else "Enable user") },
text = {
Text(
text = if (newDisabled) {
"This will disable the user for this property."
} else {
"This will enable the user for this property."
},
style = MaterialTheme.typography.bodySmall
)
},
confirmButton = {
Button(
onClick = {
val userId = user.userId
if (!userId.isNullOrBlank()) {
viewModel.updateDisabled(propertyId, userId, newDisabled)
}
disableTarget.value = null
}
) {
Text(if (newDisabled) "Disable" else "Enable")
}
},
dismissButton = {
Button(onClick = { disableTarget.value = null }) {
Text("Cancel")
}
}
)
}
}

View File

@@ -0,0 +1,235 @@
package com.android.trisolarispms.ui.users
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.trisolarispms.data.api.ApiClient
import com.android.trisolarispms.data.api.model.PropertyAccessCodeCreateRequest
import com.android.trisolarispms.data.api.model.PropertyAccessCodeResponse
import com.android.trisolarispms.data.api.model.PropertyUserResponse
import com.android.trisolarispms.data.api.model.PropertyAccessCodeJoinRequest
import com.android.trisolarispms.data.api.model.UserRolesUpdateRequest
import com.android.trisolarispms.data.api.model.PropertyUserDisabledRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class PropertyUsersState(
val isLoading: Boolean = false,
val error: String? = null,
val message: String? = null,
val users: List<PropertyUserUi> = emptyList(),
val accessCode: PropertyAccessCodeResponse? = null,
val propertyCode: String? = null
)
class PropertyUsersViewModel : ViewModel() {
private val _state = MutableStateFlow(PropertyUsersState())
val state: StateFlow<PropertyUsersState> = _state
fun loadAll(propertyId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val response = ApiClient.create().searchPropertyUsers(
propertyId = propertyId,
phone = null
)
val body = response.body()
if (response.isSuccessful && body != null) {
val mapped = body.map {
PropertyUserUi(
userId = it.userId,
roles = it.roles,
name = it.name,
phoneE164 = it.phoneE164,
disabled = it.disabled,
superAdmin = it.superAdmin
)
}
_state.update { it.copy(isLoading = false, users = mapped, 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") }
}
}
}
fun searchUsers(propertyId: String, phoneInput: String) {
val digits = phoneInput.filter { it.isDigit() }
if (digits.length < 6) {
_state.update { it.copy(users = emptyList(), error = null, isLoading = false, message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val response = ApiClient.create().searchPropertyUsers(
propertyId = propertyId,
phone = digits
)
val body = response.body()
if (response.isSuccessful && body != null) {
val mapped = body.map {
PropertyUserUi(
userId = it.userId,
roles = it.roles,
name = it.name,
phoneE164 = it.phoneE164,
disabled = it.disabled,
superAdmin = it.superAdmin
)
}
_state.update { it.copy(isLoading = false, users = mapped, error = null) }
} else {
_state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Search failed") }
}
}
}
fun createAccessCode(propertyId: String, roles: List<String>) {
if (roles.isEmpty()) {
_state.update { it.copy(error = "Select at least one role", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val response = ApiClient.create().createAccessCode(
propertyId = propertyId,
body = PropertyAccessCodeCreateRequest(roles = roles)
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
isLoading = false,
accessCode = body,
message = "Access code created"
)
}
} 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 joinProperty(propertyId: String, code: String) {
val digits = code.filter { it.isDigit() }
if (digits.length != 6) {
_state.update { it.copy(error = "Code must be 6 digits", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val response = ApiClient.create().joinAccessCode(
PropertyAccessCodeJoinRequest(propertyId = propertyId, code = digits)
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update {
it.copy(
isLoading = false,
message = "Joined property",
error = null
)
}
} else {
_state.update { it.copy(isLoading = false, error = "Join failed: ${response.code()}") }
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Join failed") }
}
}
}
fun updateRoles(propertyId: String, userId: String, roles: List<String>) {
if (roles.isEmpty()) {
_state.update { it.copy(error = "Select at least one role", message = null) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val response = ApiClient.create().updateUserRoles(
propertyId = propertyId,
userId = userId,
body = UserRolesUpdateRequest(roles = roles)
)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { current ->
val updated = current.users.map { user ->
if (user.userId == userId) {
user.copy(roles = body.roles)
} else user
}
current.copy(
isLoading = false,
users = updated,
message = "Roles updated"
)
}
} 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") }
}
}
}
fun updateDisabled(propertyId: String, userId: String, disabled: Boolean) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null, message = null) }
try {
val response = ApiClient.create().updateUserDisabled(
propertyId = propertyId,
userId = userId,
body = PropertyUserDisabledRequest(disabled = disabled)
)
if (response.isSuccessful) {
_state.update { current ->
val updated = current.users.map { user ->
if (user.userId == userId) {
user.copy(disabled = disabled)
} else user
}
current.copy(
isLoading = false,
users = updated,
message = if (disabled) "User disabled" else "User enabled"
)
}
} 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") }
}
}
}
fun loadPropertyCode(propertyId: String) {
viewModelScope.launch {
try {
val response = ApiClient.create().getPropertyCode(propertyId)
val body = response.body()
if (response.isSuccessful && body != null) {
_state.update { it.copy(propertyCode = body.code) }
}
} catch (_: Exception) {
// ignore code load errors
}
}
}
}

View File

@@ -0,0 +1,61 @@
package com.android.trisolarispms.ui.users
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.lifecycle.viewmodel.compose.viewModel
@Composable
fun SuperAdminUserDirectoryScreen(
onBack: () -> Unit,
viewModel: UserDirectoryViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val showSearchDialog = remember { mutableStateOf(false) }
val searchQuery = remember { mutableStateOf("") }
LaunchedEffect(Unit) {
viewModel.loadAll(UserDirectoryMode.SuperAdmin)
}
UserDirectoryScaffold(
title = "User Directory",
onBack = onBack,
showSearchIcon = state.users.isNotEmpty(),
onSearchClick = { showSearchDialog.value = true },
showAccessCodeIcon = false,
onAccessCodeClick = {},
isLoading = state.isLoading,
error = state.error,
users = state.users,
emptyText = "No users found"
) { user ->
PropertyUserCard(
user = user,
canEditRoles = false,
canDisable = false,
onEditRoles = {},
onToggleDisabled = {}
)
}
UserSearchDialog(
visible = showSearchDialog.value,
title = "Search users",
queryLabel = "Phone (min 6 digits)",
queryValue = searchQuery.value,
onQueryChange = { searchQuery.value = it },
onSearch = {
showSearchDialog.value = false
viewModel.search(UserDirectoryMode.SuperAdmin, it)
},
onClear = {
showSearchDialog.value = false
searchQuery.value = ""
viewModel.loadAll(UserDirectoryMode.SuperAdmin)
}
)
}

View File

@@ -0,0 +1,78 @@
package com.android.trisolarispms.ui.users
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PropertyUserCard(
user: PropertyUserUi,
canEditRoles: Boolean,
canDisable: Boolean,
onEditRoles: () -> Unit,
onToggleDisabled: () -> Unit
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = user.name ?: user.userId ?: "User", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (canEditRoles) {
Button(onClick = onEditRoles) {
Text("Edit roles")
}
}
if (canDisable) {
Button(onClick = onToggleDisabled) {
Text(if (user.disabled) "Enable" else "Disable")
}
}
}
}
user.phoneE164?.let { Text(text = it, style = MaterialTheme.typography.bodySmall) }
if (user.roles.isNotEmpty()) {
Text(text = "Roles: ${user.roles.joinToString()}", style = MaterialTheme.typography.bodySmall)
}
if (user.superAdmin) {
Text(text = "Super Admin", style = MaterialTheme.typography.bodySmall)
}
if (user.disabled) {
Text(
text = "Disabled",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
fun canDisableUser(
user: PropertyUserUi,
canDisableAdmin: Boolean,
canDisableManager: Boolean
): Boolean {
if (user.userId.isNullOrBlank()) return false
if (canDisableAdmin) return true
if (!canDisableManager) return false
val allowed = setOf("STAFF", "AGENT", "HOUSEKEEPING", "FINANCE", "GUIDE", "SUPERVISOR")
return user.roles.all { allowed.contains(it) }
}

View File

@@ -0,0 +1,141 @@
package com.android.trisolarispms.ui.users
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.VpnKey
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
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.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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun UserDirectoryScaffold(
title: String,
onBack: () -> Unit,
showSearchIcon: Boolean,
onSearchClick: () -> Unit,
showAccessCodeIcon: Boolean,
onAccessCodeClick: () -> Unit,
isLoading: Boolean,
error: String?,
users: List<PropertyUserUi>,
emptyText: String,
beforeListContent: @Composable () -> Unit = {},
itemContent: @Composable (PropertyUserUi) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (showAccessCodeIcon) {
IconButton(onClick = onAccessCodeClick) {
Icon(Icons.Default.VpnKey, contentDescription = "Generate access code")
}
}
if (showSearchIcon) {
IconButton(onClick = onSearchClick) {
Icon(Icons.Default.Search, contentDescription = "Search users")
}
}
},
colors = TopAppBarDefaults.topAppBarColors()
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.Top
) {
beforeListContent()
if (isLoading) {
Spacer(modifier = Modifier.height(12.dp))
CircularProgressIndicator()
}
error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, color = MaterialTheme.colorScheme.error)
}
Spacer(modifier = Modifier.height(12.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(users) { user ->
itemContent(user)
}
}
if (!isLoading && error == null && users.isEmpty()) {
Text(text = emptyText)
}
}
}
}
@Composable
fun UserSearchDialog(
visible: Boolean,
title: String,
queryLabel: String,
queryValue: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
onClear: () -> Unit
) {
if (!visible) return
AlertDialog(
onDismissRequest = onClear,
title = { Text(title) },
text = {
OutlinedTextField(
value = queryValue,
onValueChange = onQueryChange,
label = { Text(queryLabel) },
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
Button(
onClick = { onSearch(queryValue) }
) {
Text("Search")
}
},
dismissButton = {
Button(onClick = onClear) {
Text("Clear")
}
}
)
}

View File

@@ -0,0 +1,137 @@
package com.android.trisolarispms.ui.users
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
sealed class UserDirectoryMode {
data object SuperAdmin : UserDirectoryMode()
data class Property(val propertyId: String) : UserDirectoryMode()
}
data class UserDirectoryState(
val isLoading: Boolean = false,
val error: String? = null,
val message: String? = null,
val users: List<PropertyUserUi> = emptyList()
)
class UserDirectoryViewModel : ViewModel() {
private val _state = MutableStateFlow(UserDirectoryState())
val state: StateFlow<UserDirectoryState> = _state
fun loadAll(mode: UserDirectoryMode) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
when (mode) {
UserDirectoryMode.SuperAdmin -> {
val response = api.listUsers(null)
val body = response.body()
if (response.isSuccessful && body != null) {
val mapped = body.map {
PropertyUserUi(
userId = it.id,
name = it.name,
phoneE164 = it.phoneE164,
disabled = it.disabled,
superAdmin = it.superAdmin
)
}
_state.update { it.copy(isLoading = false, users = mapped, error = null) }
} else {
_state.update { it.copy(isLoading = false, error = "Load failed: ${response.code()}") }
}
}
is UserDirectoryMode.Property -> {
val response = api.searchPropertyUsers(
propertyId = mode.propertyId,
phone = null
)
val body = response.body()
if (response.isSuccessful && body != null) {
val mapped = body.map {
PropertyUserUi(
userId = it.userId,
roles = it.roles,
name = it.name,
phoneE164 = it.phoneE164,
disabled = it.disabled,
superAdmin = it.superAdmin
)
}
_state.update { it.copy(isLoading = false, users = mapped, 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") }
}
}
}
fun search(mode: UserDirectoryMode, phoneInput: String) {
val digits = phoneInput.filter { it.isDigit() }
if (digits.length < 6) {
_state.update { it.copy(users = emptyList(), error = null, isLoading = false) }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
val api = ApiClient.create()
when (mode) {
UserDirectoryMode.SuperAdmin -> {
val response = api.listUsers(digits)
val body = response.body()
if (response.isSuccessful && body != null) {
val mapped = body.map {
PropertyUserUi(
userId = it.id,
name = it.name,
phoneE164 = it.phoneE164,
disabled = it.disabled,
superAdmin = it.superAdmin
)
}
_state.update { it.copy(isLoading = false, users = mapped, error = null) }
} else {
_state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") }
}
}
is UserDirectoryMode.Property -> {
val response = api.searchPropertyUsers(
propertyId = mode.propertyId,
phone = digits
)
val body = response.body()
if (response.isSuccessful && body != null) {
val mapped = body.map {
PropertyUserUi(
userId = it.userId,
roles = it.roles,
name = it.name,
phoneE164 = it.phoneE164,
disabled = it.disabled,
superAdmin = it.superAdmin
)
}
_state.update { it.copy(isLoading = false, users = mapped, error = null) }
} else {
_state.update { it.copy(isLoading = false, error = "Search failed: ${response.code()}") }
}
}
}
} catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage ?: "Search failed") }
}
}
}
}