project setup
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, FlatList } from "react-native";
|
||||
import { Dialog, IconButton } from "react-native-paper";
|
||||
|
||||
import { DialogContainer } from "@/components/ui/DialogContainer";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { User } from "@/store/models/User.model";
|
||||
|
||||
interface BulkDeleteUserModalProps {
|
||||
visible: boolean;
|
||||
users: User[];
|
||||
onDismiss: () => void;
|
||||
onDelete: (userIds: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const BulkDeleteUserModal = ({
|
||||
visible,
|
||||
users,
|
||||
onDismiss,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: BulkDeleteUserModalProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!users || users.length === 0) return null;
|
||||
|
||||
const handleDelete = () => {
|
||||
try {
|
||||
setError(null);
|
||||
const userIds = users.map((user) => user.id);
|
||||
onDelete(userIds);
|
||||
} catch (err) {
|
||||
setError("Failed to delete users. Please try again.");
|
||||
console.error("Error in BulkDeleteModal:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContainer visible={visible} onDismiss={onDismiss}>
|
||||
<Dialog.Title>
|
||||
<View className="w-full flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="pr-3">
|
||||
{users.length === 1 ? "Delete User" : "Bulk Delete Users"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ alignSelf: "flex-start" }}>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size={20}
|
||||
onPress={onDismiss}
|
||||
disabled={isLoading}
|
||||
accessibilityLabel="Close dialog"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<Text className="mb-2">
|
||||
{users.length === 1
|
||||
? `Are you sure you want to delete ${users[0].firstName} ${users[0].lastName}?`
|
||||
: `Are you sure you want to delete ${users.length} users?`}
|
||||
</Text>
|
||||
|
||||
{users.length > 1 && (
|
||||
<View className="max-h-40 mb-2">
|
||||
<FlatList
|
||||
data={users}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<Text className="text-sm py-1">
|
||||
{item.firstName} {item.lastName}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && <Text className="text-highRisk mt-2">{error}</Text>}
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions>
|
||||
<Button className="!ml-2" onPress={handleDelete} disabled={isLoading}>
|
||||
{users.length === 1 ? "Delete" : "Delete All"}
|
||||
</Button>
|
||||
</Dialog.Actions>
|
||||
{isLoading && (
|
||||
<View
|
||||
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
|
||||
pointerEvents="auto"
|
||||
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<LoadingSpinner size={16} />
|
||||
</View>
|
||||
)}
|
||||
</DialogContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { Dialog, IconButton } from "react-native-paper";
|
||||
|
||||
import { DialogContainer } from "@/components/ui/DialogContainer";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
|
||||
interface DeleteUserModalProps {
|
||||
visible: boolean;
|
||||
user: User | null;
|
||||
onDismiss: () => void;
|
||||
onDelete: (user: User) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const DeleteUserModal = ({
|
||||
visible,
|
||||
user,
|
||||
onDismiss,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: DeleteUserModalProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleDelete = () => {
|
||||
try {
|
||||
setError(null);
|
||||
onDelete(user);
|
||||
} catch (err) {
|
||||
setError("Failed to delete user. Please try again.");
|
||||
console.error("Error in UserDeleteModal:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContainer visible={visible} onDismiss={onDismiss}>
|
||||
<Dialog.Title>
|
||||
<View className="w-full flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="pr-3">Delete User</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ alignSelf: "flex-start" }}>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size={20}
|
||||
onPress={onDismiss}
|
||||
disabled={isLoading}
|
||||
accessibilityLabel="Close dialog"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<Text>
|
||||
Are you sure you want to delete {user.firstName} {user.lastName}?
|
||||
</Text>
|
||||
{error && <Text className="text-highRisk mt-2">{error}</Text>}
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions>
|
||||
<Button className="!ml-2" onPress={handleDelete} disabled={isLoading}>
|
||||
Delete
|
||||
</Button>
|
||||
</Dialog.Actions>
|
||||
{isLoading && (
|
||||
<View
|
||||
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
|
||||
pointerEvents="auto"
|
||||
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<LoadingSpinner size={16} />
|
||||
</View>
|
||||
)}
|
||||
</DialogContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { router } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import { Avatar, DataTable, Text } from "react-native-paper";
|
||||
|
||||
import { CustomCheckbox } from "@/components/ui/CustomCheckbox";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
import { RiskIndicator } from "@/components/ui/RiskIndicator";
|
||||
import { UserActions } from "@/components/features/users/UserActions";
|
||||
import { UserTableHeader } from "@/components/features/users/UserTableHeader";
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
|
||||
interface DesktopUserAccountsTableProps {
|
||||
users: User[];
|
||||
selectedUsers: string[];
|
||||
sortColumn: string | null;
|
||||
sortDirection: TableSortDirection;
|
||||
onSort: (column: string) => void;
|
||||
onSelectUser: (userId: string) => void;
|
||||
onEdit: (user: User) => void;
|
||||
onLock: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
}
|
||||
|
||||
export const DesktopUserAccountsTable = ({
|
||||
users,
|
||||
selectedUsers,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
onSelectUser,
|
||||
onEdit,
|
||||
onLock,
|
||||
onDelete,
|
||||
}: DesktopUserAccountsTableProps) => {
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<DataTable>
|
||||
<UserTableHeader
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
|
||||
{users.map((user) => (
|
||||
<DataTable.Row key={user.id}>
|
||||
<DataTable.Cell>
|
||||
<View className="flex-row items-center pr-2">
|
||||
<View>
|
||||
<CustomCheckbox
|
||||
checked={selectedUsers.includes(user.id)}
|
||||
onPress={() => onSelectUser(user.id)}
|
||||
/>
|
||||
</View>
|
||||
{user.imageUrl ? (
|
||||
<Avatar.Image
|
||||
source={{ uri: user.imageUrl }}
|
||||
size={28}
|
||||
style={{ marginRight: 10, marginLeft: 10 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar.Text
|
||||
label={user.firstName.charAt(0) + user.lastName.charAt(0)}
|
||||
size={28}
|
||||
style={{ marginRight: 10, marginLeft: 10 }}
|
||||
/>
|
||||
)}
|
||||
<View className="flex-row items-center flex-shrink px-1 py-1">
|
||||
<Text
|
||||
numberOfLines={10}
|
||||
onPress={() => router.push(`/accounts/users/${user.id}`)}
|
||||
>
|
||||
{user.firstName + " " + user.lastName}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell style={{ justifyContent: "center" }}>
|
||||
<RiskIndicator risk={user.riskStatus} size={SizeEnum.Moderate} />
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell>
|
||||
<View className="flex-row items-center flex-shrink px-1 py-1">
|
||||
<Text numberOfLines={10}>{user.email}</Text>
|
||||
</View>
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell>
|
||||
<View className="flex-row items-center flex-shrink px-1 py-1">
|
||||
<Text numberOfLines={10}>{user.title}</Text>
|
||||
</View>
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell style={{ justifyContent: "center", width: 100 }}>
|
||||
<UserActions
|
||||
user={user}
|
||||
onView={(u) => router.push(`/accounts/users/${u.id}`)}
|
||||
onEdit={onEdit}
|
||||
onLock={onLock}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</DataTable.Cell>
|
||||
</DataTable.Row>
|
||||
))}
|
||||
</DataTable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { Dialog, IconButton } from "react-native-paper";
|
||||
|
||||
import { DialogContainer } from "@/components/ui/DialogContainer";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
|
||||
interface LockUserModalProps {
|
||||
visible: boolean;
|
||||
user: User | null;
|
||||
onDismiss: () => void;
|
||||
onLock: (user: User) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const LockUserModal = ({
|
||||
visible,
|
||||
user,
|
||||
onDismiss,
|
||||
onLock,
|
||||
isLoading = false,
|
||||
}: LockUserModalProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const action = user.isLocked ? "unlock" : "lock";
|
||||
const actionTitle = action.charAt(0).toUpperCase() + action.slice(1);
|
||||
|
||||
const handleLock = () => {
|
||||
try {
|
||||
setError(null);
|
||||
onLock(user);
|
||||
} catch (err) {
|
||||
setError(`Failed to ${action} user. Please try again.`);
|
||||
console.error(`Error in UserLockModal (${action}):`, err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContainer visible={visible} onDismiss={onDismiss}>
|
||||
<Dialog.Title>
|
||||
<View className="w-full flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="pr-3">{actionTitle} User</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ alignSelf: "flex-start" }}>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size={20}
|
||||
onPress={onDismiss}
|
||||
disabled={isLoading}
|
||||
accessibilityLabel="Close dialog"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<Text>
|
||||
Are you sure you want to {action} {user.firstName} {user.lastName}?
|
||||
</Text>
|
||||
{error && <Text className="text-highRisk mt-2">{error}</Text>}
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions>
|
||||
<Button className="!ml-2" onPress={handleLock} disabled={isLoading}>
|
||||
{actionTitle}
|
||||
</Button>
|
||||
</Dialog.Actions>
|
||||
{isLoading && (
|
||||
<View
|
||||
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
|
||||
pointerEvents="auto"
|
||||
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<LoadingSpinner size={16} />
|
||||
</View>
|
||||
)}
|
||||
</DialogContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { View, ScrollView, Text } from "react-native";
|
||||
import { Checkbox, IconButton, Avatar } from "react-native-paper";
|
||||
import { router } from "expo-router";
|
||||
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
import { RiskIndicator } from "@/components/ui/RiskIndicator";
|
||||
|
||||
interface MobileUserAccountsTableProps {
|
||||
users: User[];
|
||||
selectedUsers: string[];
|
||||
onSelectUser: (userId: string) => void;
|
||||
onEdit: (user: User) => void;
|
||||
onLock: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
}
|
||||
|
||||
export const MobileUserAccountsTable = ({
|
||||
users,
|
||||
selectedUsers,
|
||||
onSelectUser,
|
||||
onEdit,
|
||||
onLock,
|
||||
onDelete,
|
||||
}: MobileUserAccountsTableProps) => {
|
||||
const openDetails = (user: User) => {
|
||||
router.push(`/accounts/users/${user.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<View
|
||||
key={user.id}
|
||||
className="mb-3 p-4 bg-white rounded-md shadow-sm"
|
||||
>
|
||||
<View className="flex-row justify-between items-center mb-3">
|
||||
<View className="flex-row items-center flex-1 min-w-0">
|
||||
<View>
|
||||
<Checkbox
|
||||
status={
|
||||
selectedUsers.includes(user.id) ? "checked" : "unchecked"
|
||||
}
|
||||
onPress={() => onSelectUser(user.id)}
|
||||
/>
|
||||
</View>
|
||||
{user.imageUrl ? (
|
||||
<Avatar.Image
|
||||
source={{ uri: user.imageUrl }}
|
||||
size={28}
|
||||
style={{ marginRight: 10, marginLeft: 10 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar.Text
|
||||
size={28}
|
||||
label={`${user.firstName.charAt(0)}${user.lastName.charAt(
|
||||
0
|
||||
)}`}
|
||||
style={{ marginRight: 10, marginLeft: 10 }}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<View className="flex-row items-center">
|
||||
<Text
|
||||
className="font-medium"
|
||||
numberOfLines={0}
|
||||
style={{ flexShrink: 1, overflow: "visible" }}
|
||||
onPress={() => openDetails(user)}
|
||||
>
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="ml-2">
|
||||
<RiskIndicator risk={user.riskStatus} size={SizeEnum.Small} />
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
className="text-sm mb-1"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{user.email}
|
||||
</Text>
|
||||
<Text className="text-sm mb-3 text-gray-600">{user.title}</Text>
|
||||
|
||||
<View className="flex-row justify-end border-t border-gray-200 pt-3">
|
||||
<IconButton
|
||||
icon="eye-outline"
|
||||
size={20}
|
||||
onPress={() => openDetails(user)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="pencil-outline"
|
||||
size={20}
|
||||
onPress={() => {
|
||||
onEdit(user);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="lock-outline"
|
||||
size={20}
|
||||
onPress={() => {
|
||||
onLock(user);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="delete-outline"
|
||||
size={20}
|
||||
onPress={() => {
|
||||
onDelete(user);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface SelectionControlsProps {
|
||||
selectAll: boolean;
|
||||
selectedUsers: string[];
|
||||
onSelectAll: () => void;
|
||||
onBulkDelete: () => void;
|
||||
}
|
||||
|
||||
export const SelectionControls = ({
|
||||
selectAll,
|
||||
selectedUsers,
|
||||
onSelectAll,
|
||||
onBulkDelete,
|
||||
}: SelectionControlsProps) => {
|
||||
return (
|
||||
<View className="flex-row">
|
||||
{selectedUsers?.length > 0 && (
|
||||
<Button
|
||||
onPress={onBulkDelete}
|
||||
icon="delete-outline"
|
||||
className="mr-1"
|
||||
buttonTextColor="white"
|
||||
disabled={false}
|
||||
>
|
||||
Delete ({selectedUsers?.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onPress={onSelectAll}
|
||||
buttonColor="#6750a4"
|
||||
buttonTextColor="white"
|
||||
disabled={false}
|
||||
icon="account-multiple-outline"
|
||||
>
|
||||
{selectAll ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, GestureResponderEvent, ScrollView, Text } from "react-native";
|
||||
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
import { UserTableControls } from "@/components/features/users/UserTableControls";
|
||||
import { DesktopUserAccountsTable } from "@/components/features/users/DesktopUserAccountsTable";
|
||||
import { MobileUserAccountsTable } from "@/components/features/users/MobileUserAccountsTable";
|
||||
import { TablePagination } from "@/components/shared/TablePagination";
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
|
||||
interface UserAccountsTableProps {
|
||||
users: User[];
|
||||
onEdit: (user: User) => void;
|
||||
onAddUser: () => void;
|
||||
onDelete: (user: User) => void;
|
||||
onLock: (user: User) => void;
|
||||
onSelect: (userIds: string[]) => void;
|
||||
onBulkDelete: (users: User[]) => void;
|
||||
selectedUserIds?: string[];
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
totalItems: number;
|
||||
from: number;
|
||||
to: number;
|
||||
searchQuery: string;
|
||||
selectedRisks: RiskEnum[];
|
||||
sortColumn: string | null;
|
||||
sortDirection: TableSortDirection;
|
||||
onSearch: (q: string) => void;
|
||||
onSelectRisks: (risks: RiskEnum[]) => void;
|
||||
onSort: (column: string) => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAddingUser: boolean;
|
||||
}
|
||||
|
||||
export const UserAccountsTable = ({
|
||||
users,
|
||||
onEdit,
|
||||
onAddUser,
|
||||
onDelete,
|
||||
onLock,
|
||||
onSelect,
|
||||
onBulkDelete,
|
||||
selectedUserIds,
|
||||
page,
|
||||
itemsPerPage,
|
||||
totalItems,
|
||||
from,
|
||||
to,
|
||||
searchQuery,
|
||||
selectedRisks,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSearch,
|
||||
onSelectRisks,
|
||||
onSort,
|
||||
onItemsPerPageChange,
|
||||
onPageChange,
|
||||
onAddingUser,
|
||||
}: UserAccountsTableProps) => {
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number }>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [prevSelectedUserIds, setPrevSelectedUserIds] = useState<
|
||||
string[] | undefined
|
||||
>(selectedUserIds);
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
const handleSort = (column: string) => onSort(column);
|
||||
const handleSearch = (query: string) => onSearch(query);
|
||||
const handleSelectRisks = (risks: RiskEnum[]) => onSelectRisks(risks);
|
||||
const handleItemsPerPageChange = (value: number) =>
|
||||
onItemsPerPageChange(value);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectAll) {
|
||||
setSelectedUsers([]);
|
||||
} else {
|
||||
setSelectedUsers(users.map((user) => user.id) || []);
|
||||
}
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
const handleSelectUser = (userId: string) => {
|
||||
if (selectedUsers.includes(userId)) {
|
||||
setSelectedUsers(selectedUsers.filter((id) => id !== userId));
|
||||
} else {
|
||||
setSelectedUsers([...selectedUsers, userId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
|
||||
const selectedUserObjects = users.filter((user) =>
|
||||
selectedUsers.includes(user.id)
|
||||
);
|
||||
|
||||
if (onBulkDelete) {
|
||||
onBulkDelete(selectedUserObjects || []);
|
||||
} else {
|
||||
alert(`Would delete ${selectedUsers.length} users`);
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(selectedUsers);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
if (onEdit) {
|
||||
onEdit(user);
|
||||
} else {
|
||||
alert(`Edit user: ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (user: User) => {
|
||||
if (onDelete) {
|
||||
onDelete(user);
|
||||
} else {
|
||||
alert(`Delete user: ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = (user: User) => {
|
||||
if (onLock) {
|
||||
onLock(user);
|
||||
} else {
|
||||
alert(`Lock user: ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelect) {
|
||||
onSelect(selectedUsers);
|
||||
}
|
||||
}, [selectedUsers, onSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevSelectedUserIds &&
|
||||
prevSelectedUserIds.length > 0 &&
|
||||
selectedUserIds &&
|
||||
selectedUserIds.length === 0
|
||||
) {
|
||||
setSelectedUsers([]);
|
||||
setSelectAll(false);
|
||||
}
|
||||
|
||||
setPrevSelectedUserIds(selectedUserIds);
|
||||
}, [selectedUserIds, prevSelectedUserIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSelectedUsers = selectedUsers.filter((userId) =>
|
||||
users.some((user) => user.id === userId)
|
||||
);
|
||||
|
||||
if (validSelectedUsers.length !== selectedUsers.length) {
|
||||
setSelectedUsers(validSelectedUsers);
|
||||
}
|
||||
|
||||
const allDisplayedSelected =
|
||||
users.every((user) => validSelectedUsers.includes(user.id)) ?? false;
|
||||
setSelectAll(allDisplayedSelected && users.length > 0);
|
||||
}, [users, selectedUsers]);
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<UserTableControls
|
||||
searchQuery={searchQuery}
|
||||
selectAll={selectAll}
|
||||
selectedItems={selectedUsers}
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
selectedRisks={selectedRisks}
|
||||
onSearch={handleSearch}
|
||||
onSelectAll={handleSelectAll}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onMenuDismiss={() => setMenuVisible(false)}
|
||||
onItemsPerPageChange={handleItemsPerPageChange}
|
||||
onSelectRisks={handleSelectRisks}
|
||||
onAddUser={onAddUser}
|
||||
onAddingUser={onAddingUser}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<View
|
||||
className={`flex-1 items-center justify-center ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-lg text-gray-500">
|
||||
No users match your search{searchQuery && ` for "${searchQuery}"`}
|
||||
</Text>
|
||||
</View>
|
||||
) : isMobile ? (
|
||||
<MobileUserAccountsTable
|
||||
users={users}
|
||||
selectedUsers={selectedUsers}
|
||||
onSelectUser={handleSelectUser}
|
||||
onEdit={handleEdit}
|
||||
onLock={handleLock}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<ScrollView className="flex-1">
|
||||
<DesktopUserAccountsTable
|
||||
users={users}
|
||||
selectedUsers={selectedUsers}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onSelectUser={handleSelectUser}
|
||||
onEdit={handleEdit}
|
||||
onLock={handleLock}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<TablePagination
|
||||
page={page}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={totalItems}
|
||||
from={from}
|
||||
to={to}
|
||||
onPageChange={onPageChange}
|
||||
onItemsPerPageButtonPress={(event: GestureResponderEvent) => {
|
||||
const { pageX, pageY } = event.nativeEvent;
|
||||
setMenuAnchor({ x: pageX, y: pageY });
|
||||
setMenuVisible(true);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { View } from "react-native";
|
||||
import { IconButton } from "react-native-paper";
|
||||
|
||||
import { User } from "@/store/models/User.model";
|
||||
|
||||
interface UserActionsProps {
|
||||
user: User;
|
||||
onView: (user: User) => void;
|
||||
onEdit: (user: User) => void;
|
||||
onLock: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
}
|
||||
|
||||
export const UserActions = ({
|
||||
user,
|
||||
onView,
|
||||
onEdit,
|
||||
onLock,
|
||||
onDelete,
|
||||
}: UserActionsProps) => {
|
||||
return (
|
||||
<View className="flex-row justify-center">
|
||||
<IconButton icon="eye-outline" onPress={() => onView(user)} size={20} />
|
||||
<IconButton
|
||||
icon="pencil-outline"
|
||||
onPress={() => onEdit(user)}
|
||||
size={20}
|
||||
/>
|
||||
<IconButton
|
||||
icon="lock-outline"
|
||||
onPress={() => onLock(user)}
|
||||
size={20}
|
||||
iconColor={user.isLocked ? "#F83434" : undefined}
|
||||
/>
|
||||
<IconButton
|
||||
icon="delete-outline"
|
||||
onPress={() => onDelete(user)}
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { View, ScrollView, Text } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
|
||||
import { useGetUserByIdQuery } from "@/store/api/usersApi";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { NoData } from "@/components/ui/NoData";
|
||||
import { AccountDetails } from "@/components/features/accounts/AccountDetails";
|
||||
|
||||
export const UserDetailsPage = () => {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetUserByIdQuery(id, { refetchOnMountOrArgChange: true });
|
||||
|
||||
return (
|
||||
<View className={`flex-1 bg-primaryPurpleLight`}>
|
||||
<View className={`${isMobile ? "p-1" : "p-5"} flex-1`}>
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
isMobile ? "mb-1" : "mb-4"
|
||||
} text-white`}
|
||||
>
|
||||
{user?.firstName} {user?.lastName} details
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
>
|
||||
<View
|
||||
className={`bg-lightGray rounded-lg ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
} flex-1`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 justify-center items-center py-10">
|
||||
<LoadingSpinner />
|
||||
</View>
|
||||
) : error || !user ? (
|
||||
<View className="flex-1 justify-center items-center py-10">
|
||||
<NoData
|
||||
message="User not found"
|
||||
onRetry={refetch}
|
||||
isRetrying={isFetching}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<AccountDetails user={user} isLoading={isLoading || isFetching} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { View, Text, ScrollView, Dimensions } from "react-native";
|
||||
import { Dialog, TextInput, IconButton } from "react-native-paper";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DialogContainer } from "@/components/ui/DialogContainer";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { RiskEnum, RiskLabels } from "@/constants/riskEnum";
|
||||
import { RoleEnum } from "@/constants/roleEnum";
|
||||
import { OperationMode } from "@/constants/operationModeEnum";
|
||||
import { CustomPicker, PickerItem } from "@/components/ui/CustomPicker";
|
||||
import {
|
||||
userSchemaWithId,
|
||||
UserFormValues,
|
||||
} from "@/components/features/users/schemas/user.schema";
|
||||
|
||||
interface UserModalProps {
|
||||
visible: boolean;
|
||||
mode?: OperationMode;
|
||||
user?: User | null;
|
||||
isLoading?: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: Partial<User>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const UserModal = ({
|
||||
visible,
|
||||
mode = OperationMode.ADD,
|
||||
user = null,
|
||||
isLoading = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: UserModalProps) => {
|
||||
const isEdit = mode === OperationMode.EDIT;
|
||||
|
||||
const defaultValues: UserFormValues = {
|
||||
id: user?.id,
|
||||
firstName: user?.firstName ?? "",
|
||||
lastName: user?.lastName ?? "",
|
||||
email: user?.email ?? "",
|
||||
title: user?.title ?? "",
|
||||
riskStatus: user?.riskStatus ?? RiskEnum.LOW_RISK,
|
||||
};
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userSchemaWithId),
|
||||
defaultValues,
|
||||
mode: "onChange",
|
||||
shouldUnregister: false,
|
||||
});
|
||||
|
||||
const userRef = useRef<User | null>(user);
|
||||
userRef.current = user;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const nextDefaults: UserFormValues = {
|
||||
id: userRef.current?.id,
|
||||
firstName: userRef.current?.firstName ?? "",
|
||||
lastName: userRef.current?.lastName ?? "",
|
||||
email: userRef.current?.email ?? "",
|
||||
title: userRef.current?.title ?? "",
|
||||
riskStatus: userRef.current?.riskStatus ?? RiskEnum.LOW_RISK,
|
||||
};
|
||||
reset(nextDefaults, { keepDefaultValues: false });
|
||||
}, [visible, reset, user?.id]);
|
||||
|
||||
const windowHeight = Dimensions.get("window").height;
|
||||
const dialogVerticalMargin = 24;
|
||||
const dialogMaxHeight = windowHeight - dialogVerticalMargin - 60;
|
||||
const approxChrome = 140;
|
||||
const maxDialogContentHeight = Math.max(200, dialogMaxHeight - approxChrome);
|
||||
|
||||
const submit = (vals: UserFormValues) => {
|
||||
const payload: Partial<User> = {
|
||||
id: vals.id,
|
||||
firstName: vals.firstName.trim(),
|
||||
lastName: vals.lastName.trim(),
|
||||
email: vals.email.trim().toLowerCase(),
|
||||
title: vals.title.trim(),
|
||||
riskStatus: vals.riskStatus,
|
||||
role: isEdit ? user?.role ?? RoleEnum.USER : RoleEnum.USER,
|
||||
};
|
||||
return onSubmit(payload);
|
||||
};
|
||||
|
||||
const titleText = isEdit ? "Edit User" : "Add User";
|
||||
const submitText = isEdit ? "Edit" : "Add";
|
||||
|
||||
return (
|
||||
<DialogContainer
|
||||
visible={visible}
|
||||
onDismiss={onClose}
|
||||
dialogStyle={{
|
||||
alignSelf: "center",
|
||||
marginVertical: dialogVerticalMargin,
|
||||
maxHeight: dialogMaxHeight,
|
||||
}}
|
||||
>
|
||||
<Dialog.Title>
|
||||
<View className="w-full flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="pr-3">{titleText}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ alignSelf: "flex-start" }}>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size={20}
|
||||
onPress={onClose}
|
||||
disabled={isLoading}
|
||||
accessibilityLabel="Close dialog"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<ScrollView
|
||||
style={{ maxHeight: maxDialogContentHeight }}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">First Name</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="firstName"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
className={`border ${
|
||||
errors.firstName ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="Enter first name"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.firstName ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.firstName.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Last Name</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lastName"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
className={`border ${
|
||||
errors.lastName ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="Enter last name"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.lastName ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.lastName.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Email</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
className={`border ${
|
||||
errors.email ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="Enter email"
|
||||
keyboardType="email-address"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
autoCapitalize="none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.email.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Title</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
className={`border ${
|
||||
errors.title ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="Enter title"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.title ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.title.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Risk Status</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="riskStatus"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View className="border border-gray-300 rounded overflow-hidden">
|
||||
<CustomPicker
|
||||
selectedValue={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="h-10"
|
||||
enabled={!isLoading}
|
||||
>
|
||||
{Object.values(RiskEnum).map((v) => (
|
||||
<PickerItem key={v} label={RiskLabels[v]} value={v} />
|
||||
))}
|
||||
</CustomPicker>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Dialog.Content>
|
||||
|
||||
<Dialog.Actions>
|
||||
<Button
|
||||
onPress={handleSubmit(submit)}
|
||||
disabled={isLoading || (isEdit && !isDirty)}
|
||||
className="!ml-2"
|
||||
>
|
||||
{submitText}
|
||||
</Button>
|
||||
</Dialog.Actions>
|
||||
|
||||
{isLoading && (
|
||||
<View
|
||||
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
|
||||
pointerEvents="auto"
|
||||
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<LoadingSpinner size={16} />
|
||||
</View>
|
||||
)}
|
||||
</DialogContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { TableControls } from "@/components/shared/TableControls";
|
||||
import { SelectionControls } from "@/components/features/users/SelectionControls";
|
||||
import { RiskEnum, RiskLabels } from "@/constants/riskEnum";
|
||||
import {
|
||||
CombinedFilterMenu,
|
||||
CombinedFilterSection,
|
||||
} from "@/components/shared/filters/CombinedFilterMenu";
|
||||
import {
|
||||
SelectedFilterChips,
|
||||
SelectedChip,
|
||||
} from "@/components/shared/filters/SelectedFilterChips";
|
||||
import { RiskIndicator } from "@/components/ui/RiskIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface UserTableControlsProps {
|
||||
searchQuery: string;
|
||||
selectAll?: boolean;
|
||||
selectedItems?: string[];
|
||||
menuVisible: boolean;
|
||||
menuAnchor: { x: number; y: number };
|
||||
selectedRisks?: RiskEnum[];
|
||||
onSearch: (query: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onBulkDelete: () => void;
|
||||
onMenuDismiss: () => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
onSelectRisks: (risks: RiskEnum[]) => void;
|
||||
itemsPerPage?: number;
|
||||
onAddUser: () => void;
|
||||
onAddingUser: boolean;
|
||||
}
|
||||
|
||||
export const UserTableControls = ({
|
||||
searchQuery,
|
||||
selectAll = false,
|
||||
selectedItems = [],
|
||||
menuVisible,
|
||||
menuAnchor,
|
||||
selectedRisks = [],
|
||||
onSearch,
|
||||
onSelectAll,
|
||||
onBulkDelete,
|
||||
onMenuDismiss,
|
||||
onItemsPerPageChange,
|
||||
onSelectRisks,
|
||||
itemsPerPage = 25,
|
||||
onAddUser,
|
||||
onAddingUser = false,
|
||||
}: UserTableControlsProps) => {
|
||||
const renderFilterComponent = () => {
|
||||
const riskOptions = Object.values(RiskEnum).map((risk) => ({
|
||||
value: risk,
|
||||
label: RiskLabels[risk],
|
||||
icon: <RiskIndicator risk={risk} />,
|
||||
}));
|
||||
|
||||
const sections: CombinedFilterSection[] = [];
|
||||
|
||||
sections.push({
|
||||
key: "risk",
|
||||
title: "Risk",
|
||||
options: riskOptions,
|
||||
selected: selectedRisks,
|
||||
onChange: (values) => onSelectRisks(values as RiskEnum[]),
|
||||
});
|
||||
|
||||
return (
|
||||
<CombinedFilterMenu
|
||||
sections={sections}
|
||||
showResetButton={true}
|
||||
onResetAll={() => onSelectRisks([])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFilterChips = () => {
|
||||
const chips: SelectedChip[] = selectedRisks.map((risk) => ({
|
||||
key: `risk-${risk}`,
|
||||
label: RiskLabels[risk],
|
||||
onRemove: () =>
|
||||
onSelectRisks(
|
||||
selectedRisks.filter((selectedFilter) => selectedFilter !== risk)
|
||||
),
|
||||
}));
|
||||
|
||||
return chips.length > 0 ? <SelectedFilterChips chips={chips} /> : null;
|
||||
};
|
||||
|
||||
const renderActionButtons = () => {
|
||||
return (
|
||||
<View className="flex-row items-center">
|
||||
{onAddUser ? (
|
||||
<View className="mr-2">
|
||||
<Button
|
||||
onPress={onAddUser}
|
||||
disabled={onAddingUser}
|
||||
icon="account-plus-outline"
|
||||
buttonColor="#6750a4"
|
||||
buttonTextColor="white"
|
||||
>
|
||||
Add User
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
<SelectionControls
|
||||
selectAll={selectAll}
|
||||
selectedUsers={selectedItems}
|
||||
onSelectAll={onSelectAll}
|
||||
onBulkDelete={onBulkDelete}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableControls
|
||||
searchQuery={searchQuery}
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
searchPlaceholder="Search users"
|
||||
onSearch={onSearch}
|
||||
onMenuDismiss={onMenuDismiss}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
itemsPerPage={itemsPerPage}
|
||||
renderFilterComponent={renderFilterComponent}
|
||||
renderFilterChips={renderFilterChips}
|
||||
renderActionButtons={renderActionButtons}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { DataTable, Text } from "react-native-paper";
|
||||
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { SortableHeader } from "@/components/shared/SortableHeader";
|
||||
|
||||
interface UserTableHeaderProps {
|
||||
sortColumn: string | null;
|
||||
sortDirection: TableSortDirection;
|
||||
onSort: (column: string) => void;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export const UserTableHeader = ({
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: UserTableHeaderProps) => {
|
||||
return (
|
||||
<DataTable.Header>
|
||||
<SortableHeader
|
||||
title="Name"
|
||||
column="name"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
title="Risk Status"
|
||||
column="risk"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
centerAlign
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<SortableHeader
|
||||
title="Email"
|
||||
column="email"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
title="Title"
|
||||
column="title"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<DataTable.Title style={{ justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12 }}>Actions</Text>
|
||||
</DataTable.Title>
|
||||
</DataTable.Header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useMemo, useState, useDeferredValue } from "react";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
import { UserAccountsTable } from "@/components/features/users/UserAccountsTable";
|
||||
import { UserModal } from "@/components/features/users/UserModal";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import {
|
||||
useDeleteUserMutation,
|
||||
useGetUsersQuery,
|
||||
useLockUserMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUsersMutation,
|
||||
useCreateUserMutation,
|
||||
} from "@/store/api/usersApi";
|
||||
import { BulkDeleteUserModal } from "@/components/features/users/BulkDeleteUserModal";
|
||||
import { LockUserModal } from "@/components/features/users/LockUserModal";
|
||||
import { DeleteUserModal } from "@/components/features/users/DeleteUserModal";
|
||||
import { NoData } from "@/components/ui/NoData";
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
import { useAuthContext } from "@/auth/AuthContext";
|
||||
import { OperationMode } from "@/constants/operationModeEnum";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
import { AppSnackbar } from "@/components/ui/AppSnackbar";
|
||||
|
||||
export const UsersPage = () => {
|
||||
const params = useLocalSearchParams<{ q?: string }>();
|
||||
const { user } = useAuthContext();
|
||||
const [page, setPage] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRisks, setSelectedRisks] = useState<RiskEnum[]>([]);
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<TableSortDirection>(
|
||||
TableSortDirection.ASCENDING
|
||||
);
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
const deferredSearch = useDeferredValue(searchQuery);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(deferredSearch);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(
|
||||
() => setDebouncedSearch(deferredSearch.trim()),
|
||||
450
|
||||
);
|
||||
return () => clearTimeout(handler);
|
||||
}, [deferredSearch]);
|
||||
|
||||
// Initialize search query from route param
|
||||
useEffect(() => {
|
||||
const qParam = (params.q ?? "").toString().trim();
|
||||
if (qParam && qParam !== searchQuery) {
|
||||
setSearchQuery(qParam);
|
||||
setPage(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.q]);
|
||||
|
||||
const queryArgs = useMemo(() => {
|
||||
const allowedSorts = ["name", "email", "title", "risk"] as const;
|
||||
const sortBy = (allowedSorts as readonly string[]).includes(
|
||||
sortColumn || ""
|
||||
)
|
||||
? (sortColumn as (typeof allowedSorts)[number])
|
||||
: undefined;
|
||||
const sortDir = sortDirection;
|
||||
const risks = selectedRisks.length ? selectedRisks : undefined;
|
||||
const searchParam =
|
||||
debouncedSearch && debouncedSearch.length >= 2
|
||||
? debouncedSearch
|
||||
: undefined;
|
||||
return {
|
||||
roleType: "user" as const,
|
||||
page: page + 1,
|
||||
limit: itemsPerPage,
|
||||
search: searchParam,
|
||||
risks,
|
||||
sortBy,
|
||||
sortDir,
|
||||
};
|
||||
}, [
|
||||
page,
|
||||
itemsPerPage,
|
||||
debouncedSearch,
|
||||
selectedRisks,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
const {
|
||||
data: usersPage,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useGetUsersQuery(queryArgs, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const users = usersPage?.items ?? [];
|
||||
const totalItems = usersPage?.total ?? 0;
|
||||
const from = users.length ? page * itemsPerPage + 1 : 0;
|
||||
const to = users.length ? Math.min(from + users.length - 1, totalItems) : 0;
|
||||
|
||||
const [deleteUser, { isLoading: isDeleting }] = useDeleteUserMutation();
|
||||
const [lockUser, { isLoading: isLocking }] = useLockUserMutation();
|
||||
const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation();
|
||||
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
|
||||
const [bulkDeleteUsers, { isLoading: isBulkDeleting }] =
|
||||
useDeleteUsersMutation();
|
||||
const [userModalVisible, setUserModalVisible] = useState(false);
|
||||
const [userModalMode, setUserModalMode] = useState<OperationMode>(
|
||||
OperationMode.ADD
|
||||
);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [bulkDeleteModalVisible, setBulkDeleteModalVisible] = useState(false);
|
||||
const [lockModalVisible, setLockModalVisible] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [selectedUsersIds, setSelectedUsersIds] = useState<string[]>([]);
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("");
|
||||
|
||||
const showSnackbar = (message: string) => {
|
||||
setSnackbarMessage(message);
|
||||
setSnackbarVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
setUserModalMode(OperationMode.EDIT);
|
||||
setUserModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddUser = () => {
|
||||
setCurrentUser(null);
|
||||
setUserModalMode(OperationMode.ADD);
|
||||
setUserModalVisible(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const openLockModal = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
setLockModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: User) => {
|
||||
try {
|
||||
await deleteUser(user.id).unwrap();
|
||||
if (selectedUsersIds.includes(user.id)) {
|
||||
setSelectedUsersIds(selectedUsersIds.filter((id) => id !== user.id));
|
||||
}
|
||||
refetch();
|
||||
showSnackbar(
|
||||
`${user.firstName} ${user.lastName} has been deleted successfully`
|
||||
);
|
||||
setDeleteModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error deleting user ${user.firstName} ${user.lastName}:`,
|
||||
error
|
||||
);
|
||||
showSnackbar(`Error deleting ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLockUser = async (user: User) => {
|
||||
const action = user.isLocked ? "unlock" : "lock";
|
||||
try {
|
||||
await lockUser(user.id).unwrap();
|
||||
refetch();
|
||||
showSnackbar(
|
||||
`${user.firstName} ${user.lastName} has been ${action}ed successfully`
|
||||
);
|
||||
setLockModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error ${action}ing user ${user.firstName} ${user.lastName}:`,
|
||||
error
|
||||
);
|
||||
showSnackbar(`Error ${action}ing ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDeleteUsers = (users: User[]) => {
|
||||
if (users.length === 0) return;
|
||||
|
||||
setSelectedUsersIds(users.map((user) => user.id));
|
||||
setSelectedUsers(users);
|
||||
setBulkDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmBulkDelete = async () => {
|
||||
if (selectedUsersIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const userIds = selectedUsersIds;
|
||||
const count = selectedUsersIds.length;
|
||||
await bulkDeleteUsers(userIds).unwrap();
|
||||
refetch();
|
||||
showSnackbar(`${count} users deleted successfully`);
|
||||
setBulkDeleteModalVisible(false);
|
||||
setSelectedUsersIds([]);
|
||||
} catch (error: any) {
|
||||
console.error(`Error deleting multiple users:`, error);
|
||||
const errorMessage =
|
||||
error?.data?.error ||
|
||||
error?.data?.details ||
|
||||
"Error deleting multiple users";
|
||||
showSnackbar(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitUser = async (payload: Partial<User>) => {
|
||||
try {
|
||||
if (userModalMode === OperationMode.EDIT) {
|
||||
await updateUser(payload as User).unwrap();
|
||||
showSnackbar(
|
||||
`${payload.firstName} ${payload.lastName} updated successfully`
|
||||
);
|
||||
} else {
|
||||
const res = await createUser(payload).unwrap();
|
||||
showSnackbar(
|
||||
`${res.firstName} ${res.lastName} added successfully${
|
||||
!res.isActivated ? " and invitation sent" : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
setUserModalVisible(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error(`Error saving user:`, error);
|
||||
showSnackbar(`Error saving user`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Refetch when auth user state changes (e.g., after activation/login)
|
||||
refetch();
|
||||
}, [user?.id, user?.isActivated, refetch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className={`flex-1 bg-primaryPurpleLight ${isMobile ? "p-1" : "p-5"}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
isMobile ? "mb-1" : "mb-4"
|
||||
} text-white`}
|
||||
>
|
||||
Users manager
|
||||
</Text>
|
||||
|
||||
<View
|
||||
className={`bg-lightGray rounded-lg ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
} flex-1`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 justify-center items-center py-10">
|
||||
<LoadingSpinner />
|
||||
<Text className="mt-2">Loading users...</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className="flex-1 justify-center items-center py-10">
|
||||
<NoData
|
||||
message="Error loading users"
|
||||
onRetry={refetch}
|
||||
isRetrying={isFetching}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<UserAccountsTable
|
||||
users={users}
|
||||
onSelect={setSelectedUsersIds}
|
||||
selectedUserIds={selectedUsersIds}
|
||||
onEdit={handleEdit}
|
||||
onAddUser={handleAddUser}
|
||||
onDelete={openDeleteModal}
|
||||
onLock={openLockModal}
|
||||
onBulkDelete={handleBulkDeleteUsers}
|
||||
page={page}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={totalItems}
|
||||
from={from}
|
||||
to={to}
|
||||
searchQuery={searchQuery}
|
||||
selectedRisks={selectedRisks}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onAddingUser={isCreating}
|
||||
onSearch={(q) => {
|
||||
setSearchQuery(q);
|
||||
setPage(0);
|
||||
}}
|
||||
onSelectRisks={(risks) => {
|
||||
setSelectedRisks(risks);
|
||||
setPage(0);
|
||||
}}
|
||||
onSort={(column) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((d) =>
|
||||
d === TableSortDirection.ASCENDING
|
||||
? TableSortDirection.DESCENDING
|
||||
: TableSortDirection.ASCENDING
|
||||
);
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection(TableSortDirection.ASCENDING);
|
||||
}
|
||||
setPage(0);
|
||||
}}
|
||||
onItemsPerPageChange={(value) => {
|
||||
setItemsPerPage(value);
|
||||
setPage(0);
|
||||
}}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<UserModal
|
||||
visible={userModalVisible}
|
||||
mode={userModalMode}
|
||||
user={currentUser}
|
||||
onClose={() => setUserModalVisible(false)}
|
||||
onSubmit={handleSubmitUser}
|
||||
isLoading={isUpdating || isCreating}
|
||||
/>
|
||||
|
||||
<DeleteUserModal
|
||||
visible={deleteModalVisible}
|
||||
user={currentUser}
|
||||
onDismiss={() => setDeleteModalVisible(false)}
|
||||
onDelete={handleDeleteUser}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
|
||||
<BulkDeleteUserModal
|
||||
visible={bulkDeleteModalVisible}
|
||||
users={selectedUsers}
|
||||
onDismiss={() => setBulkDeleteModalVisible(false)}
|
||||
onDelete={confirmBulkDelete}
|
||||
isLoading={isBulkDeleting}
|
||||
/>
|
||||
|
||||
<LockUserModal
|
||||
visible={lockModalVisible}
|
||||
user={currentUser}
|
||||
onDismiss={() => setLockModalVisible(false)}
|
||||
onLock={handleLockUser}
|
||||
isLoading={isLocking}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<AppSnackbar
|
||||
visible={snackbarVisible}
|
||||
onDismiss={() => setSnackbarVisible(false)}
|
||||
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
|
||||
>
|
||||
{snackbarMessage}
|
||||
</AppSnackbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
|
||||
export const userSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Email is required")
|
||||
.pipe(z.email("Enter a valid email address")),
|
||||
title: z.string().trim().min(1, "Title is required"),
|
||||
riskStatus: z.enum(RiskEnum).default(RiskEnum.LOW_RISK),
|
||||
});
|
||||
|
||||
export const userSchemaWithId = userSchema.extend({
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UserFormValues = z.input<typeof userSchemaWithId>;
|
||||
Reference in New Issue
Block a user