583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
import ModalCalendar from "@/components/ModalCalendar";
|
||
import ModalTags, { ITags } from "@/components/ModalTags";
|
||
import Task from "@/components/task";
|
||
import Dialog from "@/components/ui/Dialog";
|
||
import ModalWindow from "@/components/ui/Modal";
|
||
import { withTitle } from "@/constructors/Component";
|
||
import { UrlsTitle } from "@/enums/urls";
|
||
import apiClient from "@/services/api";
|
||
import { cn } from "@/utils/class-merge";
|
||
import {
|
||
BookOpenIcon,
|
||
CalendarDaysIcon,
|
||
DocumentDuplicateIcon,
|
||
InboxArrowDownIcon,
|
||
PencilIcon,
|
||
TrashIcon,
|
||
} from "@heroicons/react/24/outline";
|
||
import { FunctionComponent } from "preact";
|
||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||
import { Calendar, CalendarDateTemplateEvent } from "primereact/calendar";
|
||
import { Nullable } from "primereact/ts-helpers";
|
||
import { SubmitHandler, useForm } from "react-hook-form";
|
||
import {
|
||
IApiResponse,
|
||
IAPITag,
|
||
ICreateTaskResponse,
|
||
IDeleteTaskResponse,
|
||
IEditTaskResponse,
|
||
ITask,
|
||
ITaskDetails,
|
||
ITaskForm,
|
||
IViewTagsResponse,
|
||
} from "./profile_tasks.dto";
|
||
|
||
const calendarStyles = {
|
||
root: "inline-flex w-full relative",
|
||
input: "font-sans text-base text-gray-600 bg-white p-3",
|
||
panel: "bg-white min-w-full p-2",
|
||
header: "flex items-center justify-between p-2 text-gray-700 font-semibold m-0 mb-4",
|
||
title: "leading-8 mx-auto text-xl",
|
||
previousButton: "flex items-center justify-center cursor-pointer w-8 h-8 text-gray-600",
|
||
nextButton: "flex items-center justify-center cursor-pointer w-8 h-8 text-gray-600",
|
||
table: "border-collapse w-full",
|
||
tableHeaderCell: "p-2 text-center",
|
||
weekDay: "text-gray-600 font-normal pb-4",
|
||
monthTitle: "text-gray-700 font-semibold mr-2",
|
||
yearTitle: "text-gray-700 font-semibold",
|
||
monthPicker: "grid grid-cols-3 gap-2",
|
||
yearPicker: "grid grid-cols-2 gap-2",
|
||
month:
|
||
"p-2 text-center cursor-pointer rounded-lg hover:bg-[rgba(251,194,199,0.1)] [&.p-highlight]:bg-[rgba(251,194,199,0.2)]",
|
||
year: "p-2 text-center cursor-pointer rounded-lg hover:bg-[rgba(251,194,199,0.1)] [&.p-highlight]:bg-[rgba(251,194,199,0.2)]",
|
||
};
|
||
|
||
const ProfileCalendar: FunctionComponent = () => {
|
||
const [currentDate, setCurrentDate] = useState(new Date());
|
||
const [tasks, setTasks] = useState<ITask[]>([]);
|
||
const [selectedTasks, setSelectedTasks] = useState<ITask[]>([]);
|
||
const [openModal, setIsOpen] = useState(false);
|
||
const [openModalCalendar, setOpenModalCalendar] = useState(false);
|
||
const [openModalTags, setOpenModalTags] = useState(false);
|
||
const [isEdit, setIsEdit] = useState(false);
|
||
const [isEditModal, setIsEditModal] = useState(false);
|
||
const [editContent, setEditContent] = useState<ITask | null>(null);
|
||
const [calendarDate, setCalendarDate] = useState<Nullable<Date>>();
|
||
const [tags, setTags] = useState<ITags>({ first: 0, second: 0, overdue: false });
|
||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||
const [subjectChoices, setSubjectChoices] = useState<IAPITag[]>([]);
|
||
const [taskTypeChoices, setTaskTypeChoices] = useState<IAPITag[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
const {
|
||
handleSubmit,
|
||
register,
|
||
reset,
|
||
setError,
|
||
formState: { errors },
|
||
} = useForm<ITaskForm>({
|
||
defaultValues: {
|
||
subject: {
|
||
name: "",
|
||
id: 0,
|
||
},
|
||
taskType: {
|
||
name: "",
|
||
id: 0,
|
||
},
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
fetchTasks();
|
||
fetchTags();
|
||
}, []);
|
||
|
||
const fetchTags = async () => {
|
||
try {
|
||
const response = await apiClient<IViewTagsResponse>("/api/tags/view_tags/");
|
||
setSubjectChoices(response.subjects);
|
||
setTaskTypeChoices(response.taskTypes);
|
||
} catch (error) {
|
||
console.error("Failed to fetch tags:", error);
|
||
}
|
||
};
|
||
|
||
const fetchTasks = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const response = await apiClient<IApiResponse>("/api/tasks/view_tasks/");
|
||
|
||
const convertedTasks: ITask[] = response.tasks.map((apiTask) => ({
|
||
id: apiTask.id.toString(),
|
||
name: apiTask.title,
|
||
checked: apiTask.isCompleted,
|
||
date: new Date(apiTask.due_date),
|
||
description: apiTask.description,
|
||
priority: apiTask.priority,
|
||
subject: apiTask.subject,
|
||
taskType: apiTask.taskType,
|
||
new: false,
|
||
}));
|
||
|
||
setTasks(convertedTasks);
|
||
} catch (error) {
|
||
console.error("Failed to fetch tasks:", error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const example_tags = useMemo(
|
||
() => ({
|
||
first: subjectChoices,
|
||
second: taskTypeChoices,
|
||
}),
|
||
[subjectChoices, taskTypeChoices]
|
||
);
|
||
|
||
const saveTask: SubmitHandler<ITaskForm> = async (data) => {
|
||
if (!calendarDate) {
|
||
setError("date", { message: "Выберите дату" });
|
||
return;
|
||
}
|
||
if ((!editContent?.subject.id || !editContent.taskType.id) && (!tags.first || !tags.second)) {
|
||
setError("subject", { message: "Выберите теги" });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const selectedSubject = editContent?.subject.id || tags.first;
|
||
const selectedTaskType = editContent?.taskType.id || tags.second;
|
||
|
||
// Format date to DD-MM-YYYYTHH:MM
|
||
const formattedDate = calendarDate
|
||
.toLocaleString("en-GB", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
})
|
||
.replace(",", "T")
|
||
.replace(/\//g, "-")
|
||
.replace("T ", "T");
|
||
|
||
const taskData = {
|
||
title: data.name,
|
||
description: data.description || "",
|
||
subject: selectedSubject,
|
||
taskType: selectedTaskType,
|
||
dateTime_due: formattedDate,
|
||
telegram_notifications: false,
|
||
};
|
||
|
||
if (!editContent) {
|
||
const response = await apiClient<ICreateTaskResponse>("/api/tasks/create_task/", {
|
||
method: "POST",
|
||
body: JSON.stringify(taskData),
|
||
});
|
||
|
||
if (!response.success) {
|
||
throw new Error(response.message);
|
||
}
|
||
} else {
|
||
const response = await apiClient<IEditTaskResponse>(`/api/tasks/edit_task/${editContent.id}/`, {
|
||
method: "PUT",
|
||
body: JSON.stringify(taskData),
|
||
});
|
||
|
||
if (!response.success) {
|
||
throw new Error(response.message);
|
||
}
|
||
}
|
||
|
||
await fetchTasks();
|
||
setIsOpen(false);
|
||
setTags({ first: 0, second: 0, overdue: false });
|
||
} catch (error) {
|
||
console.error("Failed to save task:", error);
|
||
}
|
||
};
|
||
|
||
const handleDeleteTask = async () => {
|
||
if (!editContent) return;
|
||
|
||
try {
|
||
const response = await apiClient<IDeleteTaskResponse>(`/api/tasks/delete_task/${editContent.id}/`, {
|
||
method: "DELETE",
|
||
});
|
||
|
||
if (!response.success) {
|
||
throw new Error(response.message);
|
||
}
|
||
|
||
await fetchTasks();
|
||
setIsOpen(false);
|
||
setShowDeleteDialog(false);
|
||
} catch (error) {
|
||
console.error("Failed to delete task:", error);
|
||
}
|
||
};
|
||
|
||
const handleTaskCheck = async (taskId: string) => {
|
||
try {
|
||
await apiClient(`/api/tasks/toggle_complete_task/${taskId}/`, {
|
||
method: "PATCH",
|
||
});
|
||
|
||
setTasks((prevTasks) =>
|
||
prevTasks.map((task) => (task.id === taskId ? { ...task, checked: !task.checked } : task))
|
||
);
|
||
} catch (error) {
|
||
console.error("Failed to mark task:", error);
|
||
}
|
||
};
|
||
|
||
const handleViewTask = async (taskId: string) => {
|
||
try {
|
||
const taskDetails = await apiClient<ITaskDetails>(`/api/tasks/view_task/${taskId}/`);
|
||
|
||
const task: ITask = {
|
||
id: taskId,
|
||
name: taskDetails.title,
|
||
checked: false,
|
||
date: new Date(taskDetails.dateTime_due),
|
||
description: taskDetails.description,
|
||
subject: taskDetails.subject,
|
||
taskType: taskDetails.task_type,
|
||
priority: taskDetails.priority,
|
||
};
|
||
|
||
setIsOpen(true);
|
||
setIsEdit(true);
|
||
setEditContent(task);
|
||
setCalendarDate(task.date);
|
||
setIsEditModal(false);
|
||
} catch (error) {
|
||
console.error("Failed to fetch task details:", error);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!currentDate) return;
|
||
|
||
const tasksForDate = tasks.filter((task) => {
|
||
const taskDate = task.date;
|
||
return (
|
||
taskDate.getDate() === currentDate.getDate() &&
|
||
taskDate.getMonth() === currentDate.getMonth() &&
|
||
taskDate.getFullYear() === currentDate.getFullYear()
|
||
);
|
||
});
|
||
|
||
setSelectedTasks(tasksForDate);
|
||
}, [currentDate, tasks]);
|
||
|
||
useEffect(() => {
|
||
if (editContent) reset({ ...editContent, date: editContent.date.toISOString().slice(0, 16) });
|
||
else reset();
|
||
}, [editContent]);
|
||
|
||
useEffect(() => {
|
||
if (!editContent) return;
|
||
const newEditContent = editContent;
|
||
if (tags.first) newEditContent.subject = subjectChoices.find((tag) => tag.id === tags.first)!;
|
||
if (tags.second) newEditContent.taskType = taskTypeChoices.find((tag) => tag.id === tags.second)!;
|
||
setEditContent(newEditContent);
|
||
}, [tags]);
|
||
|
||
const tasksCount = (date: CalendarDateTemplateEvent) => {
|
||
return tasks.filter((task) => {
|
||
const taskDate = task.date;
|
||
return (
|
||
taskDate.getDate() === date.day && taskDate.getMonth() === date.month && taskDate.getFullYear() === date.year
|
||
);
|
||
}).length;
|
||
};
|
||
|
||
const hasTasksOnDate = (date: CalendarDateTemplateEvent) => {
|
||
return tasks.some((task) => {
|
||
const taskDate = task.date;
|
||
return (
|
||
taskDate.getDate() === date.day && taskDate.getMonth() === date.month && taskDate.getFullYear() === date.year
|
||
);
|
||
});
|
||
};
|
||
|
||
const dateTemplate = (date: CalendarDateTemplateEvent) => {
|
||
const isHighlighted = hasTasksOnDate(date);
|
||
const countT = tasksCount(date);
|
||
const isSelected =
|
||
currentDate &&
|
||
currentDate.getDate() === date.day &&
|
||
currentDate.getMonth() === date.month &&
|
||
currentDate.getFullYear() === date.year;
|
||
const isToday =
|
||
new Date().getDate() === date.day &&
|
||
new Date().getMonth() === date.month &&
|
||
new Date().getFullYear() === date.year;
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative flex h-20 w-full cursor-pointer items-start justify-start rounded-lg p-2",
|
||
"hover:bg-[rgba(251,194,199,0.1)]",
|
||
{
|
||
"bg-[rgba(251,194,199,0.4)]": isSelected,
|
||
"bg-[rgba(251,194,199,0.2)]": isToday && !isSelected,
|
||
}
|
||
)}
|
||
>
|
||
<span>{date.day}</span>
|
||
{isHighlighted && (
|
||
<div class="absolute top-2 right-2 flex h-fit w-2 flex-col items-center gap-1 md:h-2 md:w-fit md:flex-row">
|
||
{Array.from({ length: countT > 3 ? 3 : countT }).map((_, i) => (
|
||
<span key={i} className="size-2 rounded-full bg-pink-400" />
|
||
))}
|
||
{countT > 3 && <span className="text-xs font-bold text-pink-400 select-none">+</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const formatDate = (date: Date) => {
|
||
return new Intl.DateTimeFormat("ru-RU", {
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
}).format(date);
|
||
};
|
||
|
||
const pt = {
|
||
root: { className: calendarStyles.root },
|
||
input: { root: { className: calendarStyles.input } },
|
||
panel: { className: calendarStyles.panel },
|
||
header: { className: calendarStyles.header },
|
||
title: { className: calendarStyles.title },
|
||
previousButton: { className: calendarStyles.previousButton },
|
||
nextButton: { className: calendarStyles.nextButton },
|
||
table: { className: calendarStyles.table },
|
||
tableHeaderCell: { className: calendarStyles.tableHeaderCell },
|
||
weekDay: { className: calendarStyles.weekDay },
|
||
monthTitle: { className: calendarStyles.monthTitle },
|
||
yearTitle: { className: calendarStyles.yearTitle },
|
||
monthPicker: { className: calendarStyles.monthPicker },
|
||
yearPicker: { className: calendarStyles.yearPicker },
|
||
month: { className: calendarStyles.month },
|
||
year: { className: calendarStyles.year },
|
||
};
|
||
|
||
return (
|
||
<div class="flex w-full flex-col items-center">
|
||
{isLoading ? (
|
||
<div class="flex w-full flex-1 items-center justify-center">
|
||
<div class="text-2xl">Загрузка...</div>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ModalTags
|
||
refreshTags={fetchTags}
|
||
isOpen={openModalTags}
|
||
setIsOpen={setOpenModalTags}
|
||
tagsList={example_tags}
|
||
value={tags}
|
||
onClose={() => {
|
||
setTags({ first: 0, second: 0, overdue: false });
|
||
}}
|
||
onChange={setTags}
|
||
/>
|
||
<ModalCalendar
|
||
isOpen={openModalCalendar}
|
||
setIsOpen={setOpenModalCalendar}
|
||
onClose={() => {
|
||
if (isEdit && !isEditModal) setCalendarDate(null);
|
||
}}
|
||
onChange={(e) => isEditModal && setCalendarDate(e.value)}
|
||
value={calendarDate!}
|
||
/>
|
||
<ModalWindow
|
||
isOpen={openModal}
|
||
setIsOpen={setIsOpen}
|
||
onClose={() => {
|
||
setIsEdit(false);
|
||
setEditContent(null);
|
||
setIsEditModal(false);
|
||
setTags({ first: 0, second: 0, overdue: false });
|
||
setCalendarDate(null);
|
||
}}
|
||
>
|
||
{isEdit && editContent && (
|
||
<form
|
||
class="flex h-full w-full flex-col items-start justify-between"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (isEditModal) handleSubmit(saveTask)();
|
||
else setIsEditModal(!isEditModal);
|
||
}}
|
||
>
|
||
<div class="flex w-full flex-row items-start justify-between">
|
||
<div class="flex flex-1 flex-col gap-1 pe-2">
|
||
<input
|
||
class={cn("w-full p-2 text-2xl outline-0", {
|
||
"rounded-md bg-gray-400/30 ring-1 ring-gray-400": isEditModal,
|
||
})}
|
||
disabled={!isEditModal}
|
||
placeholder="Название"
|
||
{...register("name", {
|
||
required: "Заполните название",
|
||
maxLength: { value: 20, message: "Максимум 20 символов в названии" },
|
||
})}
|
||
/>
|
||
<textarea
|
||
class={cn("h-[5rem] w-full resize-none p-2 outline-0", {
|
||
"rounded-md bg-gray-400/30 ring-1 ring-gray-400": isEditModal,
|
||
})}
|
||
disabled={!isEditModal}
|
||
placeholder={isEditModal ? "Описание" : ""}
|
||
{...register("description", {
|
||
maxLength: { value: 200, message: "Максимум 200 символов в описании" },
|
||
})}
|
||
/>
|
||
<input
|
||
type="datetime-local"
|
||
value={calendarDate ? calendarDate.toISOString().slice(0, 16) : ""}
|
||
hidden
|
||
{...register("date")}
|
||
/>
|
||
<input type="checkbox" hidden {...register("checked")} />
|
||
</div>
|
||
<div class="flex flex-row gap-4">
|
||
<div
|
||
className="flex cursor-pointer flex-col items-center gap-3"
|
||
onClick={() => {
|
||
if (isEditModal) {
|
||
handleSubmit(saveTask)();
|
||
setIsEditModal(!isEditModal);
|
||
} else setIsEditModal(!isEditModal);
|
||
}}
|
||
>
|
||
{isEditModal ? (
|
||
<>
|
||
<InboxArrowDownIcon class="size-6" />
|
||
<p class="text-[0.7rem]">Сохранить</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<PencilIcon class="size-6" />
|
||
<p class="text-[0.7rem]">Редактировать</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div
|
||
className="flex cursor-pointer flex-col items-center gap-3"
|
||
onClick={() => setShowDeleteDialog(true)}
|
||
>
|
||
<TrashIcon class="size-6" />
|
||
<p class="text-[0.7rem]">Удалить</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{errors.name && <p class="text-red-500">{errors.name.message}</p>}
|
||
{errors.description && <p class="text-red-500">{errors.description.message}</p>}
|
||
{errors.date && <p class="text-red-500">{errors.date.message}</p>}
|
||
{errors.subject && <p class="text-red-500">{errors.subject.message}</p>}
|
||
<div class="flex flex-col items-center gap-6 self-center md:flex-row md:justify-start md:self-start">
|
||
<div
|
||
class={cn(
|
||
"flex h-full flex-row items-center gap-1 rounded-2xl bg-[rgba(251,194,199,0.38)] px-2 py-1",
|
||
{
|
||
"cursor-pointer": isEditModal,
|
||
}
|
||
)}
|
||
onClick={() => {
|
||
if (!isEditModal) return;
|
||
setOpenModalCalendar(true);
|
||
setCalendarDate(editContent.date);
|
||
}}
|
||
>
|
||
<CalendarDaysIcon class="size-10" />
|
||
<p>
|
||
{Intl.DateTimeFormat("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(calendarDate!)}
|
||
</p>
|
||
</div>
|
||
<div
|
||
class={cn(
|
||
"flex h-full flex-col items-start gap-1 rounded-2xl bg-[rgba(251,194,199,0.38)] px-4 py-2",
|
||
{
|
||
"cursor-pointer": isEditModal,
|
||
}
|
||
)}
|
||
onClick={() => {
|
||
if (!isEditModal) return;
|
||
setTags({ first: editContent.subject.id, second: editContent.taskType.id, overdue: false });
|
||
setOpenModalTags(true);
|
||
}}
|
||
>
|
||
<p class="flex flex-row gap-2">
|
||
<BookOpenIcon class="size-5" />
|
||
{editContent.subject.name}
|
||
</p>
|
||
<p class="flex flex-row gap-2">
|
||
<DocumentDuplicateIcon class="size-5" />
|
||
{editContent.taskType.name}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</ModalWindow>
|
||
<Dialog
|
||
isOpen={showDeleteDialog}
|
||
setIsOpen={setShowDeleteDialog}
|
||
title="Удаление задачи"
|
||
onConfirm={handleDeleteTask}
|
||
confirmText="Удалить"
|
||
cancelText="Отмена"
|
||
>
|
||
<p class="mb-6 text-sm sm:text-[1rem]">"Вы уверены, что хотите удалить эту задачу?"</p>
|
||
</Dialog>
|
||
<Calendar
|
||
value={currentDate}
|
||
onChange={(e) => setCurrentDate(e ? e.value! : new Date())}
|
||
inline
|
||
pt={pt}
|
||
dateTemplate={dateTemplate}
|
||
className="[&_.p-datepicker]:!border-0 [&_.p-datepicker-calendar]:border-separate [&_.p-datepicker-calendar]:border-spacing-2 [&_td]:rounded-lg [&_td]:border [&_td]:border-[rgba(251,194,199,0.38)]"
|
||
/>
|
||
|
||
<div class="mt-8 w-full px-4">
|
||
<h2 class="mb-4 text-2xl font-semibold">Задачи на {formatDate(currentDate)}</h2>
|
||
{selectedTasks.length > 0 ? (
|
||
<div class="flex flex-col gap-4">
|
||
{selectedTasks.map((task) => (
|
||
<Task
|
||
key={task.id}
|
||
name={task.name}
|
||
checked={task.checked}
|
||
priority={task.priority}
|
||
overdue={task.date < new Date()}
|
||
onClick={() => handleViewTask(task.id)}
|
||
onMarkClick={() => handleTaskCheck(task.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div class="rounded-lg bg-[rgba(251,194,199,0.2)] p-4 text-center">На этот день задач нет</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default withTitle(UrlsTitle.CALENDAR, ProfileCalendar);
|