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,46 @@
import { Divider } from "react-native-paper";
import { User } from "@/store/models/User.model";
import { PersonalInfo } from "@/components/features/accounts/PersonalInfo";
import { PersonalMetrics } from "@/components/features/accounts/PersonalMetrics";
import { PersonalTodoList } from "@/components/features/accounts/PersonalTodoList";
import { PersonalLoginList } from "@/components/features/accounts/PersonalLoginList";
import { PersonalActions } from "@/components/features/accounts/PersonalActions";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
interface AccountDetailsProps {
user: User;
isLoading: boolean;
}
export const AccountDetails = ({ user, isLoading }: AccountDetailsProps) => {
const isMobile = useAppSelector(selectIsMobile);
return (
<>
<PersonalActions user={user} isLoading={isLoading} />
<Divider
style={{ height: 6, backgroundColor: "#C4BEBE" }}
className={isMobile ? "my-1" : "my-4"}
/>
<PersonalInfo user={user} manager={user.manager} />
<Divider
style={{ height: 6, backgroundColor: "#C4BEBE" }}
className={isMobile ? "my-1" : "my-4"}
/>
<PersonalMetrics user={user} />
<Divider
style={{ height: 6, backgroundColor: "#C4BEBE" }}
className={isMobile ? "my-1" : "my-4"}
/>
<PersonalTodoList user={user} />
<Divider
style={{ height: 6, backgroundColor: "#C4BEBE" }}
className={isMobile ? "my-1" : "my-4"}
/>
<PersonalLoginList user={user} />
</>
);
};
+33
View File
@@ -0,0 +1,33 @@
import { ReactNode } from "react";
import { View, Text } from "react-native";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
interface InfoRowProps {
label: string;
value: string | ReactNode;
}
export const InfoRow = ({ label, value }: InfoRowProps) => {
const isMobile = useAppSelector(selectIsMobile);
return (
<View className="flex-row mb-2 items-center">
<Text
className={`font-medium mb-2 mr-[2px] text-gray-600 ${
isMobile ? "w-[50%]" : "w-[28%]"
}`}
>
{label}:
</Text>
<Text
className={`font-medium mb-2 text-black ${
isMobile ? "w-[50%]" : "w-[72%]"
}`}
>
{value || "-"}
</Text>
</View>
);
};
@@ -0,0 +1,169 @@
import { useState } from "react";
import { View } from "react-native";
import { IconButton } from "react-native-paper";
import { User } from "@/store/models/User.model";
import { UserModal } from "@/components/features/users/UserModal";
import { DeleteUserModal } from "@/components/features/users/DeleteUserModal";
import { LockUserModal } from "@/components/features/users/LockUserModal";
import { AdminModal } from "@/components/features/admins/AdminModal";
import { OperationMode } from "@/constants/operationModeEnum";
import { RoleEnum } from "@/constants/roleEnum";
import {
useUpdateUserMutation,
useDeleteUserMutation,
useLockUserMutation,
useGetUserByIdQuery,
} from "@/store/api/usersApi";
import { AppSnackbar } from "@/components/ui/AppSnackbar";
interface PersonalActionsProps {
user: User;
isLoading: boolean;
}
export const PersonalActions = ({ user, isLoading }: PersonalActionsProps) => {
const [editModalVisible, setEditModalVisible] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [lockModalVisible, setLockModalVisible] = useState(false);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const { refetch } = useGetUserByIdQuery(user.id, { skip: !user.id });
const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation();
const [deleteUser, { isLoading: isDeleting }] = useDeleteUserMutation();
const [lockUser, { isLoading: isLocking }] = useLockUserMutation();
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarVisible(true);
};
const handleEdit = () => {
setEditModalVisible(true);
};
const handleDelete = () => {
setDeleteModalVisible(true);
};
const handleLock = () => {
setLockModalVisible(true);
};
const handleUpdateUser = async (updatedUser: Partial<User>) => {
try {
await updateUser(updatedUser).unwrap();
showSnackbar(
`${updatedUser.firstName} ${updatedUser.lastName} updated successfully`
);
setEditModalVisible(false);
refetch();
} catch (error) {
showSnackbar("Error updating user");
console.error("Error updating user:", error);
}
};
const handleDeleteUser = async (userToDelete: User) => {
try {
await deleteUser(userToDelete.id).unwrap();
showSnackbar(
`${userToDelete.firstName} ${userToDelete.lastName} deleted successfully`
);
setDeleteModalVisible(false);
refetch();
} catch (error) {
showSnackbar("Error deleting user");
console.error("Error deleting user:", error);
}
};
const handleLockUser = async (userToLock: User) => {
try {
await lockUser(userToLock.id).unwrap();
const action = userToLock.isLocked ? "unlocked" : "locked";
showSnackbar(
`${userToLock.firstName} ${userToLock.lastName} ${action} successfully`
);
setLockModalVisible(false);
refetch();
} catch (error) {
showSnackbar("Error updating user lock status");
console.error("Error locking/unlocking user:", error);
}
};
return (
<>
<View className="flex-row justify-end">
<IconButton
icon="pencil-outline"
onPress={handleEdit}
size={24}
iconColor="#7A757C"
disabled={isLoading}
/>
<IconButton
icon="lock-outline"
onPress={handleLock}
size={24}
iconColor={user.isLocked ? "#F83434" : "#7A757C"}
disabled={isLoading}
/>
<IconButton
icon="delete-outline"
onPress={handleDelete}
size={24}
iconColor="#7A757C"
disabled={isLoading}
/>
</View>
{user.role === RoleEnum.ADMIN || user.role === RoleEnum.MANAGER ? (
<AdminModal
visible={editModalVisible}
onClose={() => setEditModalVisible(false)}
onSubmit={handleUpdateUser}
admin={user}
mode={OperationMode.EDIT}
isLoading={isUpdating}
/>
) : (
<UserModal
visible={editModalVisible}
onClose={() => setEditModalVisible(false)}
onSubmit={handleUpdateUser}
user={user}
mode={OperationMode.EDIT}
isLoading={isUpdating}
/>
)}
<DeleteUserModal
visible={deleteModalVisible}
user={user}
onDismiss={() => setDeleteModalVisible(false)}
onDelete={handleDeleteUser}
isLoading={isDeleting}
/>
<LockUserModal
visible={lockModalVisible}
user={user}
onDismiss={() => setLockModalVisible(false)}
onLock={handleLockUser}
isLoading={isLocking}
/>
<AppSnackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
>
{snackbarMessage}
</AppSnackbar>
</>
);
};
@@ -0,0 +1,55 @@
import { View } from "react-native";
import { Avatar } from "react-native-paper";
import { User } from "@/store/models/User.model";
import { getInitials, getRoleLabel } from "@/utils/userUtils";
import { InfoRow } from "@/components/features/accounts/InfoRow";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
interface PersonalInfoProps {
user: User;
manager?: User | null;
}
export const PersonalInfo = ({ user, manager }: PersonalInfoProps) => {
const isMobile = useAppSelector(selectIsMobile);
const managerFullName =
manager && manager?.firstName + " " + manager?.lastName;
return (
<View className="bg-lightGray rounded-md">
<View className="flex-row items-center justify-between mb-4">
<View className="flex-row items-center">
{user.imageUrl ? (
<Avatar.Image source={{ uri: user.imageUrl }} size={56} />
) : (
<Avatar.Text
label={getInitials(user.firstName, user.lastName)}
size={50}
/>
)}
</View>
</View>
<View className={`gap-6 ${isMobile ? "flex-col" : "flex-row"}`}>
<View className="flex-1">
<InfoRow label="First Name" value={user.firstName || "-"} />
<InfoRow label="Middle Name" value={user.middleName || "-"} />
<InfoRow label="Last Name" value={user.lastName || "-"} />
</View>
<View className="flex-1">
<InfoRow label="Email" value={user.email || "-"} />
<InfoRow label="Phone Number" value={user.phoneNumber || "-"} />
<InfoRow label="Department" value={user.department || "-"} />
</View>
<View className="flex-1">
<InfoRow label="Role" value={getRoleLabel(user.role)} />
<InfoRow label="Reports To" value={managerFullName || "-"} />
</View>
</View>
</View>
);
};
@@ -0,0 +1,16 @@
import { View, Text } from "react-native";
import { User } from "@/store/models/User.model";
interface PersonalLoginListProps {
user: User;
}
export const PersonalLoginList = ({ user }: PersonalLoginListProps) => {
return (
<View className="bg-lightGray rounded-md">
<Text className="font-medium my-4 text-gray-600">Log-in List:</Text>
<Text className="font-medium text-black">No logins assigned</Text>
</View>
);
};
@@ -0,0 +1,84 @@
import { View, Text } from "react-native";
import { User } from "@/store/models/User.model";
import { InfoRow } from "./InfoRow";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { useGetUserLogsQuery } from "@/store/api/usersApi";
import { formatDate, formatTime } from "@/utils/dateUtils";
import { RiskIndicator } from "@/components/ui/RiskIndicator";
import { SizeEnum } from "@/constants/sizeEnum";
import { RiskEnum, RiskLabels } from "@/constants/riskEnum";
import { SecurityTypeLabels } from "@/constants/securityTypeEnum";
interface PersonalMetricsProps {
user: User;
}
export const PersonalMetrics = ({ user }: PersonalMetricsProps) => {
const isMobile = useAppSelector(selectIsMobile);
const {
data: logsData,
isLoading: logsLoading,
isError: logsError,
} = useGetUserLogsQuery(
{ page: 1, limit: 1, search: user.email },
{ skip: !user?.email }
);
const lastLogItem = logsData?.items?.[0];
const lastLoginAt = lastLogItem?.lastLoginAt;
const activity = lastLogItem?.activity;
const lastLoginDisplay = logsLoading
? "Loading..."
: logsError
? "-"
: lastLoginAt
? `${formatDate(lastLoginAt)} ${formatTime(lastLoginAt)}`
: "-";
const lastPasswordChangeDisplay = logsLoading
? "Loading..."
: logsError
? "-"
: activity === "Password reset" && lastLoginAt
? `${formatDate(lastLoginAt)} ${formatTime(lastLoginAt)}`
: "-";
return (
<View className="bg-lightGray rounded-md">
<View className={`gap-4 ${isMobile ? "flex-col" : "flex-row"}`}>
<View className="flex-1 mt-4">
<InfoRow label="Last Log-in" value={lastLoginDisplay} />
<InfoRow
label="Last Password Change"
value={lastPasswordChangeDisplay}
/>
</View>
<View className="flex-1 mt-4">
{user.riskStatus && (
<InfoRow
label="Risk Status"
value={
<View className="flex-row items-baseline gap-4">
<Text>
{user.riskStatus === RiskEnum.NO_RISK
? RiskLabels[user.riskStatus]
: RiskLabels[user.riskStatus].split(" ")[0]}
</Text>
<RiskIndicator risk={user.riskStatus} size={SizeEnum.Small} />
</View>
}
/>
)}
<InfoRow
label="Security Type"
value={SecurityTypeLabels[user.securityType]}
/>
</View>
</View>
</View>
);
};
@@ -0,0 +1,69 @@
import { View, Text } from "react-native";
import { useGetTodosByUserQuery } from "@/store/api/todosApi";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
import { SizeEnum } from "@/constants/sizeEnum";
import { StatusTableItem } from "@/components/ui/StatusTableItem";
import { User } from "@/store/models/User.model";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { NoData } from "@/components/ui/NoData";
interface PersonalTodoListProps {
user: User;
}
export const PersonalTodoList = ({ user }: PersonalTodoListProps) => {
const {
data: todos,
isLoading,
isFetching,
error,
refetch,
} = useGetTodosByUserQuery(user.id, { skip: !user.id });
return (
<View className="bg-lightGray rounded-md">
<Text className="font-medium my-4 text-gray-600">To-Do List:</Text>
{isLoading ? (
<LoadingSpinner />
) : error ? (
<NoData
onRetry={refetch}
message="Failed to load todos"
isRetrying={isFetching}
/>
) : todos && todos.length > 0 ? (
<View style={{ gap: 8 }}>
{todos.map((todo) => (
<View
key={todo.id}
className="bg-white rounded-lg p-4 mb-3 shadow-sm"
>
<View className="flex-row items-center justify-between mb-2">
<Text
className="text-base font-medium flex-1 mr-2"
numberOfLines={10}
>
{todo.task}
</Text>
<View className="flex-row items-center gap-2">
<StatusTableItem todoStatus={todo.status} />
<PriorityIndicator
priority={todo.priority}
size={SizeEnum.Small}
/>
</View>
</View>
</View>
))}
</View>
) : (
<Text className="font-medium text-black">
{user.role === "USER"
? "No todos assigned for this user"
: "No todos assigned"}
</Text>
)}
</View>
);
};
@@ -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>;
+34
View File
@@ -0,0 +1,34 @@
import { View, Text } from "react-native";
import { Button } from "react-native-paper";
import { router } from "expo-router";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
export const AccessDenied = () => {
const isMobile = useAppSelector(selectIsMobile);
return (
<View className="flex-1 bg-primaryPurpleLight">
<View className={`${isMobile ? "p-1" : "p-5"} flex-1`}>
<View className={`${isMobile ? "p-1" : "p-5"} bg-lightGray rounded-lg flex-1`}>
<View className="flex-1 justify-center items-center p-4">
<Text className="text-2xl font-bold mb-4 text-highRisk">
Access Denied
</Text>
<Text className="text-base mb-6 text-center">
You do not have permission to access this page. Please contact an
administrator if you believe this is an error.
</Text>
<Button
mode="contained"
onPress={() => router.replace("/dashboard/user-dashboard")}
className="bg-primary"
>
Return to Dashboard
</Button>
</View>
</View>
</View>
</View>
);
};
@@ -0,0 +1,131 @@
import { Text, View, Pressable, ScrollView } from "react-native";
import { router } from "expo-router";
import { useAuthContext } from "@/auth/AuthContext";
import { ToDoList } from "@/components/features/dashboard/todo/ToDoList";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { RiskScoreIndicator } from "@/components/ui/RiskScoreIndicator";
import { useGetTodosQuery } from "@/store/api/todosApi";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { RoleEnum } from "@/constants/roleEnum";
import { NoData } from "@/components/ui/NoData";
const Dashboard = () => {
const { user } = useAuthContext();
const isMobile = useAppSelector(selectIsMobile);
const isManager = user?.role === RoleEnum.MANAGER;
const queryArgs = isManager ? undefined : { assigneeUserId: user?.id };
const { data, error, refetch, isLoading, isFetching } =
useGetTodosQuery(queryArgs);
const allTodos = data?.items ?? [];
const teamTodos = isManager ? allTodos : [];
const myTodos = isManager
? allTodos.filter((t) => t.assignees?.some((a) => a.id === user?.id))
: allTodos;
return (
<View
className={`flex-1 bg-primaryPurpleLight ${isMobile ? "p-1" : "p-5"}`}
>
<View className="p-0">
<Text
className={`text-2xl font-bold ${
isMobile ? "mb-1" : "mb-4"
} text-white`}
>
User dashboard
</Text>
</View>
<ScrollView>
<View className="flex flex-col lg:flex-row flex-1">
<View className="w-full lg:w-1/2 lg:pr-2 mb-4 lg:mb-0">
<View className="bg-lightGray rounded-lg p-4 mb-4">
<Text className="text-xl font-bold mb-4 text-black">
Risk Score Indicators
</Text>
<View className="flex flex-row flex-wrap justify-around gap-4">
<RiskScoreIndicator
value={100}
subtitle="MFA setup"
size={110}
/>
<RiskScoreIndicator
value={50}
subtitle="Fishing emails"
size={110}
/>
<RiskScoreIndicator
value={25}
subtitle="Login location change"
size={110}
/>
</View>
</View>
<View className="bg-lightGray rounded-lg p-4 mb-4">
<Text className="text-xl font-bold mb-4 text-black">
User Report (High Risk)
</Text>
<Text className="font-normal text-black">Some high risk</Text>
</View>
</View>
<View className="w-full lg:w-1/2 lg:pl-2 flex-1">
{isLoading ? (
<View className="bg-lightGray rounded-lg p-4 flex-1 items-center justify-center">
<LoadingSpinner />
</View>
) : error ? (
<View className="bg-lightGray rounded-lg p-4">
<NoData
onRetry={refetch}
message="No todos found"
isRetrying={isFetching}
/>
</View>
) : (
<View>
<View className="bg-lightGray rounded-lg p-4 mb-4">
<Pressable
onPress={() => router.push("/(tabs)/accounts/todos")}
>
<Text className="text-xl font-bold mb-4 text-black">
Your To-Do List
</Text>
</Pressable>
<ToDoList
todos={myTodos}
emptyMessage="No tasks assigned to you"
/>
</View>
{user?.role === RoleEnum.MANAGER && (
<View className="bg-lightGray rounded-lg p-4 mb-4">
<Pressable
onPress={() => router.push("/(tabs)/accounts/todos")}
>
<Text className="text-xl font-bold mb-4 text-black">
Your Team To-Do List
</Text>
</Pressable>
<ToDoList
todos={teamTodos}
emptyMessage="No tasks assigned to your team"
/>
</View>
)}
</View>
)}
</View>
</View>
</ScrollView>
</View>
);
};
export default Dashboard;
@@ -0,0 +1,45 @@
import { View, Pressable } from "react-native";
import { Text } from "react-native-paper";
import { Todo } from "@/store/models/Todo.model";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
import { SizeEnum } from "@/constants/sizeEnum";
import { StatusTableItem } from "@/components/ui/StatusTableItem";
import { Ionicons } from "@expo/vector-icons";
interface DesktopTodoListItemProps {
item: Todo;
dialogOpen: boolean;
setDialogOpen: (open: boolean) => void;
}
export const DesktopTodoListItem = ({
item,
dialogOpen,
setDialogOpen,
}: DesktopTodoListItemProps) => {
return (
<View className="flex-1">
<View className="flex-row items-center">
<Text className="text-base font-medium flex-1 mr-2" numberOfLines={1}>
{item.title || item.task}
</Text>
<View className="flex-row items-center gap-2">
<StatusTableItem todoStatus={item.status} />
<PriorityIndicator priority={item.priority} size={SizeEnum.Small} />
</View>
<Pressable onPress={() => setDialogOpen(true)}>
<Ionicons
name="eye-outline"
size={20}
color="black"
style={{ marginLeft: 5 }}
/>
</Pressable>
</View>
<Text className="text-sm text-gray-700 mt-0.5" numberOfLines={2}>
{item.task}
</Text>
</View>
);
};
@@ -0,0 +1,50 @@
import { Pressable, View } from "react-native";
import { Badge, Text } from "react-native-paper";
import { SizeEnum } from "@/constants/sizeEnum";
import { TodoStatusColors } from "@/constants/todoStatusEnum";
import { Ionicons } from "@expo/vector-icons";
import { Todo } from "@/store/models/Todo.model";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
interface MobileTodoListItemProps {
item: Todo;
dialogOpen: boolean;
setDialogOpen: (open: boolean) => void;
}
export const MobileTodoListItem = ({
item,
dialogOpen,
setDialogOpen,
}: MobileTodoListItemProps) => {
return (
<View className="flex-1">
<View className="flex-row items-center mb-2">
<View className="mr-2">
<Badge
size={12}
style={{ backgroundColor: TodoStatusColors[item.status] }}
/>
</View>
<Text className="font-bold flex-1 flex-wrap" numberOfLines={0}>
{item.title}
</Text>
<View className="ml-2">
<PriorityIndicator priority={item.priority} size={SizeEnum.Small} />
</View>
<Pressable onPress={() => setDialogOpen(true)}>
<Ionicons
name="eye-outline"
size={20}
color="black"
style={{ marginLeft: 5 }}
/>
</Pressable>
</View>
<Text className="text-sm text-gray-600" numberOfLines={2}>
{item.task}
</Text>
</View>
);
};
@@ -0,0 +1,28 @@
import { FlatList, Text, View } from "react-native";
import { ToDoListItem } from "@/components/features/dashboard/todo/ToDoListItem";
import { Todo } from "@/store/models/Todo.model";
interface ToDoListProps {
todos: Todo[];
emptyMessage?: string;
}
export const ToDoList = ({ todos, emptyMessage }: ToDoListProps) => {
if (todos?.length === 0) {
return (
<View className="flex-1 justify-center items-center py-10">
<Text className="mt-2">{emptyMessage}</Text>
</View>
);
}
return (
<FlatList
data={todos}
renderItem={({ item }) => <ToDoListItem item={item} />}
keyExtractor={(item) => item.id}
contentContainerStyle={{ paddingBottom: 20 }}
/>
);
};
@@ -0,0 +1,44 @@
import { useState } from "react";
import { View } from "react-native";
import { Todo } from "@/store/models/Todo.model";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { TodoDetailsModal } from "@/components/features/todos/TodoDetailsModal";
import { MobileTodoListItem } from "@/components/features/dashboard/todo/MobileTodoListItem";
import { DesktopTodoListItem } from "@/components/features/dashboard/todo/DesktopTodoListItem";
interface ToDoListItemProps {
item: Todo;
}
export const ToDoListItem = ({ item }: ToDoListItemProps) => {
const isMobile = useAppSelector(selectIsMobile);
const [dialogOpen, setDialogOpen] = useState(false);
return (
<View className="bg-white rounded-lg p-4 mb-3 shadow-sm">
<View className="flex-row items-center justify-between mb-2">
{isMobile ? (
<MobileTodoListItem
dialogOpen={dialogOpen}
setDialogOpen={setDialogOpen}
item={item}
/>
) : (
<DesktopTodoListItem
dialogOpen={dialogOpen}
setDialogOpen={setDialogOpen}
item={item}
/>
)}
</View>
<TodoDetailsModal
visible={dialogOpen}
todo={item}
onClose={() => setDialogOpen(false)}
/>
</View>
);
};
@@ -0,0 +1,58 @@
import { DataTable, Text } from "react-native-paper";
import { View } from "react-native";
import { UserLoginLog } from "@/store/api/usersApi";
import { formatDate, formatTime } from "@/utils/dateUtils";
import { LogsTableHeader } from "@/components/features/logs/LogsTableHeader";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
interface DesktopLogsTableProps {
logs: UserLoginLog[];
sortColumn: string | null;
sortDirection: TableSortDirection;
onSort: (column: string) => void;
}
export const DesktopLogsTable = ({
logs,
sortColumn,
sortDirection,
onSort,
}: DesktopLogsTableProps) => {
return (
<DataTable>
<LogsTableHeader
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
{logs.map((log) => (
<DataTable.Row key={log.id}>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink pr-2">
<Text numberOfLines={1}>{log.name}</Text>
</View>
</DataTable.Cell>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink pr-2">
<Text numberOfLines={1}>{log.email}</Text>
</View>
</DataTable.Cell>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink pr-2">
<Text numberOfLines={1}>
{formatDate(log.lastLoginAt)} {formatTime(log.lastLoginAt)}
</Text>
</View>
</DataTable.Cell>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink pr-2">
<Text numberOfLines={1}>{log.activity}</Text>
</View>
</DataTable.Cell>
</DataTable.Row>
))}
</DataTable>
);
};
+136
View File
@@ -0,0 +1,136 @@
import {
useDeferredValue,
useEffect,
useMemo,
useState,
useCallback,
} from "react";
import { View, Text } from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { LogsTable } from "@/components/features/logs/LogsTable";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { NoData } from "@/components/ui/NoData";
import { useGetUserLogsQuery } from "@/store/api/usersApi";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
export const LogsPage = () => {
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 isMobile = useAppSelector(selectIsMobile);
const deferredSearch = useDeferredValue(searchQuery);
const [debouncedSearch, setDebouncedSearch] = useState(deferredSearch);
useEffect(() => {
const handler = setTimeout(
() => setDebouncedSearch(deferredSearch.trim()),
450
);
return () => clearTimeout(handler);
}, [deferredSearch]);
const queryArgs = useMemo(() => {
const searchParam =
debouncedSearch && debouncedSearch.length >= 2
? debouncedSearch
: undefined;
return {
page: page + 1,
limit: itemsPerPage,
search: searchParam,
} as const;
}, [page, itemsPerPage, debouncedSearch]);
const {
data: logsData,
isLoading,
isFetching,
error,
refetch,
} = useGetUserLogsQuery(queryArgs);
const logs = logsData?.items ?? [];
const totalItems = logsData?.total ?? 0;
const from = logs.length ? page * itemsPerPage + 1 : 0;
const to = logs.length ? Math.min(from + logs.length - 1, totalItems) : 0;
// Refetch whenever the screen/page gains focus
useFocusEffect(
useCallback(() => {
refetch();
}, [refetch])
);
return (
<View
className={`flex-1 p-1 ${isMobile ? "p-1" : "p-5"} bg-primaryPurpleLight`}
>
<Text
className={`text-2xl font-bold ${
isMobile ? "mb-1" : "mb-4"
} text-white`}
>
Users login activity
</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 logs...</Text>
</View>
) : error ? (
<View className="flex-1 justify-center items-center py-10">
<NoData
message="Error loading logs"
onRetry={refetch}
isRetrying={isFetching}
/>
</View>
) : (
<LogsTable
logs={logs}
page={page}
itemsPerPage={itemsPerPage}
totalItems={totalItems}
from={from}
to={to}
searchQuery={searchQuery}
onSearch={(q) => {
setSearchQuery(q);
setPage(0);
}}
sortColumn={sortColumn}
sortDirection={sortDirection}
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>
</View>
);
};
+134
View File
@@ -0,0 +1,134 @@
import { useMemo, useState } from "react";
import { View, Text, ScrollView, GestureResponderEvent } from "react-native";
import { LogsTableControls } from "@/components/features/logs/LogsTableControls";
import { TablePagination } from "@/components/shared/TablePagination";
import { UserLoginLog } from "@/store/api/usersApi";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { DesktopLogsTable } from "@/components/features/logs/DesktopLogsTable";
import { MobileLogsTable } from "@/components/features/logs/MobileLogsTable";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
interface LogsTableProps {
logs: UserLoginLog[];
page: number;
itemsPerPage: number;
totalItems: number;
from: number;
to: number;
searchQuery: string;
onSearch: (q: string) => void;
onItemsPerPageChange: (value: number) => void;
onPageChange: (page: number) => void;
sortColumn: string | null;
sortDirection: TableSortDirection;
onSort: (column: string) => void;
}
export const LogsTable = ({
logs,
page,
itemsPerPage,
totalItems,
from,
to,
searchQuery,
onSearch,
onItemsPerPageChange,
onPageChange,
sortColumn,
sortDirection,
onSort,
}: LogsTableProps) => {
const [menuVisible, setMenuVisible] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number }>({
x: 0,
y: 0,
});
const isMobile = useAppSelector(selectIsMobile);
const handleItemsPerPageButtonPress = (event: GestureResponderEvent) => {
const { pageX, pageY } = event.nativeEvent;
setMenuAnchor({ x: pageX, y: pageY });
setMenuVisible(true);
};
const sortedLogs = useMemo(() => {
if (!sortColumn) return logs;
const dir = sortDirection === TableSortDirection.DESCENDING ? -1 : 1;
const safe = (v?: string) => (v ?? "").toString().toLowerCase();
const out = [...logs];
out.sort((a, b) => {
let comp = 0;
switch (sortColumn) {
case "lastLoginAt": {
const at = new Date(a.lastLoginAt).getTime();
const bt = new Date(b.lastLoginAt).getTime();
comp = at - bt;
break;
}
case "name":
comp = safe(a.name).localeCompare(safe(b.name));
break;
case "email":
comp = safe(a.email).localeCompare(safe(b.email));
break;
case "activity":
comp = safe(a.activity).localeCompare(safe(b.activity));
break;
default:
comp = 0;
}
return comp * dir;
});
return out;
}, [logs, sortColumn, sortDirection]);
return (
<View className="flex-1">
<LogsTableControls
searchQuery={searchQuery}
menuVisible={menuVisible}
menuAnchor={menuAnchor}
onSearch={(q) => onSearch(q)}
onMenuDismiss={() => setMenuVisible(false)}
onItemsPerPageChange={onItemsPerPageChange}
itemsPerPage={itemsPerPage}
/>
{logs.length === 0 ? (
<View
className={`flex-1 items-center justify-center ${
isMobile ? "p-1" : "p-5"
}`}
>
<Text className="text-lg text-gray-500">
No logs match your search{searchQuery && ` for "${searchQuery}"`}
</Text>
</View>
) : isMobile ? (
<MobileLogsTable logs={sortedLogs} />
) : (
<ScrollView className="flex-1">
<DesktopLogsTable
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
logs={sortedLogs}
/>
</ScrollView>
)}
<TablePagination
page={page}
itemsPerPage={itemsPerPage}
totalItems={totalItems}
from={from}
to={to}
onPageChange={onPageChange}
onItemsPerPageButtonPress={handleItemsPerPageButtonPress}
/>
</View>
);
};
@@ -0,0 +1,41 @@
import { View } from "react-native";
import { TableControls } from "@/components/shared/TableControls";
interface LogsTableControlsProps {
searchQuery: string;
menuVisible: boolean;
menuAnchor: { x: number; y: number };
onSearch: (query: string) => void;
onMenuDismiss: () => void;
onItemsPerPageChange: (value: number) => void;
itemsPerPage?: number;
itemsPerPageMenuOptions?: number[];
}
export const LogsTableControls = ({
searchQuery,
menuVisible,
menuAnchor,
onSearch,
onMenuDismiss,
onItemsPerPageChange,
itemsPerPage = 25,
itemsPerPageMenuOptions,
}: LogsTableControlsProps) => {
return (
<View>
<TableControls
searchQuery={searchQuery}
menuVisible={menuVisible}
menuAnchor={menuAnchor}
searchPlaceholder="Search logs"
onSearch={onSearch}
onMenuDismiss={onMenuDismiss}
onItemsPerPageChange={onItemsPerPageChange}
itemsPerPage={itemsPerPage}
itemsPerPageMenuOptions={itemsPerPageMenuOptions || [25, 50, 100]}
/>
</View>
);
};
@@ -0,0 +1,49 @@
import { DataTable } from "react-native-paper";
import { SortableHeader } from "@/components/shared/SortableHeader";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
interface LogsTableHeaderProps {
sortColumn: string | null;
sortDirection: TableSortDirection;
onSort: (column: string) => void;
}
export const LogsTableHeader = ({
sortColumn,
sortDirection,
onSort,
}: LogsTableHeaderProps) => {
return (
<DataTable.Header>
<SortableHeader
title="Name"
column="name"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
<SortableHeader
title="Email"
column="email"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
<SortableHeader
title="Time"
column="lastLoginAt"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
<SortableHeader
title="Login activity"
column="activity"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
</DataTable.Header>
);
};
@@ -0,0 +1,34 @@
import { ScrollView, Text, View } from "react-native";
import { UserLoginLog } from "@/store/api/usersApi";
import { formatDate, formatTime } from "@/utils/dateUtils";
interface MobileLogsTableProps {
logs: UserLoginLog[];
}
export const MobileLogsTable = ({ logs }: MobileLogsTableProps) => {
return (
<View className="flex-1">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 16 }}
>
{logs.map((log) => (
<View key={log.id} className="mb-3 p-4 bg-white rounded-md shadow-sm">
<Text className="text-base font-semibold">
{log.name}{" "}
<Text className="font-normal text-gray-600">({log.email})</Text>
</Text>
<Text className="text-sm text-gray-700 mt-1">
Time: {formatDate(log.lastLoginAt)} {formatTime(log.lastLoginAt)}
</Text>
<Text className="text-sm text-gray-700 mt-1">
Login activity: {log.activity}
</Text>
</View>
))}
</ScrollView>
</View>
);
};
@@ -0,0 +1,178 @@
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
import { Pressable, Text, View } from "react-native";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { IoniconsName } from "@/types/index";
import { useRoleAccess } from "@/hooks/useRoleAccess";
const HIDDEN_PATHS = ["/accounts/admins", "/accounts/todos"];
interface TabItem {
label: string;
route: string;
icon?: IoniconsName;
}
interface TabGroup {
basePath: string;
tabs: TabItem[];
}
export const ContentTabs = () => {
const pathname = usePathname();
const isMobile = useAppSelector(selectIsMobile);
const { isManager } = useRoleAccess();
// Hide top tabs when user lacks permission and is on restricted Accounts pages
if (!isManager && HIDDEN_PATHS.includes(pathname)) {
return null;
}
const tabGroups: TabGroup[] = [
{
basePath: "/",
tabs: [
{
label: "Dashboard",
route: "/user-dashboard/index",
icon: "home",
},
],
},
{
basePath: "/accounts",
tabs: [
{
label: "Users",
route: "/accounts/users",
icon: "person-outline",
},
...(isManager
? [
{
label: "Admins",
route: "/accounts/admins",
icon: "settings" as IoniconsName,
},
{
label: "Todos",
route: "/accounts/todos",
icon: "list" as IoniconsName,
},
]
: []),
],
},
{
basePath: "/logs",
tabs: [{ label: "System", route: "/logs/system", icon: "server" }],
},
{
basePath: "/reports",
tabs: [{ label: "Inbox", route: "/reports/inbox", icon: "mail" }],
},
{
basePath: "/data-protection",
tabs: [
{ label: "General", route: "/data-protection/general", icon: "cog" },
],
},
{
basePath: "/settings",
tabs: [{ label: "General", route: "/settings/general", icon: "cog" }],
},
];
let currentSection = "";
if (
pathname.includes("/dashboard") ||
pathname === "/(tabs)" ||
pathname === "/"
) {
currentSection = "/";
} else if (pathname.includes("/accounts")) {
currentSection = "/accounts";
} else if (pathname.includes("/settings")) {
currentSection = "/settings";
} else if (pathname.includes("/logs")) {
currentSection = "/logs";
} else if (pathname.includes("/reports")) {
currentSection = "/reports";
} else if (pathname.includes("/data-protection")) {
currentSection = "/data-protection";
}
const activeGroup = tabGroups.find(
(group) => group.basePath === currentSection
);
if (!activeGroup || activeGroup.tabs.length === 0) {
return null;
}
const isTabActive = (tabRoute: string) => {
if (tabRoute === "/user-dashboard/index") {
if (pathname === "/(tabs)" || pathname === "/") {
return true;
}
return pathname.includes("/user-dashboard");
}
const routeSegments = tabRoute.split("/");
const lastSegment = routeSegments[routeSegments.length - 1];
return pathname.includes(lastSegment);
};
return (
<View
className={`bg-primaryPurpleLight ${isMobile ? "px-1" : "px-2"} ${
isMobile ? "pt-2 pb-0" : "pt-3"
}`}
>
<View className={`flex-row ${isMobile ? "justify-start gap-3" : ""}`}>
{activeGroup.tabs.map((tab, index) => {
const active = isTabActive(tab.route);
return (
<Pressable
key={index}
onPress={() => router.push(tab.route as any)}
className={`relative mx-1 border-b-2 transition-colors duration-200
${active ? "border-white" : "border-transparent"}
${isMobile ? "px-1" : "px-4"}
`}
style={{ paddingBottom: isMobile ? 6 : 4 }}
>
{isMobile ? (
<View className="items-center">
<Ionicons
name={tab.icon as IoniconsName}
size={20}
color="white"
style={{ opacity: active ? 1 : 0.7 }}
/>
<Text
className="text-white text-[10px] mt-0.5"
style={{ opacity: active ? 1 : 0.7 }}
>
{tab.label}
</Text>
</View>
) : (
<Text
className={`text-base text-white transition-colors duration-200
${active ? "font-medium" : "font-normal"}
`}
>
{tab.label}
</Text>
)}
</Pressable>
);
})}
</View>
</View>
);
};
@@ -0,0 +1,112 @@
import { View, Animated } from "react-native";
import { useState, useMemo, useEffect, useRef } from "react";
import { debounce } from "lodash";
import { router, useLocalSearchParams, usePathname } from "expo-router";
import { SearchBar } from "@/components/ui/SearchBar";
import { SearchDropdown } from "@/components/ui/SearchDropdown";
import { SizeEnum } from "@/constants/sizeEnum";
import { UserButton } from "@/components/auth/UserButton";
import { SearchResult } from "@/store/api/searchApi";
export const HeaderControls = () => {
const [searchQuery, setSearchQuery] = useState("");
const params = useLocalSearchParams<{ q?: string }>();
const pathname = usePathname();
const searchWidth = useRef(new Animated.Value(250)).current;
const isOnSearchPage =
typeof pathname === "string" && pathname.includes("/search");
const debouncedSearch = useMemo(
() =>
debounce((query: string) => {
if (!query.trim()) return;
}, 300),
[]
);
useEffect(() => {
debouncedSearch(searchQuery);
// Animate width based on whether user is typing
// Use spring animation for smoother feel
Animated.spring(searchWidth, {
toValue: searchQuery.trim() ? 500 : 250,
useNativeDriver: false,
tension: 100,
friction: 10,
}).start();
return debouncedSearch.cancel;
}, [searchQuery, debouncedSearch, searchWidth]);
useEffect(() => {
if (!isOnSearchPage) return;
const q = (params.q ?? "").toString();
setSearchQuery(q);
}, [isOnSearchPage, params.q]);
useEffect(() => {
if (!isOnSearchPage) return;
const current = (params.q ?? "").toString();
if (searchQuery.trim() !== "" && searchQuery !== current) {
router.setParams({ q: searchQuery });
}
}, [isOnSearchPage, searchQuery, params.q]);
const handleResultSelect = (result: SearchResult) => {
const q = result.title || "";
const encodedQ = encodeURIComponent(q);
// Immediately clear search and collapse animation to prevent lag
setSearchQuery("");
Animated.timing(searchWidth, {
toValue: 250,
duration: 200,
useNativeDriver: false,
}).start();
// Navigate after starting animation
switch (result.type) {
case "admin":
router.push(`/(tabs)/accounts/admins?q=${encodedQ}`);
break;
case "user":
router.push(`/(tabs)/accounts/users?q=${encodedQ}`);
break;
case "todo":
router.push(`/(tabs)/accounts/todos?q=${encodedQ}`);
break;
default: {
router.push(`/(tabs)/search?q=${encodedQ}`);
break;
}
}
};
const handleViewAllResults = (query: string) => {
router.push(`/(tabs)/search?q=${encodeURIComponent(query)}`);
};
return (
<View className="flex-row items-center justify-end px-4 absolute right-0 top-[6px] h-8">
<Animated.View
className="relative"
style={{ zIndex: 1000, width: searchWidth }}
id="search-container"
>
<SearchBar
searchQuery={searchQuery}
onChange={setSearchQuery}
placeholderText="Search"
size={SizeEnum.Small}
/>
<SearchDropdown
searchQuery={searchQuery}
onResultSelect={handleResultSelect}
onViewAllResults={handleViewAllResults}
/>
</Animated.View>
<UserButton />
</View>
);
};
+307
View File
@@ -0,0 +1,307 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { usePathname } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Animated, Platform, View } from "react-native";
import { useAppSelector, useAppDispatch } from "@/store/hooks";
import {
selectIsMobile,
setSidebarCollapsed,
} from "@/store/screen/screenSizeSlice";
import { SignOutButton } from "@/components/auth/SignOutButton";
import { useAuthContext } from "@/auth/AuthContext";
import { SidebarToggle } from "@/components/features/navigation/SidebarToggle";
import { SidebarItem } from "@/components/features/navigation/SidebarItem";
const SIDEBAR_STATE_KEY = "sidebarCollapsedState";
const saveCollapsedState = async (isCollapsed: boolean) => {
try {
if (Platform.OS === "web") {
localStorage.setItem(SIDEBAR_STATE_KEY, JSON.stringify(isCollapsed));
} else {
await AsyncStorage.setItem(
SIDEBAR_STATE_KEY,
JSON.stringify(isCollapsed)
);
}
} catch (error) {
console.error("Error saving sidebar state:", error);
}
};
const loadCollapsedState = async (): Promise<boolean | null> => {
try {
let value;
if (Platform.OS === "web") {
value = localStorage.getItem(SIDEBAR_STATE_KEY);
} else {
value = await AsyncStorage.getItem(SIDEBAR_STATE_KEY);
}
return value ? JSON.parse(value) : null;
} catch (error) {
console.error("Error loading sidebar state:", error);
return null;
}
};
export const Sidebar = ({
mobileHidden = false,
onRequestMobileToggle,
}: {
mobileHidden?: boolean;
onRequestMobileToggle?: () => void;
}) => {
const { isAuthenticated } = useAuthContext();
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const [labelsVisible, setLabelsVisible] = useState(true);
const animatedWidth = useRef(new Animated.Value(200)).current;
const textOpacity = useRef(new Animated.Value(1)).current;
const iconTranslateX = useRef(new Animated.Value(0)).current;
const paddingLeft = useRef(new Animated.Value(16)).current; // 0.5rem = 8px
const isMobile = useAppSelector(selectIsMobile);
const dispatch = useAppDispatch();
useEffect(() => {
const loadSavedState = async () => {
if (isMobile) {
setCollapsed(true);
setLabelsVisible(false);
textOpacity.setValue(0);
iconTranslateX.setValue(12);
paddingLeft.setValue(8);
Animated.timing(animatedWidth, {
toValue: mobileHidden ? 0 : 80,
duration: 250,
useNativeDriver: false,
}).start();
return;
}
const savedState = await loadCollapsedState();
if (savedState !== null) {
setCollapsed(savedState);
dispatch(setSidebarCollapsed(savedState));
setLabelsVisible(!savedState);
textOpacity.setValue(savedState ? 0 : 1);
iconTranslateX.setValue(savedState ? 12 : 0);
paddingLeft.setValue(8);
Animated.timing(animatedWidth, {
toValue: savedState ? 80 : 200,
duration: 300,
useNativeDriver: false,
}).start();
}
};
loadSavedState();
}, [
animatedWidth,
textOpacity,
iconTranslateX,
paddingLeft,
isMobile,
mobileHidden,
dispatch,
]);
const isActive = (path: string) => {
if (path === "/user-dashboard" || path === "/dashboard") {
return (
pathname.includes("/user-dashboard") ||
pathname === "/(tabs)" ||
pathname === "/"
);
}
if (path === "/accounts") {
return pathname.includes("/accounts");
}
if (path === "/settings") {
return (
pathname.startsWith("/settings") ||
pathname.startsWith("/(tabs)/settings")
);
}
if (path === "/data-protection") {
return (
pathname.startsWith("/data-protection") ||
pathname.startsWith("/(tabs)/data-protection")
);
}
return pathname.includes(path);
};
const toggleSidebar = () => {
if (isMobile) {
onRequestMobileToggle?.();
return;
}
const newCollapsedState = !collapsed;
if (!newCollapsedState) {
setLabelsVisible(true);
}
Animated.parallel([
Animated.timing(animatedWidth, {
toValue: newCollapsedState ? 80 : 200,
duration: 300,
useNativeDriver: false,
}),
Animated.timing(textOpacity, {
toValue: newCollapsedState ? 0 : 1,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(iconTranslateX, {
toValue: newCollapsedState ? 12 : 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(paddingLeft, {
toValue: 8,
duration: 300,
useNativeDriver: false,
}),
]).start(() => {
setLabelsVisible(!newCollapsedState);
});
if (newCollapsedState) {
setLabelsVisible(true);
}
setCollapsed(newCollapsedState);
dispatch(setSidebarCollapsed(newCollapsedState));
saveCollapsedState(newCollapsedState);
};
return (
<Animated.View
className="border-r px-2"
style={{
width: animatedWidth,
backgroundColor: "#49385D",
borderRightColor: "#49385D",
borderRightWidth: undefined,
paddingHorizontal: undefined,
flex: isMobile ? undefined : 1,
height: "100%",
overflow: "hidden",
}}
>
<View className="flex-1 flex-col">
{!(isMobile && mobileHidden) && (
<View
className="border-b-2 border-white"
style={{
paddingBottom: isMobile ? 6 : 0,
paddingTop: isMobile ? 6 : 0,
}}
>
<SidebarToggle
onPress={toggleSidebar}
textOpacity={textOpacity}
iconTranslateX={iconTranslateX}
showLabel={labelsVisible}
className="h-[40px] w-full justify-start"
/>
</View>
)}
{!(isMobile && mobileHidden) && (
<View>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/dashboard/user-dashboard"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/dashboard/user-dashboard"
icon="home-outline"
label="Home"
isFirst
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/accounts/users"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/accounts"
icon="person-outline"
label="Accounts"
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/logs/system"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/logs"
icon="checkbox-outline"
label="Logs"
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/reports/inbox"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/reports"
icon="copy-outline"
label="Reports"
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/data-protection/general"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/data-protection"
icon="lock-closed-outline"
label="Data Protection"
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/settings/general"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/settings"
icon="settings-outline"
label="Settings"
/>
</View>
)}
{!(isMobile && mobileHidden) && (
<View className="mb-4 mt-auto px-2">
{isAuthenticated && (
<SignOutButton
textOpacity={textOpacity}
iconTranslateX={iconTranslateX}
paddingLeft={paddingLeft}
/>
)}
</View>
)}
</View>
</Animated.View>
);
};
@@ -0,0 +1,81 @@
import { Animated, Pressable, Text } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { IoniconsName } from "@/types/index";
interface SidebarItemProps {
paddingLeft: Animated.Value;
routerPushValue: any;
isMobile: boolean;
isActive: (path: string) => boolean;
iconTranslateX: Animated.Value;
textOpacity: Animated.Value;
path: string;
icon: IoniconsName;
label: string;
isFirst?: boolean;
showLabel?: boolean;
}
export const SidebarItem = ({
paddingLeft,
routerPushValue,
isMobile,
isActive,
iconTranslateX,
textOpacity,
path,
icon,
label,
isFirst,
showLabel = true,
}: SidebarItemProps) => {
return (
<Animated.View
style={{
paddingLeft,
paddingRight: 8,
}}
>
<Pressable
onPress={() => router.push(routerPushValue)}
className={`flex-row items-center py-3 rounded-lg w-full ${
isFirst && !isMobile ? "mt-4" : isMobile ? "mt-1" : ""
} ${showLabel ? "justify-start" : "justify-center"}`}
style={{
backgroundColor: isActive(path) ? "#5A4570" : "transparent",
}}
>
<Animated.View
style={{
alignItems: "center",
...(showLabel
? { paddingLeft: 8, transform: [{ translateX: iconTranslateX }] }
: {}),
}}
>
<Ionicons name={icon as IoniconsName} size={24} color="white" />
</Animated.View>
{showLabel && (
<Animated.View
style={{
opacity: textOpacity,
marginLeft: 12,
overflow: "hidden",
}}
>
<Text
className={`${
isActive(path) ? "text-white font-medium" : "text-gray-300"
}`}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
)}
</Pressable>
</Animated.View>
);
};
@@ -0,0 +1,60 @@
import { Pressable, Text, Animated } from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface SidebarToggleProps {
textOpacity?: Animated.Value;
iconTranslateX?: Animated.Value;
onPress: () => void;
showLabel?: boolean;
size?: number;
color?: string;
className?: string;
}
export const SidebarToggle = ({
textOpacity,
iconTranslateX,
onPress,
showLabel = false,
size = 24,
color = "white",
className = "",
}: SidebarToggleProps) => {
return (
<Animated.View
style={{
paddingRight: showLabel ? 4 : 8,
paddingLeft: showLabel ? 16 : 8,
}}
>
<Pressable
onPress={onPress}
className={`flex-row items-center rounded-lg ${className}`}
>
<Animated.View
style={{
alignItems: "center",
justifyContent: "center",
...(showLabel && iconTranslateX !== undefined
? { transform: [{ translateX: iconTranslateX }] }
: { width: "100%" }),
}}
>
<Ionicons name="menu" size={size} color={color} />
</Animated.View>
{showLabel && (
<Animated.View
style={{
...(textOpacity !== undefined ? { opacity: textOpacity } : {}),
overflow: "hidden",
}}
>
<Text className="text-white text-lg ml-2" numberOfLines={1}>
Options
</Text>
</Animated.View>
)}
</Pressable>
</Animated.View>
);
};
+81
View File
@@ -0,0 +1,81 @@
import { SearchResult } from "@/store/api/searchApi";
import { normalizeEnumLike } from "@/utils/formattingUtils";
import { memo } from "react";
import { Text, View, TouchableOpacity } from "react-native";
import { Card, Divider } from "react-native-paper";
interface ResultRowProps {
item: SearchResult;
expanded: boolean;
onToggle: (id: string) => void;
}
const ResultRowComponent = ({ item, expanded, onToggle }: ResultRowProps) => {
return (
<Card className="mb-4 overflow-hidden">
<TouchableOpacity onPress={() => onToggle(item.id)}>
<View className="p-3">
<Text className="font-semibold text-base" numberOfLines={1}>
{item.title}
</Text>
{item.subtitle ? (
<Text className="text-sm text-gray-600" numberOfLines={1}>
{normalizeEnumLike(item.subtitle)}
</Text>
) : null}
{item.description ? (
<Text className="text-xs text-gray-500" numberOfLines={2}>
{normalizeEnumLike(item.description)}
</Text>
) : null}
</View>
</TouchableOpacity>
{expanded && (
<View className="px-3 pb-3">
<Divider />
<View className="mt-3">
<Text className="text-xs text-gray-500 mb-1">
Type:{" "}
{item.type.charAt(0).toUpperCase() +
item.type.slice(1).toLowerCase()}
</Text>
{item.createdAt ? (
<Text className="text-xs text-gray-500 mb-1">
Created: {new Date(item.createdAt).toLocaleString()}
</Text>
) : null}
{item.meta ? (
<>
{item.type !== "todo" ? (
<>
{item.meta?.isLocked !== undefined ? (
<Text className="text-xs text-gray-500 mb-1">
Locked: {item.meta.isLocked ? "Yes" : "No"}
</Text>
) : null}
</>
) : (
<>
{item.meta?.createdBy ? (
<Text className="text-xs text-gray-500 mb-1">
Created by: {item.meta.createdBy}
</Text>
) : null}
{item.meta?.assignees ? (
<Text className="text-xs text-gray-500 mb-1">
Assignees: {item.meta.assignees}
</Text>
) : null}
</>
)}
</>
) : null}
</View>
</View>
)}
</Card>
);
};
export const ResultRow = memo(ResultRowComponent);
ResultRow.displayName = "ResultRow";
@@ -0,0 +1,23 @@
import { View } from "react-native";
import { Button } from "@/components/ui/Button";
interface AddTodoControlsProps {
onAddTodo: () => void;
}
export const AddTodoControls = ({ onAddTodo }: AddTodoControlsProps) => {
return (
<View className="flex-row">
<Button
onPress={onAddTodo}
icon="note-plus-outline"
buttonColor="#6750a4"
buttonTextColor="white"
disabled={false}
>
Add Todo
</Button>
</View>
);
};
@@ -0,0 +1,34 @@
import { View } from "react-native";
import { IconButton } from "react-native-paper";
import { Todo } from "@/store/models/Todo.model";
interface AdminTodoActionsProps {
todo: Todo;
onView: (todo: Todo) => void;
onEdit: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
}
export const AdminTodoActions = ({
todo,
onView,
onEdit,
onDelete,
}: AdminTodoActionsProps) => {
return (
<View className="flex-row justify-center">
<IconButton icon="eye-outline" onPress={() => onView(todo)} size={20} />
<IconButton
icon="pencil-outline"
onPress={() => onEdit(todo)}
size={20}
/>
<IconButton
icon="delete-outline"
onPress={() => onDelete(todo)}
size={20}
/>
</View>
);
};
@@ -0,0 +1,63 @@
import { DataTable } from "react-native-paper";
import { Text } from "react-native";
import { SortableHeader } from "@/components/shared/SortableHeader";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
interface AdminTodoTableHeaderProps {
sortColumn: string | null;
sortDirection: TableSortDirection;
onSort: (column: string) => void;
}
export const AdminTodoTableHeader = ({
sortColumn,
sortDirection,
onSort,
}: AdminTodoTableHeaderProps) => {
return (
<DataTable.Header>
<SortableHeader
title="Title"
column="title"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
<SortableHeader
title="Task"
column="task"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
<SortableHeader
title="Status"
column="status"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
centerAlign
/>
<SortableHeader
title="Priority"
column="priority"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
centerAlign
/>
<SortableHeader
title="Date Created"
column="createdAt"
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
centerAlign
/>
<DataTable.Title style={{ justifyContent: "center" }}>
<Text style={{ fontSize: 12, color: "black" }}>Actions</Text>
</DataTable.Title>
</DataTable.Header>
);
};
@@ -0,0 +1,377 @@
import { useEffect, useMemo, useState, useDeferredValue } from "react";
import { View, Text, ScrollView, GestureResponderEvent } from "react-native";
import { useAuthContext } from "@/auth/AuthContext";
import { Todo } from "@/store/models/Todo.model";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { NoData } from "@/components/ui/NoData";
import {
useCreateTodoMutation,
useDeleteTodoMutation,
useGetTodosQuery,
useUpdateTodoMutation,
} from "@/store/api/todosApi";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
import { AdminTodosTableControls } from "@/components/features/todos/AdminTodosTableControls";
import { TodoStatus } from "@/constants/todoStatusEnum";
import { TablePagination } from "@/components/shared/TablePagination";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { MobileAdminTodosTable } from "@/components/features/todos/MobileAdminTodosTable";
import { DesktopAdminTodosTable } from "@/components/features/todos/DesktopAdminTodosTable";
import { PriorityStatus } from "@/constants/priorityStatusEnum";
import { TodoModal } from "@/components/features/todos/TodoModal";
import { DeleteTodoModal } from "@/components/features/todos/DeleteTodoModal";
import { OperationMode } from "@/constants/operationModeEnum";
import { AppSnackbar } from "@/components/ui/AppSnackbar";
interface CreateTodoPayload {
title: string;
task: string;
status: TodoStatus;
priority: PriorityStatus;
dueDate: Date;
createdById: string;
assigneeIds: string[];
}
export const AdminTodosTable = ({ initialSearchQuery = "" }: { initialSearchQuery?: string }) => {
const { user } = useAuthContext();
const [page, setPage] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(25);
const [searchQuery, setSearchQuery] = useState("");
const [selectedStatuses, setSelectedStatuses] = useState<TodoStatus[]>([]);
const [selectedPriorities, setSelectedPriorities] = useState<
PriorityStatus[]
>([]);
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<TableSortDirection>(
TableSortDirection.ASCENDING
);
const deferredSearch = useDeferredValue(searchQuery);
const [debouncedSearch, setDebouncedSearch] = useState("");
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(deferredSearch.trim()), 450);
return () => clearTimeout(t);
}, [deferredSearch]);
// Initialize search from route param if provided
useEffect(() => {
const q = (initialSearchQuery || "").trim();
if (q && q !== searchQuery) {
setSearchQuery(q);
setPage(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialSearchQuery]);
const queryArgs = useMemo(() => {
const allowedSorts = [
"title",
"dueDate",
"status",
"priority",
"createdAt",
] 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 {
page: page + 1,
limit: itemsPerPage,
search: searchParam,
statuses: selectedStatuses.length ? selectedStatuses : undefined,
priorities: selectedPriorities.length ? selectedPriorities : undefined,
sortBy,
sortDir,
};
}, [
page,
itemsPerPage,
debouncedSearch,
selectedStatuses,
selectedPriorities,
sortColumn,
sortDirection,
]);
const {
data: todosPage,
isLoading,
isFetching,
error,
refetch,
} = useGetTodosQuery(queryArgs);
const todos = todosPage?.items ?? [];
const totalItems = todosPage?.total ?? 0;
const [createTodo, { isLoading: isCreatingTodo }] = useCreateTodoMutation();
const [updateTodo, { isLoading: isUpdatingTodo }] = useUpdateTodoMutation();
const [deleteTodo, { isLoading: isDeletingTodo }] = useDeleteTodoMutation();
const [menuVisible, setMenuVisible] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number }>({
x: 0,
y: 0,
});
const [addTodoModalVisible, setAddTodoModalVisible] = useState(false);
const [editTodoModalVisible, setEditTodoModalVisible] = useState(false);
const [deleteTodoModalVisible, setDeleteTodoModalVisible] = useState(false);
const [currentTodo, setCurrentTodo] = useState<Todo | null>(null);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarVisible(true);
};
const handleCreateTodo = async (
todo: Partial<Todo> & { assigneeIds?: string[]; userId?: string }
) => {
try {
const unique = (arr: string[]) => Array.from(new Set(arr.filter(Boolean)));
const payload: CreateTodoPayload = {
title: todo.title || "",
task: todo.task || "",
status: todo.status || TodoStatus.NEW,
priority: todo.priority || PriorityStatus.LOW_PRIORITY,
dueDate: todo.dueDate || new Date(),
createdById: user?.id || "",
assigneeIds: unique([...(todo.assigneeIds || []), ...(todo.userId ? [todo.userId] : [])]),
};
await createTodo(payload).unwrap();
showSnackbar("Todo created successfully");
setAddTodoModalVisible(false);
refetch();
} catch {
showSnackbar("Error creating todo");
}
};
const handleUpdateTodo = async (
todo: Partial<Todo> & { assigneeIds?: string[]; userId?: string }
) => {
try {
const body: any = {
...(todo.id ? { id: todo.id } : {}),
...(todo.title ? { title: todo.title } : {}),
...(todo.task ? { task: todo.task } : {}),
...(todo.status ? { status: todo.status } : {}),
...(todo.priority ? { priority: todo.priority } : {}),
...(todo.dueDate ? { dueDate: todo.dueDate } : {}),
};
const combinedAssignees = Array.from(
new Set([...(todo.assigneeIds || []), ...(todo.userId ? [todo.userId] : [])])
);
if (combinedAssignees.length) {
body.assigneeIds = combinedAssignees;
}
await updateTodo(body).unwrap();
showSnackbar("Todo updated successfully");
setEditTodoModalVisible(false);
refetch();
} catch {
showSnackbar("Error updating todo");
}
};
const handleDeleteTodo = async (todo: Todo) => {
try {
await deleteTodo(todo.id).unwrap();
refetch();
showSnackbar(`${todo.title} has been deleted successfully`);
setDeleteTodoModalVisible(false);
} catch {
showSnackbar("Error deleting todo");
}
};
const handleSort = (column: string) => {
setSortColumn(column);
setSortDirection(
sortDirection === TableSortDirection.ASCENDING
? TableSortDirection.DESCENDING
: TableSortDirection.ASCENDING
);
setPage(0);
};
const handleItemsPerPageChange = (value: number) => {
setItemsPerPage(value);
};
const handleSearch = (query: string) => {
setSearchQuery(query);
setPage(0);
};
const handleEdit = (todo: Todo) => {
setCurrentTodo(todo);
setEditTodoModalVisible(true);
};
const openDeleteModal = (todo: Todo) => {
setCurrentTodo(todo);
setDeleteTodoModalVisible(true);
};
const isMobile = useAppSelector(selectIsMobile);
const from = todos.length ? page * itemsPerPage + 1 : 0;
const to = todos.length ? Math.min(from + todos.length - 1, totalItems) : 0;
useEffect(() => {
setPage(0);
}, [itemsPerPage]);
return (
<>
<View className="flex-1">
<Text
className={`text-2xl font-bold ${
isMobile ? "mb-1" : "mb-4"
} text-white`}
>
Todos manager
</Text>
<View
className={`${
isMobile ? "p-1" : "p-5"
} bg-lightGray rounded-lg flex-1`}
>
{isLoading ? (
<View className="flex-1 justify-center items-center">
<LoadingSpinner />
<Text className="mt-2">Loading todos...</Text>
</View>
) : error ? (
<NoData
message="Error loading todos"
onRetry={refetch}
isRetrying={isFetching}
/>
) : (
<View className="flex-1 flex flex-col">
<AdminTodosTableControls
searchQuery={searchQuery}
menuVisible={menuVisible}
menuAnchor={menuAnchor}
selectedStatuses={selectedStatuses}
selectedPriorities={selectedPriorities}
onSearch={handleSearch}
onMenuDismiss={() => setMenuVisible(false)}
onItemsPerPageChange={handleItemsPerPageChange}
onSelectStatuses={setSelectedStatuses}
onSelectPriorities={setSelectedPriorities}
onAddTodo={() => setAddTodoModalVisible(true)}
itemsPerPage={itemsPerPage}
/>
<TodoModal
visible={addTodoModalVisible}
onClose={() => setAddTodoModalVisible(false)}
onSubmit={handleCreateTodo}
isLoading={isCreatingTodo}
mode={OperationMode.ADD}
/>
<TodoModal
visible={editTodoModalVisible}
todo={currentTodo}
onClose={() => setEditTodoModalVisible(false)}
onSubmit={handleUpdateTodo}
isLoading={isUpdatingTodo}
mode={OperationMode.EDIT}
/>
<DeleteTodoModal
visible={deleteTodoModalVisible}
todo={currentTodo}
onDismiss={() => setDeleteTodoModalVisible(false)}
onDelete={handleDeleteTodo}
isLoading={isDeletingTodo}
/>
<View className="flex-1 overflow-hidden">
{searchQuery && todos.length === 0 ? (
<View
className={`flex-1 items-center justify-center ${
isMobile ? "p-1" : "p-5"
}`}
>
<Text className="text-lg text-gray-500">
No todos match your search for &quot;{searchQuery}&quot;
</Text>
</View>
) : searchQuery.length === 0 && todos.length === 0 ? (
<View
className={`flex-1 items-center justify-center ${
isMobile ? "p-1" : "p-5"
}`}
>
<Text className="text-lg text-gray-500">
No todos found
</Text>
</View>
) : isMobile ? (
<View className="flex-1">
<MobileAdminTodosTable
todos={todos}
onEdit={handleEdit}
onDelete={openDeleteModal}
/>
</View>
) : (
<View className="flex-1" style={{ height: 400 }}>
<ScrollView className="flex-1">
<DesktopAdminTodosTable
todos={todos}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
onEdit={handleEdit}
onDelete={openDeleteModal}
/>
</ScrollView>
</View>
)}
</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,166 @@
import { View } from "react-native";
import { TableControls } from "@/components/shared/TableControls";
import { AddTodoControls } from "@/components/features/todos/AddTodoControls";
import {
CombinedFilterMenu,
CombinedFilterSection,
} from "@/components/shared/filters/CombinedFilterMenu";
import {
SelectedFilterChips,
SelectedChip,
} from "@/components/shared/filters/SelectedFilterChips";
import {
TodoStatus,
TodoStatusLabels,
TodoStatusColors,
} from "@/constants/todoStatusEnum";
import {
PriorityStatus,
PriorityStatusLabels,
} from "@/constants/priorityStatusEnum";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
interface AdminTodoTableControlsProps {
searchQuery: string;
menuVisible: boolean;
menuAnchor: { x: number; y: number };
selectedStatuses: TodoStatus[];
selectedPriorities: PriorityStatus[];
onSearch: (query: string) => void;
onMenuDismiss: () => void;
onItemsPerPageChange: (value: number) => void;
onSelectStatuses: (statuses: TodoStatus[]) => void;
onSelectPriorities: (priorities: PriorityStatus[]) => void;
onAddTodo: () => void;
itemsPerPage: number;
}
export const AdminTodosTableControls = ({
searchQuery,
menuVisible,
menuAnchor,
selectedStatuses = [],
selectedPriorities = [],
onSearch,
onMenuDismiss,
onItemsPerPageChange,
onSelectStatuses,
onSelectPriorities,
onAddTodo,
itemsPerPage = 25,
}: AdminTodoTableControlsProps) => {
const renderFilterComponent = () => {
const sections: CombinedFilterSection[] = [];
const statusOptions = Object.values(TodoStatus).map((status) => ({
value: status,
label: TodoStatusLabels[status],
icon: (
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: TodoStatusColors[status],
}}
/>
),
}));
sections.push({
key: "status",
title: "Status",
options: statusOptions,
selected: selectedStatuses,
onChange: (values) => onSelectStatuses(values as TodoStatus[]),
});
const priorityOptions = Object.values(PriorityStatus).map((priority) => ({
value: priority,
label: PriorityStatusLabels[priority],
icon: <PriorityIndicator priority={priority} />,
}));
sections.push({
key: "priority",
title: "Priority",
options: priorityOptions,
selected: selectedPriorities,
onChange: (values) => onSelectPriorities(values as PriorityStatus[]),
});
const chips: SelectedChip[] = [];
selectedStatuses.forEach((status) =>
chips.push({
key: `status-${status}`,
label: TodoStatusLabels[status],
onRemove: () =>
onSelectStatuses(selectedStatuses.filter((s) => s !== status)),
})
);
selectedPriorities.forEach((priority) =>
chips.push({
key: `priority-${priority}`,
label: PriorityStatusLabels[priority],
onRemove: () =>
onSelectPriorities(selectedPriorities.filter((p) => p !== priority)),
})
);
return (
<CombinedFilterMenu
sections={sections}
showResetButton={true}
onResetAll={() => {
onSelectStatuses([]);
onSelectPriorities([]);
}}
/>
);
};
const renderFilterChips = () => {
const chips: SelectedChip[] = [];
selectedStatuses.forEach((status) =>
chips.push({
key: `status-${status}`,
label: TodoStatusLabels[status],
onRemove: () =>
onSelectStatuses(selectedStatuses.filter((s) => s !== status)),
})
);
selectedPriorities.forEach((priority) =>
chips.push({
key: `priority-${priority}`,
label: PriorityStatusLabels[priority],
onRemove: () =>
onSelectPriorities(selectedPriorities.filter((p) => p !== priority)),
})
);
return chips.length > 0 ? <SelectedFilterChips chips={chips} /> : null;
};
const renderActionButtons = () => <AddTodoControls onAddTodo={onAddTodo} />;
return (
<TableControls
searchQuery={searchQuery}
menuVisible={menuVisible}
menuAnchor={menuAnchor}
searchPlaceholder="Search todos"
onSearch={onSearch}
onMenuDismiss={onMenuDismiss}
onItemsPerPageChange={onItemsPerPageChange}
itemsPerPage={itemsPerPage}
renderFilterComponent={renderFilterComponent}
renderFilterChips={renderFilterChips}
renderActionButtons={renderActionButtons}
/>
);
};
@@ -0,0 +1,94 @@
import { useState } from "react";
import { View, Text } from "react-native";
import { Dialog, IconButton } from "react-native-paper";
import { DialogContainer } from "@/components/ui/DialogContainer";
import { Todo } from "@/store/models/Todo.model";
import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { AppSnackbar } from "@/components/ui/AppSnackbar";
interface DeleteTodoModalProps {
visible: boolean;
todo: Todo | null;
onDismiss: () => void;
onDelete: (todo: Todo) => void;
isLoading?: boolean;
}
export const DeleteTodoModal = ({
visible,
todo,
onDismiss,
onDelete,
isLoading = false,
}: DeleteTodoModalProps) => {
const [error, setError] = useState<string | null>(null);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarVisible(true);
};
if (!todo) return null;
const handleDelete = () => {
try {
setError(null);
onDelete(todo);
} catch {
showSnackbar("Failed to delete todo. Please try again.");
}
};
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 Todo</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 {todo.title}?</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>
<AppSnackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
>
{snackbarMessage}
</AppSnackbar>
</>
);
};
@@ -0,0 +1,104 @@
import { useState } from "react";
import { View, Text } from "react-native";
import { DataTable } from "react-native-paper";
import { Todo } from "@/store/models/Todo.model";
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
import { AdminTodoTableHeader } from "@/components/features/todos/AdminTodoTableHeader";
import { AdminTodoActions } from "@/components/features/todos/AdminTodoActions";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
import { StatusTableItem } from "@/components/ui/StatusTableItem";
import { TodoDetailsModal } from "@/components/features/todos/TodoDetailsModal";
import { formatDateMDY, formatTime } from "@/utils/dateUtils";
interface DesktopAdminTodoTableProps {
todos: Todo[];
sortColumn: string | null;
sortDirection: TableSortDirection;
onSort: (column: string) => void;
onEdit: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
}
export const DesktopAdminTodosTable = ({
todos,
sortColumn,
sortDirection,
onSort,
onEdit,
onDelete,
}: DesktopAdminTodoTableProps) => {
const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedTodo, setSelectedTodo] = useState<Todo | null>(null);
const openDetails = (todo: Todo) => {
setSelectedTodo(todo);
setDetailsOpen(true);
};
const closeDetails = () => setDetailsOpen(false);
return (
<DataTable>
<AdminTodoTableHeader
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={onSort}
/>
{todos.map((todo) => (
<DataTable.Row key={todo.id}>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink px-1 py-1">
<Text onPress={() => openDetails(todo)} numberOfLines={10}>
{todo.title}
</Text>
</View>
</DataTable.Cell>
<DataTable.Cell>
<View className="flex-row items-center flex-shrink px-1 py-1">
<Text numberOfLines={10}>
{todo.task && todo.task.length > 50
? `${todo.task.slice(0, 50)}...`
: todo.task}
</Text>
</View>
</DataTable.Cell>
<DataTable.Cell
style={{
justifyContent: "center",
}}
>
<StatusTableItem todoStatus={todo.status} />
</DataTable.Cell>
<DataTable.Cell
style={{
justifyContent: "center",
}}
>
<PriorityIndicator priority={todo.priority} />
</DataTable.Cell>
<DataTable.Cell style={{ justifyContent: "center" }}>
<Text className="px-1 py-1">{`${formatDateMDY(
todo.createdAt
)} ${formatTime(todo.createdAt)}`}</Text>
</DataTable.Cell>
<DataTable.Cell style={{ justifyContent: "center" }}>
<AdminTodoActions
todo={todo}
onView={(t) => openDetails(t)}
onEdit={onEdit}
onDelete={onDelete}
/>
</DataTable.Cell>
</DataTable.Row>
))}
{selectedTodo && (
<TodoDetailsModal
visible={detailsOpen}
todo={selectedTodo}
onClose={closeDetails}
/>
)}
</DataTable>
);
};
@@ -0,0 +1,112 @@
import { View, Text, ScrollView } from "react-native";
import { Badge, IconButton } from "react-native-paper";
import { useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import { TodoDetailsModal } from "@/components/features/todos/TodoDetailsModal";
import { Todo } from "@/store/models/Todo.model";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
import { SizeEnum } from "@/constants/sizeEnum";
import { TodoStatusColors } from "@/constants/todoStatusEnum";
import { formatDateMDY, formatTime } from "@/utils/dateUtils";
interface MobileAdminTodoTableProps {
todos: Todo[];
onEdit: (todo: Todo) => void;
onDelete: (todo: Todo) => void;
}
export const MobileAdminTodosTable = ({
todos,
onEdit,
onDelete,
}: MobileAdminTodoTableProps) => {
const [detailsOpen, setDetailsOpen] = useState(false);
const [selectedTodo, setSelectedTodo] = useState<Todo | null>(null);
const openDetails = (todo: Todo) => {
setSelectedTodo(todo);
setDetailsOpen(true);
};
const closeDetails = () => setDetailsOpen(false);
return (
<View className="flex-1">
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingBottom: 16 }}
>
{todos.map((todo) => {
const taskPreview =
todo.task && todo.task.length > 50
? `${todo.task.slice(0, 50)}...`
: todo.task;
return (
<View
key={todo.id}
className="mb-3 p-4 bg-white rounded-md shadow-sm"
>
<View className="flex-row justify-between items-center mb-3">
<View className="mr-2">
<Badge
size={16}
style={{ backgroundColor: TodoStatusColors[todo.status] }}
/>
</View>
<Text className="font-bold flex-1 flex-wrap" numberOfLines={0}>
{todo.title}
</Text>
<View className="ml-2 flex-row items-center">
<PriorityIndicator
priority={todo.priority}
size={SizeEnum.Small}
/>
</View>
</View>
<View className="flex-row items-center mb-2">
<Ionicons name="calendar-outline" size={14} color="#6b7280" />
<Text className="text-xs text-gray-500 ml-1">
{`${formatDateMDY(todo.createdAt)} ${formatTime(
todo.createdAt
)}`}
</Text>
</View>
<Text className="text-sm mb-3 text-gray-600" numberOfLines={0}>
{taskPreview}
</Text>
<View className="flex-row justify-end border-t border-gray-200 pt-3">
<IconButton
icon="eye-outline"
size={20}
onPress={() => openDetails(todo)}
/>
<IconButton
icon="pencil-outline"
size={20}
onPress={() => {
onEdit(todo);
}}
/>
<IconButton
icon="delete-outline"
size={20}
onPress={() => {
onDelete(todo);
}}
/>
</View>
</View>
);
})}
</ScrollView>
{selectedTodo && (
<TodoDetailsModal
visible={detailsOpen}
todo={selectedTodo}
onClose={closeDetails}
/>
)}
</View>
);
};
+440
View File
@@ -0,0 +1,440 @@
import { useMemo, useState, useCallback } from "react";
import { View, ScrollView, TextInput, Pressable, Text } from "react-native";
import { Avatar, Button, Menu } from "react-native-paper";
import { Ionicons } from "@expo/vector-icons";
import { formatDistanceToNow, formatDistanceToNowStrict } from "date-fns";
import {
useGetTodoCommentsQuery,
useAddCommentMutation,
useUpdateCommentMutation,
useDeleteCommentMutation,
} from "@/store/api/commentsApi";
import { useAuthContext } from "@/auth/AuthContext";
import { useGetUsersQuery } from "@/store/api/usersApi";
import { AppSnackbar } from "@/components/ui/AppSnackbar";
interface TodoCommentsProps {
todoId: string;
}
// Helper: compact relative time like "2h ago"
const timeAgo = (dateInput: string | Date | null | undefined) => {
if (!dateInput) return "";
const date = typeof dateInput === "string" ? new Date(dateInput) : dateInput;
try {
const raw = formatDistanceToNowStrict(date, { addSuffix: false });
// raw examples: "2 hours", "1 minute", "less than a minute"
const m = raw.match(
/(\d+)\s+(second|seconds|minute|minutes|hour|hours|day|days|week|weeks|month|months|year|years)/
);
if (m) {
const n = m[1];
const unit = m[2];
const abbr = unit.startsWith("second")
? "s"
: unit.startsWith("minute")
? "m"
: unit.startsWith("hour")
? "h"
: unit.startsWith("day")
? "d"
: unit.startsWith("week")
? "w"
: unit.startsWith("month")
? "mo"
: "y";
return `${n}${abbr} ago`;
}
// fallback to full format
return formatDistanceToNow(date, { addSuffix: true });
} catch {
return "";
}
};
const LONG_COMMENT_THRESHOLD = 220;
interface TodoCommentsProps {
todoId: string;
}
export const TodoComments = ({ todoId }: TodoCommentsProps) => {
const { user } = useAuthContext();
const {
data: comments,
isLoading,
isError,
refetch,
} = useGetTodoCommentsQuery(todoId);
const [addComment, { isLoading: isAdding }] = useAddCommentMutation();
const [updateComment, { isLoading: isUpdating }] = useUpdateCommentMutation();
const [deleteComment, { isLoading: isDeleting }] = useDeleteCommentMutation();
const [content, setContent] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarVisible, setSnackbarVisible] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [menuId, setMenuId] = useState<string | null>(null);
const showSnackbar = (message: string) => {
setSnackbarMessage(message);
setSnackbarVisible(true);
};
const canPost = useMemo(() => !!user, [user]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed) return;
try {
await addComment({ todoId, content: trimmed }).unwrap();
setContent("");
} catch {
showSnackbar("Error creating comment");
}
};
const startEdit = (id: string, current: string) => {
setEditingId(id);
setEditContent(current);
};
const cancelEdit = () => {
setEditingId(null);
setEditContent("");
};
const saveEdit = async () => {
const trimmed = editContent.trim();
if (!editingId || !trimmed) return;
try {
await updateComment({
todoId,
commentId: editingId,
content: trimmed,
}).unwrap();
setEditingId(null);
setEditContent("");
} catch {
showSnackbar("Error updating comment");
}
};
const onDelete = async (id: string) => {
try {
// simple confirm for web; on native it won't show but action still works if called
if (typeof window !== "undefined") {
const ok = window.confirm?.("Delete this comment?") ?? true;
if (!ok) return;
}
await deleteComment({ todoId, commentId: id }).unwrap();
} catch {
showSnackbar("Error deleting comment");
}
};
// Mention support
const extractMentionQuery = useCallback((text: string) => {
// find last segment starting with @ that has no whitespace afterwards yet
const match = text.match(/@([\w.-]{1,30})$/);
return match ? match[1] : "";
}, []);
const mentionQuery = extractMentionQuery(editingId ? editContent : content);
const { data: mentionUsers } = useGetUsersQuery(
mentionQuery
? { search: mentionQuery, page: 1, limit: 5, roleType: "admin" }
: undefined,
{ skip: !mentionQuery }
);
const insertMention = (fullName: string) => {
const setter = editingId ? setEditContent : setContent;
const text = editingId ? editContent : content;
// replace trailing @query with @Full Name and a space
const replaced = text.replace(/@([\w.-]{1,30})$/, `@${fullName} `);
setter(replaced);
};
const renderContentWithMentions = (text: string) => {
const parts = text.split(/(@[\w.-]+)/g);
return parts.map((p, i) =>
p.startsWith("@") ? (
<Text key={i} className="text-primaryPurpleDark font-medium">
{p}
</Text>
) : (
<Text key={i}>{p}</Text>
)
);
};
const getCommentAuthorName = (commentUser: {
id: string;
firstName?: string;
lastName?: string;
}) => {
if (!commentUser) return "Unknown User";
if (commentUser.id === user?.id) return "You";
return `${(commentUser.firstName || "").trim()} ${(
commentUser.lastName || ""
).trim()}`.trim();
};
return (
<>
<View className="flex-1">
<Text className="text-lg mb-2">Comments</Text>
{isLoading ? (
<Text>Loading comments...</Text>
) : isError ? (
<View>
<Text className="text-red-500 mb-2">Failed to load comments</Text>
<Button mode="text" onPress={refetch}>
Retry
</Button>
</View>
) : comments && comments.length > 0 ? (
<ScrollView
className="max-h-96"
contentContainerStyle={{ paddingBottom: 8, paddingTop: 4 }}
showsVerticalScrollIndicator
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
scrollEnabled
persistentScrollbar
style={{ maxHeight: 384, minHeight: 0, flexGrow: 0 }}
>
{comments.map((c, idx) => {
const isExpanded = !!expanded[c.id];
const isLong = (c.content?.length || 0) > LONG_COMMENT_THRESHOLD;
const prevUserId = idx > 0 ? comments[idx - 1]?.user?.id : null;
const isNewUserBlock = idx > 0 && prevUserId !== c.user?.id;
return (
<View key={c.id} className="mb-2">
{isNewUserBlock ? (
<View
style={{
height: 1,
backgroundColor: "#D9D9D9",
marginVertical: 8,
}}
/>
) : null}
<View className="py-1">
<View className="flex-row">
{c.user?.imageUrl ? (
<Avatar.Image
source={{ uri: c.user.imageUrl }}
size={32}
style={{ marginRight: 10, marginTop: 0 }}
/>
) : (
<Avatar.Text
label={
(c.user?.firstName?.[0] || "?") +
(c.user?.lastName?.[0] || "").toUpperCase()
}
size={32}
style={{ marginRight: 10, marginTop: 0 }}
/>
)}
<View className="flex-1">
<View
className="flex-row justify-between items-center"
style={{ minHeight: 18, marginBottom: 6 }}
>
<View className="flex-1 pr-2" style={{ minWidth: 0 }}>
<Text
className="text-[13px] font-medium text-gray-900"
numberOfLines={1}
style={{ lineHeight: 18 }}
>
{getCommentAuthorName(c.user)}
</Text>
</View>
<View className="flex-row items-center">
<Text className="text-[11px] text-gray-500">
{timeAgo(c.createdAt)}
</Text>
<View className="ml-2" style={{ flexShrink: 0 }}>
{user?.id === c.user?.id && editingId !== c.id ? (
<Menu
visible={menuId === c.id}
onDismiss={() => setMenuId(null)}
anchor={
<Pressable
onPress={() => setMenuId(c.id)}
style={{
height: 18,
width: 18,
alignItems: "center",
justifyContent: "center",
}}
>
<Ionicons
name="ellipsis-vertical"
size={16}
color="#555"
/>
</Pressable>
}
>
<Menu.Item
leadingIcon="pencil-outline"
onPress={() => {
setMenuId(null);
startEdit(c.id, c.content);
}}
title="Edit"
/>
<Menu.Item
leadingIcon="delete-outline"
onPress={() => {
setMenuId(null);
onDelete(c.id);
}}
disabled={isDeleting}
title="Delete"
/>
</Menu>
) : null}
</View>
</View>
</View>
{editingId === c.id ? (
<View style={{ marginTop: 0 }}>
<TextInput
className="border border-gray-300 rounded-md p-2 bg-white"
placeholder="Edit your comment..."
placeholderTextColor="#d9d9d9"
value={editContent}
onChangeText={setEditContent}
multiline
/>
{mentionQuery && mentionUsers?.items?.length ? (
<View className="bg-white border border-gray-200 rounded-md mt-1 shadow-sm">
{mentionUsers.items.map((u) => (
<Pressable
key={u.id}
onPress={() =>
insertMention(
`${u.firstName} ${u.lastName}`
)
}
>
<Text className="p-2">
@{u.firstName} {u.lastName}
</Text>
</Pressable>
))}
</View>
) : null}
<View className="flex-row justify-end mt-2">
<Button
compact
mode="contained"
onPress={saveEdit}
disabled={!editContent.trim() || isUpdating}
>
Save
</Button>
<Button
compact
mode="text"
onPress={cancelEdit}
disabled={isUpdating}
>
Cancel
</Button>
</View>
</View>
) : (
<View style={{ marginTop: 0 }}>
<Text
className="text-[13px] text-gray-800"
numberOfLines={isExpanded ? undefined : 4}
>
{renderContentWithMentions(c.content)}
</Text>
{isLong ? (
<Pressable
onPress={() =>
setExpanded((prev) => ({
...prev,
[c.id]: !isExpanded,
}))
}
>
<Text className="mt-1 text-[12px] font-medium text-primaryPurpleDark">
{isExpanded ? "Show less" : "Show more"}
</Text>
</Pressable>
) : null}
</View>
)}
</View>
</View>
</View>
</View>
);
})}
</ScrollView>
) : (
<Text className="text-gray-500 mb-2">No comments yet</Text>
)}
{canPost ? (
<View className="mt-3">
<TextInput
className="border border-gray-300 rounded-md p-2 bg-white"
placeholder="Write a comment..."
placeholderTextColor="#d9d9d9"
value={content}
onChangeText={setContent}
multiline
/>
{mentionQuery && mentionUsers?.items?.length ? (
<View className="bg-white border border-gray-200 rounded-md mt-1 shadow-sm">
{mentionUsers.items.map((u) => (
<Pressable
key={u.id}
onPress={() =>
insertMention(`${u.firstName} ${u.lastName}`)
}
>
<Text className="p-2">
@{u.firstName} {u.lastName}
</Text>
</Pressable>
))}
</View>
) : null}
<View className="flex-row justify-end mt-2">
<Button
mode="contained"
onPress={handleSubmit}
disabled={!content.trim() || isAdding}
>
Post
</Button>
</View>
</View>
) : null}
</View>
<AppSnackbar
visible={snackbarVisible}
onDismiss={() => setSnackbarVisible(false)}
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
>
{snackbarMessage}
</AppSnackbar>
</>
);
};
@@ -0,0 +1,97 @@
import { View, Text } from "react-native";
import { Dialog, IconButton } from "react-native-paper";
import { DialogContainer } from "@/components/ui/DialogContainer";
import { Todo } from "@/store/models/Todo.model";
import { StatusTableItem } from "@/components/ui/StatusTableItem";
import { PriorityIndicator } from "@/components/ui/PriorityIndicator";
import { SizeEnum } from "@/constants/sizeEnum";
import { formatDate } from "@/utils/dateUtils";
import { TodoComments } from "@/components/features/todos/TodoComments";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
interface TodoDetailsModalProps {
visible: boolean;
todo: Todo;
onClose: () => void;
}
export const TodoDetailsModal = ({
visible,
todo,
onClose,
}: TodoDetailsModalProps) => {
const isMobile = useAppSelector(selectIsMobile);
return (
<DialogContainer visible={visible} onDismiss={onClose}>
<Dialog.Title>
<View className="w-full flex-row items-center justify-between">
<View className="flex-1">
<Text className="pr-3">{todo.title}</Text>
</View>
<View
style={{
alignSelf: "flex-start",
alignItems: "center",
flexDirection: "row",
}}
>
<View className="flex-row items-center gap-2">
<StatusTableItem todoStatus={todo.status} />
<PriorityIndicator
priority={todo.priority}
size={SizeEnum.Small}
/>
</View>
<IconButton
icon="close"
size={20}
onPress={onClose}
accessibilityLabel="Close dialog"
style={{ margin: 0 }}
/>
</View>
</View>
</Dialog.Title>
<Dialog.Content>
<View className={isMobile ? "flex-col" : "flex-row"}>
<View className={isMobile ? "w-full" : "w-1/2 pr-2"}>
<View className="mb-2">
<Text className="text-lg">Due: {formatDate(todo.dueDate)}</Text>
</View>
<Text className="text-base">{todo.task}</Text>
</View>
{isMobile ? (
<View
style={{
height: 2,
backgroundColor: "#000000",
marginVertical: 12,
borderRadius: 1,
}}
/>
) : (
<View
style={{
width: 2,
backgroundColor: "#000000",
marginHorizontal: 12,
alignSelf: "stretch",
borderRadius: 1,
}}
/>
)}
<View className={isMobile ? "w-full mt-4" : "w-1/2 pl-4 pr-6"}>
<TodoComments todoId={todo.id} />
</View>
</View>
</Dialog.Content>
</DialogContainer>
);
};
+462
View File
@@ -0,0 +1,462 @@
import { useState, useEffect, useRef } from "react";
import { View, Text, ScrollView, Dimensions } from "react-native";
import { Dialog, TextInput, IconButton } from "react-native-paper";
import { DatePickerInput, TimePickerModal } from "react-native-paper-dates";
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 { Todo } from "@/store/models/Todo.model";
import { PriorityStatus } from "@/constants/priorityStatusEnum";
import { TodoStatus } from "@/constants/todoStatusEnum";
import { SizeEnum } from "@/constants/sizeEnum";
import { OperationMode } from "@/constants/operationModeEnum";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { DialogContainer } from "@/components/ui/DialogContainer";
import { CustomPicker, PickerItem } from "@/components/ui/CustomPicker";
import {
SearchableDropdown,
DropdownItem,
} from "@/components/ui/SearchableDropdown";
import { useGetUsersQuery } from "@/store/api/usersApi";
import { User } from "@/store/models/User.model";
import { RoleEnum } from "@/constants/roleEnum";
import {
todoSchema,
TodoFormValues,
} from "@/components/features/todos/schemas/todo.schema";
interface TodoModalProps {
visible: boolean;
onClose: () => void;
onSubmit: (
todo: Partial<Todo> & { assigneeIds?: string[]; userId?: string }
) => void;
isLoading: boolean;
todo?: Todo | null;
mode: OperationMode;
}
export const TodoModal = ({
visible,
onClose,
onSubmit,
isLoading,
todo = null,
mode = OperationMode.ADD,
}: TodoModalProps) => {
const todoRef = useRef<Todo | null>(todo);
todoRef.current = todo;
// Helpers to extract existing selections from the todo
const existingSupervisors = (todo?.assignees ?? []).filter(
(u) => u.role === RoleEnum.ADMIN || u.role === RoleEnum.MANAGER
);
const existingUser = (todo?.assignees ?? []).find((u) => u.role === RoleEnum.USER);
const defaultValues: TodoFormValues = {
id: todo?.id,
title: todo?.title ?? "",
task: todo?.task ?? "",
priority: (todo?.priority ?? "LOW_PRIORITY") as PriorityStatus,
status: (todo?.status ?? "NEW") as TodoStatus,
dueDate: todo?.dueDate ? new Date(todo.dueDate) : new Date(),
// Only supervisors (Admin/Manager) go into assigneeIds; regular user goes into userId
assigneeIds: existingSupervisors.map((u) => u.id),
userId: existingUser?.id,
};
const {
control,
handleSubmit,
reset,
watch,
setValue,
formState: { errors, isDirty, isValid },
} = useForm<TodoFormValues>({
resolver: zodResolver(todoSchema),
defaultValues,
mode: "onChange",
shouldUnregister: false,
});
const [showTimePicker, setShowTimePicker] = useState(false);
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 isMobile = useAppSelector(selectIsMobile);
const [adminSearchQuery, setAdminSearchQuery] = useState("");
const [userSearchQuery, setUserSearchQuery] = useState("");
const dueDate = (watch("dueDate") as Date) ?? new Date();
// Helper function to map User to DropdownItem
const mapUserToDropdownItem = (user: User): DropdownItem => ({
id: user.id,
label: `${user.firstName} ${user.lastName}`,
subtitle: user.title,
description: user.email,
imageUrl: user.imageUrl,
initials: `${user.firstName.charAt(0)}${user.lastName.charAt(0)}`,
});
const uniqueById = (items: DropdownItem[]) => {
const seen = new Set<string>();
const out: DropdownItem[] = [];
for (const it of items) {
if (!seen.has(it.id)) {
seen.add(it.id);
out.push(it);
}
}
return out;
};
// Fetch admin users for assignee dropdown
// Only fetch when search query has 2+ characters for better performance
const { data: adminUsersPage } = useGetUsersQuery(
{
roleType: "admin",
page: 1,
limit: 20,
sortBy: "name",
sortDir: "asc" as any,
search: adminSearchQuery.trim(),
},
{ skip: !visible || adminSearchQuery.trim().length < 2 }
);
const adminUsers = adminUsersPage?.items ?? [];
const preselectedSupervisorItems = existingSupervisors.map(mapUserToDropdownItem);
const adminDropdownItems = uniqueById([
...preselectedSupervisorItems,
...adminUsers.map(mapUserToDropdownItem),
]);
// Fetch regular users (non-admin) for user assignment dropdown
// Only fetch when search query has 2+ characters for better performance
const { data: regularUsersPage } = useGetUsersQuery(
{
roleType: "user",
page: 1,
limit: 20,
sortBy: "name",
sortDir: "asc" as any,
search: userSearchQuery.trim(),
},
{ skip: !visible || userSearchQuery.trim().length < 2 }
);
const regularUsers = regularUsersPage?.items ?? [];
const preselectedUserItems = existingUser ? [mapUserToDropdownItem(existingUser)] : [];
const regularUserDropdownItems = uniqueById([
...preselectedUserItems,
...regularUsers.map(mapUserToDropdownItem),
]);
const onTimeConfirm = ({
hours,
minutes,
}: {
hours: number;
minutes: number;
}) => {
setShowTimePicker(false);
const newDate = new Date(dueDate as Date);
newDate.setHours(hours);
newDate.setMinutes(minutes);
setValue("dueDate", newDate, { shouldDirty: true, shouldValidate: true });
};
useEffect(() => {
if (!visible) return;
const t = todoRef.current;
const admins = (t?.assignees ?? []).filter(
(u) => u.role === RoleEnum.ADMIN || u.role === RoleEnum.MANAGER
);
const user = (t?.assignees ?? []).find((u) => u.role === RoleEnum.USER);
const nextDefaults: TodoFormValues = {
id: t?.id,
title: t?.title ?? "",
task: t?.task ?? "",
priority: (t?.priority ?? "LOW_PRIORITY") as PriorityStatus,
status: (t?.status ?? "NEW") as TodoStatus,
dueDate: t?.dueDate ? new Date(t.dueDate) : new Date(),
assigneeIds: admins.map((u) => u.id),
userId: user?.id,
};
reset(nextDefaults, { keepDefaultValues: false });
}, [visible, reset, todo?.id, mode]);
const submit = (vals: TodoFormValues) => {
onSubmit({
...(mode === OperationMode.EDIT && todo ? { id: todo.id } : {}),
title: vals.title.trim(),
task: vals.task.trim(),
priority: vals.priority as PriorityStatus,
status: vals.status as TodoStatus,
dueDate: (vals.dueDate as unknown as Date) ?? new Date(),
assigneeIds: (vals.assigneeIds as string[] | undefined) ?? [],
userId: vals.userId,
});
};
const handleClose = () => {
onClose();
};
return (
<DialogContainer
visible={visible}
onDismiss={handleClose}
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">
{mode === OperationMode.ADD ? "Add Todo" : "Edit Todo"}
</Text>
</View>
<View style={{ alignSelf: "flex-start" }}>
<IconButton
icon="close"
size={20}
onPress={handleClose}
disabled={isLoading}
accessibilityLabel="Close dialog"
style={{ margin: 0 }}
/>
</View>
</View>
</Dialog.Title>
<Dialog.Content
style={{ maxHeight: maxDialogContentHeight, overflow: "hidden" }}
>
<ScrollView nestedScrollEnabled>
<View>
<View
className={isMobile ? "flex-col" : "flex-row items-stretch"}
style={{ flex: isMobile ? undefined : 1, minHeight: 0 }}
>
{/* Left column */}
<View className={isMobile ? "w-full" : "w-1/2 pr-3"}>
<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"
autoCapitalize="none"
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">Task</Text>
<Controller
control={control}
name="task"
render={({ field: { onChange, value } }) => (
<>
<TextInput
className={`border ${
errors.task ? "border-highRisk" : "border-gray-300"
} rounded p-2 text-base bg-white`}
value={value}
onChangeText={onChange}
placeholder="Enter task description"
autoCapitalize="sentences"
placeholderTextColor="#d9d9d9"
multiline
numberOfLines={6}
textAlignVertical="top"
style={{ minHeight: 120 }}
/>
{errors.task ? (
<Text className="text-highRisk text-xs mt-1">
{errors.task.message as string}
</Text>
) : null}
</>
)}
/>
</View>
<View className="mb-4">
<Text className="text-sm mb-1 font-medium">Due date</Text>
<Controller
control={control}
name="dueDate"
render={({ field: { onChange, value } }) => (
<DatePickerInput
locale="en"
label="Select date"
value={value as Date}
onChange={(date) =>
onChange((date as Date) || new Date())
}
inputMode="start"
mode="outlined"
/>
)}
/>
<Button
onPress={() => setShowTimePicker(true)}
buttonColor="white"
buttonTextColor="black"
className="mt-2"
disabled={false}
>
{dueDate?.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Button>
<TimePickerModal
visible={showTimePicker}
onDismiss={() => setShowTimePicker(false)}
onConfirm={onTimeConfirm}
hours={dueDate ? dueDate.getHours() : 0}
minutes={dueDate ? dueDate.getMinutes() : 0}
/>
{errors.dueDate ? (
<Text className="text-highRisk text-xs mt-1">
{errors.dueDate.message as string}
</Text>
) : null}
</View>
</View>
<View
className={isMobile ? "w-full" : "w-1/2 pl-3"}
style={{ flex: isMobile ? undefined : 1, minHeight: 0 }}
>
{/* Admin Assignees Dropdown */}
<Controller
control={control}
name="assigneeIds"
render={({ field: { onChange, value } }) => (
<SearchableDropdown
items={adminDropdownItems}
selectedIds={value || []}
onSelectionChange={onChange}
label="Assign to admins or managers"
placeholder="Start typing to search"
onSearchChange={setAdminSearchQuery}
error={errors.assigneeIds?.message as string}
mode="multi"
/>
)}
/>
{/* User Assignment Dropdown */}
<Controller
control={control}
name="userId"
render={({ field: { onChange, value } }) => (
<SearchableDropdown
items={regularUserDropdownItems}
selectedIds={value}
onSelectionChange={onChange}
label="Connect to user"
placeholder="Start typing to search"
onSearchChange={setUserSearchQuery}
mode="single"
/>
)}
/>
<View className="mb-4">
<Text className="text-sm mb-1 font-medium">Priority</Text>
<View className="border border-gray-300 rounded overflow-hidden">
<Controller
control={control}
name="priority"
render={({ field: { onChange, value } }) => (
<CustomPicker
selectedValue={value}
onValueChange={(v) => onChange(v)}
className="h-10"
>
<PickerItem label="Low" value="LOW_PRIORITY" />
<PickerItem
label="Moderate"
value="MODERATE_PRIORITY"
/>
<PickerItem label="High" value="HIGH_PRIORITY" />
</CustomPicker>
)}
/>
</View>
</View>
<View className="mb-4">
<Text className="text-sm mb-1 font-medium">Status</Text>
<View className="border border-gray-300 rounded overflow-hidden">
<Controller
control={control}
name="status"
render={({ field: { onChange, value } }) => (
<CustomPicker
selectedValue={value}
onValueChange={(v) => onChange(v)}
className="h-10"
>
<PickerItem label="New" value="NEW" />
<PickerItem label="In Progress" value="IN_PROGRESS" />
<PickerItem label="Completed" value="COMPLETED" />
<PickerItem label="Archived" value="ARCHIVED" />
</CustomPicker>
)}
/>
</View>
</View>
</View>
</View>
</View>
</ScrollView>
</Dialog.Content>
{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={SizeEnum.Small} />
</View>
)}
<Dialog.Actions>
<Button
onPress={handleSubmit(submit)}
disabled={isLoading || !isValid || !isDirty}
className="!ml-2"
>
{mode === OperationMode.ADD ? "Add" : "Edit"}
</Button>
</Dialog.Actions>
</DialogContainer>
);
};
+20
View File
@@ -0,0 +1,20 @@
import { View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { AdminTodosTable } from "@/components/features/todos/AdminTodosTable";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
export const TodosPage = () => {
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`}>
<AdminTodosTable initialSearchQuery={initialSearchQuery} />
</View>
</View>
);
};
@@ -0,0 +1,19 @@
import { z } from "zod";
import { PriorityStatus } from "@/constants/priorityStatusEnum";
import { TodoStatus } from "@/constants/todoStatusEnum";
export const todoSchema = z.object({
id: z.string().optional(),
title: z.string().trim().min(1, "Title is required"),
task: z.string().trim().min(1, "Task is required"),
priority: z.enum(PriorityStatus),
status: z.enum(TodoStatus),
dueDate: z.coerce.date(),
assigneeIds: z
.array(z.string())
.min(1, "Assign at least one admin or manager"),
userId: z.string().optional(), // Optional: specific user this todo is assigned to
});
export type TodoFormValues = z.input<typeof todoSchema>;
@@ -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>;