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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ interface ApiService :
|
|||||||
InboundEmailApi,
|
InboundEmailApi,
|
||||||
AmenityApi,
|
AmenityApi,
|
||||||
RatePlanApi,
|
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.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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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.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))
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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