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,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 &quot;{searchQuery}&quot;
</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>
);
};
+349
View File
@@ -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>
);
};
+20
View File
@@ -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>;