project setup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { Dialog, IconButton } from "react-native-paper";
|
||||
|
||||
import { DialogContainer } from "@/components/ui/DialogContainer";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
|
||||
interface LockUserModalProps {
|
||||
visible: boolean;
|
||||
user: User | null;
|
||||
onDismiss: () => void;
|
||||
onLock: (user: User) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const LockUserModal = ({
|
||||
visible,
|
||||
user,
|
||||
onDismiss,
|
||||
onLock,
|
||||
isLoading = false,
|
||||
}: LockUserModalProps) => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const action = user.isLocked ? "unlock" : "lock";
|
||||
const actionTitle = action.charAt(0).toUpperCase() + action.slice(1);
|
||||
|
||||
const handleLock = () => {
|
||||
try {
|
||||
setError(null);
|
||||
onLock(user);
|
||||
} catch (err) {
|
||||
setError(`Failed to ${action} user. Please try again.`);
|
||||
console.error(`Error in UserLockModal (${action}):`, err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContainer visible={visible} onDismiss={onDismiss}>
|
||||
<Dialog.Title>
|
||||
<View className="w-full flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="pr-3">{actionTitle} User</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ alignSelf: "flex-start" }}>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size={20}
|
||||
onPress={onDismiss}
|
||||
disabled={isLoading}
|
||||
accessibilityLabel="Close dialog"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<Text>
|
||||
Are you sure you want to {action} {user.firstName} {user.lastName}?
|
||||
</Text>
|
||||
{error && <Text className="text-highRisk mt-2">{error}</Text>}
|
||||
</Dialog.Content>
|
||||
<Dialog.Actions>
|
||||
<Button className="!ml-2" onPress={handleLock} disabled={isLoading}>
|
||||
{actionTitle}
|
||||
</Button>
|
||||
</Dialog.Actions>
|
||||
{isLoading && (
|
||||
<View
|
||||
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
|
||||
pointerEvents="auto"
|
||||
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<LoadingSpinner size={16} />
|
||||
</View>
|
||||
)}
|
||||
</DialogContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import { View, ScrollView, Text } from "react-native";
|
||||
import { Checkbox, IconButton, Avatar } from "react-native-paper";
|
||||
import { router } from "expo-router";
|
||||
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
import { RiskIndicator } from "@/components/ui/RiskIndicator";
|
||||
|
||||
interface MobileUserAccountsTableProps {
|
||||
users: User[];
|
||||
selectedUsers: string[];
|
||||
onSelectUser: (userId: string) => void;
|
||||
onEdit: (user: User) => void;
|
||||
onLock: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
}
|
||||
|
||||
export const MobileUserAccountsTable = ({
|
||||
users,
|
||||
selectedUsers,
|
||||
onSelectUser,
|
||||
onEdit,
|
||||
onLock,
|
||||
onDelete,
|
||||
}: MobileUserAccountsTableProps) => {
|
||||
const openDetails = (user: User) => {
|
||||
router.push(`/accounts/users/${user.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<View
|
||||
key={user.id}
|
||||
className="mb-3 p-4 bg-white rounded-md shadow-sm"
|
||||
>
|
||||
<View className="flex-row justify-between items-center mb-3">
|
||||
<View className="flex-row items-center flex-1 min-w-0">
|
||||
<View>
|
||||
<Checkbox
|
||||
status={
|
||||
selectedUsers.includes(user.id) ? "checked" : "unchecked"
|
||||
}
|
||||
onPress={() => onSelectUser(user.id)}
|
||||
/>
|
||||
</View>
|
||||
{user.imageUrl ? (
|
||||
<Avatar.Image
|
||||
source={{ uri: user.imageUrl }}
|
||||
size={28}
|
||||
style={{ marginRight: 10, marginLeft: 10 }}
|
||||
/>
|
||||
) : (
|
||||
<Avatar.Text
|
||||
size={28}
|
||||
label={`${user.firstName.charAt(0)}${user.lastName.charAt(
|
||||
0
|
||||
)}`}
|
||||
style={{ marginRight: 10, marginLeft: 10 }}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<View className="flex-row items-center">
|
||||
<Text
|
||||
className="font-medium"
|
||||
numberOfLines={0}
|
||||
style={{ flexShrink: 1, overflow: "visible" }}
|
||||
onPress={() => openDetails(user)}
|
||||
>
|
||||
{user.firstName} {user.lastName}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="ml-2">
|
||||
<RiskIndicator risk={user.riskStatus} size={SizeEnum.Small} />
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
className="text-sm mb-1"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{user.email}
|
||||
</Text>
|
||||
<Text className="text-sm mb-3 text-gray-600">{user.title}</Text>
|
||||
|
||||
<View className="flex-row justify-end border-t border-gray-200 pt-3">
|
||||
<IconButton
|
||||
icon="eye-outline"
|
||||
size={20}
|
||||
onPress={() => openDetails(user)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="pencil-outline"
|
||||
size={20}
|
||||
onPress={() => {
|
||||
onEdit(user);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="lock-outline"
|
||||
size={20}
|
||||
onPress={() => {
|
||||
onLock(user);
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon="delete-outline"
|
||||
size={20}
|
||||
onPress={() => {
|
||||
onDelete(user);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface SelectionControlsProps {
|
||||
selectAll: boolean;
|
||||
selectedUsers: string[];
|
||||
onSelectAll: () => void;
|
||||
onBulkDelete: () => void;
|
||||
}
|
||||
|
||||
export const SelectionControls = ({
|
||||
selectAll,
|
||||
selectedUsers,
|
||||
onSelectAll,
|
||||
onBulkDelete,
|
||||
}: SelectionControlsProps) => {
|
||||
return (
|
||||
<View className="flex-row">
|
||||
{selectedUsers?.length > 0 && (
|
||||
<Button
|
||||
onPress={onBulkDelete}
|
||||
icon="delete-outline"
|
||||
className="mr-1"
|
||||
buttonTextColor="white"
|
||||
disabled={false}
|
||||
>
|
||||
Delete ({selectedUsers?.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onPress={onSelectAll}
|
||||
buttonColor="#6750a4"
|
||||
buttonTextColor="white"
|
||||
disabled={false}
|
||||
icon="account-multiple-outline"
|
||||
>
|
||||
{selectAll ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, GestureResponderEvent, ScrollView, Text } from "react-native";
|
||||
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
import { UserTableControls } from "@/components/features/users/UserTableControls";
|
||||
import { DesktopUserAccountsTable } from "@/components/features/users/DesktopUserAccountsTable";
|
||||
import { MobileUserAccountsTable } from "@/components/features/users/MobileUserAccountsTable";
|
||||
import { TablePagination } from "@/components/shared/TablePagination";
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
|
||||
interface UserAccountsTableProps {
|
||||
users: User[];
|
||||
onEdit: (user: User) => void;
|
||||
onAddUser: () => void;
|
||||
onDelete: (user: User) => void;
|
||||
onLock: (user: User) => void;
|
||||
onSelect: (userIds: string[]) => void;
|
||||
onBulkDelete: (users: User[]) => void;
|
||||
selectedUserIds?: string[];
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
totalItems: number;
|
||||
from: number;
|
||||
to: number;
|
||||
searchQuery: string;
|
||||
selectedRisks: RiskEnum[];
|
||||
sortColumn: string | null;
|
||||
sortDirection: TableSortDirection;
|
||||
onSearch: (q: string) => void;
|
||||
onSelectRisks: (risks: RiskEnum[]) => void;
|
||||
onSort: (column: string) => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onAddingUser: boolean;
|
||||
}
|
||||
|
||||
export const UserAccountsTable = ({
|
||||
users,
|
||||
onEdit,
|
||||
onAddUser,
|
||||
onDelete,
|
||||
onLock,
|
||||
onSelect,
|
||||
onBulkDelete,
|
||||
selectedUserIds,
|
||||
page,
|
||||
itemsPerPage,
|
||||
totalItems,
|
||||
from,
|
||||
to,
|
||||
searchQuery,
|
||||
selectedRisks,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSearch,
|
||||
onSelectRisks,
|
||||
onSort,
|
||||
onItemsPerPageChange,
|
||||
onPageChange,
|
||||
onAddingUser,
|
||||
}: UserAccountsTableProps) => {
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number }>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const [prevSelectedUserIds, setPrevSelectedUserIds] = useState<
|
||||
string[] | undefined
|
||||
>(selectedUserIds);
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
const handleSort = (column: string) => onSort(column);
|
||||
const handleSearch = (query: string) => onSearch(query);
|
||||
const handleSelectRisks = (risks: RiskEnum[]) => onSelectRisks(risks);
|
||||
const handleItemsPerPageChange = (value: number) =>
|
||||
onItemsPerPageChange(value);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectAll) {
|
||||
setSelectedUsers([]);
|
||||
} else {
|
||||
setSelectedUsers(users.map((user) => user.id) || []);
|
||||
}
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
const handleSelectUser = (userId: string) => {
|
||||
if (selectedUsers.includes(userId)) {
|
||||
setSelectedUsers(selectedUsers.filter((id) => id !== userId));
|
||||
} else {
|
||||
setSelectedUsers([...selectedUsers, userId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedUsers.length === 0) return;
|
||||
|
||||
const selectedUserObjects = users.filter((user) =>
|
||||
selectedUsers.includes(user.id)
|
||||
);
|
||||
|
||||
if (onBulkDelete) {
|
||||
onBulkDelete(selectedUserObjects || []);
|
||||
} else {
|
||||
alert(`Would delete ${selectedUsers.length} users`);
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(selectedUsers);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
if (onEdit) {
|
||||
onEdit(user);
|
||||
} else {
|
||||
alert(`Edit user: ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (user: User) => {
|
||||
if (onDelete) {
|
||||
onDelete(user);
|
||||
} else {
|
||||
alert(`Delete user: ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = (user: User) => {
|
||||
if (onLock) {
|
||||
onLock(user);
|
||||
} else {
|
||||
alert(`Lock user: ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelect) {
|
||||
onSelect(selectedUsers);
|
||||
}
|
||||
}, [selectedUsers, onSelect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
prevSelectedUserIds &&
|
||||
prevSelectedUserIds.length > 0 &&
|
||||
selectedUserIds &&
|
||||
selectedUserIds.length === 0
|
||||
) {
|
||||
setSelectedUsers([]);
|
||||
setSelectAll(false);
|
||||
}
|
||||
|
||||
setPrevSelectedUserIds(selectedUserIds);
|
||||
}, [selectedUserIds, prevSelectedUserIds]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSelectedUsers = selectedUsers.filter((userId) =>
|
||||
users.some((user) => user.id === userId)
|
||||
);
|
||||
|
||||
if (validSelectedUsers.length !== selectedUsers.length) {
|
||||
setSelectedUsers(validSelectedUsers);
|
||||
}
|
||||
|
||||
const allDisplayedSelected =
|
||||
users.every((user) => validSelectedUsers.includes(user.id)) ?? false;
|
||||
setSelectAll(allDisplayedSelected && users.length > 0);
|
||||
}, [users, selectedUsers]);
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<UserTableControls
|
||||
searchQuery={searchQuery}
|
||||
selectAll={selectAll}
|
||||
selectedItems={selectedUsers}
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
selectedRisks={selectedRisks}
|
||||
onSearch={handleSearch}
|
||||
onSelectAll={handleSelectAll}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onMenuDismiss={() => setMenuVisible(false)}
|
||||
onItemsPerPageChange={handleItemsPerPageChange}
|
||||
onSelectRisks={handleSelectRisks}
|
||||
onAddUser={onAddUser}
|
||||
onAddingUser={onAddingUser}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<View
|
||||
className={`flex-1 items-center justify-center ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-lg text-gray-500">
|
||||
No users match your search{searchQuery && ` for "${searchQuery}"`}
|
||||
</Text>
|
||||
</View>
|
||||
) : isMobile ? (
|
||||
<MobileUserAccountsTable
|
||||
users={users}
|
||||
selectedUsers={selectedUsers}
|
||||
onSelectUser={handleSelectUser}
|
||||
onEdit={handleEdit}
|
||||
onLock={handleLock}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<ScrollView className="flex-1">
|
||||
<DesktopUserAccountsTable
|
||||
users={users}
|
||||
selectedUsers={selectedUsers}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onSelectUser={handleSelectUser}
|
||||
onEdit={handleEdit}
|
||||
onLock={handleLock}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<TablePagination
|
||||
page={page}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={totalItems}
|
||||
from={from}
|
||||
to={to}
|
||||
onPageChange={onPageChange}
|
||||
onItemsPerPageButtonPress={(event: GestureResponderEvent) => {
|
||||
const { pageX, pageY } = event.nativeEvent;
|
||||
setMenuAnchor({ x: pageX, y: pageY });
|
||||
setMenuVisible(true);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { View } from "react-native";
|
||||
import { IconButton } from "react-native-paper";
|
||||
|
||||
import { User } from "@/store/models/User.model";
|
||||
|
||||
interface UserActionsProps {
|
||||
user: User;
|
||||
onView: (user: User) => void;
|
||||
onEdit: (user: User) => void;
|
||||
onLock: (user: User) => void;
|
||||
onDelete: (user: User) => void;
|
||||
}
|
||||
|
||||
export const UserActions = ({
|
||||
user,
|
||||
onView,
|
||||
onEdit,
|
||||
onLock,
|
||||
onDelete,
|
||||
}: UserActionsProps) => {
|
||||
return (
|
||||
<View className="flex-row justify-center">
|
||||
<IconButton icon="eye-outline" onPress={() => onView(user)} size={20} />
|
||||
<IconButton
|
||||
icon="pencil-outline"
|
||||
onPress={() => onEdit(user)}
|
||||
size={20}
|
||||
/>
|
||||
<IconButton
|
||||
icon="lock-outline"
|
||||
onPress={() => onLock(user)}
|
||||
size={20}
|
||||
iconColor={user.isLocked ? "#F83434" : undefined}
|
||||
/>
|
||||
<IconButton
|
||||
icon="delete-outline"
|
||||
onPress={() => onDelete(user)}
|
||||
size={20}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { View, ScrollView, Text } from "react-native";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
|
||||
import { useGetUserByIdQuery } from "@/store/api/usersApi";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { NoData } from "@/components/ui/NoData";
|
||||
import { AccountDetails } from "@/components/features/accounts/AccountDetails";
|
||||
|
||||
export const UserDetailsPage = () => {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
const {
|
||||
data: user,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetUserByIdQuery(id, { refetchOnMountOrArgChange: true });
|
||||
|
||||
return (
|
||||
<View className={`flex-1 bg-primaryPurpleLight`}>
|
||||
<View className={`${isMobile ? "p-1" : "p-5"} flex-1`}>
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
isMobile ? "mb-1" : "mb-4"
|
||||
} text-white`}
|
||||
>
|
||||
{user?.firstName} {user?.lastName} details
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
>
|
||||
<View
|
||||
className={`bg-lightGray rounded-lg ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
} flex-1`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 justify-center items-center py-10">
|
||||
<LoadingSpinner />
|
||||
</View>
|
||||
) : error || !user ? (
|
||||
<View className="flex-1 justify-center items-center py-10">
|
||||
<NoData
|
||||
message="User not found"
|
||||
onRetry={refetch}
|
||||
isRetrying={isFetching}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<AccountDetails user={user} isLoading={isLoading || isFetching} />
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { View, Text, ScrollView, Dimensions } from "react-native";
|
||||
import { Dialog, TextInput, IconButton } from "react-native-paper";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DialogContainer } from "@/components/ui/DialogContainer";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { RiskEnum, RiskLabels } from "@/constants/riskEnum";
|
||||
import { RoleEnum } from "@/constants/roleEnum";
|
||||
import { OperationMode } from "@/constants/operationModeEnum";
|
||||
import { CustomPicker, PickerItem } from "@/components/ui/CustomPicker";
|
||||
import {
|
||||
userSchemaWithId,
|
||||
UserFormValues,
|
||||
} from "@/components/features/users/schemas/user.schema";
|
||||
|
||||
interface UserModalProps {
|
||||
visible: boolean;
|
||||
mode?: OperationMode;
|
||||
user?: User | null;
|
||||
isLoading?: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: Partial<User>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const UserModal = ({
|
||||
visible,
|
||||
mode = OperationMode.ADD,
|
||||
user = null,
|
||||
isLoading = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: UserModalProps) => {
|
||||
const isEdit = mode === OperationMode.EDIT;
|
||||
|
||||
const defaultValues: UserFormValues = {
|
||||
id: user?.id,
|
||||
firstName: user?.firstName ?? "",
|
||||
lastName: user?.lastName ?? "",
|
||||
email: user?.email ?? "",
|
||||
title: user?.title ?? "",
|
||||
riskStatus: user?.riskStatus ?? RiskEnum.LOW_RISK,
|
||||
};
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userSchemaWithId),
|
||||
defaultValues,
|
||||
mode: "onChange",
|
||||
shouldUnregister: false,
|
||||
});
|
||||
|
||||
const userRef = useRef<User | null>(user);
|
||||
userRef.current = user;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const nextDefaults: UserFormValues = {
|
||||
id: userRef.current?.id,
|
||||
firstName: userRef.current?.firstName ?? "",
|
||||
lastName: userRef.current?.lastName ?? "",
|
||||
email: userRef.current?.email ?? "",
|
||||
title: userRef.current?.title ?? "",
|
||||
riskStatus: userRef.current?.riskStatus ?? RiskEnum.LOW_RISK,
|
||||
};
|
||||
reset(nextDefaults, { keepDefaultValues: false });
|
||||
}, [visible, reset, user?.id]);
|
||||
|
||||
const windowHeight = Dimensions.get("window").height;
|
||||
const dialogVerticalMargin = 24;
|
||||
const dialogMaxHeight = windowHeight - dialogVerticalMargin - 60;
|
||||
const approxChrome = 140;
|
||||
const maxDialogContentHeight = Math.max(200, dialogMaxHeight - approxChrome);
|
||||
|
||||
const submit = (vals: UserFormValues) => {
|
||||
const payload: Partial<User> = {
|
||||
id: vals.id,
|
||||
firstName: vals.firstName.trim(),
|
||||
lastName: vals.lastName.trim(),
|
||||
email: vals.email.trim().toLowerCase(),
|
||||
title: vals.title.trim(),
|
||||
riskStatus: vals.riskStatus,
|
||||
role: isEdit ? user?.role ?? RoleEnum.USER : RoleEnum.USER,
|
||||
};
|
||||
return onSubmit(payload);
|
||||
};
|
||||
|
||||
const titleText = isEdit ? "Edit User" : "Add User";
|
||||
const submitText = isEdit ? "Edit" : "Add";
|
||||
|
||||
return (
|
||||
<DialogContainer
|
||||
visible={visible}
|
||||
onDismiss={onClose}
|
||||
dialogStyle={{
|
||||
alignSelf: "center",
|
||||
marginVertical: dialogVerticalMargin,
|
||||
maxHeight: dialogMaxHeight,
|
||||
}}
|
||||
>
|
||||
<Dialog.Title>
|
||||
<View className="w-full flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="pr-3">{titleText}</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ alignSelf: "flex-start" }}>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size={20}
|
||||
onPress={onClose}
|
||||
disabled={isLoading}
|
||||
accessibilityLabel="Close dialog"
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Dialog.Title>
|
||||
<Dialog.Content>
|
||||
<ScrollView
|
||||
style={{ maxHeight: maxDialogContentHeight }}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">First Name</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="firstName"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
className={`border ${
|
||||
errors.firstName ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="Enter first name"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.firstName ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.firstName.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Last Name</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lastName"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
className={`border ${
|
||||
errors.lastName ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="Enter last name"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.lastName ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.lastName.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Email</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
className={`border ${
|
||||
errors.email ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="Enter email"
|
||||
keyboardType="email-address"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
autoCapitalize="none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.email.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Title</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<TextInput
|
||||
className={`border ${
|
||||
errors.title ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
placeholder="Enter title"
|
||||
placeholderTextColor="#d9d9d9"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.title ? (
|
||||
<Text className="text-highRisk text-xs mt-1">
|
||||
{errors.title.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm mb-1 font-medium">Risk Status</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name="riskStatus"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View className="border border-gray-300 rounded overflow-hidden">
|
||||
<CustomPicker
|
||||
selectedValue={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="h-10"
|
||||
enabled={!isLoading}
|
||||
>
|
||||
{Object.values(RiskEnum).map((v) => (
|
||||
<PickerItem key={v} label={RiskLabels[v]} value={v} />
|
||||
))}
|
||||
</CustomPicker>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Dialog.Content>
|
||||
|
||||
<Dialog.Actions>
|
||||
<Button
|
||||
onPress={handleSubmit(submit)}
|
||||
disabled={isLoading || (isEdit && !isDirty)}
|
||||
className="!ml-2"
|
||||
>
|
||||
{submitText}
|
||||
</Button>
|
||||
</Dialog.Actions>
|
||||
|
||||
{isLoading && (
|
||||
<View
|
||||
className="absolute inset-0 bg-gray-500/30 flex items-center justify-center"
|
||||
pointerEvents="auto"
|
||||
style={{ zIndex: 1000, top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
>
|
||||
<LoadingSpinner size={16} />
|
||||
</View>
|
||||
)}
|
||||
</DialogContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { TableControls } from "@/components/shared/TableControls";
|
||||
import { SelectionControls } from "@/components/features/users/SelectionControls";
|
||||
import { RiskEnum, RiskLabels } from "@/constants/riskEnum";
|
||||
import {
|
||||
CombinedFilterMenu,
|
||||
CombinedFilterSection,
|
||||
} from "@/components/shared/filters/CombinedFilterMenu";
|
||||
import {
|
||||
SelectedFilterChips,
|
||||
SelectedChip,
|
||||
} from "@/components/shared/filters/SelectedFilterChips";
|
||||
import { RiskIndicator } from "@/components/ui/RiskIndicator";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface UserTableControlsProps {
|
||||
searchQuery: string;
|
||||
selectAll?: boolean;
|
||||
selectedItems?: string[];
|
||||
menuVisible: boolean;
|
||||
menuAnchor: { x: number; y: number };
|
||||
selectedRisks?: RiskEnum[];
|
||||
onSearch: (query: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onBulkDelete: () => void;
|
||||
onMenuDismiss: () => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
onSelectRisks: (risks: RiskEnum[]) => void;
|
||||
itemsPerPage?: number;
|
||||
onAddUser: () => void;
|
||||
onAddingUser: boolean;
|
||||
}
|
||||
|
||||
export const UserTableControls = ({
|
||||
searchQuery,
|
||||
selectAll = false,
|
||||
selectedItems = [],
|
||||
menuVisible,
|
||||
menuAnchor,
|
||||
selectedRisks = [],
|
||||
onSearch,
|
||||
onSelectAll,
|
||||
onBulkDelete,
|
||||
onMenuDismiss,
|
||||
onItemsPerPageChange,
|
||||
onSelectRisks,
|
||||
itemsPerPage = 25,
|
||||
onAddUser,
|
||||
onAddingUser = false,
|
||||
}: UserTableControlsProps) => {
|
||||
const renderFilterComponent = () => {
|
||||
const riskOptions = Object.values(RiskEnum).map((risk) => ({
|
||||
value: risk,
|
||||
label: RiskLabels[risk],
|
||||
icon: <RiskIndicator risk={risk} />,
|
||||
}));
|
||||
|
||||
const sections: CombinedFilterSection[] = [];
|
||||
|
||||
sections.push({
|
||||
key: "risk",
|
||||
title: "Risk",
|
||||
options: riskOptions,
|
||||
selected: selectedRisks,
|
||||
onChange: (values) => onSelectRisks(values as RiskEnum[]),
|
||||
});
|
||||
|
||||
return (
|
||||
<CombinedFilterMenu
|
||||
sections={sections}
|
||||
showResetButton={true}
|
||||
onResetAll={() => onSelectRisks([])}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFilterChips = () => {
|
||||
const chips: SelectedChip[] = selectedRisks.map((risk) => ({
|
||||
key: `risk-${risk}`,
|
||||
label: RiskLabels[risk],
|
||||
onRemove: () =>
|
||||
onSelectRisks(
|
||||
selectedRisks.filter((selectedFilter) => selectedFilter !== risk)
|
||||
),
|
||||
}));
|
||||
|
||||
return chips.length > 0 ? <SelectedFilterChips chips={chips} /> : null;
|
||||
};
|
||||
|
||||
const renderActionButtons = () => {
|
||||
return (
|
||||
<View className="flex-row items-center">
|
||||
{onAddUser ? (
|
||||
<View className="mr-2">
|
||||
<Button
|
||||
onPress={onAddUser}
|
||||
disabled={onAddingUser}
|
||||
icon="account-plus-outline"
|
||||
buttonColor="#6750a4"
|
||||
buttonTextColor="white"
|
||||
>
|
||||
Add User
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
<SelectionControls
|
||||
selectAll={selectAll}
|
||||
selectedUsers={selectedItems}
|
||||
onSelectAll={onSelectAll}
|
||||
onBulkDelete={onBulkDelete}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableControls
|
||||
searchQuery={searchQuery}
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
searchPlaceholder="Search users"
|
||||
onSearch={onSearch}
|
||||
onMenuDismiss={onMenuDismiss}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
itemsPerPage={itemsPerPage}
|
||||
renderFilterComponent={renderFilterComponent}
|
||||
renderFilterChips={renderFilterChips}
|
||||
renderActionButtons={renderActionButtons}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { DataTable, Text } from "react-native-paper";
|
||||
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { SortableHeader } from "@/components/shared/SortableHeader";
|
||||
|
||||
interface UserTableHeaderProps {
|
||||
sortColumn: string | null;
|
||||
sortDirection: TableSortDirection;
|
||||
onSort: (column: string) => void;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export const UserTableHeader = ({
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
}: UserTableHeaderProps) => {
|
||||
return (
|
||||
<DataTable.Header>
|
||||
<SortableHeader
|
||||
title="Name"
|
||||
column="name"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
title="Risk Status"
|
||||
column="risk"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
centerAlign
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<SortableHeader
|
||||
title="Email"
|
||||
column="email"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
title="Title"
|
||||
column="title"
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onSort={onSort}
|
||||
/>
|
||||
<DataTable.Title style={{ justifyContent: "center" }}>
|
||||
<Text style={{ fontSize: 12 }}>Actions</Text>
|
||||
</DataTable.Title>
|
||||
</DataTable.Header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useMemo, useState, useDeferredValue } from "react";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { View, Text } from "react-native";
|
||||
|
||||
import { UserAccountsTable } from "@/components/features/users/UserAccountsTable";
|
||||
import { UserModal } from "@/components/features/users/UserModal";
|
||||
import { User } from "@/store/models/User.model";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import {
|
||||
useDeleteUserMutation,
|
||||
useGetUsersQuery,
|
||||
useLockUserMutation,
|
||||
useUpdateUserMutation,
|
||||
useDeleteUsersMutation,
|
||||
useCreateUserMutation,
|
||||
} from "@/store/api/usersApi";
|
||||
import { BulkDeleteUserModal } from "@/components/features/users/BulkDeleteUserModal";
|
||||
import { LockUserModal } from "@/components/features/users/LockUserModal";
|
||||
import { DeleteUserModal } from "@/components/features/users/DeleteUserModal";
|
||||
import { NoData } from "@/components/ui/NoData";
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
import { useAuthContext } from "@/auth/AuthContext";
|
||||
import { OperationMode } from "@/constants/operationModeEnum";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
import { AppSnackbar } from "@/components/ui/AppSnackbar";
|
||||
|
||||
export const UsersPage = () => {
|
||||
const params = useLocalSearchParams<{ q?: string }>();
|
||||
const { user } = useAuthContext();
|
||||
const [page, setPage] = useState(0);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRisks, setSelectedRisks] = useState<RiskEnum[]>([]);
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<TableSortDirection>(
|
||||
TableSortDirection.ASCENDING
|
||||
);
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
const deferredSearch = useDeferredValue(searchQuery);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(deferredSearch);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(
|
||||
() => setDebouncedSearch(deferredSearch.trim()),
|
||||
450
|
||||
);
|
||||
return () => clearTimeout(handler);
|
||||
}, [deferredSearch]);
|
||||
|
||||
// Initialize search query from route param
|
||||
useEffect(() => {
|
||||
const qParam = (params.q ?? "").toString().trim();
|
||||
if (qParam && qParam !== searchQuery) {
|
||||
setSearchQuery(qParam);
|
||||
setPage(0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.q]);
|
||||
|
||||
const queryArgs = useMemo(() => {
|
||||
const allowedSorts = ["name", "email", "title", "risk"] as const;
|
||||
const sortBy = (allowedSorts as readonly string[]).includes(
|
||||
sortColumn || ""
|
||||
)
|
||||
? (sortColumn as (typeof allowedSorts)[number])
|
||||
: undefined;
|
||||
const sortDir = sortDirection;
|
||||
const risks = selectedRisks.length ? selectedRisks : undefined;
|
||||
const searchParam =
|
||||
debouncedSearch && debouncedSearch.length >= 2
|
||||
? debouncedSearch
|
||||
: undefined;
|
||||
return {
|
||||
roleType: "user" as const,
|
||||
page: page + 1,
|
||||
limit: itemsPerPage,
|
||||
search: searchParam,
|
||||
risks,
|
||||
sortBy,
|
||||
sortDir,
|
||||
};
|
||||
}, [
|
||||
page,
|
||||
itemsPerPage,
|
||||
debouncedSearch,
|
||||
selectedRisks,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
]);
|
||||
|
||||
const {
|
||||
data: usersPage,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
isFetching,
|
||||
} = useGetUsersQuery(queryArgs, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
refetchOnFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const users = usersPage?.items ?? [];
|
||||
const totalItems = usersPage?.total ?? 0;
|
||||
const from = users.length ? page * itemsPerPage + 1 : 0;
|
||||
const to = users.length ? Math.min(from + users.length - 1, totalItems) : 0;
|
||||
|
||||
const [deleteUser, { isLoading: isDeleting }] = useDeleteUserMutation();
|
||||
const [lockUser, { isLoading: isLocking }] = useLockUserMutation();
|
||||
const [updateUser, { isLoading: isUpdating }] = useUpdateUserMutation();
|
||||
const [createUser, { isLoading: isCreating }] = useCreateUserMutation();
|
||||
const [bulkDeleteUsers, { isLoading: isBulkDeleting }] =
|
||||
useDeleteUsersMutation();
|
||||
const [userModalVisible, setUserModalVisible] = useState(false);
|
||||
const [userModalMode, setUserModalMode] = useState<OperationMode>(
|
||||
OperationMode.ADD
|
||||
);
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [bulkDeleteModalVisible, setBulkDeleteModalVisible] = useState(false);
|
||||
const [lockModalVisible, setLockModalVisible] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [selectedUsersIds, setSelectedUsersIds] = useState<string[]>([]);
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("");
|
||||
|
||||
const showSnackbar = (message: string) => {
|
||||
setSnackbarMessage(message);
|
||||
setSnackbarVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
setUserModalMode(OperationMode.EDIT);
|
||||
setUserModalVisible(true);
|
||||
};
|
||||
|
||||
const handleAddUser = () => {
|
||||
setCurrentUser(null);
|
||||
setUserModalMode(OperationMode.ADD);
|
||||
setUserModalVisible(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const openLockModal = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
setLockModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: User) => {
|
||||
try {
|
||||
await deleteUser(user.id).unwrap();
|
||||
if (selectedUsersIds.includes(user.id)) {
|
||||
setSelectedUsersIds(selectedUsersIds.filter((id) => id !== user.id));
|
||||
}
|
||||
refetch();
|
||||
showSnackbar(
|
||||
`${user.firstName} ${user.lastName} has been deleted successfully`
|
||||
);
|
||||
setDeleteModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error deleting user ${user.firstName} ${user.lastName}:`,
|
||||
error
|
||||
);
|
||||
showSnackbar(`Error deleting ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLockUser = async (user: User) => {
|
||||
const action = user.isLocked ? "unlock" : "lock";
|
||||
try {
|
||||
await lockUser(user.id).unwrap();
|
||||
refetch();
|
||||
showSnackbar(
|
||||
`${user.firstName} ${user.lastName} has been ${action}ed successfully`
|
||||
);
|
||||
setLockModalVisible(false);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error ${action}ing user ${user.firstName} ${user.lastName}:`,
|
||||
error
|
||||
);
|
||||
showSnackbar(`Error ${action}ing ${user.firstName} ${user.lastName}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDeleteUsers = (users: User[]) => {
|
||||
if (users.length === 0) return;
|
||||
|
||||
setSelectedUsersIds(users.map((user) => user.id));
|
||||
setSelectedUsers(users);
|
||||
setBulkDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const confirmBulkDelete = async () => {
|
||||
if (selectedUsersIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const userIds = selectedUsersIds;
|
||||
const count = selectedUsersIds.length;
|
||||
await bulkDeleteUsers(userIds).unwrap();
|
||||
refetch();
|
||||
showSnackbar(`${count} users deleted successfully`);
|
||||
setBulkDeleteModalVisible(false);
|
||||
setSelectedUsersIds([]);
|
||||
} catch (error: any) {
|
||||
console.error(`Error deleting multiple users:`, error);
|
||||
const errorMessage =
|
||||
error?.data?.error ||
|
||||
error?.data?.details ||
|
||||
"Error deleting multiple users";
|
||||
showSnackbar(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitUser = async (payload: Partial<User>) => {
|
||||
try {
|
||||
if (userModalMode === OperationMode.EDIT) {
|
||||
await updateUser(payload as User).unwrap();
|
||||
showSnackbar(
|
||||
`${payload.firstName} ${payload.lastName} updated successfully`
|
||||
);
|
||||
} else {
|
||||
const res = await createUser(payload).unwrap();
|
||||
showSnackbar(
|
||||
`${res.firstName} ${res.lastName} added successfully${
|
||||
!res.isActivated ? " and invitation sent" : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
setUserModalVisible(false);
|
||||
refetch();
|
||||
} catch (error) {
|
||||
console.error(`Error saving user:`, error);
|
||||
showSnackbar(`Error saving user`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Refetch when auth user state changes (e.g., after activation/login)
|
||||
refetch();
|
||||
}, [user?.id, user?.isActivated, refetch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className={`flex-1 bg-primaryPurpleLight ${isMobile ? "p-1" : "p-5"}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
isMobile ? "mb-1" : "mb-4"
|
||||
} text-white`}
|
||||
>
|
||||
Users manager
|
||||
</Text>
|
||||
|
||||
<View
|
||||
className={`bg-lightGray rounded-lg ${
|
||||
isMobile ? "p-1" : "p-5"
|
||||
} flex-1`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<View className="flex-1 justify-center items-center py-10">
|
||||
<LoadingSpinner />
|
||||
<Text className="mt-2">Loading users...</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View className="flex-1 justify-center items-center py-10">
|
||||
<NoData
|
||||
message="Error loading users"
|
||||
onRetry={refetch}
|
||||
isRetrying={isFetching}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<UserAccountsTable
|
||||
users={users}
|
||||
onSelect={setSelectedUsersIds}
|
||||
selectedUserIds={selectedUsersIds}
|
||||
onEdit={handleEdit}
|
||||
onAddUser={handleAddUser}
|
||||
onDelete={openDeleteModal}
|
||||
onLock={openLockModal}
|
||||
onBulkDelete={handleBulkDeleteUsers}
|
||||
page={page}
|
||||
itemsPerPage={itemsPerPage}
|
||||
totalItems={totalItems}
|
||||
from={from}
|
||||
to={to}
|
||||
searchQuery={searchQuery}
|
||||
selectedRisks={selectedRisks}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
onAddingUser={isCreating}
|
||||
onSearch={(q) => {
|
||||
setSearchQuery(q);
|
||||
setPage(0);
|
||||
}}
|
||||
onSelectRisks={(risks) => {
|
||||
setSelectedRisks(risks);
|
||||
setPage(0);
|
||||
}}
|
||||
onSort={(column) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection((d) =>
|
||||
d === TableSortDirection.ASCENDING
|
||||
? TableSortDirection.DESCENDING
|
||||
: TableSortDirection.ASCENDING
|
||||
);
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDirection(TableSortDirection.ASCENDING);
|
||||
}
|
||||
setPage(0);
|
||||
}}
|
||||
onItemsPerPageChange={(value) => {
|
||||
setItemsPerPage(value);
|
||||
setPage(0);
|
||||
}}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<UserModal
|
||||
visible={userModalVisible}
|
||||
mode={userModalMode}
|
||||
user={currentUser}
|
||||
onClose={() => setUserModalVisible(false)}
|
||||
onSubmit={handleSubmitUser}
|
||||
isLoading={isUpdating || isCreating}
|
||||
/>
|
||||
|
||||
<DeleteUserModal
|
||||
visible={deleteModalVisible}
|
||||
user={currentUser}
|
||||
onDismiss={() => setDeleteModalVisible(false)}
|
||||
onDelete={handleDeleteUser}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
|
||||
<BulkDeleteUserModal
|
||||
visible={bulkDeleteModalVisible}
|
||||
users={selectedUsers}
|
||||
onDismiss={() => setBulkDeleteModalVisible(false)}
|
||||
onDelete={confirmBulkDelete}
|
||||
isLoading={isBulkDeleting}
|
||||
/>
|
||||
|
||||
<LockUserModal
|
||||
visible={lockModalVisible}
|
||||
user={currentUser}
|
||||
onDismiss={() => setLockModalVisible(false)}
|
||||
onLock={handleLockUser}
|
||||
isLoading={isLocking}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<AppSnackbar
|
||||
visible={snackbarVisible}
|
||||
onDismiss={() => setSnackbarVisible(false)}
|
||||
action={{ label: "Close", onPress: () => setSnackbarVisible(false) }}
|
||||
>
|
||||
{snackbarMessage}
|
||||
</AppSnackbar>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
|
||||
export const userSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required"),
|
||||
lastName: z.string().trim().min(1, "Last name is required"),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Email is required")
|
||||
.pipe(z.email("Enter a valid email address")),
|
||||
title: z.string().trim().min(1, "Title is required"),
|
||||
riskStatus: z.enum(RiskEnum).default(RiskEnum.LOW_RISK),
|
||||
});
|
||||
|
||||
export const userSchemaWithId = userSchema.extend({
|
||||
id: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UserFormValues = z.input<typeof userSchemaWithId>;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { SearchBar } from "@/components/ui/SearchBar";
|
||||
import { ItemsPerPageMenu } from "@/components/shared/ItemsPerPageMenu";
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
|
||||
interface DesktopTableControlsProps {
|
||||
searchQuery: string;
|
||||
menuVisible: boolean;
|
||||
menuAnchor: { x: number; y: number };
|
||||
searchPlaceholder?: string;
|
||||
onSearch: (query: string) => void;
|
||||
onMenuDismiss: () => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
itemsPerPage?: number;
|
||||
renderFilterComponent?: () => ReactNode;
|
||||
renderFilterChips?: () => ReactNode;
|
||||
renderActionButtons?: () => ReactNode;
|
||||
itemsPerPageMenuOptions?: number[];
|
||||
}
|
||||
|
||||
export const DesktopTableControls = ({
|
||||
searchQuery,
|
||||
menuVisible,
|
||||
menuAnchor,
|
||||
searchPlaceholder = "Search",
|
||||
onSearch,
|
||||
onMenuDismiss,
|
||||
onItemsPerPageChange,
|
||||
itemsPerPage = 25,
|
||||
renderFilterComponent,
|
||||
renderFilterChips,
|
||||
renderActionButtons,
|
||||
itemsPerPageMenuOptions,
|
||||
}: DesktopTableControlsProps) => {
|
||||
return (
|
||||
<>
|
||||
<View className="flex-row items-center justify-between mb-2" style={{ height: 40 }}>
|
||||
<View className="flex-row items-center flex-1">
|
||||
<View className="flex-1 max-w-[250px]">
|
||||
<SearchBar
|
||||
placeholderText={searchPlaceholder}
|
||||
searchQuery={searchQuery}
|
||||
onChange={onSearch}
|
||||
size={SizeEnum.Small}
|
||||
/>
|
||||
</View>
|
||||
{renderFilterComponent && (
|
||||
<View className="ml-2">{renderFilterComponent()}</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center ml-4">
|
||||
{renderActionButtons && <View>{renderActionButtons()}</View>}
|
||||
<ItemsPerPageMenu
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
onDismiss={onMenuDismiss}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
totalItems={itemsPerPage}
|
||||
itemsPerPageMenuOptions={itemsPerPageMenuOptions || [25, 50, 100]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{renderFilterChips && <View className="">{renderFilterChips()}</View>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { View, TouchableOpacity } from "react-native";
|
||||
import { Menu, Text, IconButton, Divider } from "react-native-paper";
|
||||
|
||||
import { MultiSelectGlyph } from "./filters/MultiSelectGlyph";
|
||||
|
||||
export type FilterOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
interface FilterMultiSelectProps {
|
||||
label: string;
|
||||
options: FilterOption[];
|
||||
selected: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
iconName?: string;
|
||||
className?: string;
|
||||
menuWidth?: number;
|
||||
showClearAll?: boolean;
|
||||
}
|
||||
|
||||
export const FilterMultiSelect = ({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
iconName = undefined,
|
||||
className,
|
||||
menuWidth,
|
||||
showClearAll = true,
|
||||
}: FilterMultiSelectProps) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
const next = new Set(selectedSet);
|
||||
if (next.has(value)) {
|
||||
next.delete(value);
|
||||
} else {
|
||||
next.add(value);
|
||||
}
|
||||
onChange(Array.from(next));
|
||||
};
|
||||
|
||||
const anchor = (
|
||||
<TouchableOpacity
|
||||
onPress={() => setVisible(true)}
|
||||
className={`flex-row items-center px-2 h-8 rounded ${className || ""}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={{ fontSize: 12, color: "#000" }}>{label}</Text>
|
||||
{iconName ? (
|
||||
<IconButton
|
||||
icon={iconName}
|
||||
size={18}
|
||||
onPress={() => setVisible(true)}
|
||||
/>
|
||||
) : (
|
||||
<MultiSelectGlyph />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
visible={visible}
|
||||
onDismiss={() => setVisible(false)}
|
||||
anchor={anchor}
|
||||
contentStyle={menuWidth ? { width: menuWidth } : undefined}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isSelected = selectedSet.has(opt.value);
|
||||
return (
|
||||
<Menu.Item
|
||||
key={opt.value}
|
||||
onPress={() => toggle(opt.value)}
|
||||
leadingIcon={
|
||||
isSelected ? "checkbox-marked" : "checkbox-blank-outline"
|
||||
}
|
||||
title={
|
||||
<View className="flex-row items-center">
|
||||
{opt.icon ? <View className="mr-2">{opt.icon}</View> : null}
|
||||
<Text>{opt.label}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showClearAll && (
|
||||
<>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
onPress={() => onChange([])}
|
||||
leadingIcon="close-circle-outline"
|
||||
disabled={selectedSet.size === 0}
|
||||
title="Clear all"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Menu.Item
|
||||
onPress={() => setVisible(false)}
|
||||
leadingIcon="check"
|
||||
title="Done"
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Menu } from "react-native-paper";
|
||||
|
||||
interface ItemsPerPageMenuProps {
|
||||
menuVisible: boolean;
|
||||
menuAnchor: { x: number; y: number };
|
||||
onDismiss: () => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
totalItems: number;
|
||||
itemsPerPageMenuOptions: number[];
|
||||
}
|
||||
|
||||
export const ItemsPerPageMenu = ({
|
||||
menuVisible,
|
||||
menuAnchor,
|
||||
onDismiss,
|
||||
onItemsPerPageChange,
|
||||
itemsPerPageMenuOptions,
|
||||
}: ItemsPerPageMenuProps) => {
|
||||
return (
|
||||
<Menu visible={menuVisible} onDismiss={onDismiss} anchor={menuAnchor}>
|
||||
{itemsPerPageMenuOptions.map((option) => (
|
||||
<Menu.Item
|
||||
key={option}
|
||||
onPress={() => {
|
||||
onItemsPerPageChange(option);
|
||||
onDismiss();
|
||||
}}
|
||||
title={`${option} per page`}
|
||||
/>
|
||||
))}
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { SearchBar } from "@/components/ui/SearchBar";
|
||||
import { ItemsPerPageMenu } from "@/components/shared/ItemsPerPageMenu";
|
||||
|
||||
interface MobileTableControlsProps {
|
||||
searchQuery: string;
|
||||
menuVisible: boolean;
|
||||
menuAnchor: { x: number; y: number };
|
||||
searchPlaceholder?: string;
|
||||
onSearch: (query: string) => void;
|
||||
onMenuDismiss: () => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
itemsPerPage?: number;
|
||||
renderFilterComponent?: () => React.ReactNode;
|
||||
renderFilterChips?: () => React.ReactNode;
|
||||
renderActionButtons?: () => React.ReactNode;
|
||||
itemsPerPageMenuOptions?: number[];
|
||||
}
|
||||
|
||||
export const MobileTableControls = ({
|
||||
searchQuery,
|
||||
menuVisible,
|
||||
menuAnchor,
|
||||
searchPlaceholder = "Search",
|
||||
onSearch,
|
||||
onMenuDismiss,
|
||||
onItemsPerPageChange,
|
||||
itemsPerPage = 25,
|
||||
renderFilterComponent,
|
||||
renderFilterChips,
|
||||
renderActionButtons,
|
||||
itemsPerPageMenuOptions,
|
||||
}: MobileTableControlsProps) => {
|
||||
return (
|
||||
<>
|
||||
<View className="flex-1">
|
||||
<SearchBar
|
||||
placeholderText={searchPlaceholder}
|
||||
searchQuery={searchQuery}
|
||||
onChange={onSearch}
|
||||
/>
|
||||
</View>
|
||||
{renderFilterComponent && (
|
||||
<View className="flex-row flex-wrap items-center mb-2 mt-2">
|
||||
<View className="flex-row flex-wrap items-center flex-1">
|
||||
{renderFilterComponent()}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Filter Chips Row */}
|
||||
{renderFilterChips && <View className="mb-2">{renderFilterChips()}</View>}
|
||||
|
||||
{/* Action Buttons and Items Per Page Row */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center flex-1">
|
||||
{renderActionButtons && renderActionButtons()}
|
||||
</View>
|
||||
<View className="ml-2">
|
||||
<ItemsPerPageMenu
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
onDismiss={onMenuDismiss}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
totalItems={itemsPerPage}
|
||||
itemsPerPageMenuOptions={itemsPerPageMenuOptions || [25, 50, 100]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { View } from "react-native";
|
||||
import { DataTable, Text } from "react-native-paper";
|
||||
|
||||
import { TableSortDirection } from "@/constants/tableSortDirectionEnum";
|
||||
|
||||
interface SortableHeaderProps {
|
||||
title: string;
|
||||
column: string;
|
||||
sortColumn: string | null;
|
||||
sortDirection: TableSortDirection;
|
||||
onSort: (column: string) => void;
|
||||
centerAlign?: boolean;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export const SortableHeader = ({
|
||||
title,
|
||||
column,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
onSort,
|
||||
centerAlign = false,
|
||||
style,
|
||||
}: SortableHeaderProps) => {
|
||||
return (
|
||||
<DataTable.Title
|
||||
style={{
|
||||
justifyContent: centerAlign ? "center" : "flex-start",
|
||||
...style,
|
||||
}}
|
||||
onPress={() => onSort(column)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: centerAlign ? "center" : "flex-start",
|
||||
}}
|
||||
>
|
||||
<Text style={{ marginRight: 4, fontSize: 14 }}>
|
||||
{sortColumn === column ? (
|
||||
sortDirection === TableSortDirection.ASCENDING ? (
|
||||
<Ionicons name="arrow-up" size={20} color="black" />
|
||||
) : (
|
||||
<Ionicons name="arrow-down" size={20} color="black" />
|
||||
)
|
||||
) : (
|
||||
<Ionicons name="swap-vertical-outline" size={20} color="black" />
|
||||
)}
|
||||
</Text>
|
||||
<Text>{title}</Text>
|
||||
</View>
|
||||
</DataTable.Title>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { MobileTableControls } from "./MobileTableControls";
|
||||
import { DesktopTableControls } from "./DesktopTableControls";
|
||||
|
||||
interface TableControlsProps {
|
||||
searchQuery: string;
|
||||
menuVisible: boolean;
|
||||
menuAnchor: { x: number; y: number };
|
||||
searchPlaceholder?: string;
|
||||
onSearch: (query: string) => void;
|
||||
onMenuDismiss: () => void;
|
||||
onItemsPerPageChange: (value: number) => void;
|
||||
itemsPerPage?: number;
|
||||
renderFilterComponent?: () => React.ReactNode;
|
||||
renderFilterChips?: () => React.ReactNode;
|
||||
renderActionButtons?: () => React.ReactNode;
|
||||
itemsPerPageMenuOptions?: number[];
|
||||
}
|
||||
|
||||
export const TableControls = ({
|
||||
searchQuery,
|
||||
menuVisible,
|
||||
menuAnchor,
|
||||
searchPlaceholder = "Search",
|
||||
onSearch,
|
||||
onMenuDismiss,
|
||||
onItemsPerPageChange,
|
||||
itemsPerPage = 25,
|
||||
renderFilterComponent,
|
||||
renderFilterChips,
|
||||
renderActionButtons,
|
||||
itemsPerPageMenuOptions,
|
||||
}: TableControlsProps) => {
|
||||
return (
|
||||
<>
|
||||
<View className="hidden md:flex flex-col mb-4">
|
||||
<DesktopTableControls
|
||||
searchQuery={searchQuery}
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
onSearch={onSearch}
|
||||
onMenuDismiss={onMenuDismiss}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
itemsPerPage={itemsPerPage}
|
||||
renderFilterComponent={renderFilterComponent}
|
||||
renderFilterChips={renderFilterChips}
|
||||
renderActionButtons={renderActionButtons}
|
||||
itemsPerPageMenuOptions={itemsPerPageMenuOptions}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex md:hidden flex-col mb-4">
|
||||
<MobileTableControls
|
||||
searchQuery={searchQuery}
|
||||
menuVisible={menuVisible}
|
||||
menuAnchor={menuAnchor}
|
||||
onSearch={onSearch}
|
||||
onMenuDismiss={onMenuDismiss}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
itemsPerPage={itemsPerPage}
|
||||
renderFilterComponent={renderFilterComponent}
|
||||
renderFilterChips={renderFilterChips}
|
||||
renderActionButtons={renderActionButtons}
|
||||
itemsPerPageMenuOptions={itemsPerPageMenuOptions}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { View, Text, GestureResponderEvent } from "react-native";
|
||||
import { IconButton } from "react-native-paper";
|
||||
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
|
||||
interface TablePaginationProps {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
totalItems: number;
|
||||
from: number;
|
||||
to: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onItemsPerPageButtonPress: (event: GestureResponderEvent) => void;
|
||||
}
|
||||
|
||||
export const TablePagination = ({
|
||||
page,
|
||||
itemsPerPage,
|
||||
totalItems,
|
||||
from,
|
||||
to,
|
||||
onPageChange,
|
||||
onItemsPerPageButtonPress,
|
||||
}: TablePaginationProps) => {
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1;
|
||||
|
||||
const desktopNavigationButtons = (
|
||||
<>
|
||||
<Text className="text-sm mr-2">{`${from}-${to} of ${totalItems}`}</Text>
|
||||
<IconButton
|
||||
icon="chevron-double-left"
|
||||
size={20}
|
||||
disabled={page === 0}
|
||||
onPress={() => onPageChange(0)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-left"
|
||||
size={20}
|
||||
disabled={page === 0}
|
||||
onPress={() => onPageChange(Math.max(0, page - 1))}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-right"
|
||||
size={20}
|
||||
disabled={page >= maxPage}
|
||||
onPress={() => onPageChange(Math.min(maxPage, page + 1))}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-double-right"
|
||||
size={20}
|
||||
disabled={page >= maxPage}
|
||||
onPress={() => onPageChange(maxPage)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const mobileNavigationButtons = (
|
||||
<>
|
||||
<IconButton
|
||||
icon="chevron-double-left"
|
||||
size={16}
|
||||
style={{ margin: 0 }}
|
||||
disabled={page === 0}
|
||||
onPress={() => onPageChange(0)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-left"
|
||||
size={16}
|
||||
style={{ margin: 0 }}
|
||||
disabled={page === 0}
|
||||
onPress={() => onPageChange(Math.max(0, page - 1))}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-right"
|
||||
size={16}
|
||||
style={{ margin: 0 }}
|
||||
disabled={page >= maxPage}
|
||||
onPress={() => onPageChange(Math.min(maxPage, page + 1))}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-double-right"
|
||||
size={16}
|
||||
style={{ margin: 0 }}
|
||||
disabled={page >= maxPage}
|
||||
onPress={() => onPageChange(maxPage)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const itemsPerPageControls = (
|
||||
<>
|
||||
<IconButton icon="menu" size={20} onPress={onItemsPerPageButtonPress} />
|
||||
<Text className="text-sm">{`${itemsPerPage} per page`}</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
className="mt-4 flex items-center"
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : "auto",
|
||||
width: isMobile ? "100%" : "auto",
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<View className="bg-white py-1 px-2 rounded-md w-full">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center">{mobileNavigationButtons}</View>
|
||||
<Text className="text-xs text-gray-600">{`${from}-${to} / ${totalItems}`}</Text>
|
||||
<View className="flex-row items-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
size={16}
|
||||
style={{ margin: 0 }}
|
||||
onPress={onItemsPerPageButtonPress}
|
||||
/>
|
||||
<Text className="text-xs text-gray-600">{`${itemsPerPage}/page`}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-row items-center bg-white py-0.5 px-3 rounded-md">
|
||||
{desktopNavigationButtons}
|
||||
<View className="h-4 w-px bg-gray-300 mx-2" />
|
||||
{itemsPerPageControls}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { IconButton, Text } from "react-native-paper";
|
||||
|
||||
export const CombinedFilterAnchor = ({
|
||||
setVisible,
|
||||
}: {
|
||||
setVisible: (visible: boolean) => void;
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => setVisible(true)}
|
||||
className="flex-row items-center px-2 h-8 rounded bg-white"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={{ fontSize: 12, color: "black" }}>Filter</Text>
|
||||
<IconButton
|
||||
icon="tune-vertical"
|
||||
size={18}
|
||||
onPress={() => setVisible(true)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useMemo, useState, ReactNode } from "react";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { Divider, Menu, Text } from "react-native-paper";
|
||||
|
||||
import { CombinedFilterAnchor } from "@/components/shared/filters/CombinedFilterAnchor";
|
||||
import { CombinedFilterResetButton } from "@/components/shared/filters/CombinedFilterResetButton";
|
||||
|
||||
type CombinedFilterOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export type CombinedFilterSection = {
|
||||
key: string;
|
||||
title: string;
|
||||
options: CombinedFilterOption[];
|
||||
selected: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
};
|
||||
|
||||
interface CombinedFilterMenuProps {
|
||||
sections: CombinedFilterSection[];
|
||||
onResetAll?: () => void;
|
||||
showResetButton?: boolean;
|
||||
}
|
||||
|
||||
export const CombinedFilterMenu = ({
|
||||
sections,
|
||||
onResetAll,
|
||||
showResetButton = false,
|
||||
}: CombinedFilterMenuProps) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
const totalSelected = useMemo(
|
||||
() => sections.reduce((sum, s) => sum + (s.selected?.length || 0), 0),
|
||||
[sections]
|
||||
);
|
||||
|
||||
// Determine if mobile or desktop
|
||||
const isMobile = width < 768;
|
||||
|
||||
const toggle = (sectionKey: string, value: string) => {
|
||||
const section = sections.find((s) => s.key === sectionKey);
|
||||
if (!section) return;
|
||||
const set = new Set(section.selected);
|
||||
if (set.has(value)) {
|
||||
set.delete(value);
|
||||
} else {
|
||||
set.add(value);
|
||||
}
|
||||
section.onChange(Array.from(set));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
sections.forEach((s) => s.onChange([]));
|
||||
if (onResetAll) onResetAll();
|
||||
};
|
||||
|
||||
const columnSections = useMemo(() => {
|
||||
if (isMobile) {
|
||||
return [sections];
|
||||
}
|
||||
|
||||
return sections.map((section) => [section]);
|
||||
}, [sections, isMobile]);
|
||||
|
||||
const renderSection = (
|
||||
section: CombinedFilterSection
|
||||
) => (
|
||||
<View key={section.key} className="mb-2">
|
||||
<Menu.Item
|
||||
title={<Text style={{ fontWeight: "bold" }}>{section.title}</Text>}
|
||||
disabled
|
||||
/>
|
||||
{section.options.map((opt) => {
|
||||
const isSelected = section.selected.includes(opt.value);
|
||||
return (
|
||||
<Menu.Item
|
||||
key={`${section.key}-${opt.value}`}
|
||||
onPress={() => toggle(section.key, opt.value)}
|
||||
leadingIcon={
|
||||
isSelected ? "checkbox-marked" : "checkbox-blank-outline"
|
||||
}
|
||||
title={
|
||||
<View className="flex-row items-center">
|
||||
{opt.icon ? <View className="mr-2">{opt.icon}</View> : null}
|
||||
<Text>{opt.label}</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center">
|
||||
<Menu
|
||||
visible={visible}
|
||||
onDismiss={() => setVisible(false)}
|
||||
anchor={<CombinedFilterAnchor setVisible={setVisible} />}
|
||||
contentStyle={{
|
||||
minWidth: isMobile
|
||||
? width - 32
|
||||
: Math.min(sections.length * 250, 800),
|
||||
maxWidth: isMobile ? width - 32 : undefined,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className="flex-row"
|
||||
style={{ flexWrap: isMobile ? "nowrap" : "wrap" }}
|
||||
>
|
||||
{columnSections.map((columnSects, colIndex) => (
|
||||
<View
|
||||
key={`column-${colIndex}`}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: isMobile ? undefined : 200,
|
||||
paddingRight: colIndex < columnSections.length - 1 ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{columnSects.map((section) => renderSection(section))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<Divider />
|
||||
<Menu.Item
|
||||
onPress={clearAll}
|
||||
leadingIcon="close-circle-outline"
|
||||
disabled={totalSelected <= 1}
|
||||
title="Clear all"
|
||||
/>
|
||||
<Menu.Item
|
||||
onPress={() => setVisible(false)}
|
||||
leadingIcon="check"
|
||||
title="Done"
|
||||
/>
|
||||
</Menu>
|
||||
<CombinedFilterResetButton
|
||||
showResetButton={showResetButton}
|
||||
totalSelected={totalSelected}
|
||||
clearAll={clearAll}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TouchableOpacity } from "react-native";
|
||||
import { IconButton, Text } from "react-native-paper";
|
||||
|
||||
interface CombinedFilterResetButtonProps {
|
||||
showResetButton: boolean;
|
||||
totalSelected: number;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
export const CombinedFilterResetButton = ({
|
||||
showResetButton,
|
||||
totalSelected,
|
||||
clearAll,
|
||||
}: CombinedFilterResetButtonProps) =>
|
||||
showResetButton &&
|
||||
totalSelected > 1 && (
|
||||
<TouchableOpacity
|
||||
onPress={clearAll}
|
||||
className="flex-row items-center px-2 h-8 rounded bg-white ml-2"
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={{ fontSize: 12, color: "black" }}>Reset all</Text>
|
||||
<IconButton icon="close-circle-outline" size={18} onPress={clearAll} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
export const MultiSelectGlyph = () => (
|
||||
<View className="ml-1 flex-row items-center">
|
||||
<View className="w-1 h-4 bg-gray-500 rounded" />
|
||||
<View className="ml-1 w-2 h-2 bg-gray-500 rounded-full" />
|
||||
<View className="ml-1 w-2 h-2 bg-gray-500 rounded-full" />
|
||||
</View>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { View } from "react-native";
|
||||
import { Chip } from "react-native-paper";
|
||||
|
||||
export type SelectedChip = {
|
||||
key: string;
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
interface SelectedFilterChipsProps {
|
||||
chips: SelectedChip[];
|
||||
}
|
||||
|
||||
export const SelectedFilterChips = ({ chips }: SelectedFilterChipsProps) => {
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row flex-wrap items-center" style={{ width: '100%' }}>
|
||||
{chips.map((chip) => (
|
||||
<View key={chip.key} className="mr-1 mb-1">
|
||||
<Chip onClose={chip.onRemove}>{chip.label}</Chip>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Portal, Snackbar, SnackbarProps } from "react-native-paper";
|
||||
|
||||
interface AppSnackbarProps extends SnackbarProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AppSnackbar = ({
|
||||
visible,
|
||||
onDismiss,
|
||||
action,
|
||||
wrapperStyle,
|
||||
duration,
|
||||
children,
|
||||
}: AppSnackbarProps) => {
|
||||
return (
|
||||
<Portal>
|
||||
<Snackbar
|
||||
visible={visible}
|
||||
onDismiss={onDismiss}
|
||||
action={action}
|
||||
duration={duration}
|
||||
wrapperStyle={[{ zIndex: 1001 }, wrapperStyle]}
|
||||
>
|
||||
{children}
|
||||
</Snackbar>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { ReactNode } from "react";
|
||||
import { View, Text, GestureResponderEvent } from "react-native";
|
||||
import { Button as PaperButton } from "react-native-paper";
|
||||
|
||||
interface ButtonProps {
|
||||
buttonColor?: string;
|
||||
buttonMode?: "outlined" | "contained";
|
||||
buttonTextColor?: string;
|
||||
onPress: (event: GestureResponderEvent) => void;
|
||||
disabled: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const Button = ({
|
||||
buttonColor = "#6750a4",
|
||||
buttonMode = "outlined",
|
||||
buttonTextColor = "white",
|
||||
onPress,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
icon,
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<View className={className}>
|
||||
<PaperButton
|
||||
mode={buttonMode}
|
||||
background={{ color: buttonColor }}
|
||||
buttonColor={buttonColor}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
textColor={buttonTextColor}
|
||||
icon={icon}
|
||||
style={{ borderRadius: 4, borderColor: "transparent" }}
|
||||
>
|
||||
{typeof children === "string" || typeof children === "number" ? (
|
||||
<Text>{children}</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</PaperButton>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { Text } from "react-native-paper";
|
||||
|
||||
interface CustomCheckboxProps {
|
||||
checked: boolean;
|
||||
onPress: () => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const CustomCheckbox = ({
|
||||
checked,
|
||||
onPress,
|
||||
size = 20,
|
||||
}: CustomCheckboxProps) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={1}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingRight: 8,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderWidth: checked ? 0 : 1,
|
||||
borderRadius: 2,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: checked ? "#6750a4" : "transparent",
|
||||
}}
|
||||
>
|
||||
{checked && (
|
||||
<Text
|
||||
style={{ color: "white", fontSize: size * 0.6, lineHeight: size }}
|
||||
>
|
||||
✓
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { Picker, PickerProps } from "@react-native-picker/picker";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export const PickerItem = Picker.Item;
|
||||
|
||||
interface CustomPickerProps extends PickerProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CustomPicker = ({
|
||||
selectedValue,
|
||||
onValueChange,
|
||||
children,
|
||||
}: CustomPickerProps) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Picker
|
||||
selectedValue={selectedValue}
|
||||
onValueChange={(itemValue, itemIndex) => {
|
||||
onValueChange?.(itemValue, itemIndex);
|
||||
}}
|
||||
style={[styles.pickerStyle, { appearance: "none" } as any]}
|
||||
>
|
||||
{children}
|
||||
</Picker>
|
||||
<Ionicons
|
||||
name="chevron-down"
|
||||
size={20}
|
||||
color="#333"
|
||||
style={styles.customIcon}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
pickerStyle: {
|
||||
height: 50,
|
||||
width: "100%",
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
cursor: "pointer",
|
||||
},
|
||||
customIcon: {
|
||||
position: "absolute",
|
||||
right: 12,
|
||||
top: "50%",
|
||||
transform: [{ translateY: -10 }],
|
||||
pointerEvents: "none",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ReactNode } from "react";
|
||||
import { View, StyleProp, ViewStyle } from "react-native";
|
||||
import { Dialog, Portal } from "react-native-paper";
|
||||
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
|
||||
interface DialogContainerProps {
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
dialogStyle?: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const DialogContainer = ({
|
||||
visible,
|
||||
onDismiss,
|
||||
dialogStyle,
|
||||
children,
|
||||
}: DialogContainerProps) => {
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
const dialogWidth = isMobile ? "95%" : "60%";
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View className="flex-1" pointerEvents="box-none">
|
||||
<Dialog
|
||||
visible={visible}
|
||||
onDismiss={onDismiss}
|
||||
style={[{ width: dialogWidth, alignSelf: "center" }, dialogStyle]}
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ActivityIndicator } from "react-native";
|
||||
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: SizeEnum.Large | SizeEnum.Small | number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner = ({ size, color }: LoadingSpinnerProps) => {
|
||||
let resolvedSize: number | "small" | "large" = "large";
|
||||
|
||||
if (typeof size === "number") {
|
||||
resolvedSize = size;
|
||||
} else if (size === SizeEnum.Small) {
|
||||
resolvedSize = "small";
|
||||
} else if (size === SizeEnum.Large) {
|
||||
resolvedSize = "large";
|
||||
}
|
||||
|
||||
return <ActivityIndicator size={resolvedSize} color={color || "#6750a4"} />;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { IconButton } from "react-native-paper";
|
||||
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
interface NoDataProps {
|
||||
onRetry: () => void;
|
||||
message: string;
|
||||
isRetrying: boolean;
|
||||
}
|
||||
|
||||
export const NoData = ({ onRetry, message, isRetrying }: NoDataProps) => {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center py-6">
|
||||
<IconButton iconColor="#F83434" icon="alert-circle-outline" size={48} />
|
||||
<Text className="text-center text-base font-medium mb-2">{message}</Text>
|
||||
<Text className="text-center text-sm mb-4">
|
||||
Please try again or contact support if the problem persists.
|
||||
</Text>
|
||||
{isRetrying ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<Button onPress={onRetry} disabled={isRetrying}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "react-native-paper";
|
||||
|
||||
import { PriorityStatus } from "@/constants/priorityStatusEnum";
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
|
||||
type PriorityData = {
|
||||
bars: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const PRIORITY_CONFIG: Record<string, PriorityData> = {
|
||||
[PriorityStatus.HIGH_PRIORITY]: { bars: 3, color: "bg-highRisk" },
|
||||
[PriorityStatus.MODERATE_PRIORITY]: { bars: 2, color: "bg-mediumRisk" },
|
||||
[PriorityStatus.LOW_PRIORITY]: { bars: 1, color: "bg-lowRisk" },
|
||||
};
|
||||
|
||||
const SIZE_CONFIG = {
|
||||
[SizeEnum.Small]: {
|
||||
width: "w-1",
|
||||
heights: ["h-2", "h-3", "h-4"],
|
||||
},
|
||||
[SizeEnum.Moderate]: {
|
||||
width: "w-1.5",
|
||||
heights: ["h-3", "h-4", "h-5"],
|
||||
},
|
||||
[SizeEnum.Regular]: {
|
||||
width: "w-1.5",
|
||||
heights: ["h-3", "h-4", "h-5"],
|
||||
},
|
||||
[SizeEnum.Large]: {
|
||||
width: "w-2",
|
||||
heights: ["h-4", "h-6", "h-8"],
|
||||
},
|
||||
};
|
||||
|
||||
interface PriorityIndicatorProps {
|
||||
priority: PriorityStatus | string;
|
||||
showLabel?: boolean;
|
||||
size?: SizeEnum;
|
||||
}
|
||||
|
||||
export const PriorityIndicator = ({
|
||||
priority,
|
||||
showLabel = false,
|
||||
size = SizeEnum.Moderate,
|
||||
}: PriorityIndicatorProps) => {
|
||||
const { filledBars, barColor, barSizes } = useMemo(() => {
|
||||
const priorityData = PRIORITY_CONFIG[priority] || {
|
||||
bars: 0,
|
||||
color: "bg-mediumGray",
|
||||
};
|
||||
const sizeData = SIZE_CONFIG[size] || SIZE_CONFIG[SizeEnum.Moderate];
|
||||
|
||||
return {
|
||||
filledBars: priorityData.bars,
|
||||
barColor: priorityData.color,
|
||||
barSizes: {
|
||||
width: sizeData.width,
|
||||
heights: sizeData.heights,
|
||||
},
|
||||
};
|
||||
}, [priority, size]);
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-row items-end gap-0.5 mr-1">
|
||||
<View
|
||||
className={`${barSizes.width} ${barSizes.heights[0]} rounded-sm ${
|
||||
filledBars >= 1 ? barColor : "bg-mediumGray"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<View
|
||||
className={`${barSizes.width} ${barSizes.heights[1]} rounded-sm ${
|
||||
filledBars >= 2 ? barColor : "bg-mediumGray"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<View
|
||||
className={`${barSizes.width} ${barSizes.heights[2]} rounded-sm ${
|
||||
filledBars >= 3 ? barColor : "bg-mediumGray"
|
||||
}`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{showLabel && (
|
||||
<Text className="text-xs ml-1">
|
||||
{typeof priority === "string"
|
||||
? priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase()
|
||||
: priority}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text } from "react-native-paper";
|
||||
|
||||
import { RiskEnum } from "@/constants/riskEnum";
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
|
||||
type RiskData = {
|
||||
bars: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const RISK_CONFIG: Record<string, RiskData> = {
|
||||
[RiskEnum.HIGH_RISK]: { bars: 3, color: "bg-highRisk" },
|
||||
[RiskEnum.MODERATE_RISK]: { bars: 2, color: "bg-mediumRisk" },
|
||||
[RiskEnum.LOW_RISK]: { bars: 1, color: "bg-lowRisk" },
|
||||
};
|
||||
|
||||
const SIZE_CONFIG = {
|
||||
[SizeEnum.Small]: {
|
||||
width: "w-1",
|
||||
heights: ["h-2", "h-3", "h-4"],
|
||||
},
|
||||
[SizeEnum.Moderate]: {
|
||||
width: "w-1.5",
|
||||
heights: ["h-3", "h-4", "h-5"],
|
||||
},
|
||||
[SizeEnum.Regular]: {
|
||||
width: "w-1.5",
|
||||
heights: ["h-3", "h-4", "h-5"],
|
||||
},
|
||||
[SizeEnum.Large]: {
|
||||
width: "w-2",
|
||||
heights: ["h-4", "h-6", "h-8"],
|
||||
},
|
||||
};
|
||||
|
||||
interface RiskIndicatorProps {
|
||||
risk: RiskEnum | string;
|
||||
showLabel?: boolean;
|
||||
size?: SizeEnum;
|
||||
}
|
||||
|
||||
/**
|
||||
* RiskIndicator component displays a visual representation of risk level
|
||||
* using color bars similar to a pie chart
|
||||
*/
|
||||
export const RiskIndicator = ({
|
||||
risk,
|
||||
showLabel = false,
|
||||
size = SizeEnum.Moderate,
|
||||
}: RiskIndicatorProps) => {
|
||||
const { filledBars, barColor, barSizes } = useMemo(() => {
|
||||
const riskData = RISK_CONFIG[risk] || {
|
||||
bars: 0,
|
||||
color: "bg-mediumGray",
|
||||
};
|
||||
const sizeData = SIZE_CONFIG[size] || SIZE_CONFIG[SizeEnum.Moderate];
|
||||
|
||||
return {
|
||||
filledBars: riskData.bars,
|
||||
barColor: riskData.color,
|
||||
barSizes: {
|
||||
width: sizeData.width,
|
||||
heights: sizeData.heights,
|
||||
},
|
||||
};
|
||||
}, [risk, size]);
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-row items-end gap-0.5 mr-1">
|
||||
<View
|
||||
className={`${barSizes.width} ${barSizes.heights[0]} rounded-sm ${
|
||||
filledBars >= 1 ? barColor : "bg-mediumGray"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<View
|
||||
className={`${barSizes.width} ${barSizes.heights[1]} rounded-sm ${
|
||||
filledBars >= 2 ? barColor : "bg-mediumGray"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<View
|
||||
className={`${barSizes.width} ${barSizes.heights[2]} rounded-sm ${
|
||||
filledBars >= 3 ? barColor : "bg-mediumGray"
|
||||
}`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{showLabel && (
|
||||
<Text className="text-xs ml-1">
|
||||
{typeof risk === "string"
|
||||
? risk.charAt(0).toUpperCase() + risk.slice(1).toLowerCase()
|
||||
: risk}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import PieChart from "react-native-pie-chart";
|
||||
|
||||
import { getRiskLevel } from "@/utils/getRiskLevel";
|
||||
|
||||
interface RiskScoreIndicatorProps {
|
||||
value: number;
|
||||
maxValue?: number;
|
||||
size?: number;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RiskScoreIndicator component displays a visual representation of risk level
|
||||
* using PieChart from react-native-pie-chart
|
||||
*/
|
||||
export const RiskScoreIndicator = ({
|
||||
value,
|
||||
maxValue = 100,
|
||||
size = 100,
|
||||
subtitle,
|
||||
}: RiskScoreIndicatorProps) => {
|
||||
const { color } = getRiskLevel(value);
|
||||
|
||||
const [animatedValue, setAnimatedValue] = useState(0.1);
|
||||
const finalValue = value > 0 ? value : 0.1;
|
||||
|
||||
const animationSetup = useCallback(() => {
|
||||
const startTime = Date.now();
|
||||
const duration = 1000;
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
const easeOut = (t: number) => 1 - Math.pow(1 - t, 2);
|
||||
|
||||
const currentValue = 0.1 + easeOut(progress) * (finalValue - 0.1);
|
||||
setAnimatedValue(currentValue);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
const animationFrame = requestAnimationFrame(animate);
|
||||
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [finalValue]);
|
||||
|
||||
useEffect(() => {
|
||||
animationSetup();
|
||||
}, [animationSetup]);
|
||||
|
||||
return (
|
||||
<View className="items-center">
|
||||
<View
|
||||
className="relative justify-center items-center bg-white p-3 rounded-full"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
>
|
||||
<View className="absolute rotate-[-145deg]">
|
||||
<PieChart
|
||||
widthAndHeight={size - 12}
|
||||
series={[
|
||||
{
|
||||
value: animatedValue,
|
||||
color,
|
||||
},
|
||||
{
|
||||
value:
|
||||
maxValue - animatedValue > 0 ? maxValue - animatedValue : 0.1,
|
||||
color: "white",
|
||||
},
|
||||
{ value: maxValue / 4, color: "transparent" },
|
||||
]}
|
||||
cover={{ radius: 0.8, color: "white" }}
|
||||
/>
|
||||
</View>
|
||||
<View className="absolute bottom-10 items-center">
|
||||
<Text className="text-black text-3xl font-normal">
|
||||
{Math.round(value)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{subtitle && <Text className="text-black text-sm mt-2">{subtitle}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { View } from "react-native";
|
||||
import { TextInput } from "react-native-paper";
|
||||
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
|
||||
interface SearchBarProps {
|
||||
searchQuery: string;
|
||||
onChange: (query: string) => void;
|
||||
placeholderText?: string;
|
||||
size?: SizeEnum;
|
||||
}
|
||||
|
||||
export const SearchBar = ({
|
||||
searchQuery,
|
||||
onChange,
|
||||
placeholderText = "Search",
|
||||
size = SizeEnum.Regular,
|
||||
}: SearchBarProps) => {
|
||||
const isSmall = size === SizeEnum.Small;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TextInput
|
||||
placeholder={placeholderText}
|
||||
value={searchQuery}
|
||||
onChangeText={onChange}
|
||||
mode="outlined"
|
||||
right={
|
||||
searchQuery ? (
|
||||
<TextInput.Icon icon="close" onPress={() => onChange("")} />
|
||||
) : (
|
||||
<TextInput.Icon icon="magnify" size={isSmall ? 20 : 24} />
|
||||
)
|
||||
}
|
||||
style={isSmall ? { height: 40, fontSize: 14 } : undefined}
|
||||
contentStyle={isSmall ? { paddingTop: 0, paddingBottom: 0 } : undefined}
|
||||
outlineStyle={[
|
||||
isSmall ? { borderRadius: 24 } : undefined,
|
||||
{ borderColor: "transparent" },
|
||||
]}
|
||||
placeholderTextColor="#d9d9d9"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
} from "react-native";
|
||||
import { Portal } from "react-native-paper";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import { useLazyQuickSearchQuery, SearchResult } from "@/store/api/searchApi";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectSidebarCollapsed } from "@/store/screen/screenSizeSlice";
|
||||
|
||||
interface SearchDropdownProps {
|
||||
searchQuery: string;
|
||||
onResultSelect: (result: SearchResult) => void;
|
||||
onViewAllResults: (query: string) => void;
|
||||
}
|
||||
|
||||
const getIconName = (type: SearchResult["type"]) => {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return "person-outline";
|
||||
case "admin":
|
||||
return "shield-outline";
|
||||
case "todo":
|
||||
return "list-outline";
|
||||
default:
|
||||
return "search-outline";
|
||||
}
|
||||
};
|
||||
|
||||
const getResultTypeLabel = (type: SearchResult["type"]) => {
|
||||
switch (type) {
|
||||
case "user":
|
||||
return "User";
|
||||
case "admin":
|
||||
return "Admin";
|
||||
case "todo":
|
||||
return "Todo";
|
||||
default:
|
||||
return "Result";
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchDropdown = ({
|
||||
searchQuery,
|
||||
onResultSelect,
|
||||
onViewAllResults,
|
||||
}: SearchDropdownProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const containerId = "global-search-dropdown";
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const sidebarCollapsed = useAppSelector(selectSidebarCollapsed);
|
||||
|
||||
const [triggerSearch, { data, isLoading, error }] = useLazyQuickSearchQuery();
|
||||
|
||||
// Fetch results when query changes
|
||||
useEffect(() => {
|
||||
if (searchQuery && searchQuery.trim().length >= 2) {
|
||||
triggerSearch({ q: searchQuery })
|
||||
.unwrap()
|
||||
.then(() => setIsVisible(true))
|
||||
.catch(() => setIsVisible(false));
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [searchQuery, triggerSearch]);
|
||||
|
||||
// Hide if query cleared
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) setIsVisible(false);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Close dropdown on outside click (web only)
|
||||
useEffect(() => {
|
||||
if (!isVisible || Platform.OS !== "web") return;
|
||||
|
||||
const handler = (e: MouseEvent | TouchEvent) => {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
if (e.target instanceof Node && !el.contains(e.target)) {
|
||||
setIsVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handler);
|
||||
document.addEventListener("touchstart", handler, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handler);
|
||||
document.removeEventListener("touchstart", handler);
|
||||
};
|
||||
}, [isVisible]);
|
||||
|
||||
// Group results memoized
|
||||
const groupedResults = useMemo(() => {
|
||||
if (!data?.results) return [];
|
||||
const groups: { type: string; items: SearchResult[] }[] = [];
|
||||
const map: Record<string, SearchResult[]> = {};
|
||||
data.results.forEach((r) => {
|
||||
(map[r.type] ||= []).push(r);
|
||||
});
|
||||
for (const [type, items] of Object.entries(map)) {
|
||||
groups.push({ type, items });
|
||||
}
|
||||
return groups;
|
||||
}, [data?.results]);
|
||||
|
||||
const renderGroupHeader = useCallback(
|
||||
({ type, items }: { type: string; items: SearchResult[] }) => (
|
||||
<View>
|
||||
<View className="p-2 bg-gray-50 border-b border-gray-200 flex-row items-center">
|
||||
<Ionicons
|
||||
name={getIconName(type as SearchResult["type"])}
|
||||
size={14}
|
||||
color="#6b7280"
|
||||
style={{ marginRight: 4 }}
|
||||
/>
|
||||
<Text className="font-bold text-xs text-gray-700 uppercase tracking-wider">
|
||||
{getResultTypeLabel(type as SearchResult["type"])}s ({items.length})
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(r) => `${r.type}-${r.id}`}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center p-3 border-b border-gray-100"
|
||||
onPress={() => {
|
||||
setIsVisible(false);
|
||||
onResultSelect(item);
|
||||
}}
|
||||
>
|
||||
<View className="flex-1 pl-6">
|
||||
<Text className="font-semibold text-gray-900" numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.subtitle ? (
|
||||
<Text
|
||||
className="text-sm text-gray-600 mb-1"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{item.description ? (
|
||||
<Text className="text-xs text-gray-500" numberOfLines={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
[onResultSelect]
|
||||
);
|
||||
|
||||
if (!isVisible || !searchQuery || searchQuery.length < 2) return null;
|
||||
|
||||
// Calculate dropdown positioning based on sidebar and content area
|
||||
// Sidebar widths: collapsed = 80px, expanded = 200px
|
||||
// We want dropdown to span from sidebar edge to content area with padding
|
||||
const sidebarWidth = sidebarCollapsed ? 80 : 200;
|
||||
const contentPadding = 20;
|
||||
const rightPadding = 4;
|
||||
const leftOffset = sidebarWidth + contentPadding;
|
||||
const dropdownWidth = windowWidth - leftOffset - rightPadding - 16;
|
||||
|
||||
// Calculate max height: leave space at bottom (48px from top + some bottom margin)
|
||||
const maxDropdownHeight = windowHeight - 48 - 32;
|
||||
|
||||
// For empty results, make it narrower and align to the right
|
||||
const isEmpty = data && data.results.length === 0 && !isLoading;
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
className="absolute top-12 bg-white rounded-md shadow-lg z-50 p-2"
|
||||
style={{
|
||||
left: leftOffset,
|
||||
right: rightPadding + 16,
|
||||
width: dropdownWidth,
|
||||
maxWidth: dropdownWidth,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
nativeID={containerId}
|
||||
>
|
||||
{isLoading && (
|
||||
<View className="p-4">
|
||||
<Text className="text-gray-500 text-center">Searching...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View className="p-4">
|
||||
<Text className="text-red-500 text-center">Search failed</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<View className="p-4">
|
||||
<Text className="text-gray-500 text-center">
|
||||
No results found for "{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{groupedResults.length > 0 && (
|
||||
<ScrollView
|
||||
style={{ maxHeight: maxDropdownHeight }}
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
<FlatList
|
||||
data={groupedResults}
|
||||
keyExtractor={(g) => g.type}
|
||||
renderItem={({ item }) => renderGroupHeader(item)}
|
||||
scrollEnabled={false}
|
||||
ListFooterComponent={
|
||||
<TouchableOpacity
|
||||
className="p-3 border-t border-gray-200 bg-gray-50"
|
||||
onPress={() => {
|
||||
setIsVisible(false);
|
||||
onViewAllResults(searchQuery);
|
||||
}}
|
||||
>
|
||||
<Text className="text-center text-blue-600 font-medium">
|
||||
View all results for "{searchQuery}"
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user