diff --git a/frontend/src/pages/CourseDetails.tsx b/frontend/src/pages/CourseDetails.tsx index 2919ff9..cc7e34a 100644 --- a/frontend/src/pages/CourseDetails.tsx +++ b/frontend/src/pages/CourseDetails.tsx @@ -190,7 +190,8 @@ export default function CourseDetails() { title: newTaskTitle.trim(), course_id: id, is_completed: false, - deadline: newTaskDeadline ? new Date(newTaskDeadline).toISOString() : null, + deadline: newTaskDeadline || null, + }, ]) .select() diff --git a/frontend/src/pages/TaskDetails.tsx b/frontend/src/pages/TaskDetails.tsx index e29b697..29d6072 100644 --- a/frontend/src/pages/TaskDetails.tsx +++ b/frontend/src/pages/TaskDetails.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import {useEffect, useRef, useState} from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { supabase } from '../lib/supabase'; @@ -11,6 +11,15 @@ interface Task { description?: string | null; } +interface TaskAttachment { + id: string; + task_id: string; + name: string; + url: string; + is_external: boolean; + publicUrl: string; +} + export default function TaskDetails() { const { id } = useParams(); const navigate = useNavigate(); @@ -21,6 +30,23 @@ export default function TaskDetails() { const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); const [isDeletingTask, setIsDeletingTask] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); + const [isSavingTask, setIsSavingTask] = useState(false); + const [editDeadline, setEditDeadline] = useState(''); + + const [attachments, setAttachments] = useState([]); + const [attachmentsLoading, setAttachmentsLoading] = useState(true); + const [attachmentsError, setAttachmentsError] = useState(''); + const [newAttachmentLink, setNewAttachmentLink] = useState(''); + const [selectedAttachmentFile, setSelectedAttachmentFile] = useState(null); + const [isAddingAttachment, setIsAddingAttachment] = useState(false); + const [deletingAttachmentId, setDeletingAttachmentId] = useState(null); + + + const attachmentFileInputRef = useRef(null); + useEffect(() => { const fetchTask = async () => { if (!id) { @@ -44,6 +70,9 @@ export default function TaskDetails() { setTask(null); } else { setTask(data); + setEditTitle(data.title || ''); + setEditDescription(data.description || ''); + setEditDeadline(toDatetimeLocalValue(data.deadline)); } setLoading(false); @@ -52,6 +81,59 @@ export default function TaskDetails() { fetchTask(); }, [id]); + useEffect(() => { + const fetchAttachments = async () => { + if (!id) { + setAttachmentsError('Не найден id задачи.'); + setAttachmentsLoading(false); + return; + } + + setAttachmentsLoading(true); + setAttachmentsError(''); + + const { data, error } = await supabase + .from('task_attachments') + .select('*') + .eq('task_id', id) + .order('created_at', { ascending: false }); + + if (error) { + console.error('Ошибка загрузки файлов задачи:', error); + setAttachmentsError('Не удалось загрузить вложения задачи.'); + setAttachments([]); + setAttachmentsLoading(false); + return; + } + + const mappedAttachments: TaskAttachment[] = (data || []).map((item: any) => { + let publicUrl = item.url; + + if (!item.is_external) { + const { data: publicData } = supabase.storage + .from('file_attachments') + .getPublicUrl(item.url); + + publicUrl = publicData.publicUrl; + } + + return { + id: item.id, + task_id: item.task_id, + name: item.name, + url: item.url, + is_external: item.is_external, + publicUrl, + }; + }); + + setAttachments(mappedAttachments); + setAttachmentsLoading(false); + }; + + fetchAttachments(); + }, [id]); + const formatDeadline = (deadline: string | null) => { if (!deadline) return 'Без срока'; @@ -64,12 +146,34 @@ export default function TaskDetails() { }); }; + const toDatetimeLocalValue = (deadline: string | null) => { + if (!deadline) return ''; + + const date = new Date(deadline); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + return `${year}-${month}-${day}T${hours}:${minutes}`; + }; + const isUrgent = (deadline: string | null) => { if (!deadline) return false; const diff = new Date(deadline).getTime() - Date.now(); return diff > 0 && diff < 24 * 60 * 60 * 1000; }; + const isValidUrl = (value: string) => { + try { + new URL(value); + return true; + } catch { + return false; + } + }; + const toggleTaskStatus = async () => { if (!task || isUpdatingStatus) return; @@ -92,6 +196,207 @@ export default function TaskDetails() { setIsUpdatingStatus(false); }; + const startEditing = () => { + if (!task) return; + + setEditTitle(task.title || ''); + setEditDescription(task.description || ''); + setEditDeadline(toDatetimeLocalValue(task.deadline)); + setErrorMessage(''); + setIsEditing(true); + }; + + const cancelEditing = () => { + if (!task) return; + + setEditTitle(task.title || ''); + setEditDescription(task.description || ''); + setEditDeadline(toDatetimeLocalValue(task.deadline)); + setErrorMessage(''); + setIsEditing(false); + }; + + const saveTaskChanges = async () => { + if (!task || isSavingTask) return; + + if (!editTitle.trim()) { + setErrorMessage('Введите название задачи.'); + return; + } + + setErrorMessage(''); + setIsSavingTask(true); + + const { data, error } = await supabase + .from('tasks') + .update({ + title: editTitle.trim(), + description: editDescription.trim() || null, + deadline: editDeadline || null, + }) + .eq('id', task.id) + .select() + .single(); + + if (error) { + setErrorMessage('Ошибка сохранения задачи: ' + error.message); + setIsSavingTask(false); + return; + } + + setTask(data); + setEditTitle(data.title || ''); + setEditDescription(data.description || ''); + setEditDeadline(toDatetimeLocalValue(data.deadline)); + setIsEditing(false); + setIsSavingTask(false); + }; + + const addAttachment = async () => { + if (!task) { + setAttachmentsError('Не найдена задача.'); + return; + } + + if (!selectedAttachmentFile && !newAttachmentLink.trim()) { + setAttachmentsError('Выберите файл или введите ссылку.'); + return; + } + + if (newAttachmentLink.trim() && !isValidUrl(newAttachmentLink.trim())) { + setAttachmentsError('Введите корректную ссылку. Она должна начинаться с http:// или https://'); + return; + } + + setAttachmentsError(''); + setIsAddingAttachment(true); + + if (selectedAttachmentFile) { + const safeFileName = selectedAttachmentFile.name + .replace(/\s+/g, '_') + .replace(/[^a-zA-Z0-9._-]/g, '_'); + + const filePath = `tasks/${task.id}/${Date.now()}_${safeFileName}`; + + const { error: uploadError } = await supabase.storage + .from('file_attachments') + .upload(filePath, selectedAttachmentFile); + + if (uploadError) { + setAttachmentsError('Файл не загрузился: ' + uploadError.message); + setIsAddingAttachment(false); + return; + } + + const { data: insertedAttachment, error: attachmentError } = await supabase + .from('task_attachments') + .insert([ + { + id: crypto.randomUUID(), + task_id: task.id, + name: selectedAttachmentFile.name, + url: filePath, + is_external: false, + }, + ]) + .select() + .single(); + + if (attachmentError) { + setAttachmentsError('Файл загружен, но запись не сохранилась: ' + attachmentError.message); + setIsAddingAttachment(false); + return; + } + + const { data: publicData } = supabase.storage + .from('file_attachments') + .getPublicUrl(filePath); + + setAttachments((prev) => [ + { + ...insertedAttachment, + publicUrl: publicData.publicUrl, + }, + ...prev, + ]); + + setSelectedAttachmentFile(null); + + if (attachmentFileInputRef.current) { + attachmentFileInputRef.current.value = ''; + } + } + + if (newAttachmentLink.trim()) { + const cleanLink = newAttachmentLink.trim(); + + const { data: insertedLink, error: linkError } = await supabase + .from('task_attachments') + .insert([ + { + id: crypto.randomUUID(), + task_id: task.id, + name: cleanLink, + url: cleanLink, + is_external: true, + }, + ]) + .select() + .single(); + + if (linkError) { + setAttachmentsError('Ссылка не сохранилась: ' + linkError.message); + setIsAddingAttachment(false); + return; + } + + setAttachments((prev) => [ + { + ...insertedLink, + publicUrl: cleanLink, + }, + ...prev, + ]); + + setNewAttachmentLink(''); + } + + setIsAddingAttachment(false); + }; + + const deleteAttachment = async (attachment: TaskAttachment) => { + const confirmed = window.confirm('Удалить материал задачи?'); + if (!confirmed) return; + + setAttachmentsError(''); + setDeletingAttachmentId(attachment.id); + + if (!attachment.is_external) { + const { error: storageError } = await supabase.storage + .from('file_attachments') + .remove([attachment.url]); + + if (storageError) { + setAttachmentsError('Не удалось удалить файл из хранилища: ' + storageError.message); + setDeletingAttachmentId(null); + return; + } + } + + const { error } = await supabase + .from('task_attachments') + .delete() + .eq('id', attachment.id); + + if (error) { + setAttachmentsError('Не удалось удалить материал задачи: ' + error.message); + } else { + setAttachments((prev) => prev.filter((item) => item.id !== attachment.id)); + } + + setDeletingAttachmentId(null); + }; + const deleteTask = async () => { if (!task || isDeletingTask) return; @@ -183,109 +488,319 @@ export default function TaskDetails() {
-

- {task.title} -

- -
+ {isEditing ? ( + <> + setEditTitle(e.target.value)} + placeholder="Название задачи" + disabled={isSavingTask} + style={{ + width: '100%', + padding: '10px 12px', + borderRadius: '10px', + border: '1px solid var(--color-border)', + background: 'var(--color-bg-secondary)', + color: 'var(--color-text-main)', + fontSize: '24px', + fontWeight: 700, + marginBottom: '10px', + opacity: isSavingTask ? 0.7 : 1, + }} + /> + + setEditDeadline(e.target.value)} + disabled={isSavingTask} + style={{ + padding: '10px 12px', + borderRadius: '10px', + border: '1px solid var(--color-border)', + background: 'var(--color-bg-secondary)', + color: 'var(--color-text-main)', + marginBottom: '10px', + opacity: isSavingTask ? 0.7 : 1, + }} + /> + + ) : ( +

+ {task.title} +

+ )} + +
{task.deadline ? `Дедлайн: ${formatDeadline(task.deadline)}` : 'Без срока'} - + Статус: {task.is_completed ? 'Выполнена' : 'В работе'}
{errorMessage && ( -
- {errorMessage} -
+
+ {errorMessage} +
)}
-

- {task.description?.trim() - ? task.description - : '[Здесь будет подробное описание задачи.]'} -

- {!task.description?.trim() && ( -

- [Например: Необходимо подготовить презентацию для защиты проекта. Текст для слайдов - должен быть написан до вечера пятницы, чтобы успеть собрать дизайн.] -

+ {isEditing ? ( + <> + + +