469 lines
17 KiB
TypeScript
469 lines
17 KiB
TypeScript
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>
|
|
);
|
|
};
|