Files
anti-hvost/src/pages/profile_calendar.tsx
2025-05-12 01:43:10 +03:00

583 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);