diff --git a/backend/migrations/20250716085710-add-parent-task-id-to-tasks.js b/backend/migrations/20250716085710-add-parent-task-id-to-tasks.js new file mode 100644 index 0000000..706dd86 --- /dev/null +++ b/backend/migrations/20250716085710-add-parent-task-id-to-tasks.js @@ -0,0 +1,24 @@ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn('tasks', 'parent_task_id', { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'tasks', + key: 'id' + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }); + + await queryInterface.addIndex('tasks', ['parent_task_id']); + }, + + async down (queryInterface) { + await queryInterface.removeIndex('tasks', ['parent_task_id']); + await queryInterface.removeColumn('tasks', 'parent_task_id'); + } +}; diff --git a/backend/models/task.js b/backend/models/task.js index 1d5bf2a..e95486a 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -124,6 +124,14 @@ module.exports = (sequelize) => { key: 'id', }, }, + parent_task_id: { + type: DataTypes.INTEGER, + allowNull: true, + references: { + model: 'tasks', + key: 'id', + }, + }, completed_at: { type: DataTypes.DATE, allowNull: true, @@ -144,6 +152,9 @@ module.exports = (sequelize) => { { fields: ['last_generated_date'], }, + { + fields: ['parent_task_id'], + }, ], } ); @@ -160,6 +171,17 @@ module.exports = (sequelize) => { as: 'RecurringChildren', foreignKey: 'recurring_parent_id', }); + + // Self-referencing association for subtasks + Task.belongsTo(models.Task, { + as: 'ParentTask', + foreignKey: 'parent_task_id', + }); + + Task.hasMany(models.Task, { + as: 'Subtasks', + foreignKey: 'parent_task_id', + }); }; // Define enum constants diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 7b5471f..6ab6e2c 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -608,6 +608,36 @@ router.get('/task/:id', async (req, res) => { } }); +// GET /api/task/:id/subtasks +router.get('/task/:id/subtasks', async (req, res) => { + try { + const subtasks = await Task.findAll({ + where: { + parent_task_id: req.params.id, + user_id: req.currentUser.id, + }, + include: [ + { + model: Tag, + attributes: ['id', 'name'], + through: { attributes: [] }, + }, + { model: Project, attributes: ['name'], required: false }, + ], + order: [['created_at', 'ASC']], + }); + + const serializedSubtasks = await Promise.all( + subtasks.map((subtask) => serializeTask(subtask)) + ); + + res.json(serializedSubtasks); + } catch (error) { + console.error('Error fetching subtasks:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // POST /api/task router.post('/task', async (req, res) => { try { @@ -618,8 +648,10 @@ router.post('/task', async (req, res) => { status, note, project_id, + parent_task_id, tags, Tags, + subtasks, today, recurrence_type, recurrence_interval, @@ -683,9 +715,38 @@ router.post('/task', async (req, res) => { taskAttributes.project_id = project_id; } + // Handle parent task assignment + if (parent_task_id && parent_task_id.toString().trim()) { + const parentTask = await Task.findOne({ + where: { id: parent_task_id, user_id: req.currentUser.id }, + }); + if (!parentTask) { + return res.status(400).json({ error: 'Invalid parent task.' }); + } + taskAttributes.parent_task_id = parent_task_id; + } + const task = await Task.create(taskAttributes); await updateTaskTags(task, tagsData, req.currentUser.id); + // Handle subtasks creation + if (subtasks && Array.isArray(subtasks)) { + const subtaskPromises = subtasks + .filter(subtask => subtask.name && subtask.name.trim()) + .map(subtask => Task.create({ + name: subtask.name.trim(), + parent_task_id: task.id, + user_id: req.currentUser.id, + priority: Task.PRIORITY.MEDIUM, + status: Task.STATUS.NOT_STARTED, + today: false, + recurrence_type: 'none', + completion_based: false, + })); + + await Promise.all(subtaskPromises); + } + // Log task creation event try { await TaskEventService.logTaskCreated( @@ -747,8 +808,10 @@ router.patch('/task/:id', async (req, res) => { note, due_date, project_id, + parent_task_id, tags, Tags, + subtasks, today, recurrence_type, recurrence_interval, @@ -927,9 +990,87 @@ router.patch('/task/:id', async (req, res) => { taskAttributes.project_id = null; } + // Handle parent task assignment + if (parent_task_id && parent_task_id.toString().trim()) { + const parentTask = await Task.findOne({ + where: { id: parent_task_id, user_id: req.currentUser.id }, + }); + if (!parentTask) { + return res.status(400).json({ error: 'Invalid parent task.' }); + } + taskAttributes.parent_task_id = parent_task_id; + } else if (parent_task_id === null || parent_task_id === '') { + taskAttributes.parent_task_id = null; + } + await task.update(taskAttributes); await updateTaskTags(task, tagsData, req.currentUser.id); + // Handle subtasks updates + if (subtasks && Array.isArray(subtasks)) { + // Delete existing subtasks that are not in the new list + const existingSubtasks = await Task.findAll({ + where: { parent_task_id: task.id, user_id: req.currentUser.id }, + }); + + const subtasksToKeep = subtasks.filter((s) => s.id && !s.isNew); + const subtasksToDelete = existingSubtasks.filter( + (existing) => + !subtasksToKeep.find((keep) => keep.id === existing.id) + ); + + // Delete removed subtasks + if (subtasksToDelete.length > 0) { + await Task.destroy({ + where: { + id: subtasksToDelete.map((s) => s.id), + user_id: req.currentUser.id, + }, + }); + } + + // Update edited subtasks + const editedSubtasks = subtasks.filter( + (s) => s.isEdited && s.id && s.name && s.name.trim() + ); + if (editedSubtasks.length > 0) { + const updatePromises = editedSubtasks.map((subtask) => + Task.update( + { name: subtask.name.trim() }, + { + where: { + id: subtask.id, + user_id: req.currentUser.id, + }, + } + ) + ); + + await Promise.all(updatePromises); + } + + // Create new subtasks + const newSubtasks = subtasks.filter( + (s) => s.isNew && s.name && s.name.trim() + ); + if (newSubtasks.length > 0) { + const subtaskPromises = newSubtasks.map((subtask) => + Task.create({ + name: subtask.name.trim(), + parent_task_id: task.id, + user_id: req.currentUser.id, + priority: Task.PRIORITY.MEDIUM, + status: Task.STATUS.NOT_STARTED, + today: false, + recurrence_type: 'none', + completion_based: false, + }) + ); + + await Promise.all(subtaskPromises); + } + } + // Log task update events try { const changes = {}; diff --git a/frontend/components/Task/TaskForm/TaskSubtasksSection.tsx b/frontend/components/Task/TaskForm/TaskSubtasksSection.tsx new file mode 100644 index 0000000..af7de3e --- /dev/null +++ b/frontend/components/Task/TaskForm/TaskSubtasksSection.tsx @@ -0,0 +1,240 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Task } from '../../../entities/Task'; +import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { useTranslation } from 'react-i18next'; +import { fetchSubtasks } from '../../../utils/tasksService'; + +interface SubtaskData { + id?: number; + name: string; + isNew?: boolean; + isEdited?: boolean; +} + +interface TaskSubtasksSectionProps { + parentTaskId: number; + subtasks: SubtaskData[]; + onSubtasksChange: (subtasks: SubtaskData[]) => void; + onSectionMount?: () => void; +} + +const TaskSubtasksSection: React.FC = ({ + parentTaskId, + subtasks, + onSubtasksChange, + onSectionMount, +}) => { + const [newSubtaskName, setNewSubtaskName] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [editingName, setEditingName] = useState(''); + const { t } = useTranslation(); + const subtasksSectionRef = useRef(null); + const addInputRef = useRef(null); + + useEffect(() => { + if (parentTaskId && subtasks.length === 0) { + loadExistingSubtasks(); + } + }, [parentTaskId, subtasks.length]); + + useEffect(() => { + // Scroll to bottom when section is mounted (opened) + if (onSectionMount) { + scrollToBottom(); + onSectionMount(); + } + }, [onSectionMount]); + + const loadExistingSubtasks = async () => { + try { + setIsLoading(true); + const existingSubtasks = await fetchSubtasks(parentTaskId); + const subtaskData = existingSubtasks.map(task => ({ + id: task.id, + name: task.name, + isNew: false, + })); + onSubtasksChange(subtaskData); + } catch (error) { + // Handle silently or show error if needed + } finally { + setIsLoading(false); + } + }; + + const scrollToBottom = () => { + setTimeout(() => { + if (subtasksSectionRef.current) { + subtasksSectionRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'end' + }); + } + }, 100); + }; + + const handleCreateSubtask = () => { + if (!newSubtaskName.trim()) return; + + const newSubtask: SubtaskData = { + name: newSubtaskName.trim(), + isNew: true, + }; + + onSubtasksChange([...subtasks, newSubtask]); + setNewSubtaskName(''); + + // Scroll to bottom after adding new subtask + scrollToBottom(); + }; + + const handleDeleteSubtask = (index: number) => { + const updatedSubtasks = subtasks.filter((_, i) => i !== index); + onSubtasksChange(updatedSubtasks); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleCreateSubtask(); + } + }; + + const handleEditSubtask = (index: number) => { + setEditingIndex(index); + setEditingName(subtasks[index].name); + }; + + const handleSaveEdit = () => { + if (!editingName.trim() || editingIndex === null) return; + + const updatedSubtasks = subtasks.map((subtask, index) => { + if (index === editingIndex) { + const isNameChanged = subtask.name !== editingName.trim(); + return { + ...subtask, + name: editingName.trim(), + isNew: subtask.isNew || false, + isEdited: !subtask.isNew && isNameChanged, // Mark as edited if it's existing and name changed + }; + } + return subtask; + }); + + onSubtasksChange(updatedSubtasks); + setEditingIndex(null); + setEditingName(''); + }; + + const handleCancelEdit = () => { + setEditingIndex(null); + setEditingName(''); + }; + + const handleEditKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveEdit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelEdit(); + } + }; + + return ( +
+ {/* Existing Subtasks */} + {isLoading ? ( +
+ {t('loading.subtasks', 'Loading subtasks...')} +
+ ) : subtasks.length > 0 ? ( +
+ {subtasks.map((subtask, index) => ( +
+ {editingIndex === index ? ( +
+ setEditingName(e.target.value)} + onKeyPress={handleEditKeyPress} + onBlur={handleSaveEdit} + className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:text-white" + autoFocus + /> + +
+ ) : ( + handleEditSubtask(index)} + title={t('actions.clickToEdit', 'Click to edit')} + > + {subtask.name} + {subtask.isNew && ( + + (new) + + )} + {subtask.isEdited && ( + + (edited) + + )} + + )} + +
+ ))} +
+ ) : ( +
+ {t('subtasks.noSubtasks', 'No subtasks yet')} +
+ )} + + {/* Add New Subtask */} +
+ setNewSubtaskName(e.target.value)} + onKeyPress={handleKeyPress} + placeholder={t('subtasks.placeholder', 'Add a subtask...')} + className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white" + /> + +
+
+ ); +}; + +export default TaskSubtasksSection; \ No newline at end of file diff --git a/frontend/components/Task/TaskHeader.tsx b/frontend/components/Task/TaskHeader.tsx index ecf9304..1b538dc 100644 --- a/frontend/components/Task/TaskHeader.tsx +++ b/frontend/components/Task/TaskHeader.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { CalendarDaysIcon, @@ -6,12 +6,14 @@ import { PlayIcon, ArrowPathIcon, ArrowRightIcon, + Squares2X2Icon, } from '@heroicons/react/24/outline'; import { TagIcon, FolderIcon } from '@heroicons/react/24/solid'; import { useTranslation } from 'react-i18next'; import TaskPriorityIcon from './TaskPriorityIcon'; import { Project } from '../../entities/Project'; import { Task, StatusType } from '../../entities/Task'; +import { fetchSubtasks } from '../../utils/tasksService'; interface TaskHeaderProps { task: Task; @@ -22,6 +24,10 @@ interface TaskHeaderProps { onToggleToday?: (taskId: number) => Promise; onTaskUpdate?: (task: Task) => Promise; isOverdue?: boolean; + // Props for subtasks functionality + showSubtasks?: boolean; + hasSubtasks?: boolean; + onSubtasksToggle?: (e: React.MouseEvent) => void; } const TaskHeader: React.FC = ({ @@ -33,9 +39,14 @@ const TaskHeader: React.FC = ({ onToggleToday, onTaskUpdate, isOverdue = false, + // Props for subtasks functionality + showSubtasks, + hasSubtasks, + onSubtasksToggle, }) => { const { t } = useTranslation(); + const formatDueDate = (dueDate: string) => { const today = new Date().toISOString().split('T')[0]; const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) @@ -75,6 +86,7 @@ const TaskHeader: React.FC = ({ } }; + const handleTodayToggle = async (e: React.MouseEvent) => { e.stopPropagation(); // Prevent opening task modal if (onToggleToday && task.id) { @@ -273,6 +285,30 @@ const TaskHeader: React.FC = ({ )} + + {/* Show Subtasks Controls */} + {hasSubtasks && ( + + )} @@ -420,6 +456,30 @@ const TaskHeader: React.FC = ({ )} + + {/* Show Subtasks Controls - Mobile */} + {hasSubtasks && ( + + )} @@ -427,4 +487,163 @@ const TaskHeader: React.FC = ({ ); }; +// Subtasks Display Component +interface SubtasksDisplayProps { + showSubtasks: boolean; + loadingSubtasks: boolean; + subtasks: Task[]; + onTaskClick: (e: React.MouseEvent, task: Task) => void; +} + +const SubtasksDisplay: React.FC = ({ + showSubtasks, + loadingSubtasks, + subtasks, + onTaskClick, +}) => { + const { t } = useTranslation(); + + if (!showSubtasks) return null; + + return ( +
+ {loadingSubtasks ? ( +
+ {t('loading.subtasks', 'Loading subtasks...')} +
+ ) : subtasks.length > 0 ? ( + subtasks.map((subtask) => ( +
+
{ + e.stopPropagation(); + onTaskClick(e, subtask); + }} + > +
+ {/* Left side - Task info */} +
+ + + {subtask.name} + +
+ + {/* Right side - Status indicator */} +
+ {subtask.status === 'done' || subtask.status === 2 ? ( + + ✓ + + ) : null} +
+
+
+
+ )) + ) : ( +
+ {t('subtasks.noSubtasks', 'No subtasks found')} +
+ )} +
+ ); +}; + +// TaskWithSubtasks Component that combines both +interface TaskWithSubtasksProps extends TaskHeaderProps { + onSubtaskClick?: (subtask: Task) => void; +} + +const TaskWithSubtasks: React.FC = (props) => { + const [showSubtasks, setShowSubtasks] = useState(false); + const [subtasks, setSubtasks] = useState([]); + const [loadingSubtasks, setLoadingSubtasks] = useState(false); + const [hasSubtasks, setHasSubtasks] = useState(false); + + // Check if task has subtasks on mount + useEffect(() => { + const checkSubtasks = async () => { + if (!props.task.id) return; + + console.log('Checking subtasks for task:', props.task.id, props.task.name); + + try { + const subtasksData = await fetchSubtasks(props.task.id); + console.log('Subtasks data:', subtasksData); + setHasSubtasks(subtasksData.length > 0); + } catch (error) { + console.error('Error fetching subtasks:', error); + setHasSubtasks(false); + } + }; + + checkSubtasks(); + }, [props.task.id]); + + const loadSubtasks = async () => { + if (!props.task.id) return; + + setLoadingSubtasks(true); + try { + const subtasksData = await fetchSubtasks(props.task.id); + setSubtasks(subtasksData); + } catch (error) { + console.error('Failed to load subtasks:', error); + setSubtasks([]); + } finally { + setLoadingSubtasks(false); + } + }; + + const handleSubtasksToggle = async (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent opening task modal + + if (!showSubtasks && subtasks.length === 0) { + await loadSubtasks(); + } + + setShowSubtasks(!showSubtasks); + }; + + return ( + <> + + { + e.stopPropagation(); + if (props.onSubtaskClick) { + props.onSubtaskClick(task); + } + }} + /> + + ); +}; + +export { TaskWithSubtasks }; export default TaskHeader; diff --git a/frontend/components/Task/TaskItem.tsx b/frontend/components/Task/TaskItem.tsx index ef5756d..c92de93 100644 --- a/frontend/components/Task/TaskItem.tsx +++ b/frontend/components/Task/TaskItem.tsx @@ -1,10 +1,137 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Task } from '../../entities/Task'; import { Project } from '../../entities/Project'; import TaskHeader from './TaskHeader'; + +// Import SubtasksDisplay component from TaskHeader +interface SubtasksDisplayProps { + showSubtasks: boolean; + loadingSubtasks: boolean; + subtasks: Task[]; + onTaskClick: (e: React.MouseEvent, task: Task) => void; + onTaskUpdate: (task: Task) => Promise; + loadSubtasks: () => Promise; +} + +const SubtasksDisplay: React.FC = ({ + showSubtasks, + loadingSubtasks, + subtasks, + onTaskClick, + onTaskUpdate, + loadSubtasks, +}) => { + const { t } = useTranslation(); + + if (!showSubtasks) return null; + + return ( +
+ {loadingSubtasks ? ( +
+ {t('loading.subtasks', 'Loading subtasks...')} +
+ ) : subtasks.length > 0 ? ( + subtasks.map((subtask) => ( +
+
{ + e.stopPropagation(); + onTaskClick(e, subtask); + }} + > +
+
+ {subtask.status === 'done' || subtask.status === 2 ? ( +
{ + e.stopPropagation(); + if (subtask.id) { + try { + const updatedSubtask = await toggleTaskCompletion(subtask.id); + await onTaskUpdate(updatedSubtask); + // Refresh subtasks to show updated status + await loadSubtasks(); + } catch (error) { + console.error('Error toggling subtask completion:', error); + } + } + }} + > + + + +
+ ) : ( +
{ + e.stopPropagation(); + if (subtask.id) { + try { + const updatedSubtask = await toggleTaskCompletion(subtask.id); + await onTaskUpdate(updatedSubtask); + // Refresh subtasks to show updated status + await loadSubtasks(); + } catch (error) { + console.error('Error toggling subtask completion:', error); + } + } + }} + /> + )} + + {subtask.name} + +
+
+ {subtask.status === 'done' || subtask.status === 2 ? ( + + ✓ + + ) : subtask.status === 'in_progress' || subtask.status === 1 ? ( + + ▶ + + ) : null} +
+
+
+
+ )) + ) : ( +
+ {t('subtasks.noSubtasks', 'No subtasks found')} +
+ )} +
+ ); +}; import TaskModal from './TaskModal'; -import { toggleTaskCompletion } from '../../utils/tasksService'; +import { toggleTaskCompletion, fetchSubtasks } from '../../utils/tasksService'; import { isTaskOverdue } from '../../utils/dateUtils'; +import { useTranslation } from 'react-i18next'; interface TaskItemProps { task: Task; @@ -24,14 +151,99 @@ const TaskItem: React.FC = ({ onToggleToday, }) => { const [isModalOpen, setIsModalOpen] = useState(false); + const [subtaskModalOpen, setSubtaskModalOpen] = useState(false); + const [selectedSubtask, setSelectedSubtask] = useState(null); const [projectList, setProjectList] = useState(projects); + + // Subtasks state + const [showSubtasks, setShowSubtasks] = useState(false); + const [subtasks, setSubtasks] = useState([]); + const [loadingSubtasks, setLoadingSubtasks] = useState(false); + const [hasSubtasks, setHasSubtasks] = useState(false); + + // Calculate completion percentage + const calculateCompletionPercentage = () => { + if (subtasks.length === 0) return 0; + const completedCount = subtasks.filter(subtask => + subtask.status === 'done' || subtask.status === 2 + ).length; + return Math.round((completedCount / subtasks.length) * 100); + }; + + const completionPercentage = calculateCompletionPercentage(); - // Dispatch global modal events + // Check if task has subtasks on mount + useEffect(() => { + const checkSubtasks = async () => { + if (!task.id) return; + + try { + const subtasksData = await fetchSubtasks(task.id); + setHasSubtasks(subtasksData.length > 0); + } catch (error) { + console.error('Error fetching subtasks:', error); + setHasSubtasks(false); + } + }; + + checkSubtasks(); + }, [task.id]); + + const loadSubtasks = async () => { + if (!task.id) return; + + setLoadingSubtasks(true); + try { + const subtasksData = await fetchSubtasks(task.id); + setSubtasks(subtasksData); + } catch (error) { + console.error('Failed to load subtasks:', error); + setSubtasks([]); + } finally { + setLoadingSubtasks(false); + } + }; + + // Reload subtasks when showSubtasks changes to true + useEffect(() => { + if (showSubtasks && subtasks.length === 0) { + loadSubtasks(); + } + }, [showSubtasks, subtasks.length]); + + const handleSubtasksToggle = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!showSubtasks && subtasks.length === 0) { + await loadSubtasks(); + } + + setShowSubtasks(!showSubtasks); + }; const handleTaskClick = () => { setIsModalOpen(true); }; + const handleSubtaskClick = (subtask: Task) => { + setSelectedSubtask(subtask); + setSubtaskModalOpen(true); + }; + + const handleSubtaskSave = async (updatedSubtask: Task) => { + await onTaskUpdate(updatedSubtask); + setSubtaskModalOpen(false); + setSelectedSubtask(null); + }; + + const handleSubtaskDelete = async () => { + if (selectedSubtask && selectedSubtask.id) { + await onTaskDelete(selectedSubtask.id); + setSubtaskModalOpen(false); + setSelectedSubtask(null); + } + }; + const handleSave = async (updatedTask: Task) => { await onTaskUpdate(updatedTask); setIsModalOpen(false); @@ -93,22 +305,53 @@ const TaskItem: React.FC = ({ const isOverdue = isTaskOverdue(task); return ( -
- +
+ + + {/* Progress bar at bottom of parent task */} + {subtasks.length > 0 && ( +
+
+
+
+
+ )} +
+ + { + e.stopPropagation(); + handleSubtaskClick(task); + }} onTaskUpdate={onTaskUpdate} - isOverdue={isOverdue} + loadSubtasks={loadSubtasks} /> = ({ projects={projectList} onCreateProject={handleCreateProject} /> -
+ + {selectedSubtask && ( + { + setSubtaskModalOpen(false); + setSelectedSubtask(null); + }} + task={selectedSubtask} + onSave={handleSubtaskSave} + onDelete={handleSubtaskDelete} + projects={projectList} + onCreateProject={handleCreateProject} + /> + )} + ); }; diff --git a/frontend/components/Task/TaskModal.tsx b/frontend/components/Task/TaskModal.tsx index 227814d..655549e 100644 --- a/frontend/components/Task/TaskModal.tsx +++ b/frontend/components/Task/TaskModal.tsx @@ -6,7 +6,7 @@ import { useToast } from '../Shared/ToastContext'; import TimelinePanel from './TimelinePanel'; import { Project } from '../../entities/Project'; import { useStore } from '../../store/useStore'; -import { fetchTaskById } from '../../utils/tasksService'; +import { fetchTaskById, fetchSubtasks } from '../../utils/tasksService'; import { getTaskIntelligenceEnabled } from '../../utils/profileService'; import { analyzeTaskName, @@ -20,6 +20,7 @@ import { Cog6ToothIcon, ArrowPathIcon, TrashIcon, + Squares2X2Icon, } from '@heroicons/react/24/outline'; // Import form sections @@ -29,6 +30,7 @@ import TaskTagsSection from './TaskForm/TaskTagsSection'; import TaskProjectSection from './TaskForm/TaskProjectSection'; import TaskMetadataSection from './TaskForm/TaskMetadataSection'; import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection'; +import TaskSubtasksSection from './TaskForm/TaskSubtasksSection'; interface TaskModalProps { isOpen: boolean; @@ -73,6 +75,7 @@ const TaskModal: React.FC = ({ const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true); const [isTimelineExpanded, setIsTimelineExpanded] = useState(false); + const [subtasks, setSubtasks] = useState>([]); // Collapsible section states const [expandedSections, setExpandedSections] = useState({ @@ -80,11 +83,24 @@ const TaskModal: React.FC = ({ project: false, metadata: false, recurrence: false, + subtasks: false, }); const { showSuccessToast, showErrorToast } = useToast(); const { t } = useTranslation(); + const scrollToSubtasksSection = () => { + setTimeout(() => { + const subtasksSection = document.querySelector('[data-section="subtasks"]'); + if (subtasksSection) { + subtasksSection.scrollIntoView({ + behavior: 'smooth', + block: 'end' + }); + } + }, 300); // Give time for section to expand + }; + const toggleSection = useCallback( (section: keyof typeof expandedSections) => { setExpandedSections((prev) => { @@ -95,17 +111,22 @@ const TaskModal: React.FC = ({ // Auto-scroll to show the expanded section if (newExpanded[section]) { - setTimeout(() => { - const scrollContainer = document.querySelector( - '.absolute.inset-0.overflow-y-auto' - ); - if (scrollContainer) { - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: 'smooth', - }); - } - }, 100); // Small delay to ensure DOM is updated + // Special handling for subtasks section + if (section === 'subtasks') { + scrollToSubtasksSection(); + } else { + setTimeout(() => { + const scrollContainer = document.querySelector( + '.absolute.inset-0.overflow-y-auto' + ); + if (scrollContainer) { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: 'smooth', + }); + } + }, 100); // Small delay to ensure DOM is updated + } } return newExpanded; @@ -278,7 +299,11 @@ const TaskModal: React.FC = ({ }; const handleSubmit = () => { - onSave({ ...formData, tags: tags.map((tag) => ({ name: tag })) }); + onSave({ + ...formData, + tags: tags.map((tag) => ({ name: tag })), + subtasks: subtasks + } as any); const taskLink = ( {t('task.updated', 'Task')}{' '} @@ -389,6 +414,31 @@ const TaskModal: React.FC = ({ }; }, [isOpen]); + // Load existing subtasks when modal opens + useEffect(() => { + if (isOpen && task.id) { + const loadExistingSubtasks = async () => { + try { + const existingSubtasks = await fetchSubtasks(task.id!); + const subtaskData = existingSubtasks.map(subtask => ({ + id: subtask.id, + name: subtask.name, + isNew: false, + })); + setSubtasks(subtaskData); + } catch (error) { + // Handle silently - don't show error for this + setSubtasks([]); + } + }; + + loadExistingSubtasks(); + } else if (!isOpen) { + // Reset subtasks when modal closes + setSubtasks([]); + } + }, [isOpen, task.id]); + if (!isOpen) return null; return createPortal( @@ -589,6 +639,23 @@ const TaskModal: React.FC = ({ />
)} + + {expandedSections.subtasks && ( +
+

+ {t( + 'forms.task.subtasks', + 'Subtasks' + )} +

+ +
+ )}
@@ -705,6 +772,27 @@ const TaskModal: React.FC = ({ )} + + {/* Subtasks Toggle */} + {/* Right side: Timeline Toggle Button */} diff --git a/frontend/utils/tasksService.ts b/frontend/utils/tasksService.ts index a12f956..69f9ee4 100644 --- a/frontend/utils/tasksService.ts +++ b/frontend/utils/tasksService.ts @@ -97,6 +97,16 @@ export const fetchTaskByUuid = async (uuid: string): Promise => { return await response.json(); }; +export const fetchSubtasks = async (parentTaskId: number): Promise => { + const response = await fetch(`/api/task/${parentTaskId}/subtasks`, { + credentials: 'include', + headers: getDefaultHeaders(), + }); + + await handleAuthResponse(response, 'Failed to fetch subtasks.'); + return await response.json(); +}; + export const toggleTaskToday = async (taskId: number): Promise => { const response = await fetch(`/api/task/${taskId}/toggle-today`, { method: 'PATCH',