project setup
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useMemo, useState, useDeferredValue } from "react";
|
||||
import { View, Text, ScrollView, GestureResponderEvent } from "react-native";
|
||||
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { NoData } from "@/components/ui/NoData";
|
||||
import {
|
||||
useGetUsersQuery,
|
||||
useCreateUserMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUserMutation,
|
||||
useResendInvitationMutation,
|
||||
} from "@/store/api/usersApi";
|
||||
import { AdminAccountsTableControls } from "@/components/features/admins/AdminAccountsTableControls";
|
||||
import { DesktopAdminAccountsTable } from "@/components/features/admins/DesktopAdminAccountsTable";
|
||||
import { MobileAdminAccountsTable } from "@/components/features/admins/MobileAdminAccountsTable";
|
||||
import { TablePagination } from "@/components/shared/TablePagination";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { AdminModal } from "@/components/features/admins/AdminModal";
|
||||
import { OperationMode } from "@/constants/operationModeEnum";
|
||||
import { DeleteUserModal } from "@/components/features/users/DeleteUserModal";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { AppSnackbar } from "@/components/ui/AppSnackbar";
|
||||
|
||||
interface AdminAccountsTableProps {
|
||||
initialSearchQuery?: string;
|
||||
}
|
||||
|
||||
export const AdminAccountsTable = ({
|
||||
initialSearchQuery = "",
|
||||
}: AdminAccountsTableProps) => {
|
||||
const [page, setPage] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<TableSortDirection>(
|
||||
TableSortDirection.ASCENDING
|
||||
);
|
||||
|
||||
const deferredSearch = useDeferredValue(searchQuery);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
const queryArgs = useMemo(() => {
|
||||
const allowedSorts = ["name", "email", "title", "role", "risk"] as const;
|
||||
const sortBy = (allowedSorts as readonly string[]).includes(
|
||||
sortColumn || ""
|
||||
)
|
||||
? (sortColumn as (typeof allowedSorts)[number])
|
||||
: undefined;
|
||||
const sortDir = sortDirection;
|
||||
const searchParam =
|
||||
debouncedSearch && debouncedSearch.length >= 2
|
||||
? debouncedSearch
|
||||
: undefined;
|
||||
return {
|
||||
roleType: "admin" as const,
|
||||
page: page + 1,
|
||||
limit: itemsPerPage,
|
||||
search: searchParam,
|
||||
sortBy,
|
||||
sortDir,
|
||||
};
|
||||
}, [page, itemsPerPage, debouncedSearch, sortColumn, sortDirection]);
|
||||
|
||||
const {
|
||||
data: adminsPageData,
|
||||
isLoading,
|
||||
error: fetchError,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useGetUsersQuery(queryArgs, { refetchOnMountOrArgChange: true });
|
||||
const adminUsers = adminsPageData?.items ?? [];
|
||||
const [createAdmin, { isLoading: isCreating }] = useCreateUserMutation();
|
||||
const [updateAdmin, { isLoading: isUpdating }] = useUpdateUserMutation();
|
||||
const [deleteAdmin, { isLoading: isDeleting }] = useDeleteUserMutation();
|
||||
const [resendInvitation, { isLoading: isResending }] =
|
||||
useResendInvitationMutation();
|
||||
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState({ x: 0, y: 0 });
|
||||
const [addAdminModalVisible, setAddAdminModalVisible] = useState(false);
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [currentAdmin, setCurrentAdmin] = useState<User | null>(null);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("");
|
||||
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
||||
|
||||
const showSnackbar = (message: string) => {
|
||||
setSnackbarMessage(message);
|
||||
setSnackbarVisible(true);
|
||||
};
|
||||
|
||||
const totalItems = adminsPageData?.total ?? 0;
|
||||
const from = adminUsers.length ? page * itemsPerPage + 1 : 0;
|
||||
const to = adminUsers.length
|
||||
? Math.min(from + adminUsers.length - 1, totalItems)
|
||||
: 0;
|
||||
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
setSortColumn(column);
|
||||
setSortDirection(
|
||||
sortDirection === TableSortDirection.ASCENDING
|
||||
? TableSortDirection.DESCENDING
|
||||
: TableSortDirection.ASCENDING
|
||||
);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleItemsPerPageChange = (count: number) => {
|
||||
setItemsPerPage(count);
|
||||
};
|
||||
|
||||
const handleCreateAdmin = async (admin: Partial<User>) => {
|
||||
try {
|
||||
await createAdmin(admin).unwrap();
|
||||
showSnackbar(
|
||||
`${admin.firstName} ${admin.lastName} created. Invitation email sent if no password was provided.`
|
||||
);
|
||||
setAddAdminModalVisible(false);
|
||||
} catch {
|
||||
showSnackbar("Error creating admin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateAdmin = async (admin: Partial<User>) => {
|
||||
try {
|
||||
await updateAdmin(admin).unwrap();
|
||||
showSnackbar(`${admin.firstName} ${admin.lastName} updated successfully`);
|
||||
setEditModalVisible(false);
|
||||
} catch {
|
||||
showSnackbar("Error updating admin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAdmin = async (admin: User) => {
|
||||
try {
|
||||
await deleteAdmin(admin.id).unwrap();
|
||||
showSnackbar(`${admin.firstName} ${admin.lastName} deleted successfully`);
|
||||
setDeleteModalVisible(false);
|
||||
} catch {
|
||||
showSnackbar("Error deleting admin");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendInvite = async (admin: User) => {
|
||||
try {
|
||||
await resendInvitation(admin.id).unwrap();
|
||||
showSnackbar(`Invitation resent to ${admin.email}`);
|
||||
} catch {
|
||||
showSnackbar("Error resending invitation");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (admin: User) => {
|
||||
setCurrentAdmin(admin);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (admin: User) => {
|
||||
setCurrentAdmin(admin);
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(deferredSearch.trim()), 450);
|
||||
return () => clearTimeout(t);
|
||||
}, [deferredSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = (initialSearchQuery || "").trim();
|
||||
if (q && q !== searchQuery) {
|
||||
setSearchQuery(q);
|
||||
setPage(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialSearchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [itemsPerPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
isMobile ? "mb-1" : "mb-4"
|
||||
} text-white`}
|
||||
>
|
||||
Admins manager
|
||||
</Text>
|
||||
<View
|
||||
className={`bg-lightGray rounded-lg ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
} flex-1`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<LoadingSpinner />
|
||||
<Text className="mt-2">Loading admins...</Text>
|
||||
</View>
|
||||
) : fetchError ? (
|
||||
<NoData
|
||||
message="Error loading admins"
|
||||
onRetry={refetch}
|
||||
isRetrying={isFetching}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1">
|
||||
<AdminAccountsTableControls
|
||||
searchQuery={searchQuery}
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
onSearch={handleSearch}
|
||||
onMenuDismiss={() => setMenuVisible(false)}
|
||||
onItemsPerPageChange={handleItemsPerPageChange}
|
||||
onAddAdmin={() => setAddAdminModalVisible(true)}
|
||||
/>
|
||||
|
||||
<AdminModal
|
||||
visible={addAdminModalVisible}
|
||||
onClose={() => setAddAdminModalVisible(false)}
|
||||
onSubmit={handleCreateAdmin}
|
||||
isLoading={isCreating}
|
||||
mode={OperationMode.ADD}
|
||||
/>
|
||||
|
||||
<AdminModal
|
||||
visible={editModalVisible}
|
||||
admin={currentAdmin}
|
||||
onClose={() => setEditModalVisible(false)}
|
||||
onSubmit={handleUpdateAdmin}
|
||||
isLoading={isUpdating}
|
||||
mode={OperationMode.EDIT}
|
||||
/>
|
||||
|
||||
<DeleteUserModal
|
||||
visible={deleteModalVisible}
|
||||
user={currentAdmin}
|
||||
onDismiss={() => setDeleteModalVisible(false)}
|
||||
onDelete={handleDeleteAdmin}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
|
||||
<View className="flex-1 overflow-hidden">
|
||||
{searchQuery && adminUsers.length === 0 ? (
|
||||
<View
|
||||
className={`flex-1 items-center justify-center ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-lg text-gray-500">
|
||||
No admins match your search for "{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
) : searchQuery.length === 0 && adminUsers.length === 0 ? (
|
||||
<View
|
||||
className={`flex-1 items-center justify-center ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-lg text-gray-500">
|
||||
No admins found
|
||||
</Text>
|
||||
</View>
|
||||
) : isMobile ? (
|
||||
<View className="flex-1">
|
||||
<MobileAdminAccountsTable
|
||||
admins={adminUsers}
|
||||
onEdit={handleEdit}
|
||||
onDelete={openDeleteModal}
|
||||
onResend={handleResendInvite}
|
||||
isResending={isResending}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView className="flex-1 h-96">
|
||||
<DesktopAdminAccountsTable
|
||||
admins={adminUsers}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onEdit={handleEdit}
|
||||
onDelete={openDeleteModal}
|
||||
onResend={handleResendInvite}
|
||||
isResending={isResending}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TablePagination
|
||||
page={page}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={totalItems}
|
||||
from={from}
|
||||
to={to}
|
||||
onPageChange={setPage}
|
||||
onItemsPerPageButtonPress={(event: GestureResponderEvent) => {
|
||||
const { pageX, pageY } = event.nativeEvent;
|
||||
setMenuAnchor({ x: pageX, y: pageY });
|
||||
setMenuVisible(true);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<AppSnackbar
|
||||
visible={snackbarVisible}
|
||||
onDismiss={() => setSnackbarVisible(false)}
|
||||
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
|
||||
>
|
||||
{snackbarMessage}
|
||||
</AppSnackbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { TableControls } from "@/components/shared/TableControls";
|
||||
import { AdminControls } from "@/components/features/admins/AdminControls";
|
||||
|
||||
interface AdminTableControlsProps {
|
||||
searchQuery: string;
|
||||
menuVisible: boolean;
|
||||
menuAnchor: { x: number; y: number };
|
||||
onSearch: (query: string) => void;
|
||||
onMenuDismiss: () => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
onAddAdmin?: () => void;
|
||||
itemsPerPage?: number;
|
||||
}
|
||||
|
||||
export const AdminAccountsTableControls = ({
|
||||
searchQuery,
|
||||
menuVisible,
|
||||
menuAnchor,
|
||||
onSearch,
|
||||
onMenuDismiss,
|
||||
onItemsPerPageChange,
|
||||
onAddAdmin = () => {},
|
||||
itemsPerPage = 25,
|
||||
}: AdminTableControlsProps) => {
|
||||
const renderActionButtons = () => {
|
||||
return <AdminControls onAddAdmin={onAddAdmin} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<TableControls
|
||||
searchQuery={searchQuery}
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
searchPlaceholder="Search admins"
|
||||
onSearch={onSearch}
|
||||
onMenuDismiss={onMenuDismiss}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
itemsPerPage={itemsPerPage}
|
||||
renderActionButtons={renderActionButtons}
|
||||
itemsPerPageMenuOptions={[5, 10, 25]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { View } from "react-native";
|
||||
import { IconButton } from "react-native-paper";
|
||||
|
||||
import { User } from "@/store/models/User.model";
|
||||
|
||||
interface AdminActionsProps {
|
||||
admin: User;
|
||||
onView: (admin: User) => void;
|
||||
onEdit: (admin: User) => void;
|
||||
onDelete: (admin: User) => void;
|
||||
onResend: (admin: User) => void;
|
||||
isResending?: boolean;
|
||||
}
|
||||
|
||||
export const AdminActions = ({
|
||||
admin,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onResend,
|
||||
isResending,
|
||||
}: AdminActionsProps) => {
|
||||
return (
|
||||
<View className="flex-row justify-center">
|
||||
<IconButton icon="eye-outline" onPress={() => onView(admin)} size={20} />
|
||||
{!admin.isActivated && (
|
||||
<IconButton
|
||||
icon="email-send-outline"
|
||||
onPress={() => onResend(admin)}
|
||||
size={20}
|
||||
disabled={Boolean(isResending)}
|
||||
accessibilityLabel="Resend invitation"
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon="pencil-outline"
|
||||
onPress={() => onEdit(admin)}
|
||||
size={20}
|
||||
/>
|
||||
<IconButton
|
||||
icon="delete-outline"
|
||||
onPress={() => onDelete(admin)}
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface AdminControlsProps {
|
||||
onAddAdmin: () => void;
|
||||
}
|
||||
|
||||
export const AdminControls = ({
|
||||
onAddAdmin,
|
||||
}: AdminControlsProps) => {
|
||||
return (
|
||||
<View className="flex-row">
|
||||
<Button
|
||||
onPress={onAddAdmin}
|
||||
icon="account-plus-outline"
|
||||
buttonColor="#6750a4"
|
||||
buttonTextColor="white"
|
||||
disabled={false}
|
||||
>
|
||||
Add Admin
|
||||
</Button>
|
||||
</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 AdminDetailsPage = () => {
|
||||
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="Admin not found"
|
||||
onRetry={refetch}
|
||||
isRetrying={isFetching}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<AccountDetails user={user} isLoading={isLoading || isFetching} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useEffect, useMemo, 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 { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { RoleEnum } from "@/constants/roleEnum";
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
import { OperationMode } from "@/constants/operationModeEnum";
|
||||
import { DialogContainer } from "@/components/ui/DialogContainer";
|
||||
import { useAuthContext } from "@/auth/AuthContext";
|
||||
import { useGetUsersQuery } from "@/store/api/usersApi";
|
||||
import { CustomPicker, PickerItem } from "@/components/ui/CustomPicker";
|
||||
import {
|
||||
adminSchema,
|
||||
AdminFormValues,
|
||||
} from "@/components/features/admins/schemas/admin.schema";
|
||||
|
||||
interface AdminModalProps {
|
||||
visible: boolean;
|
||||
mode?: OperationMode;
|
||||
admin?: User | null;
|
||||
isLoading: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: Partial<User>) => void;
|
||||
}
|
||||
|
||||
export const AdminModal = ({
|
||||
visible,
|
||||
mode = OperationMode.ADD,
|
||||
admin = null,
|
||||
isLoading,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: AdminModalProps) => {
|
||||
const isEdit = mode === OperationMode.EDIT;
|
||||
const { user: currentUser } = useAuthContext();
|
||||
const { data: adminsData } = useGetUsersQuery(
|
||||
{
|
||||
roleType: "admin",
|
||||
limit: 1000,
|
||||
},
|
||||
{ skip: !visible }
|
||||
);
|
||||
const managerOptions = useMemo(
|
||||
() => (adminsData?.items || []).filter((u) => u.role === RoleEnum.MANAGER),
|
||||
[adminsData]
|
||||
);
|
||||
|
||||
const adminRef = useRef<User | null>(admin);
|
||||
adminRef.current = admin;
|
||||
const currentUserRef = useRef(currentUser);
|
||||
currentUserRef.current = currentUser;
|
||||
|
||||
const defaultValues: AdminFormValues = {
|
||||
id: admin?.id,
|
||||
email: admin?.email ?? "",
|
||||
firstName: admin?.firstName ?? "",
|
||||
lastName: admin?.lastName ?? "",
|
||||
title: admin?.title ?? "",
|
||||
role: admin?.role ?? RoleEnum.ADMIN,
|
||||
riskStatus: admin?.riskStatus ?? RiskEnum.LOW_RISK,
|
||||
managerId:
|
||||
admin?.managerId ??
|
||||
(currentUser?.role === RoleEnum.MANAGER ? currentUser.id : undefined),
|
||||
};
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors, isDirty, isValid },
|
||||
} = useForm<AdminFormValues>({
|
||||
resolver: zodResolver(adminSchema),
|
||||
defaultValues,
|
||||
mode: "onChange",
|
||||
shouldUnregister: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const nextDefaults: AdminFormValues = {
|
||||
id: adminRef.current?.id,
|
||||
email: adminRef.current?.email ?? "",
|
||||
firstName: adminRef.current?.firstName ?? "",
|
||||
lastName: adminRef.current?.lastName ?? "",
|
||||
title: adminRef.current?.title ?? "",
|
||||
role: adminRef.current?.role ?? RoleEnum.ADMIN,
|
||||
riskStatus: adminRef.current?.riskStatus ?? RiskEnum.LOW_RISK,
|
||||
managerId:
|
||||
adminRef.current?.managerId ??
|
||||
(currentUserRef.current?.role === RoleEnum.MANAGER
|
||||
? currentUserRef.current?.id
|
||||
: undefined),
|
||||
};
|
||||
reset(nextDefaults, { keepDefaultValues: false });
|
||||
}, [visible, reset, admin?.id, currentUser?.id, currentUser?.role]);
|
||||
|
||||
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 selectedRole = watch("role");
|
||||
|
||||
const submit = (vals: AdminFormValues) => {
|
||||
const payload: Partial<User> = {
|
||||
id: vals.id,
|
||||
email: vals.email.trim().toLowerCase(),
|
||||
firstName: vals.firstName.trim(),
|
||||
lastName: vals.lastName.trim(),
|
||||
title: vals.title.trim(),
|
||||
role: vals.role || RoleEnum.ADMIN,
|
||||
riskStatus: vals.riskStatus || RiskEnum.LOW_RISK,
|
||||
// Only Managers can assign Admins to a Manager (Managers themselves cannot be reassigned)
|
||||
...(currentUser?.role === RoleEnum.MANAGER && vals.role === RoleEnum.ADMIN
|
||||
? { managerId: vals.managerId ?? undefined }
|
||||
: {}),
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
const titleText = isEdit ? "Edit Admin" : "Add Admin";
|
||||
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 }}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
>
|
||||
<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 address"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
/>
|
||||
{errors.email ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.email.message as string}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<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"
|
||||
autoCapitalize="words"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
/>
|
||||
{errors.firstName ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.firstName.message as string}
|
||||
</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"
|
||||
autoCapitalize="words"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
/>
|
||||
{errors.lastName ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.lastName.message as string}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Password field removed for invitation-only flow */}
|
||||
|
||||
<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 job title"
|
||||
autoCapitalize="words"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
/>
|
||||
{errors.title ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.title.message as string}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Role</Text>
|
||||
<View className="border border-gray-300 rounded overflow-hidden">
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomPicker
|
||||
selectedValue={value}
|
||||
onValueChange={(v) => onChange(v)}
|
||||
>
|
||||
<PickerItem label="Admin" value={RoleEnum.ADMIN} />
|
||||
<PickerItem label="Manager" value={RoleEnum.MANAGER} />
|
||||
</CustomPicker>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{currentUser?.role === RoleEnum.MANAGER &&
|
||||
selectedRole === RoleEnum.ADMIN ? (
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">
|
||||
Assign to Manager
|
||||
</Text>
|
||||
<View className="border border-gray-300 rounded overflow-hidden">
|
||||
<Controller
|
||||
control={control}
|
||||
name="managerId"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomPicker
|
||||
selectedValue={value ?? undefined}
|
||||
onValueChange={(v) => onChange(v)}
|
||||
>
|
||||
{managerOptions.map((m) => (
|
||||
<PickerItem
|
||||
key={m.id}
|
||||
label={
|
||||
`${m.firstName} ${m.lastName}`.trim() || m.email
|
||||
}
|
||||
value={m.id}
|
||||
/>
|
||||
))}
|
||||
</CustomPicker>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</Dialog.Content>
|
||||
|
||||
<Dialog.Actions>
|
||||
<Button
|
||||
onPress={handleSubmit(submit)}
|
||||
disabled={isLoading || !isValid || (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,46 @@
|
||||
import { DataTable, Text } from "react-native-paper";
|
||||
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { SortableHeader } from "@/components/shared/SortableHeader";
|
||||
|
||||
interface AdminTableHeaderProps {
|
||||
sortColumn: string | null;
|
||||
sortDirection: TableSortDirection;
|
||||
onSort: (column: string) => void;
|
||||
}
|
||||
|
||||
export const AdminTableHeader = ({
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: AdminTableHeaderProps) => {
|
||||
return (
|
||||
<DataTable.Header>
|
||||
<SortableHeader
|
||||
title="Name"
|
||||
column="firstName"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
title="Email"
|
||||
column="email"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
title="Role"
|
||||
column="role"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
centerAlign
|
||||
/>
|
||||
<DataTable.Title style={{ justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12 }}>Actions</Text>
|
||||
</DataTable.Title>
|
||||
</DataTable.Header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { View } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
|
||||
import { AdminAccountsTable } from "@/components/features/admins/AdminAccountsTable";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
|
||||
export const AdminsPage = () => {
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
const params = useLocalSearchParams<{ q?: string }>();
|
||||
const initialSearchQuery = params.q ?? "";
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-primaryPurpleLight">
|
||||
<View className={`${isMobile ? "p-1" : "p-5"} flex-1`}>
|
||||
<AdminAccountsTable initialSearchQuery={initialSearchQuery} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
import { Avatar, DataTable, Text, Chip } from "react-native-paper";
|
||||
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { AdminTableHeader } from "@/components/features/admins/AdminTableHeader";
|
||||
import { AdminActions } from "@/components/features/admins/AdminActions";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { RoleEnum } from "@/constants/roleEnum";
|
||||
import { getInitials } from "@/utils/userUtils";
|
||||
import { useAuthContext } from "@/auth/AuthContext";
|
||||
|
||||
interface DesktopAdminAccountsTableProps {
|
||||
admins: User[];
|
||||
sortColumn: string | null;
|
||||
sortDirection: TableSortDirection;
|
||||
onSort: (column: string) => void;
|
||||
onEdit: (admin: User) => void;
|
||||
onDelete: (admin: User) => void;
|
||||
onResend: (admin: User) => void;
|
||||
isResending?: boolean;
|
||||
}
|
||||
|
||||
export const DesktopAdminAccountsTable = ({
|
||||
admins,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onResend,
|
||||
isResending,
|
||||
}: DesktopAdminAccountsTableProps) => {
|
||||
const { user } = useAuthContext();
|
||||
const router = useRouter();
|
||||
|
||||
const openDetails = (admin: User) => {
|
||||
router.push({
|
||||
pathname: "/accounts/admins/[id]",
|
||||
params: { id: admin.id },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DataTable>
|
||||
<AdminTableHeader
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
|
||||
{admins.map((admin) => (
|
||||
<DataTable.Row key={admin.id}>
|
||||
<DataTable.Cell>
|
||||
<View className="flex-row items-center flex-shrink pr-2">
|
||||
{admin.imageUrl ? (
|
||||
<Avatar.Image
|
||||
source={{
|
||||
uri: admin.imageUrl,
|
||||
}}
|
||||
size={28}
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar.Text
|
||||
label={getInitials(admin.firstName, admin.lastName)}
|
||||
size={28}
|
||||
style={{ marginRight: 10 }}
|
||||
/>
|
||||
)}
|
||||
<View className="flex-row items-center flex-shrink px-1 py-1">
|
||||
<Text
|
||||
onPress={() => openDetails(admin)}
|
||||
numberOfLines={10}
|
||||
className="flex-shrink"
|
||||
>
|
||||
{admin.firstName} {admin.lastName}
|
||||
</Text>
|
||||
{admin.managerId === user?.id ? (
|
||||
<View className="mb-3 w-1 h-1 rounded-full bg-primaryPurple ml-1" />
|
||||
) : null}
|
||||
{!admin.isActivated && (
|
||||
<Chip
|
||||
compact
|
||||
mode="flat"
|
||||
style={{ marginLeft: 8 }}
|
||||
textStyle={{ fontSize: 12 }}
|
||||
>
|
||||
Invited
|
||||
</Chip>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell>
|
||||
<View className="flex-row items-center flex-shrink pr-2">
|
||||
<Text numberOfLines={2}>{admin.email}</Text>
|
||||
</View>
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center flex-shrink">
|
||||
<Text numberOfLines={2}>
|
||||
{admin.role === RoleEnum.ADMIN ? "Admin" : "Manager"}
|
||||
</Text>
|
||||
</View>
|
||||
</DataTable.Cell>
|
||||
<DataTable.Cell style={{ justifyContent: "center" }}>
|
||||
<AdminActions
|
||||
admin={admin}
|
||||
onView={openDetails}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onResend={onResend}
|
||||
isResending={isResending}
|
||||
/>
|
||||
</DataTable.Cell>
|
||||
</DataTable.Row>
|
||||
))}
|
||||
</DataTable>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { View, ScrollView, Text } from "react-native";
|
||||
import { Avatar, IconButton, Chip } from "react-native-paper";
|
||||
import { useRouter } from "expo-router";
|
||||
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { RoleEnum } from "@/constants/roleEnum";
|
||||
|
||||
interface MobileAdminAccountsTableProps {
|
||||
admins: User[];
|
||||
onEdit: (admin: User) => void;
|
||||
onDelete: (admin: User) => void;
|
||||
onResend?: (admin: User) => void;
|
||||
isResending?: boolean;
|
||||
}
|
||||
|
||||
export const MobileAdminAccountsTable = ({
|
||||
admins,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onResend,
|
||||
isResending,
|
||||
}: MobileAdminAccountsTableProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const openDetails = (admin: User) => {
|
||||
router.push({
|
||||
pathname: "/accounts/admins/[id]",
|
||||
params: { id: admin.id },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
>
|
||||
{admins.map((admin) => (
|
||||
<View
|
||||
key={admin.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">
|
||||
<Avatar.Image
|
||||
source={{
|
||||
uri: admin.imageUrl,
|
||||
}}
|
||||
size={28}
|
||||
style={{ marginRight: 10, marginLeft: 10 }}
|
||||
/>
|
||||
<View className="flex-row items-center flex-1">
|
||||
<Text
|
||||
className="font-bold ml-2"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
onPress={() => openDetails(admin)}
|
||||
>
|
||||
{admin.firstName} {admin.lastName}
|
||||
</Text>
|
||||
{admin.managerId ? (
|
||||
<View className="w-1.5 h-1.5 rounded-full bg-blue-500 ml-2" />
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
className="text-sm mb-1"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{admin.email}
|
||||
</Text>
|
||||
<Text className="text-sm mb-1 text-gray-600">
|
||||
{admin.role === RoleEnum.ADMIN ? "Admin" : "Manager"}
|
||||
</Text>
|
||||
{!admin.isActivated && (
|
||||
<Chip compact mode="flat" textStyle={{ fontSize: 12 }}>
|
||||
Invited
|
||||
</Chip>
|
||||
)}
|
||||
<View className="flex-row justify-end border-t border-gray-200 pt-3">
|
||||
<IconButton
|
||||
icon="eye-outline"
|
||||
size={20}
|
||||
onPress={() => openDetails(admin)}
|
||||
/>
|
||||
{!admin.isActivated && (
|
||||
<IconButton
|
||||
icon="email-send-outline"
|
||||
size={20}
|
||||
disabled={Boolean(isResending)}
|
||||
onPress={() => onResend?.(admin)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon="pencil-outline"
|
||||
size={20}
|
||||
onPress={() => {
|
||||
onEdit(admin);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="delete-outline"
|
||||
size={20}
|
||||
onPress={() => {
|
||||
onDelete(admin);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { RoleEnum } from "@/constants/roleEnum";
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
|
||||
export const adminSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Email is required")
|
||||
.pipe(z.email("Please enter a valid email address")),
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
title: z.string().trim().min(1, "Title is required"),
|
||||
role: z.enum(RoleEnum),
|
||||
riskStatus: z.enum(RiskEnum).default(RiskEnum.LOW_RISK),
|
||||
managerId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
export type AdminFormValues = z.input<typeof adminSchema>;
|
||||
Reference in New Issue
Block a user