project setup
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
EXPO_PUBLIC_BACKEND_URL=http://localhost:5000/api
|
||||||
|
EXPO_PUBLIC_APP_BASE_URL=http://localhost:8081
|
||||||
+43
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { AdminDetailsPage } from "@/components/features/admins/AdminDetailsPage";
|
||||||
|
|
||||||
|
const AdminDetails = () => {
|
||||||
|
return <AdminDetailsPage />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminDetails;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
const Accounts = () => {
|
||||||
|
return <Redirect href="/accounts/users" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Accounts;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { UserDetailsPage } from "@/components/features/users/UserDetailsPage";
|
||||||
|
|
||||||
|
const UserDetails = () => {
|
||||||
|
return <UserDetailsPage />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserDetails;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { UsersPage } from "@/components/features/users/UsersPage";
|
||||||
|
|
||||||
|
const Users = () => {
|
||||||
|
return <UsersPage />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Users;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
const DataProtection = () => {
|
||||||
|
return <Redirect href="/data-protection/general" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataProtection;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import Dashboard from "./dashboard";
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
const Logs = () => {
|
||||||
|
return <Redirect href="/logs/system" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logs;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { LogsPage } from "@/components/features/logs/LogsPage";
|
||||||
|
|
||||||
|
const SystemLogs = () => {
|
||||||
|
return <LogsPage />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemLogs;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
const Reports = () => {
|
||||||
|
return <Redirect href="/reports/inbox" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Reports;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
const Settings = () => {
|
||||||
|
return <Redirect href="/settings/general" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -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;
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
|
"nativewind/babel",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 "{searchQuery}"
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : searchQuery.length === 0 && adminUsers.length === 0 ? (
|
||||||
|
<View
|
||||||
|
className={`flex-1 items-center justify-center ${
|
||||||
|
isMobile ? "p-1" : "p-5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text className="text-lg text-gray-500">
|
||||||
|
No admins found
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : isMobile ? (
|
||||||
|
<View className="flex-1">
|
||||||
|
<MobileAdminAccountsTable
|
||||||
|
admins={adminUsers}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={openDeleteModal}
|
||||||
|
onResend={handleResendInvite}
|
||||||
|
isResending={isResending}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView className="flex-1 h-96">
|
||||||
|
<DesktopAdminAccountsTable
|
||||||
|
admins={adminUsers}
|
||||||
|
sortColumn={sortColumn}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={openDeleteModal}
|
||||||
|
onResend={handleResendInvite}
|
||||||
|
isResending={isResending}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TablePagination
|
||||||
|
page={page}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
totalItems={totalItems}
|
||||||
|
from={from}
|
||||||
|
to={to}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onItemsPerPageButtonPress={(event: GestureResponderEvent) => {
|
||||||
|
const { pageX, pageY } = event.nativeEvent;
|
||||||
|
setMenuAnchor({ x: pageX, y: pageY });
|
||||||
|
setMenuVisible(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<AppSnackbar
|
||||||
|
visible={snackbarVisible}
|
||||||
|
onDismiss={() => setSnackbarVisible(false)}
|
||||||
|
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
|
||||||
|
>
|
||||||
|
{snackbarMessage}
|
||||||
|
</AppSnackbar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { TableControls } from "@/components/shared/TableControls";
|
||||||
|
import { AdminControls } from "@/components/features/admins/AdminControls";
|
||||||
|
|
||||||
|
interface AdminTableControlsProps {
|
||||||
|
searchQuery: string;
|
||||||
|
menuVisible: boolean;
|
||||||
|
menuAnchor: { x: number; y: number };
|
||||||
|
onSearch: (query: string) => void;
|
||||||
|
onMenuDismiss: () => void;
|
||||||
|
onItemsPerPageChange: (value: number) => void;
|
||||||
|
onAddAdmin?: () => void;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminAccountsTableControls = ({
|
||||||
|
searchQuery,
|
||||||
|
menuVisible,
|
||||||
|
menuAnchor,
|
||||||
|
onSearch,
|
||||||
|
onMenuDismiss,
|
||||||
|
onItemsPerPageChange,
|
||||||
|
onAddAdmin = () => {},
|
||||||
|
itemsPerPage = 25,
|
||||||
|
}: AdminTableControlsProps) => {
|
||||||
|
const renderActionButtons = () => {
|
||||||
|
return <AdminControls onAddAdmin={onAddAdmin} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableControls
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
menuVisible={menuVisible}
|
||||||
|
menuAnchor={menuAnchor}
|
||||||
|
searchPlaceholder="Search admins"
|
||||||
|
onSearch={onSearch}
|
||||||
|
onMenuDismiss={onMenuDismiss}
|
||||||
|
onItemsPerPageChange={onItemsPerPageChange}
|
||||||
|
itemsPerPage={itemsPerPage}
|
||||||
|
renderActionButtons={renderActionButtons}
|
||||||
|
itemsPerPageMenuOptions={[5, 10, 25]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
import { IconButton } from "react-native-paper";
|
||||||
|
|
||||||
|
import { User } from "@/store/models/User.model";
|
||||||
|
|
||||||
|
interface AdminActionsProps {
|
||||||
|
admin: User;
|
||||||
|
onView: (admin: User) => void;
|
||||||
|
onEdit: (admin: User) => void;
|
||||||
|
onDelete: (admin: User) => void;
|
||||||
|
onResend: (admin: User) => void;
|
||||||
|
isResending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminActions = ({
|
||||||
|
admin,
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onResend,
|
||||||
|
isResending,
|
||||||
|
}: AdminActionsProps) => {
|
||||||
|
return (
|
||||||
|
<View className="flex-row justify-center">
|
||||||
|
<IconButton icon="eye-outline" onPress={() => onView(admin)} size={20} />
|
||||||
|
{!admin.isActivated && (
|
||||||
|
<IconButton
|
||||||
|
icon="email-send-outline"
|
||||||
|
onPress={() => onResend(admin)}
|
||||||
|
size={20}
|
||||||
|
disabled={Boolean(isResending)}
|
||||||
|
accessibilityLabel="Resend invitation"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon="pencil-outline"
|
||||||
|
onPress={() => onEdit(admin)}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="delete-outline"
|
||||||
|
onPress={() => onDelete(admin)}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
interface AdminControlsProps {
|
||||||
|
onAddAdmin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminControls = ({
|
||||||
|
onAddAdmin,
|
||||||
|
}: AdminControlsProps) => {
|
||||||
|
return (
|
||||||
|
<View className="flex-row">
|
||||||
|
<Button
|
||||||
|
onPress={onAddAdmin}
|
||||||
|
icon="account-plus-outline"
|
||||||
|
buttonColor="#6750a4"
|
||||||
|
buttonTextColor="white"
|
||||||
|
disabled={false}
|
||||||
|
>
|
||||||
|
Add Admin
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { View, ScrollView, Text } from "react-native";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
import { useGetUserByIdQuery } from "@/store/api/usersApi";
|
||||||
|
import { useAppSelector } from "@/store/hooks";
|
||||||
|
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||||
|
import { NoData } from "@/components/ui/NoData";
|
||||||
|
import { AccountDetails } from "@/components/features/accounts/AccountDetails";
|
||||||
|
|
||||||
|
export const AdminDetailsPage = () => {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const isMobile = useAppSelector(selectIsMobile);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: user,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useGetUserByIdQuery(id, { refetchOnMountOrArgChange: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className={`flex-1 bg-primaryPurpleLight`}>
|
||||||
|
<View className={`${isMobile ? "p-1" : "p-5"} flex-1`}>
|
||||||
|
<Text
|
||||||
|
className={`text-2xl font-bold ${
|
||||||
|
isMobile ? "mb-1" : "mb-4"
|
||||||
|
} text-white`}
|
||||||
|
>
|
||||||
|
{user?.firstName} {user?.lastName} details
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ paddingBottom: 16 }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className={`bg-lightGray rounded-lg ${
|
||||||
|
isMobile ? "p-1" : "p-5"
|
||||||
|
} flex-1`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="flex-1 justify-center items-center py-10">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</View>
|
||||||
|
) : error || !user ? (
|
||||||
|
<View className="flex-1 justify-center items-center py-10">
|
||||||
|
<NoData
|
||||||
|
message="Admin not found"
|
||||||
|
onRetry={refetch}
|
||||||
|
isRetrying={isFetching}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<AccountDetails user={user} isLoading={isLoading || isFetching} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { View, Text, ScrollView, Dimensions } from "react-native";
|
||||||
|
import { Dialog, TextInput, IconButton } from "react-native-paper";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||||
|
import { User } from "@/store/models/User.model";
|
||||||
|
import { RoleEnum } from "@/constants/roleEnum";
|
||||||
|
import { RiskEnum } from "@/constants/riskEnum";
|
||||||
|
import { OperationMode } from "@/constants/operationModeEnum";
|
||||||
|
import { DialogContainer } from "@/components/ui/DialogContainer";
|
||||||
|
import { useAuthContext } from "@/auth/AuthContext";
|
||||||
|
import { useGetUsersQuery } from "@/store/api/usersApi";
|
||||||
|
import { CustomPicker, PickerItem } from "@/components/ui/CustomPicker";
|
||||||
|
import {
|
||||||
|
adminSchema,
|
||||||
|
AdminFormValues,
|
||||||
|
} from "@/components/features/admins/schemas/admin.schema";
|
||||||
|
|
||||||
|
interface AdminModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
mode?: OperationMode;
|
||||||
|
admin?: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (payload: Partial<User>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminModal = ({
|
||||||
|
visible,
|
||||||
|
mode = OperationMode.ADD,
|
||||||
|
admin = null,
|
||||||
|
isLoading,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: AdminModalProps) => {
|
||||||
|
const isEdit = mode === OperationMode.EDIT;
|
||||||
|
const { user: currentUser } = useAuthContext();
|
||||||
|
const { data: adminsData } = useGetUsersQuery(
|
||||||
|
{
|
||||||
|
roleType: "admin",
|
||||||
|
limit: 1000,
|
||||||
|
},
|
||||||
|
{ skip: !visible }
|
||||||
|
);
|
||||||
|
const managerOptions = useMemo(
|
||||||
|
() => (adminsData?.items || []).filter((u) => u.role === RoleEnum.MANAGER),
|
||||||
|
[adminsData]
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminRef = useRef<User | null>(admin);
|
||||||
|
adminRef.current = admin;
|
||||||
|
const currentUserRef = useRef(currentUser);
|
||||||
|
currentUserRef.current = currentUser;
|
||||||
|
|
||||||
|
const defaultValues: AdminFormValues = {
|
||||||
|
id: admin?.id,
|
||||||
|
email: admin?.email ?? "",
|
||||||
|
firstName: admin?.firstName ?? "",
|
||||||
|
lastName: admin?.lastName ?? "",
|
||||||
|
title: admin?.title ?? "",
|
||||||
|
role: admin?.role ?? RoleEnum.ADMIN,
|
||||||
|
riskStatus: admin?.riskStatus ?? RiskEnum.LOW_RISK,
|
||||||
|
managerId:
|
||||||
|
admin?.managerId ??
|
||||||
|
(currentUser?.role === RoleEnum.MANAGER ? currentUser.id : undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { errors, isDirty, isValid },
|
||||||
|
} = useForm<AdminFormValues>({
|
||||||
|
resolver: zodResolver(adminSchema),
|
||||||
|
defaultValues,
|
||||||
|
mode: "onChange",
|
||||||
|
shouldUnregister: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const nextDefaults: AdminFormValues = {
|
||||||
|
id: adminRef.current?.id,
|
||||||
|
email: adminRef.current?.email ?? "",
|
||||||
|
firstName: adminRef.current?.firstName ?? "",
|
||||||
|
lastName: adminRef.current?.lastName ?? "",
|
||||||
|
title: adminRef.current?.title ?? "",
|
||||||
|
role: adminRef.current?.role ?? RoleEnum.ADMIN,
|
||||||
|
riskStatus: adminRef.current?.riskStatus ?? RiskEnum.LOW_RISK,
|
||||||
|
managerId:
|
||||||
|
adminRef.current?.managerId ??
|
||||||
|
(currentUserRef.current?.role === RoleEnum.MANAGER
|
||||||
|
? currentUserRef.current?.id
|
||||||
|
: undefined),
|
||||||
|
};
|
||||||
|
reset(nextDefaults, { keepDefaultValues: false });
|
||||||
|
}, [visible, reset, admin?.id, currentUser?.id, currentUser?.role]);
|
||||||
|
|
||||||
|
const windowHeight = Dimensions.get("window").height;
|
||||||
|
const dialogVerticalMargin = 24;
|
||||||
|
const dialogMaxHeight = windowHeight - dialogVerticalMargin - 60;
|
||||||
|
const approxChrome = 140;
|
||||||
|
const maxDialogContentHeight = Math.max(200, dialogMaxHeight - approxChrome);
|
||||||
|
|
||||||
|
const selectedRole = watch("role");
|
||||||
|
|
||||||
|
const submit = (vals: AdminFormValues) => {
|
||||||
|
const payload: Partial<User> = {
|
||||||
|
id: vals.id,
|
||||||
|
email: vals.email.trim().toLowerCase(),
|
||||||
|
firstName: vals.firstName.trim(),
|
||||||
|
lastName: vals.lastName.trim(),
|
||||||
|
title: vals.title.trim(),
|
||||||
|
role: vals.role || RoleEnum.ADMIN,
|
||||||
|
riskStatus: vals.riskStatus || RiskEnum.LOW_RISK,
|
||||||
|
// Only Managers can assign Admins to a Manager (Managers themselves cannot be reassigned)
|
||||||
|
...(currentUser?.role === RoleEnum.MANAGER && vals.role === RoleEnum.ADMIN
|
||||||
|
? { managerId: vals.managerId ?? undefined }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleText = isEdit ? "Edit Admin" : "Add Admin";
|
||||||
|
const submitText = isEdit ? "Edit" : "Add";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContainer
|
||||||
|
visible={visible}
|
||||||
|
onDismiss={onClose}
|
||||||
|
dialogStyle={{
|
||||||
|
alignSelf: "center",
|
||||||
|
marginVertical: dialogVerticalMargin,
|
||||||
|
maxHeight: dialogMaxHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Title>
|
||||||
|
<View className="w-full flex-row items-center justify-between">
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="pr-3">{titleText}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ alignSelf: "flex-start" }}>
|
||||||
|
<IconButton
|
||||||
|
icon="close"
|
||||||
|
size={20}
|
||||||
|
onPress={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
accessibilityLabel="Close dialog"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Content>
|
||||||
|
<ScrollView
|
||||||
|
style={{ maxHeight: maxDialogContentHeight }}
|
||||||
|
contentContainerStyle={{ paddingBottom: 16 }}
|
||||||
|
>
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm mb-1 font-medium">Email</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
className={`border ${
|
||||||
|
errors.email ? "border-highRisk" : "border-gray-300"
|
||||||
|
} rounded p-2 text-base bg-white`}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="Enter email address"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
placeholderTextColor="#d9d9d9"
|
||||||
|
/>
|
||||||
|
{errors.email ? (
|
||||||
|
<Text className="text-highRisk text-xs mt-1">
|
||||||
|
{errors.email.message as string}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm mb-1 font-medium">First Name</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="firstName"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
className={`border ${
|
||||||
|
errors.firstName ? "border-highRisk" : "border-gray-300"
|
||||||
|
} rounded p-2 text-base bg-white`}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="Enter first name"
|
||||||
|
autoCapitalize="words"
|
||||||
|
placeholderTextColor="#d9d9d9"
|
||||||
|
/>
|
||||||
|
{errors.firstName ? (
|
||||||
|
<Text className="text-highRisk text-xs mt-1">
|
||||||
|
{errors.firstName.message as string}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm mb-1 font-medium">Last Name</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
className={`border ${
|
||||||
|
errors.lastName ? "border-highRisk" : "border-gray-300"
|
||||||
|
} rounded p-2 text-base bg-white`}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="Enter last name"
|
||||||
|
autoCapitalize="words"
|
||||||
|
placeholderTextColor="#d9d9d9"
|
||||||
|
/>
|
||||||
|
{errors.lastName ? (
|
||||||
|
<Text className="text-highRisk text-xs mt-1">
|
||||||
|
{errors.lastName.message as string}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Password field removed for invitation-only flow */}
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm mb-1 font-medium">Title</Text>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="title"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
className={`border ${
|
||||||
|
errors.title ? "border-highRisk" : "border-gray-300"
|
||||||
|
} rounded p-2 text-base bg-white`}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder="Enter job title"
|
||||||
|
autoCapitalize="words"
|
||||||
|
placeholderTextColor="#d9d9d9"
|
||||||
|
/>
|
||||||
|
{errors.title ? (
|
||||||
|
<Text className="text-highRisk text-xs mt-1">
|
||||||
|
{errors.title.message as string}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm mb-1 font-medium">Role</Text>
|
||||||
|
<View className="border border-gray-300 rounded overflow-hidden">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="role"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<CustomPicker
|
||||||
|
selectedValue={value}
|
||||||
|
onValueChange={(v) => onChange(v)}
|
||||||
|
>
|
||||||
|
<PickerItem label="Admin" value={RoleEnum.ADMIN} />
|
||||||
|
<PickerItem label="Manager" value={RoleEnum.MANAGER} />
|
||||||
|
</CustomPicker>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{currentUser?.role === RoleEnum.MANAGER &&
|
||||||
|
selectedRole === RoleEnum.ADMIN ? (
|
||||||
|
<View className="mb-4">
|
||||||
|
<Text className="text-sm mb-1 font-medium">
|
||||||
|
Assign to Manager
|
||||||
|
</Text>
|
||||||
|
<View className="border border-gray-300 rounded overflow-hidden">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="managerId"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<CustomPicker
|
||||||
|
selectedValue={value ?? undefined}
|
||||||
|
onValueChange={(v) => onChange(v)}
|
||||||
|
>
|
||||||
|
{managerOptions.map((m) => (
|
||||||
|
<PickerItem
|
||||||
|
key={m.id}
|
||||||
|
label={
|
||||||
|
`${m.firstName} ${m.lastName}`.trim() || m.email
|
||||||
|
}
|
||||||
|
value={m.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CustomPicker>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
</Dialog.Content>
|
||||||
|
|
||||||
|
<Dialog.Actions>
|
||||||
|
<Button
|
||||||
|
onPress={handleSubmit(submit)}
|
||||||
|
disabled={isLoading || !isValid || (isEdit && !isDirty)}
|
||||||
|
className="!ml-2"
|
||||||
|
>
|
||||||
|
{submitText}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Actions>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<View
|
||||||
|
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
|
||||||
|
pointerEvents="auto"
|
||||||
|
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
|
||||||
|
>
|
||||||
|
<LoadingSpinner size={16} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</DialogContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { DataTable, Text } from "react-native-paper";
|
||||||
|
|
||||||
|
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||||
|
import { SortableHeader } from "@/components/shared/SortableHeader";
|
||||||
|
|
||||||
|
interface AdminTableHeaderProps {
|
||||||
|
sortColumn: string | null;
|
||||||
|
sortDirection: TableSortDirection;
|
||||||
|
onSort: (column: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminTableHeader = ({
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
}: AdminTableHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<DataTable.Header>
|
||||||
|
<SortableHeader
|
||||||
|
title="Name"
|
||||||
|
column="firstName"
|
||||||
|
sortColumn={sortColumn}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
title="Email"
|
||||||
|
column="email"
|
||||||
|
sortColumn={sortColumn}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
title="Role"
|
||||||
|
column="role"
|
||||||
|
sortColumn={sortColumn}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={onSort}
|
||||||
|
centerAlign
|
||||||
|
/>
|
||||||
|
<DataTable.Title style={{ justifyContent: "center" }}>
|
||||||
|
<Text style={{ fontSize: 12 }}>Actions</Text>
|
||||||
|
</DataTable.Title>
|
||||||
|
</DataTable.Header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
import { AdminAccountsTable } from "@/components/features/admins/AdminAccountsTable";
|
||||||
|
import { useAppSelector } from "@/store/hooks";
|
||||||
|
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||||
|
|
||||||
|
export const AdminsPage = () => {
|
||||||
|
const isMobile = useAppSelector(selectIsMobile);
|
||||||
|
const params = useLocalSearchParams<{ q?: string }>();
|
||||||
|
const initialSearchQuery = params.q ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-primaryPurpleLight">
|
||||||
|
<View className={`${isMobile ? "p-1" : "p-5"} flex-1`}>
|
||||||
|
<AdminAccountsTable initialSearchQuery={initialSearchQuery} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Avatar, DataTable, Text, Chip } from "react-native-paper";
|
||||||
|
|
||||||
|
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||||
|
import { AdminTableHeader } from "@/components/features/admins/AdminTableHeader";
|
||||||
|
import { AdminActions } from "@/components/features/admins/AdminActions";
|
||||||
|
import { User } from "@/store/models/User.model";
|
||||||
|
import { RoleEnum } from "@/constants/roleEnum";
|
||||||
|
import { getInitials } from "@/utils/userUtils";
|
||||||
|
import { useAuthContext } from "@/auth/AuthContext";
|
||||||
|
|
||||||
|
interface DesktopAdminAccountsTableProps {
|
||||||
|
admins: User[];
|
||||||
|
sortColumn: string | null;
|
||||||
|
sortDirection: TableSortDirection;
|
||||||
|
onSort: (column: string) => void;
|
||||||
|
onEdit: (admin: User) => void;
|
||||||
|
onDelete: (admin: User) => void;
|
||||||
|
onResend: (admin: User) => void;
|
||||||
|
isResending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesktopAdminAccountsTable = ({
|
||||||
|
admins,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onResend,
|
||||||
|
isResending,
|
||||||
|
}: DesktopAdminAccountsTableProps) => {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const openDetails = (admin: User) => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/accounts/admins/[id]",
|
||||||
|
params: { id: admin.id },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable>
|
||||||
|
<AdminTableHeader
|
||||||
|
sortColumn={sortColumn}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={onSort}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{admins.map((admin) => (
|
||||||
|
<DataTable.Row key={admin.id}>
|
||||||
|
<DataTable.Cell>
|
||||||
|
<View className="flex-row items-center flex-shrink pr-2">
|
||||||
|
{admin.imageUrl ? (
|
||||||
|
<Avatar.Image
|
||||||
|
source={{
|
||||||
|
uri: admin.imageUrl,
|
||||||
|
}}
|
||||||
|
size={28}
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar.Text
|
||||||
|
label={getInitials(admin.firstName, admin.lastName)}
|
||||||
|
size={28}
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View className="flex-row items-center flex-shrink px-1 py-1">
|
||||||
|
<Text
|
||||||
|
onPress={() => openDetails(admin)}
|
||||||
|
numberOfLines={10}
|
||||||
|
className="flex-shrink"
|
||||||
|
>
|
||||||
|
{admin.firstName} {admin.lastName}
|
||||||
|
</Text>
|
||||||
|
{admin.managerId === user?.id ? (
|
||||||
|
<View className="mb-3 w-1 h-1 rounded-full bg-primaryPurple ml-1" />
|
||||||
|
) : null}
|
||||||
|
{!admin.isActivated && (
|
||||||
|
<Chip
|
||||||
|
compact
|
||||||
|
mode="flat"
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
textStyle={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Invited
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</DataTable.Cell>
|
||||||
|
<DataTable.Cell>
|
||||||
|
<View className="flex-row items-center flex-shrink pr-2">
|
||||||
|
<Text numberOfLines={2}>{admin.email}</Text>
|
||||||
|
</View>
|
||||||
|
</DataTable.Cell>
|
||||||
|
<DataTable.Cell
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center flex-shrink">
|
||||||
|
<Text numberOfLines={2}>
|
||||||
|
{admin.role === RoleEnum.ADMIN ? "Admin" : "Manager"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</DataTable.Cell>
|
||||||
|
<DataTable.Cell style={{ justifyContent: "center" }}>
|
||||||
|
<AdminActions
|
||||||
|
admin={admin}
|
||||||
|
onView={openDetails}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onResend={onResend}
|
||||||
|
isResending={isResending}
|
||||||
|
/>
|
||||||
|
</DataTable.Cell>
|
||||||
|
</DataTable.Row>
|
||||||
|
))}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { View, ScrollView, Text } from "react-native";
|
||||||
|
import { Avatar, IconButton, Chip } from "react-native-paper";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
|
||||||
|
import { User } from "@/store/models/User.model";
|
||||||
|
import { RoleEnum } from "@/constants/roleEnum";
|
||||||
|
|
||||||
|
interface MobileAdminAccountsTableProps {
|
||||||
|
admins: User[];
|
||||||
|
onEdit: (admin: User) => void;
|
||||||
|
onDelete: (admin: User) => void;
|
||||||
|
onResend?: (admin: User) => void;
|
||||||
|
isResending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileAdminAccountsTable = ({
|
||||||
|
admins,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onResend,
|
||||||
|
isResending,
|
||||||
|
}: MobileAdminAccountsTableProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const openDetails = (admin: User) => {
|
||||||
|
router.push({
|
||||||
|
pathname: "/accounts/admins/[id]",
|
||||||
|
params: { id: admin.id },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="flex-1">
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1"
|
||||||
|
contentContainerStyle={{ paddingBottom: 16 }}
|
||||||
|
>
|
||||||
|
{admins.map((admin) => (
|
||||||
|
<View
|
||||||
|
key={admin.id}
|
||||||
|
className="mb-3 p-4 bg-white rounded-md shadow-sm"
|
||||||
|
>
|
||||||
|
<View className="flex-row justify-between items-center mb-3">
|
||||||
|
<View className="flex-row items-center flex-1">
|
||||||
|
<Avatar.Image
|
||||||
|
source={{
|
||||||
|
uri: admin.imageUrl,
|
||||||
|
}}
|
||||||
|
size={28}
|
||||||
|
style={{ marginRight: 10, marginLeft: 10 }}
|
||||||
|
/>
|
||||||
|
<View className="flex-row items-center flex-1">
|
||||||
|
<Text
|
||||||
|
className="font-bold ml-2"
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
onPress={() => openDetails(admin)}
|
||||||
|
>
|
||||||
|
{admin.firstName} {admin.lastName}
|
||||||
|
</Text>
|
||||||
|
{admin.managerId ? (
|
||||||
|
<View className="w-1.5 h-1.5 rounded-full bg-blue-500 ml-2" />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-sm mb-1"
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="tail"
|
||||||
|
>
|
||||||
|
{admin.email}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm mb-1 text-gray-600">
|
||||||
|
{admin.role === RoleEnum.ADMIN ? "Admin" : "Manager"}
|
||||||
|
</Text>
|
||||||
|
{!admin.isActivated && (
|
||||||
|
<Chip compact mode="flat" textStyle={{ fontSize: 12 }}>
|
||||||
|
Invited
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
<View className="flex-row justify-end border-t border-gray-200 pt-3">
|
||||||
|
<IconButton
|
||||||
|
icon="eye-outline"
|
||||||
|
size={20}
|
||||||
|
onPress={() => openDetails(admin)}
|
||||||
|
/>
|
||||||
|
{!admin.isActivated && (
|
||||||
|
<IconButton
|
||||||
|
icon="email-send-outline"
|
||||||
|
size={20}
|
||||||
|
disabled={Boolean(isResending)}
|
||||||
|
onPress={() => onResend?.(admin)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
icon="pencil-outline"
|
||||||
|
size={20}
|
||||||
|
onPress={() => {
|
||||||
|
onEdit(admin);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="delete-outline"
|
||||||
|
size={20}
|
||||||
|
onPress={() => {
|
||||||
|
onDelete(admin);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { RoleEnum } from "@/constants/roleEnum";
|
||||||
|
import { RiskEnum } from "@/constants/riskEnum";
|
||||||
|
|
||||||
|
export const adminSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Email is required")
|
||||||
|
.pipe(z.email("Please enter a valid email address")),
|
||||||
|
firstName: z.string().trim().min(1, "First name is required"),
|
||||||
|
lastName: z.string().trim().min(1, "Last name is required"),
|
||||||
|
title: z.string().trim().min(1, "Title is required"),
|
||||||
|
role: z.enum(RoleEnum),
|
||||||
|
riskStatus: z.enum(RiskEnum).default(RiskEnum.LOW_RISK),
|
||||||
|
managerId: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AdminFormValues = z.input<typeof adminSchema>;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 "{searchQuery}"
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
Reference in New Issue
Block a user