project setup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user