users and permission manage
This commit is contained in:
@@ -9,6 +9,7 @@ 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.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.ui.AppRoute
|
||||
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.razorpay.RazorpaySettingsScreen
|
||||
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.AddRoomTypeScreen
|
||||
import com.android.trisolarispms.ui.roomtype.AmenitiesScreen
|
||||
@@ -78,28 +82,69 @@ class MainActivity : ComponentActivity() {
|
||||
val roomFormKey = remember { mutableStateOf(0) }
|
||||
val amenitiesReturnRoute = remember { mutableStateOf<AppRoute>(AppRoute.Home) }
|
||||
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 ->
|
||||
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 ->
|
||||
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
|
||||
it == "ADMIN" || it == "MANAGER" || it == "STAFF"
|
||||
} == true
|
||||
}
|
||||
val canManageRazorpaySettings: (String) -> Boolean = { propertyId ->
|
||||
state.isSuperAdmin || state.propertyRoles[propertyId]?.any {
|
||||
it == "ADMIN" || it == "MANAGER"
|
||||
} == true
|
||||
state.isSuperAdmin || state.propertyRoles[propertyId]?.contains("ADMIN") == true
|
||||
}
|
||||
val canDeleteCashPayment: (String) -> Boolean = { propertyId ->
|
||||
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) {
|
||||
when (currentRoute) {
|
||||
AppRoute.Home -> Unit
|
||||
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(
|
||||
currentRoute.propertyId,
|
||||
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) {
|
||||
AppRoute.Home -> HomeScreen(
|
||||
userId = state.userId,
|
||||
userName = state.userName,
|
||||
isSuperAdmin = state.isSuperAdmin,
|
||||
onUserDirectory = { route.value = AppRoute.SuperAdminUsers },
|
||||
onLogout = authViewModel::signOut,
|
||||
onAddProperty = { route.value = AppRoute.AddProperty },
|
||||
onAmenities = {
|
||||
amenitiesReturnRoute.value = AppRoute.Home
|
||||
@@ -203,7 +265,20 @@ class MainActivity : ComponentActivity() {
|
||||
selectedPropertyName.value = 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(
|
||||
onBack = { route.value = AppRoute.Home },
|
||||
@@ -275,11 +350,26 @@ class MainActivity : ComponentActivity() {
|
||||
is AppRoute.ActiveRoomStays -> ActiveRoomStaysScreen(
|
||||
propertyId = currentRoute.propertyId,
|
||||
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) },
|
||||
onCreateBooking = { route.value = AppRoute.CreateBooking(currentRoute.propertyId) },
|
||||
canCreateBooking = canCreateBookingFor(currentRoute.propertyId),
|
||||
showRazorpaySettings = canManageRazorpaySettings(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 ->
|
||||
val fromAt = booking.checkInAt?.takeIf { it.isNotBlank() }
|
||||
?: 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(
|
||||
propertyId = currentRoute.propertyId,
|
||||
onBack = {
|
||||
@@ -516,13 +637,14 @@ class MainActivity : ComponentActivity() {
|
||||
onViewCardInfo = { route.value = AppRoute.CardInfo(currentRoute.propertyId) },
|
||||
canManageRooms = canManageProperty(currentRoute.propertyId),
|
||||
canViewCardInfo = canViewCardInfo(currentRoute.propertyId),
|
||||
canIssueTemporaryCard = canIssueTemporaryCard(currentRoute.propertyId),
|
||||
onEditRoom = {
|
||||
selectedRoom.value = it
|
||||
roomFormKey.value++
|
||||
route.value = AppRoute.EditRoom(currentRoute.propertyId, it.id ?: "")
|
||||
},
|
||||
onIssueTemporaryCard = {
|
||||
if (it.id != null) {
|
||||
if (it.id != null && canIssueTemporaryCard(currentRoute.propertyId)) {
|
||||
selectedRoom.value = it
|
||||
route.value = AppRoute.IssueTemporaryCard(currentRoute.propertyId, it.id)
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ interface ApiService :
|
||||
InboundEmailApi,
|
||||
AmenityApi,
|
||||
RatePlanApi,
|
||||
RazorpaySettingsApi
|
||||
RazorpaySettingsApi,
|
||||
UserAdminApi
|
||||
|
||||
@@ -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.PropertyDto
|
||||
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 retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
@@ -28,18 +30,30 @@ interface PropertyApi {
|
||||
): Response<PropertyDto>
|
||||
|
||||
@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")
|
||||
suspend fun updateUserRoles(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("userId") userId: String,
|
||||
@Body body: UserRolesUpdateRequest
|
||||
): Response<ActionResponse>
|
||||
): Response<PropertyUserResponse>
|
||||
|
||||
@DELETE("properties/{propertyId}/users/{userId}")
|
||||
suspend fun deletePropertyUser(
|
||||
@Path("propertyId") propertyId: String,
|
||||
@Path("userId") userId: String
|
||||
): 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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.android.trisolarispms.data.api.model
|
||||
|
||||
data class PropertyCreateRequest(
|
||||
val code: String,
|
||||
val code: String? = null,
|
||||
val name: String,
|
||||
val addressText: String? = null,
|
||||
val timezone: String? = null,
|
||||
@@ -36,3 +36,7 @@ data class PropertyDto(
|
||||
val emailAddresses: List<String>? = null,
|
||||
val allowedTransportModes: List<String>? = null
|
||||
)
|
||||
|
||||
data class PropertyCodeResponse(
|
||||
val code: String? = null
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -74,6 +74,9 @@ sealed interface AppRoute {
|
||||
val pendingAmount: Long?,
|
||||
val guestPhone: String?
|
||||
) : 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 AddAmenity : AppRoute
|
||||
data class EditAmenity(val amenityId: String) : AppRoute
|
||||
|
||||
@@ -17,47 +17,129 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(text = "Sign in", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val selectedCountry = findPhoneCountryOption(state.phoneCountryCode)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = phoneCountryMenuExpanded.value,
|
||||
onExpandedChange = { phoneCountryMenuExpanded.value = !phoneCountryMenuExpanded.value },
|
||||
modifier = Modifier.weight(0.35f)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.phone,
|
||||
onValueChange = viewModel::onPhoneChange,
|
||||
label = { Text("Phone number") },
|
||||
placeholder = { Text("10-digit mobile") },
|
||||
value = "${selectedCountry.code} +${selectedCountry.dialCode}",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
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),
|
||||
prefix = { Text(state.countryCode) },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Default country: India (+91)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
modifier = Modifier.weight(0.65f)
|
||||
)
|
||||
}
|
||||
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(
|
||||
onClick = {
|
||||
if (activity != null) {
|
||||
@@ -66,9 +148,9 @@ fun AuthScreen(viewModel: AuthViewModel = viewModel()) {
|
||||
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))
|
||||
|
||||
@@ -2,10 +2,13 @@ package com.android.trisolarispms.ui.auth
|
||||
|
||||
data class AuthUiState(
|
||||
val countryCode: String = "+91",
|
||||
val phoneCountryCode: String = "IN",
|
||||
val phoneNationalNumber: String = "",
|
||||
val phone: String = "",
|
||||
val code: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val isCodeSent: Boolean = false,
|
||||
val resendAvailableAt: Long? = null,
|
||||
val verificationId: String? = null,
|
||||
val error: String? = null,
|
||||
val userId: String? = null,
|
||||
|
||||
@@ -14,6 +14,8 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AuthViewModel(
|
||||
@@ -23,6 +25,7 @@ class AuthViewModel(
|
||||
val state: StateFlow<AuthUiState> = _state
|
||||
|
||||
private var resendToken: PhoneAuthProvider.ForceResendingToken? = null
|
||||
private var resendCooldownJob: Job? = null
|
||||
|
||||
init {
|
||||
val user = auth.currentUser
|
||||
@@ -31,13 +34,28 @@ class AuthViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun onPhoneChange(value: String) {
|
||||
val digits = value.filter { it.isDigit() }.take(10)
|
||||
_state.update { it.copy(phone = digits, error = null) }
|
||||
fun onPhoneCountryChange(value: String) {
|
||||
val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(value)
|
||||
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) {
|
||||
_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) {
|
||||
@@ -57,9 +75,14 @@ class AuthViewModel(
|
||||
}
|
||||
|
||||
fun sendCode(activity: ComponentActivity) {
|
||||
val now = System.currentTimeMillis()
|
||||
val resendAt = state.value.resendAvailableAt
|
||||
if (resendAt != null && now < resendAt) {
|
||||
return
|
||||
}
|
||||
val phone = buildE164Phone()
|
||||
if (phone == null) {
|
||||
setError("Enter a valid 10-digit phone number")
|
||||
setError("Enter a valid phone number")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,10 +106,12 @@ class AuthViewModel(
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isCodeSent = true,
|
||||
resendAvailableAt = System.currentTimeMillis() + 60_000,
|
||||
verificationId = verificationId,
|
||||
error = null
|
||||
)
|
||||
}
|
||||
startResendCooldown()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +129,10 @@ class AuthViewModel(
|
||||
}
|
||||
|
||||
private fun buildE164Phone(): String? {
|
||||
val digits = state.value.phone.trim()
|
||||
if (digits.length != 10) return null
|
||||
return "${state.value.countryCode}$digits"
|
||||
val option = com.android.trisolarispms.ui.booking.findPhoneCountryOption(state.value.phoneCountryCode)
|
||||
val digits = state.value.phoneNationalNumber.trim()
|
||||
if (digits.length != option.maxLength) return null
|
||||
return "+${option.dialCode}$digits"
|
||||
}
|
||||
|
||||
fun verifyCode() {
|
||||
@@ -121,6 +147,10 @@ class AuthViewModel(
|
||||
setError("Enter the code")
|
||||
return
|
||||
}
|
||||
if (code.length != 6) {
|
||||
setError("Enter the 6-digit code")
|
||||
return
|
||||
}
|
||||
|
||||
val credential = PhoneAuthProvider.getCredential(verificationId, code)
|
||||
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) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null, userId = userId) }
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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.People
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -29,9 +30,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.android.trisolarispms.ui.property.PropertyListViewModel
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -39,6 +42,8 @@ fun HomeScreen(
|
||||
userId: String?,
|
||||
userName: String?,
|
||||
isSuperAdmin: Boolean,
|
||||
onUserDirectory: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onAddProperty: () -> Unit,
|
||||
onAmenities: () -> Unit,
|
||||
onImageTags: () -> Unit,
|
||||
@@ -46,10 +51,16 @@ fun HomeScreen(
|
||||
selectedPropertyId: String?,
|
||||
onSelectProperty: (String, String) -> 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) }
|
||||
val state by viewModel.state.collectAsState()
|
||||
val joinState by joinViewModel.state.collectAsState()
|
||||
val joinPropertyId = remember { mutableStateOf("") }
|
||||
val joinCode = remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(refreshKey) {
|
||||
viewModel.refresh()
|
||||
@@ -58,6 +69,13 @@ fun HomeScreen(
|
||||
LaunchedEffect(Unit) {
|
||||
onRefreshProfile()
|
||||
}
|
||||
LaunchedEffect(joinState.joinedPropertyId, joinState.joinedRoles) {
|
||||
val joinedPropertyId = joinState.joinedPropertyId
|
||||
if (!joinedPropertyId.isNullOrBlank()) {
|
||||
onRefreshProfile()
|
||||
onJoinPropertySuccess(joinedPropertyId, joinState.joinedRoles)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -65,6 +83,11 @@ fun HomeScreen(
|
||||
title = { Text("Trisolaris PMS") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(),
|
||||
actions = {
|
||||
if (isSuperAdmin) {
|
||||
IconButton(onClick = onUserDirectory) {
|
||||
Icon(Icons.Default.People, contentDescription = "User Directory")
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { menuExpanded = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Menu")
|
||||
}
|
||||
@@ -79,6 +102,13 @@ fun HomeScreen(
|
||||
onAddProperty()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Logout") },
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
onLogout()
|
||||
}
|
||||
)
|
||||
if (isSuperAdmin) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Update Tags") },
|
||||
@@ -115,6 +145,41 @@ fun HomeScreen(
|
||||
if (userId != null) {
|
||||
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) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@@ -92,20 +92,6 @@ fun AddPropertyScreen(
|
||||
) {
|
||||
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(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.android.trisolarispms.ui.property
|
||||
|
||||
data class AddPropertyState(
|
||||
val code: String = "",
|
||||
val name: String = "",
|
||||
val addressText: String = "",
|
||||
val timezone: String = "Asia/Kolkata",
|
||||
val currency: String = "INR",
|
||||
val isLoading: Boolean = false,
|
||||
val codeError: String? = null,
|
||||
val nameError: String? = null,
|
||||
val error: String? = null,
|
||||
val createdPropertyId: String? = null,
|
||||
|
||||
@@ -13,15 +13,9 @@ class AddPropertyViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(AddPropertyState())
|
||||
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) {
|
||||
_state.update { current ->
|
||||
val nextCode = if (current.codeAuto) generateCode(value) else current.code
|
||||
current.copy(name = value, nameError = null, error = null, code = nextCode)
|
||||
current.copy(name = value, nameError = null, error = null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,27 +34,21 @@ class AddPropertyViewModel : ViewModel() {
|
||||
fun applyPlace(name: String?, address: String?) {
|
||||
_state.update { current ->
|
||||
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(
|
||||
name = nextName,
|
||||
addressText = address ?: current.addressText,
|
||||
code = nextCode,
|
||||
error = null,
|
||||
nameError = null,
|
||||
codeError = null
|
||||
nameError = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
val code = state.value.code.trim()
|
||||
val name = state.value.name.trim()
|
||||
val codeError = validateCode(code)
|
||||
val nameError = validateName(name)
|
||||
if (codeError != null || nameError != null) {
|
||||
if (nameError != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
codeError = codeError,
|
||||
nameError = nameError,
|
||||
error = "Please fix the highlighted fields"
|
||||
)
|
||||
@@ -73,7 +61,7 @@ class AddPropertyViewModel : ViewModel() {
|
||||
try {
|
||||
val api = ApiClient.create()
|
||||
val body = PropertyCreateRequest(
|
||||
code = code,
|
||||
code = null,
|
||||
name = name,
|
||||
addressText = state.value.addressText.takeIf { it.isNotBlank() },
|
||||
timezone = state.value.timezone.takeIf { it.isNotBlank() },
|
||||
@@ -101,19 +89,6 @@ class AddPropertyViewModel : ViewModel() {
|
||||
_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? {
|
||||
if (name.isBlank()) return "Name is required"
|
||||
if (name.length < 2) return "Name is too short"
|
||||
|
||||
@@ -96,7 +96,7 @@ fun RazorpayQrScreen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Razorpay QR") },
|
||||
title = { Text("Generate Payment Links | QR") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
if (isViewingQr) {
|
||||
|
||||
@@ -61,6 +61,7 @@ fun RoomsScreen(
|
||||
onViewCardInfo: () -> Unit,
|
||||
canManageRooms: Boolean,
|
||||
canViewCardInfo: Boolean,
|
||||
canIssueTemporaryCard: Boolean,
|
||||
onEditRoom: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
||||
onIssueTemporaryCard: (com.android.trisolarispms.data.api.model.RoomDto) -> Unit,
|
||||
viewModel: RoomListViewModel = viewModel(),
|
||||
@@ -195,12 +196,17 @@ fun RoomsScreen(
|
||||
onClick = {
|
||||
if (nfcSupported &&
|
||||
room.hasNfc != false &&
|
||||
room.tempCardActive != true
|
||||
room.tempCardActive != true &&
|
||||
canIssueTemporaryCard
|
||||
) {
|
||||
onIssueTemporaryCard(room)
|
||||
}
|
||||
},
|
||||
onLongClick = { onEditRoom(room) }
|
||||
onLongClick = {
|
||||
if (canManageRooms) {
|
||||
onEditRoom(room)
|
||||
}
|
||||
}
|
||||
),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
|
||||
@@ -14,8 +14,10 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
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.Payment
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -30,6 +32,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -50,10 +56,15 @@ fun ActiveRoomStaysScreen(
|
||||
propertyId: String,
|
||||
propertyName: String,
|
||||
onBack: () -> Unit,
|
||||
showBack: Boolean,
|
||||
onViewRooms: () -> Unit,
|
||||
onCreateBooking: () -> Unit,
|
||||
canCreateBooking: Boolean,
|
||||
showRazorpaySettings: Boolean,
|
||||
onRazorpaySettings: () -> Unit,
|
||||
showUserAdmin: Boolean,
|
||||
onUserAdmin: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onManageRoomStay: (BookingListItem) -> Unit,
|
||||
onViewBookingStays: (BookingListItem) -> Unit,
|
||||
onOpenBookingDetails: (BookingListItem) -> Unit,
|
||||
@@ -61,6 +72,7 @@ fun ActiveRoomStaysScreen(
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val selectedBooking = remember { mutableStateOf<BookingListItem?>(null) }
|
||||
val menuExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(propertyId) {
|
||||
viewModel.load(propertyId)
|
||||
@@ -71,9 +83,11 @@ fun ActiveRoomStaysScreen(
|
||||
TopAppBar(
|
||||
title = { Text(propertyName) },
|
||||
navigationIcon = {
|
||||
if (showBack) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onViewRooms) {
|
||||
@@ -84,15 +98,37 @@ fun ActiveRoomStaysScreen(
|
||||
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()
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (canCreateBooking) {
|
||||
FloatingActionButton(onClick = onCreateBooking) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Create Booking")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user