Files
AthenaFE/components/features/todos/TodoModal.tsx
T
2026-05-19 18:46:16 +02:00

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>
);
};