project setup

This commit is contained in:
Sone
2025-11-02 10:08:56 +01:00
commit 568838003e
174 changed files with 27100 additions and 0 deletions
@@ -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>
);
};
+42
View File
@@ -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>
);
};
+286
View File
@@ -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>
);
};
+375
View File
@@ -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>;