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
+2
View File
@@ -0,0 +1,2 @@
EXPO_PUBLIC_BACKEND_URL=http://localhost:5000/api
EXPO_PUBLIC_APP_BASE_URL=http://localhost:8081
+43
View File
@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
.env
# typescript
*.tsbuildinfo
app-example
_deprecated
.windsurfrules
+2
View File
@@ -0,0 +1,2 @@
# AthenaFE
+44
View File
@@ -0,0 +1,44 @@
{
"expo": {
"name": "project-athena",
"slug": "project-athena",
"version": "1.0.0",
"orientation": "portrait",
"icon": "assets/images/logo.png",
"scheme": "projectathena",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "assets/images/logo_no_background.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "assets/images/logo_no_background.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "assets/images/logo.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
"expo-web-browser",
"expo-font"
],
"experiments": {
"typedRoutes": true
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Redirect, Stack } from "expo-router";
import { View } from "react-native";
import { useAuthContext } from "@/auth/AuthContext";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
const AuthRoutesLayout = () => {
const { isAuthenticated, bootstrapping } = useAuthContext();
if (bootstrapping) {
return (
<View className="flex-1 p-5 bg-primaryPurpleLight">
<View
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
>
<LoadingSpinner />
</View>
</View>
);
}
if (isAuthenticated) {
return <Redirect href={"/"} />;
}
return <Stack screenOptions={{ headerShown: false }} />;
};
export default AuthRoutesLayout;
+113
View File
@@ -0,0 +1,113 @@
import { useRouter } from "expo-router";
import { useState } from "react";
import { View, Text } from "react-native";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AuthLayout } from "@/components/auth/AuthLayout";
import { AuthHeader } from "@/components/auth/AuthHeader";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthFooter } from "@/components/auth/AuthFooter";
import { FormInput } from "@/components/auth/FormInput";
import { getApiUrl } from "@/store/api/baseApi";
import { AuthButton } from "@/components/auth/AuthButton";
const schema = z.object({
email: z.email("Enter a valid email"),
});
type FormValues = z.infer<typeof schema>;
const ForgotPasswordScreen = () => {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const {
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onSubmit",
reValidateMode: "onChange",
shouldUnregister: false,
defaultValues: { email: "" },
});
const email = watch("email");
const onSubmit = async (values: FormValues) => {
setSubmitting(true);
setError("");
setMessage("");
try {
const res = await fetch(
`${getApiUrl()?.replace(/\/$/, "")}/auth/password/forgot`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: values.email }),
}
);
const data = await res.json();
if (!res.ok) {
throw new Error(data?.error || "Failed to request reset");
}
setMessage("If an account exists for this email, a reset link was sent");
} catch (error: unknown) {
if (error instanceof Error) {
setError(error.message);
} else {
setError("Failed to request reset");
}
} finally {
setSubmitting(false);
}
};
return (
<AuthLayout>
<AuthHeader title="Project Athena" />
<AuthCard title="Forgot Password" subtitle="We'll email you a reset link">
{!!error && (
<Text style={{ color: "#F83434", marginBottom: 8 }}>{error}</Text>
)}
{!!message && (
<Text style={{ color: "#22C927", marginBottom: 8 }}>{message}</Text>
)}
<FormInput
label={errors.email?.message || "Email"}
value={email}
onChangeText={(t) => setValue("email", t, { shouldValidate: true })}
icon="email"
disabled={submitting}
onSubmitEditing={handleSubmit(onSubmit)}
returnKeyType="send"
submitBehavior="blurAndSubmit"
/>
<View style={{ marginTop: 8 }}>
<AuthButton
onPress={handleSubmit(onSubmit)}
loading={submitting}
disabled={submitting}
>
{submitting ? "Sending..." : "Send Reset Link"}
</AuthButton>
</View>
</AuthCard>
<AuthFooter
question="Remembered your password?"
actionText="Sign in"
onPress={() => router.push("/sign-in")}
disabled={submitting}
/>
</AuthLayout>
);
};
export default ForgotPasswordScreen;
+158
View File
@@ -0,0 +1,158 @@
import { useRouter, useLocalSearchParams } from "expo-router";
import { useState } from "react";
import { View, Text } from "react-native";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AuthLayout } from "@/components/auth/AuthLayout";
import { AuthHeader } from "@/components/auth/AuthHeader";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthFooter } from "@/components/auth/AuthFooter";
import { FormInput } from "@/components/auth/FormInput";
import { PasswordInput } from "@/components/auth/PasswordInput";
import { AuthButton } from "@/components/auth/AuthButton";
import { getApiUrl } from "@/store/api/baseApi";
const schema = z
.object({
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
});
type FormVals = z.infer<typeof schema>;
const ResetPasswordScreen = () => {
const router = useRouter();
const { token: tokenParam } = useLocalSearchParams<{ token?: string }>();
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [tokenOverride, setTokenOverride] = useState("");
const {
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<FormVals>({
resolver: zodResolver(schema),
mode: "onSubmit",
reValidateMode: "onChange",
shouldUnregister: false,
defaultValues: { password: "", confirmPassword: "" },
});
const password = watch("password");
const confirmPassword = watch("confirmPassword");
const tokenFromLink =
typeof tokenParam === "string" && tokenParam.length > 0 ? tokenParam : "";
const token = tokenFromLink || tokenOverride;
const onSubmit = async (vals: FormVals) => {
setSubmitting(true);
setError("");
setMessage("");
try {
if (!token) {
throw new Error("Reset link token is required");
}
const res = await fetch(
`${getApiUrl()?.replace(/\/$/, "")}/auth/password/reset`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password: vals.password }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error || "Failed to reset password");
}
setMessage("Password has been reset. Redirecting to sign in...");
setTimeout(() => router.replace("/sign-in"), 1500);
} catch (e: any) {
setError(e?.message || "Failed to reset password");
} finally {
setSubmitting(false);
}
};
return (
<AuthLayout>
<AuthHeader title="Project Athena" />
<AuthCard
title="Reset Password"
subtitle={
tokenFromLink
? "Set a new password"
: "Paste the reset link token and set a new password"
}
>
{!!error && (
<Text style={{ color: "#F83434", marginBottom: 8 }}>{error}</Text>
)}
{!!message && (
<Text style={{ color: "#22C927", marginBottom: 8 }}>{message}</Text>
)}
{!tokenFromLink && (
<FormInput
label={"Reset token"}
value={tokenOverride}
onChangeText={setTokenOverride}
icon="key"
disabled={submitting}
onSubmitEditing={handleSubmit(onSubmit)}
returnKeyType="next"
/>
)}
<PasswordInput
label={errors.password?.message || "New Password"}
value={password}
onChangeText={(t) =>
setValue("password", t, { shouldValidate: true })
}
disabled={submitting}
onSubmitEditing={handleSubmit(onSubmit)}
returnKeyType="next"
/>
<PasswordInput
label={errors.confirmPassword?.message || "Confirm Password"}
value={confirmPassword}
onChangeText={(t) =>
setValue("confirmPassword", t, { shouldValidate: true })
}
disabled={submitting}
onSubmitEditing={handleSubmit(onSubmit)}
returnKeyType="send"
submitBehavior="blurAndSubmit"
/>
<View style={{ marginTop: 8 }}>
<AuthButton
onPress={handleSubmit(onSubmit)}
loading={submitting}
disabled={submitting}
>
{submitting ? "Resetting password..." : "Reset Password"}
</AuthButton>
</View>
</AuthCard>
<AuthFooter
question="Remembered your password?"
actionText="Sign in"
onPress={() => router.push("/sign-in")}
disabled={submitting}
/>
</AuthLayout>
);
};
export default ResetPasswordScreen;
+86
View File
@@ -0,0 +1,86 @@
import { useRouter } from "expo-router";
import { useState, useEffect } from "react";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
import { AuthLayout } from "@/components/auth/AuthLayout";
import { AuthHeader } from "@/components/auth/AuthHeader";
import { AuthCard } from "@/components/auth/AuthCard";
import { SignInForm } from "@/components/auth/SignInForm";
import { ForgotPasswordLink } from "@/components/auth/ForgotPasswordLink";
import { useAuthContext } from "@/auth/AuthContext";
WebBrowser.maybeCompleteAuthSession();
const useWarmUpBrowser = () => {
useEffect(() => {
if (Platform.OS !== "web") {
WebBrowser.warmUpAsync();
return () => {
WebBrowser.coolDownAsync();
};
}
}, []);
};
const SignIn = () => {
const router = useRouter();
const { login, isAuthenticated, loading } = useAuthContext();
useWarmUpBrowser();
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const successMessage = "";
const isResettingPassword = false;
const onForgotPasswordPress = async () => {
router.push("/forgot-password");
};
const onSignInSubmit = async (vals: {
emailAddress: string;
password: string;
}) => {
setErrorMessage("");
setIsLoading(true);
try {
await login(vals.emailAddress, vals.password);
router.replace("/");
} catch (err: any) {
const msg = err?.message || "Invalid credentials";
setErrorMessage(msg);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!loading && isAuthenticated) {
router.replace("/");
}
}, [loading, isAuthenticated, router]);
return (
<AuthLayout>
<AuthHeader title="Project Athena" />
<AuthCard title="Welcome Back" subtitle="Sign in to continue">
<SignInForm
errorMessage={errorMessage}
successMessage={successMessage}
isLoading={isLoading}
onSignInSubmit={onSignInSubmit}
/>
<ForgotPasswordLink
onPress={onForgotPasswordPress}
isResetting={isResettingPassword}
disabled={isResettingPassword || isLoading}
/>
</AuthCard>
</AuthLayout>
);
};
export default SignIn;
+112
View File
@@ -0,0 +1,112 @@
import { Ionicons } from "@expo/vector-icons";
import { Redirect, Tabs, usePathname, router } from "expo-router";
import { Pressable, View } from "react-native";
import { useState } from "react";
import { useAuthContext } from "@/auth/AuthContext";
import { ContentTabs } from "@/components/features/navigation/ContentTabs";
import { Sidebar } from "@/components/features/navigation/Sidebar";
import { SidebarToggle } from "@/components/features/navigation/SidebarToggle";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { HeaderControls } from "@/components/features/navigation/HeaderControls";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { UserButton } from "@/components/auth/UserButton";
const TabsLayout = () => {
const isMobile = useAppSelector(selectIsMobile);
const { isAuthenticated, bootstrapping } = useAuthContext();
const pathname = usePathname();
const [mobileHidden, setMobileHidden] = useState(true);
if (bootstrapping) {
return (
<View className="flex-1 bg-primaryPurpleLight justify-center items-center">
<LoadingSpinner />
</View>
);
}
if (!isAuthenticated) {
if (pathname !== "/sign-in") {
return <Redirect href="/sign-in" />;
}
return null;
}
return (
<View className="flex-1 flex-row">
<View
style={{
flexShrink: 0,
}}
>
<Sidebar
mobileHidden={isMobile ? mobileHidden : false}
onRequestMobileToggle={() => setMobileHidden((v) => !v)}
/>
</View>
<View className="flex-1 bg-primaryPurpleLight">
<View className="relative">
<View style={{ paddingLeft: isMobile && mobileHidden ? 80 : 0 }}>
<ContentTabs />
</View>
{isMobile && mobileHidden && (
<View
className="absolute z-50"
style={{
left: 0,
top: 0,
height: 56,
width: 80,
backgroundColor: "transparent",
borderBottomColor: "transparent",
borderBottomWidth: 0,
paddingLeft: 4,
paddingRight: 4,
display: "flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
}}
>
<SidebarToggle
onPress={() => setMobileHidden(false)}
showLabel={!mobileHidden}
/>
</View>
)}
{isMobile ? (
<View className="absolute flex flex-row items-center justify-center z-50 right-4 top-2 space-x-3">
<Pressable className="p-1" onPress={() => router.push("/search")}>
<Ionicons name="search" size={20} color="white" />
</Pressable>
<UserButton />
</View>
) : (
<HeaderControls />
)}
</View>
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: { display: "none" },
}}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="accounts/index" />
<Tabs.Screen name="logs/index" />
<Tabs.Screen name="reports/index" />
<Tabs.Screen name="data-protection/index" />
<Tabs.Screen name="settings/index" />
<Tabs.Screen name="search" />
</Tabs>
</View>
</View>
);
};
export default TabsLayout;
+7
View File
@@ -0,0 +1,7 @@
import { AdminDetailsPage } from "@/components/features/admins/AdminDetailsPage";
const AdminDetails = () => {
return <AdminDetailsPage />;
};
export default AdminDetails;
+11
View File
@@ -0,0 +1,11 @@
import { AdminsPage } from "@/components/features/admins/AdminsPage";
import { useRoleAccess } from "@/hooks/useRoleAccess";
import { AccessDenied } from "@/components/features/auth/AccessDenied";
const Admins = () => {
const { isManager } = useRoleAccess();
if (!isManager) return <AccessDenied />;
return <AdminsPage />;
};
export default Admins;
+7
View File
@@ -0,0 +1,7 @@
import { Redirect } from "expo-router";
const Accounts = () => {
return <Redirect href="/accounts/users" />;
};
export default Accounts;
+11
View File
@@ -0,0 +1,11 @@
import { TodosPage } from "@/components/features/todos/TodosPage";
import { useRoleAccess } from "@/hooks/useRoleAccess";
import { AccessDenied } from "@/components/features/auth/AccessDenied";
const Todos = () => {
const { isManager } = useRoleAccess();
if (!isManager) return <AccessDenied />;
return <TodosPage />;
};
export default Todos;
+7
View File
@@ -0,0 +1,7 @@
import { UserDetailsPage } from "@/components/features/users/UserDetailsPage";
const UserDetails = () => {
return <UserDetailsPage />;
};
export default UserDetails;
+7
View File
@@ -0,0 +1,7 @@
import { UsersPage } from "@/components/features/users/UsersPage";
const Users = () => {
return <UsersPage />;
};
export default Users;
+7
View File
@@ -0,0 +1,7 @@
import { Redirect } from "expo-router";
const Dashboard = () => {
return <Redirect href="/dashboard/user-dashboard" />;
};
export default Dashboard;
@@ -0,0 +1,7 @@
import UserDashboardPage from "@/components/features/dashboard/UserDashboardPage";
const UserDashboard = () => {
return <UserDashboardPage />;
};
export default UserDashboard;
@@ -0,0 +1,28 @@
import { Text, View } from "react-native";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
const General = () => {
const isMobile = useAppSelector(selectIsMobile);
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`}
>
General Security
</Text>
<Text className="text-base text-gray-600">
This is the General Security tab content. It would typically show
application errors, exceptions, and critical issues that need attention.
</Text>
</View>
);
};
export default General;
+7
View File
@@ -0,0 +1,7 @@
import { Redirect } from "expo-router";
const DataProtection = () => {
return <Redirect href="/data-protection/general" />;
};
export default DataProtection;
+3
View File
@@ -0,0 +1,3 @@
import Dashboard from "./dashboard";
export default Dashboard;
+7
View File
@@ -0,0 +1,7 @@
import { Redirect } from "expo-router";
const Logs = () => {
return <Redirect href="/logs/system" />;
};
export default Logs;
+7
View File
@@ -0,0 +1,7 @@
import { LogsPage } from "@/components/features/logs/LogsPage";
const SystemLogs = () => {
return <LogsPage />;
};
export default SystemLogs;
+28
View File
@@ -0,0 +1,28 @@
import { Text, View } from "react-native";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
const Inbox = () => {
const isMobile = useAppSelector(selectIsMobile);
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`}
>
Inbox
</Text>
<Text className="text-base text-gray-600">
This is the Inbox tab content. It would typically show application
errors, exceptions, and critical issues that need attention.
</Text>
</View>
);
};
export default Inbox;
+7
View File
@@ -0,0 +1,7 @@
import { Redirect } from "expo-router";
const Reports = () => {
return <Redirect href="/reports/inbox" />;
};
export default Reports;
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useState, useCallback, useMemo } from "react";
import { View, Text, FlatList } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { ActivityIndicator } from "react-native-paper";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { SearchResult, useGlobalSearchQuery } from "@/store/api/searchApi";
import { SearchBar } from "@/components/ui/SearchBar";
import { SizeEnum } from "@/constants/sizeEnum";
import { ResultRow } from "@/components/features/search/ResultRow";
import { normalizeEnumLike } from "@/utils/formattingUtils";
import { useDebounce } from "@/hooks/useDebounce";
import { Button } from "@/components/ui/Button";
const SearchPage = () => {
const params = useLocalSearchParams<{ q?: string }>();
const [q, setQ] = useState(params.q ?? "");
const isMobile = useAppSelector(selectIsMobile);
useEffect(() => {
setQ(params.q ?? "");
}, [params.q]);
const debouncedQ = useDebounce(q, 300);
const [limit] = useState(100);
const { data, isFetching, isLoading, isError } = useGlobalSearchQuery(
{ q: debouncedQ, page: 1, limit },
{
skip: !debouncedQ || debouncedQ.trim().length < 2,
refetchOnMountOrArgChange: true,
refetchOnFocus: true,
}
);
const results = useMemo(() => data?.results ?? [], [data?.results]);
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => ({ ...prev, [id]: !prev[id] }));
}, []);
const isExpanded = useCallback(
(id: string) => !!expandedIds[id],
[expandedIds]
);
// Reset expansions when new query results arrive
useEffect(() => {
if (data?.results) setExpandedIds({});
}, [debouncedQ, data?.results]);
// Update route param only after debounce
useEffect(() => {
router.setParams({ q: debouncedQ || "" });
}, [debouncedQ]);
// Preprocess heavy formatting outside render
const formattedResults = useMemo(
() =>
results.map((item) => ({
...item,
subtitle: item.subtitle ? normalizeEnumLike(item.subtitle) : null,
description: item.description
? normalizeEnumLike(item.description)
: null,
createdAtFormatted: item.createdAt
? new Date(item.createdAt).toLocaleString()
: null,
})),
[results]
);
const renderResult = useCallback(
({ item }: { item: SearchResult }) => (
<ResultRow
item={item}
expanded={isExpanded(item.id)}
onToggle={toggleExpanded}
/>
),
[isExpanded, toggleExpanded]
);
return (
<View className="flex-1 p-4">
<View>
{isMobile && (
<Button
onPress={() => router.push("/dashboard/user-dashboard")}
disabled={isFetching}
className="mb-2"
>
Go to User Dashboard
</Button>
)}
<SearchBar
searchQuery={q}
onChange={setQ}
placeholderText="Search"
size={SizeEnum.Regular}
/>
</View>
{!q || q.trim().length < 2 ? (
<View className="mt-6">
<Text className="text-gray-600">Start typing to search.</Text>
</View>
) : isLoading && results.length === 0 ? (
<View className="mt-6">
<ActivityIndicator />
</View>
) : isError && results.length === 0 ? (
<View className="mt-6">
<Text className="text-red-500">Search failed. Please try again.</Text>
</View>
) : (
<View className="flex-1">
<Text className="text-gray-700 mb-2 mt-2">
Results: {results.length}
</Text>
<FlatList
data={formattedResults as SearchResult[]}
keyExtractor={(item) => `${item.type}-${item.id}`}
renderItem={renderResult}
contentContainerStyle={{ paddingBottom: 16 }}
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
/>
{isFetching && (
<View className="my-3">
<ActivityIndicator />
</View>
)}
</View>
)}
</View>
);
};
export default SearchPage;
+29
View File
@@ -0,0 +1,29 @@
import { Text, View } from "react-native";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
const GeneralSettings = () => {
const isMobile = useAppSelector(selectIsMobile);
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`}
>
General Settings
</Text>
<Text className="text-base text-gray-600">
This is the General Settings tab content. It would typically show
application preferences, language settings, and other general
configuration options.
</Text>
</View>
);
};
export default GeneralSettings;
+7
View File
@@ -0,0 +1,7 @@
import { Redirect } from "expo-router";
const Settings = () => {
return <Redirect href="/settings/general" />;
};
export default Settings;
+42
View File
@@ -0,0 +1,42 @@
import { Stack } from "expo-router";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { Provider as ReduxProvider } from "react-redux";
import { Provider as PaperProvider, MD3LightTheme } from "react-native-paper";
import { AuthProvider } from "@/auth/AuthContext";
import { ScreenSizeListenerWrapper } from "@/hooks/useScreenSizeListener";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { store } from "store";
import "./globals.css";
const theme = {
...MD3LightTheme,
colors: {
...MD3LightTheme.colors,
},
};
const RootLayout = () => {
useDocumentTitle();
return (
<ReduxProvider store={store}>
<AuthProvider>
<PaperProvider theme={theme}>
<SafeAreaProvider>
<ScreenSizeListenerWrapper />
<SafeAreaView className="flex-1">
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
</SafeAreaView>
</SafeAreaProvider>
</PaperProvider>
</AuthProvider>
</ReduxProvider>
);
};
export default RootLayout;
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

+169
View File
@@ -0,0 +1,169 @@
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
import * as WebBrowser from "expo-web-browser";
import { getApiUrl, baseApi } from "@/store/api/baseApi";
import { getToken, setToken, loadPersistedToken } from "@/auth/token";
import { store } from "@/store/index";
import { User } from "@/store/models/User.model";
WebBrowser.maybeCompleteAuthSession();
type AuthContextType = {
user: User | null;
token: string | null;
loading: boolean;
bootstrapping: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (p: {
firstName: string;
lastName: string;
email: string;
password: string;
}) => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuthContext = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuthContext must be used within AuthProvider");
return ctx;
};
export type AuthProviderProps = {
children: ReactNode;
};
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<User | null>(null);
const [tokenState, setTokenState] = useState<string | null>(getToken());
const [bootstrapping, setBootstrapping] = useState(true);
const [loading, setLoading] = useState(false);
const isAuthenticated = !!tokenState && !!user;
const syncUser = async (jwt?: string | null) => {
const useToken = jwt ?? tokenState;
if (!useToken) return;
try {
const res = await fetch(`${getApiUrl()?.replace(/\/$/, "")}/auth/me`, {
headers: { Authorization: `Bearer ${useToken}` },
});
if (!res.ok) throw new Error("Failed to fetch current user");
const u: User = await res.json();
setUser(u);
} catch (e) {
console.warn("/auth/me failed:", e);
}
};
useEffect(() => {
(async () => {
try {
const t = await loadPersistedToken();
setTokenState(t);
if (t) await syncUser(t);
} finally {
setBootstrapping(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const login: AuthContextType["login"] = async (email, password) => {
setLoading(true);
try {
const res = await fetch(`${getApiUrl()?.replace(/\/$/, "")}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const status = res.status;
let msg = "Invalid credentials";
try {
const errData = await res.json();
msg = errData?.message || errData?.error || msg;
} catch {
// non-JSON response, keep default msg
}
// Only replace the message for explicit 'not activated' cases
if (status === 403 && /not activated/i.test(msg)) {
msg =
"Account is not activated. Please use your invitation link to set a password.";
}
throw new Error(msg);
}
const data = await res.json();
await setToken(data.token);
setTokenState(data.token);
setUser(data.user);
// Clear/refresh caches after login for correct user context
store.dispatch(baseApi.util.resetApiState());
store.dispatch(baseApi.util.invalidateTags(["Users", "Todos"]));
} finally {
setLoading(false);
}
};
const register: AuthContextType["register"] = async ({
firstName,
lastName,
email,
password,
}) => {
setLoading(true);
try {
const res = await fetch(
`${getApiUrl()?.replace(/\/$/, "")}/auth/register`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ firstName, lastName, email, password }),
}
);
if (!res.ok) throw new Error("Registration failed");
const data = await res.json();
await setToken(data.token);
setTokenState(data.token);
setUser(data.user);
// Invalidate users cache to refresh activation statuses after register
store.dispatch(baseApi.util.invalidateTags(["Users"]));
} finally {
setLoading(false);
}
};
const logout: AuthContextType["logout"] = async () => {
await setToken(null);
setTokenState(null);
setUser(null);
// Clear RTK Query caches on logout to prevent stale cross-user data
store.dispatch(baseApi.util.resetApiState());
};
return (
<AuthContext.Provider
value={{
user,
token: tokenState,
loading,
bootstrapping,
isAuthenticated,
login,
register,
logout,
}}
>
{children}
</AuthContext.Provider>
);
};
+37
View File
@@ -0,0 +1,37 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
let inMemoryToken: string | null = null;
export const getToken = (): string | null => inMemoryToken;
export const setToken = async (token: string | null) => {
inMemoryToken = token;
try {
if (Platform.OS === "web") {
if (token) localStorage.setItem("auth_token", token);
else localStorage.removeItem("auth_token");
} else {
if (token) await SecureStore.setItemAsync("auth_token", token);
else await SecureStore.deleteItemAsync("auth_token");
}
} catch (e) {
console.warn("Token persistence error:", e);
}
};
export const loadPersistedToken = async (): Promise<string | null> => {
try {
if (Platform.OS === "web") {
const t = localStorage.getItem("auth_token");
inMemoryToken = t;
return t;
}
const t = await SecureStore.getItemAsync("auth_token");
inMemoryToken = t;
return t;
} catch (e) {
console.warn("Token load error:", e);
return null;
}
};
+9
View File
@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};
+31
View File
@@ -0,0 +1,31 @@
import { Button } from "react-native-paper";
import { ReactNode } from "react";
interface AuthButtonProps {
onPress: () => void;
loading?: boolean;
disabled?: boolean;
textColor?: string;
children: ReactNode;
className?: string;
}
export const AuthButton = ({
onPress,
loading = false,
disabled = false,
children,
}: AuthButtonProps) => {
return (
<Button
mode="contained"
onPress={onPress}
className="py-2 rounded-lg"
buttonColor="#6750a4"
disabled={disabled || loading}
loading={loading}
>
{children}
</Button>
);
};
+45
View File
@@ -0,0 +1,45 @@
import { Surface } from "react-native-paper";
import { View, Text } from "react-native";
interface AuthCardProps {
children: React.ReactNode;
title?: string;
subtitle?: string;
}
export const AuthCard = ({ children, title, subtitle }: AuthCardProps) => {
return (
<Surface
style={{
borderRadius: 20,
padding: 24,
backgroundColor: "rgba(255,255,255,0.9)",
elevation: 4,
}}
>
{(title || subtitle) && (
<View style={{ alignItems: "center", marginBottom: 24 }}>
{title && (
<Text
style={{
fontSize: 24,
fontWeight: "bold",
color: "#6750a4",
marginBottom: subtitle ? 12 : 0,
textAlign: "center",
}}
>
{title}
</Text>
)}
{subtitle && (
<Text style={{ fontSize: 16, color: "rgba(0,0,0,0.6)" }}>
{subtitle}
</Text>
)}
</View>
)}
{children}
</Surface>
);
};
+24
View File
@@ -0,0 +1,24 @@
import { View, Text, TouchableOpacity } from "react-native";
interface AuthFooterProps {
question: string;
actionText: string;
onPress: () => void;
disabled?: boolean;
}
export const AuthFooter = ({
question,
actionText,
onPress,
disabled,
}: AuthFooterProps) => {
return (
<View className="flex-row items-center justify-center mt-6">
<Text className="text-white">{question}</Text>
<TouchableOpacity onPress={onPress} disabled={disabled}>
<Text className="text-white font-bold ml-2">{actionText}</Text>
</TouchableOpacity>
</View>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { View, Text, Image } from "react-native";
interface AuthHeaderProps {
title?: string;
}
export const AuthHeader = ({ title = "Project Athena" }: AuthHeaderProps) => {
return (
<View style={{ alignItems: "center", marginBottom: 36 }}>
<Image
source={require("../../assets/images/logo_no_background.png")}
style={{ width: 80, height: 80, marginBottom: 16 }}
resizeMode="contain"
/>
<Text
style={{
fontSize: 28,
fontWeight: "bold",
color: "white",
marginBottom: 4,
}}
>
{title}
</Text>
</View>
);
};
+50
View File
@@ -0,0 +1,50 @@
import {
View,
ScrollView,
KeyboardAvoidingView,
Platform,
useWindowDimensions,
} from "react-native";
import { StatusBar } from "expo-status-bar";
import { LinearGradient } from "expo-linear-gradient";
interface AuthLayoutProps {
children: React.ReactNode;
}
export const AuthLayout = ({ children }: AuthLayoutProps) => {
const { width } = useWindowDimensions();
const isWideScreen = width > 768;
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<StatusBar style="light" />
<LinearGradient
colors={["#A78FA1", "#6750a4", "#49385D"]}
style={{ flex: 1 }}
>
<ScrollView
contentContainerStyle={{
flexGrow: 1,
justifyContent: "center",
paddingHorizontal: isWideScreen ? 32 : 16,
paddingVertical: isWideScreen ? 32 : 24,
}}
keyboardShouldPersistTaps="handled"
>
<View
style={{
alignSelf: isWideScreen ? "center" : "stretch",
width: isWideScreen ? 550 : "100%",
}}
>
{children}
</View>
</ScrollView>
</LinearGradient>
</KeyboardAvoidingView>
);
};
+25
View File
@@ -0,0 +1,25 @@
import { TouchableOpacity, Text } from "react-native";
interface ForgotPasswordLinkProps {
onPress: () => void;
isResetting: boolean;
disabled: boolean;
}
export const ForgotPasswordLink = ({
onPress,
isResetting,
disabled,
}: ForgotPasswordLinkProps) => {
return (
<TouchableOpacity
className="items-center mt-4"
onPress={onPress}
disabled={disabled || isResetting}
>
<Text className="text-primaryPurple">
{isResetting ? "Sending reset email..." : "Forgot Password?"}
</Text>
</TouchableOpacity>
);
};
+68
View File
@@ -0,0 +1,68 @@
import { TextInput, TextInputProps } from "react-native-paper";
import {
KeyboardTypeOptions,
Platform,
NativeSyntheticEvent,
TextInputSubmitEditingEventData,
SubmitBehavior,
} from "react-native";
interface FormInputProps {
label: string;
value: string;
onChangeText: (text: string) => void;
onBlur?: () => void;
icon?: string;
keyboardType?: KeyboardTypeOptions;
disabled?: boolean;
autoCapitalize?: TextInputProps["autoCapitalize"];
style?: any;
onSubmitEditing?: (
e?: NativeSyntheticEvent<TextInputSubmitEditingEventData>
) => void;
returnKeyType?: any;
submitBehavior?: SubmitBehavior;
}
export const FormInput = ({
label,
value,
onChangeText,
onBlur,
icon = "pencil",
keyboardType = "default",
disabled = false,
autoCapitalize = "none",
style = {},
onSubmitEditing,
returnKeyType,
submitBehavior,
}: FormInputProps) => {
return (
<TextInput
label={label}
mode="flat"
value={value}
onChangeText={onChangeText}
onBlur={onBlur}
style={{
marginBottom: 16,
backgroundColor: "transparent",
...style,
}}
keyboardType={keyboardType}
autoCapitalize={autoCapitalize}
onSubmitEditing={onSubmitEditing}
returnKeyType={returnKeyType}
submitBehavior={submitBehavior ?? "submit"}
onKeyPress={(e) => {
if (Platform.OS === "web" && e.nativeEvent.key === "Enter") {
onSubmitEditing?.();
}
}}
left={<TextInput.Icon icon={icon} color="#6750a4" />}
theme={{ colors: { primary: "#6750a4" } }}
disabled={disabled}
/>
);
};
+28
View File
@@ -0,0 +1,28 @@
import { View, Text } from "react-native";
interface MessageDisplayProps {
message: string;
type: "error" | "success";
}
export const MessageDisplay = ({ message, type }: MessageDisplayProps) => {
if (!message) return null;
const backgroundColor =
type === "error" ? "rgba(255,0,0,0.1)" : "rgba(0,255,0,0.1)";
const textColor = type === "error" ? "#F83434" : "#22C927";
return (
<View
style={{
marginBottom: 16,
padding: 10,
backgroundColor,
borderRadius: 4,
}}
>
<Text style={{ color: textColor }}>{message}</Text>
</View>
);
};
+70
View File
@@ -0,0 +1,70 @@
import { useState } from "react";
import {
Platform,
NativeSyntheticEvent,
TextInputSubmitEditingEventData,
} from "react-native";
import { TextInput, TextInputProps } from "react-native-paper";
interface PasswordInputProps {
label: string;
value: string;
onChangeText: (text: string) => void;
onBlur?: () => void;
disabled?: boolean;
style?: any;
onSubmitEditing?: (
e?: NativeSyntheticEvent<TextInputSubmitEditingEventData>
) => void;
returnKeyType?: any;
submitBehavior?: TextInputProps["submitBehavior"];
}
export const PasswordInput = ({
label,
value,
onChangeText,
onBlur,
disabled = false,
style = {},
onSubmitEditing,
returnKeyType,
submitBehavior,
}: PasswordInputProps) => {
const [secureTextEntry, setSecureTextEntry] = useState(true);
return (
<TextInput
label={label}
mode="flat"
value={value}
secureTextEntry={secureTextEntry}
onChangeText={onChangeText}
onBlur={onBlur}
style={{
marginBottom: 16,
backgroundColor: "transparent",
...style,
}}
onSubmitEditing={onSubmitEditing}
returnKeyType={returnKeyType}
submitBehavior={submitBehavior ?? "submit"}
onKeyPress={(e) => {
if (Platform.OS === "web" && e?.nativeEvent?.key === "Enter") {
onSubmitEditing?.();
}
}}
left={<TextInput.Icon icon="lock" color="#6750a4" />}
right={
<TextInput.Icon
icon={secureTextEntry ? "eye" : "eye-off"}
color="#6750a4"
onPress={() => setSecureTextEntry(!secureTextEntry)}
/>
}
theme={{ colors: { primary: "#6750a4" } }}
disabled={disabled}
placeholderTextColor="#d9d9d9"
/>
);
};
+123
View File
@@ -0,0 +1,123 @@
import { Text } from "react-native";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormInput } from "@/components/auth/FormInput";
import { PasswordInput } from "@/components/auth/PasswordInput";
import { MessageDisplay } from "@/components/auth/MessageDisplay";
import { AuthButton } from "@/components/auth/AuthButton";
const signInSchema = z.object({
emailAddress: z
.string()
.trim()
.min(1, "Email is required")
.pipe(z.email("Enter a valid email address")),
password: z
.string()
.min(1, "Password is required")
.max(128, "Password is too long"),
});
type SignInValues = z.infer<typeof signInSchema>;
interface SignInFormProps {
errorMessage: string;
successMessage: string;
isLoading: boolean;
onSignInSubmit: (values: {
emailAddress: string;
password: string;
}) => void | Promise<void>;
}
export const SignInForm = ({
errorMessage,
successMessage,
isLoading,
onSignInSubmit,
}: SignInFormProps) => {
const { control, handleSubmit } = useForm<SignInValues>({
resolver: zodResolver(signInSchema),
defaultValues: { emailAddress: "", password: "" },
mode: "onBlur",
reValidateMode: "onBlur",
shouldUnregister: false,
});
const submit = (vals: SignInValues) =>
onSignInSubmit({
emailAddress: vals.emailAddress.trim().toLowerCase(),
password: vals.password,
});
return (
<>
<Controller
control={control}
name="emailAddress"
render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
<>
<FormInput
label="Email"
value={value}
onChangeText={onChange}
onBlur={onBlur}
icon="email"
keyboardType="email-address"
disabled={isLoading}
style={{ marginBottom: 8 }}
returnKeyType="next"
/>
{error ? (
<Text style={{ color: "#F83434", marginBottom: 8 }}>
{error.message}
</Text>
) : null}
</>
)}
/>
<Controller
control={control}
name="password"
render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
<>
<PasswordInput
label="Password"
value={value}
onChangeText={onChange}
onBlur={onBlur}
disabled={isLoading}
style={{ marginBottom: 8 }}
returnKeyType="done"
submitBehavior="blurAndSubmit"
onSubmitEditing={() => handleSubmit(submit)()}
/>
{error ? (
<Text style={{ color: "#F83434", marginBottom: 8 }}>
{error.message}
</Text>
) : null}
</>
)}
/>
{errorMessage ? (
<MessageDisplay message={errorMessage} type="error" />
) : null}
{successMessage ? (
<MessageDisplay message={successMessage} type="success" />
) : null}
<AuthButton
onPress={handleSubmit(submit)}
loading={isLoading}
disabled={isLoading}
>
{isLoading ? "Signing in..." : "Sign In"}
</AuthButton>
</>
);
};
+62
View File
@@ -0,0 +1,62 @@
import { Text, Animated, Pressable } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useAuthContext } from "@/auth/AuthContext";
interface SignOutButtonProps {
textOpacity: Animated.Value;
iconTranslateX: Animated.Value;
paddingLeft: Animated.Value;
}
export const SignOutButton = ({
textOpacity,
iconTranslateX,
paddingLeft,
}: SignOutButtonProps) => {
const router = useRouter();
const { logout } = useAuthContext();
const handleSignOut = async () => {
try {
await logout();
router.replace("/sign-in");
} catch (err) {
console.error("Logout error:", err);
}
};
return (
<Animated.View
style={{
paddingLeft,
paddingRight: 4,
}}
>
<Pressable
onPress={handleSignOut}
className="flex-row items-center py-3 rounded-lg w-full justify-start"
>
<Animated.View
style={{
width: 32,
alignItems: "center",
transform: [{ translateX: iconTranslateX }],
}}
>
<Ionicons name="log-out-outline" size={32} color="white" />
</Animated.View>
<Animated.View
style={{
opacity: textOpacity,
marginLeft: 12,
overflow: "hidden",
}}
>
<Text className="text-white" numberOfLines={1}>
Sign out
</Text>
</Animated.View>
</Pressable>
</Animated.View>
);
};
+106
View File
@@ -0,0 +1,106 @@
import { useMemo, useState } from "react";
import { View, TouchableOpacity } from "react-native";
import {
Avatar,
Divider,
Menu,
Text,
Chip,
IconButton,
} from "react-native-paper";
import { useAuthContext } from "@/auth/AuthContext";
import { getInitials, getRoleLabel } from "@/utils/userUtils";
export const UserButton = () => {
const { user, logout } = useAuthContext();
const [visible, setVisible] = useState(false);
const initials = useMemo(
() => getInitials(user?.firstName, user?.lastName),
[user?.firstName, user?.lastName]
);
if (!user) return null;
return (
<View className="flex-row items-center">
<Menu
visible={visible}
onDismiss={() => setVisible(false)}
anchor={
<TouchableOpacity
accessibilityRole="button"
onPress={() => setVisible(true)}
className="ml-3"
>
{user.imageUrl ? (
<Avatar.Image size={32} source={{ uri: user.imageUrl }} />
) : (
<Avatar.Text size={32} label={initials} />
)}
</TouchableOpacity>
}
contentStyle={{
minWidth: 300,
borderRadius: 12,
overflow: "hidden",
padding: 0,
}}
>
<View
style={{
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: "#F7F7F8",
}}
>
<View style={{ flexDirection: "row", alignItems: "center" }}>
{user.imageUrl ? (
<Avatar.Image size={44} source={{ uri: user.imageUrl }} />
) : (
<Avatar.Text size={44} label={initials} />
)}
<View
style={{
flexShrink: 1,
maxWidth: 500,
marginLeft: 8,
}}
>
<Text variant="titleSmall" numberOfLines={10}>
{user.firstName} {user.lastName}
</Text>
<Text variant="bodySmall" style={{ opacity: 0.7 }}>
{user.email}
</Text>
</View>
<View style={{ alignSelf: "flex-start", marginLeft: "auto" }}>
<IconButton
icon="close"
size={20}
onPress={() => setVisible(false)}
accessibilityLabel="Close dialog"
style={{ margin: 0 }}
/>
</View>
</View>
<View style={{ height: 10 }} />
<View style={{ flexDirection: "column", flexWrap: "wrap" }}>
<Chip compact>{getRoleLabel(user.role)}</Chip>
</View>
</View>
<Divider />
<Menu.Item
title="Sign out"
leadingIcon="logout"
onPress={async () => {
setVisible(false);
await logout();
}}
/>
</Menu>
</View>
);
};
+45
View File
@@ -0,0 +1,45 @@
import { FormInput } from "@/components/auth/FormInput";
import { MessageDisplay } from "@/components/auth/MessageDisplay";
import { AuthButton } from "@/components/auth/AuthButton";
interface VerificationFormProps {
code: string;
setCode: (value: string) => void;
errorMessage: string;
isLoading: boolean;
onVerifyPress: () => void;
}
export const VerificationForm = ({
code,
setCode,
errorMessage,
isLoading,
onVerifyPress,
}: VerificationFormProps) => {
return (
<>
<FormInput
label="Verification Code"
value={code}
onChangeText={setCode}
icon="key"
keyboardType="number-pad"
disabled={isLoading}
style={{ marginBottom: 24 }}
/>
{errorMessage ? (
<MessageDisplay message={errorMessage} type="error" />
) : null}
<AuthButton
onPress={onVerifyPress}
loading={isLoading}
disabled={isLoading}
>
Verify Email
</AuthButton>
</>
);
};
@@ -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>
);
};

Some files were not shown because too many files have changed in this diff Show More