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