Scaffold subtasks
This commit is contained in:
parent
35ac0d7852
commit
aab58f9718
8 changed files with 1035 additions and 33 deletions
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
240
frontend/components/Task/TaskForm/TaskSubtasksSection.tsx
Normal file
240
frontend/components/Task/TaskForm/TaskSubtasksSection.tsx
Normal file
|
|
@ -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<TaskSubtasksSectionProps> = ({
|
||||
parentTaskId,
|
||||
subtasks,
|
||||
onSubtasksChange,
|
||||
onSectionMount,
|
||||
}) => {
|
||||
const [newSubtaskName, setNewSubtaskName] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const { t } = useTranslation();
|
||||
const subtasksSectionRef = useRef<HTMLDivElement>(null);
|
||||
const addInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div ref={subtasksSectionRef} className="space-y-3">
|
||||
{/* Existing Subtasks */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('loading.subtasks', 'Loading subtasks...')}
|
||||
</div>
|
||||
) : subtasks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{subtasks.map((subtask, index) => (
|
||||
<div
|
||||
key={subtask.id || index}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-md"
|
||||
>
|
||||
{editingIndex === index ? (
|
||||
<div className="flex-1 flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
className="p-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400"
|
||||
title={t('actions.cancel', 'Cancel')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1 hover:text-blue-600 dark:hover:text-blue-400"
|
||||
onClick={() => handleEditSubtask(index)}
|
||||
title={t('actions.clickToEdit', 'Click to edit')}
|
||||
>
|
||||
{subtask.name}
|
||||
{subtask.isNew && (
|
||||
<span className="ml-2 text-xs text-blue-500 dark:text-blue-400">
|
||||
(new)
|
||||
</span>
|
||||
)}
|
||||
{subtask.isEdited && (
|
||||
<span className="ml-2 text-xs text-orange-500 dark:text-orange-400">
|
||||
(edited)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteSubtask(index)}
|
||||
className="p-1 text-red-500 hover:text-red-700 dark:hover:text-red-400"
|
||||
title={t('actions.delete', 'Delete')}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('subtasks.noSubtasks', 'No subtasks yet')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Subtask */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
ref={addInputRef}
|
||||
type="text"
|
||||
value={newSubtaskName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateSubtask}
|
||||
disabled={!newSubtaskName.trim()}
|
||||
className="p-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t('actions.add', 'Add')}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskSubtasksSection;
|
||||
|
|
@ -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<void>;
|
||||
onTaskUpdate?: (task: Task) => Promise<void>;
|
||||
isOverdue?: boolean;
|
||||
// Props for subtasks functionality
|
||||
showSubtasks?: boolean;
|
||||
hasSubtasks?: boolean;
|
||||
onSubtasksToggle?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
|
|
@ -33,9 +39,14 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
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<TaskHeaderProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
const handleTodayToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent opening task modal
|
||||
if (onToggleToday && task.id) {
|
||||
|
|
@ -273,6 +285,30 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<PlayIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show Subtasks Controls */}
|
||||
{hasSubtasks && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
console.log('Subtasks button clicked', e);
|
||||
if (onSubtasksToggle) {
|
||||
onSubtasksToggle(e);
|
||||
}
|
||||
}}
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
|
||||
showSubtasks
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={
|
||||
showSubtasks
|
||||
? t('tasks.hideSubtasks', 'Hide subtasks')
|
||||
: t('tasks.showSubtasks', 'Show subtasks')
|
||||
}
|
||||
>
|
||||
<Squares2X2Icon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -420,6 +456,30 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<PlayIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Show Subtasks Controls - Mobile */}
|
||||
{hasSubtasks && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
console.log('Subtasks button clicked (mobile)', e);
|
||||
if (onSubtasksToggle) {
|
||||
onSubtasksToggle(e);
|
||||
}
|
||||
}}
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
|
||||
showSubtasks
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={
|
||||
showSubtasks
|
||||
? t('tasks.hideSubtasks', 'Hide subtasks')
|
||||
: t('tasks.showSubtasks', 'Show subtasks')
|
||||
}
|
||||
>
|
||||
<Squares2X2Icon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -427,4 +487,163 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// Subtasks Display Component
|
||||
interface SubtasksDisplayProps {
|
||||
showSubtasks: boolean;
|
||||
loadingSubtasks: boolean;
|
||||
subtasks: Task[];
|
||||
onTaskClick: (e: React.MouseEvent, task: Task) => void;
|
||||
}
|
||||
|
||||
const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
|
||||
showSubtasks,
|
||||
loadingSubtasks,
|
||||
subtasks,
|
||||
onTaskClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showSubtasks) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
{loadingSubtasks ? (
|
||||
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('loading.subtasks', 'Loading subtasks...')}
|
||||
</div>
|
||||
) : subtasks.length > 0 ? (
|
||||
subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="ml-[10%] group"
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 cursor-pointer transition-all duration-200 ${
|
||||
subtask.status === 'in_progress' || subtask.status === 1
|
||||
? 'border-green-400/60 dark:border-green-500/60'
|
||||
: 'border-gray-50 dark:border-gray-800'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskClick(e, subtask);
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-1.5 flex items-center justify-between">
|
||||
{/* Left side - Task info */}
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<TaskPriorityIcon
|
||||
priority={subtask.priority}
|
||||
status={subtask.status}
|
||||
/>
|
||||
<span className={`text-sm flex-1 truncate ${
|
||||
subtask.status === 'done' || subtask.status === 2
|
||||
? 'text-gray-500 dark:text-gray-400 line-through'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{subtask.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right side - Status indicator */}
|
||||
<div className="flex items-center space-x-1">
|
||||
{subtask.status === 'done' || subtask.status === 2 ? (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
✓
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('subtasks.noSubtasks', 'No subtasks found')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// TaskWithSubtasks Component that combines both
|
||||
interface TaskWithSubtasksProps extends TaskHeaderProps {
|
||||
onSubtaskClick?: (subtask: Task) => void;
|
||||
}
|
||||
|
||||
const TaskWithSubtasks: React.FC<TaskWithSubtasksProps> = (props) => {
|
||||
const [showSubtasks, setShowSubtasks] = useState(false);
|
||||
const [subtasks, setSubtasks] = useState<Task[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<TaskHeader
|
||||
{...props}
|
||||
// Pass the subtasks state to the header
|
||||
showSubtasks={showSubtasks}
|
||||
hasSubtasks={hasSubtasks}
|
||||
onSubtasksToggle={handleSubtasksToggle}
|
||||
/>
|
||||
<SubtasksDisplay
|
||||
showSubtasks={showSubtasks}
|
||||
loadingSubtasks={loadingSubtasks}
|
||||
subtasks={subtasks}
|
||||
onTaskClick={(e, task) => {
|
||||
e.stopPropagation();
|
||||
if (props.onSubtaskClick) {
|
||||
props.onSubtaskClick(task);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { TaskWithSubtasks };
|
||||
export default TaskHeader;
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
loadSubtasks: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
|
||||
showSubtasks,
|
||||
loadingSubtasks,
|
||||
subtasks,
|
||||
onTaskClick,
|
||||
onTaskUpdate,
|
||||
loadSubtasks,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!showSubtasks) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-1">
|
||||
{loadingSubtasks ? (
|
||||
<div className="ml-12 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('loading.subtasks', 'Loading subtasks...')}
|
||||
</div>
|
||||
) : subtasks.length > 0 ? (
|
||||
subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="ml-12 group"
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 cursor-pointer transition-all duration-200 ${
|
||||
subtask.status === 'in_progress' || subtask.status === 1
|
||||
? 'border-green-400/60 dark:border-green-500/60'
|
||||
: 'border-gray-50 dark:border-gray-800'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTaskClick(e, subtask);
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
{subtask.status === 'done' || subtask.status === 2 ? (
|
||||
<div
|
||||
className="h-5 w-5 cursor-pointer hover:scale-110 transition-transform text-green-500 flex items-center justify-center"
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
onClick={async (e) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`h-5 w-5 cursor-pointer hover:scale-110 transition-transform border-2 border-current rounded-full flex-shrink-0 ${
|
||||
subtask.priority === 'high'
|
||||
? 'text-red-500'
|
||||
: subtask.priority === 'medium'
|
||||
? 'text-yellow-500'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
style={{ width: '16px', height: '16px' }}
|
||||
onClick={async (e) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className={`text-base flex-1 truncate ${
|
||||
subtask.status === 'done' || subtask.status === 2
|
||||
? 'text-gray-500 dark:text-gray-400 line-through'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{subtask.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{subtask.status === 'done' || subtask.status === 2 ? (
|
||||
<span className="text-sm text-green-600 dark:text-green-400 font-medium">
|
||||
✓
|
||||
</span>
|
||||
) : subtask.status === 'in_progress' || subtask.status === 1 ? (
|
||||
<span className="text-sm text-blue-600 dark:text-blue-400 font-medium">
|
||||
▶
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="ml-12 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('subtasks.noSubtasks', 'No subtasks found')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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<TaskItemProps> = ({
|
|||
onToggleToday,
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [subtaskModalOpen, setSubtaskModalOpen] = useState(false);
|
||||
const [selectedSubtask, setSelectedSubtask] = useState<Task | null>(null);
|
||||
const [projectList, setProjectList] = useState<Project[]>(projects);
|
||||
|
||||
// Subtasks state
|
||||
const [showSubtasks, setShowSubtasks] = useState(false);
|
||||
const [subtasks, setSubtasks] = useState<Task[]>([]);
|
||||
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<TaskItemProps> = ({
|
|||
const isOverdue = isTaskOverdue(task);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 transition-all duration-200 ease-in-out ${
|
||||
isInProgress
|
||||
? 'border-2 border-green-400/60 dark:border-green-500/60'
|
||||
: 'border-2 border-gray-50 dark:border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<TaskHeader
|
||||
task={task}
|
||||
project={project}
|
||||
onTaskClick={handleTaskClick}
|
||||
onToggleCompletion={handleToggleCompletion}
|
||||
hideProjectName={hideProjectName}
|
||||
onToggleToday={onToggleToday}
|
||||
<>
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 relative overflow-hidden transition-all duration-200 ease-in-out ${
|
||||
isInProgress
|
||||
? 'border-2 border-green-400/60 dark:border-green-500/60'
|
||||
: 'border-2 border-gray-50 dark:border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<TaskHeader
|
||||
task={task}
|
||||
project={project}
|
||||
onTaskClick={handleTaskClick}
|
||||
onToggleCompletion={handleToggleCompletion}
|
||||
hideProjectName={hideProjectName}
|
||||
onToggleToday={onToggleToday}
|
||||
onTaskUpdate={onTaskUpdate}
|
||||
isOverdue={isOverdue}
|
||||
showSubtasks={showSubtasks}
|
||||
hasSubtasks={hasSubtasks}
|
||||
onSubtasksToggle={handleSubtasksToggle}
|
||||
/>
|
||||
|
||||
{/* Progress bar at bottom of parent task */}
|
||||
{subtasks.length > 0 && (
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1 transition-all duration-300 ease-in-out ${
|
||||
showSubtasks ? 'opacity-100 transform translate-y-0' : 'opacity-0 transform translate-y-2'
|
||||
}`}>
|
||||
<div className="w-full h-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-400 via-green-500 to-green-600 transition-all duration-500 ease-out"
|
||||
style={{ width: `${completionPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubtasksDisplay
|
||||
showSubtasks={showSubtasks}
|
||||
loadingSubtasks={loadingSubtasks}
|
||||
subtasks={subtasks}
|
||||
onTaskClick={(e, task) => {
|
||||
e.stopPropagation();
|
||||
handleSubtaskClick(task);
|
||||
}}
|
||||
onTaskUpdate={onTaskUpdate}
|
||||
isOverdue={isOverdue}
|
||||
loadSubtasks={loadSubtasks}
|
||||
/>
|
||||
|
||||
<TaskModal
|
||||
|
|
@ -120,7 +363,22 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
projects={projectList}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedSubtask && (
|
||||
<TaskModal
|
||||
isOpen={subtaskModalOpen}
|
||||
onClose={() => {
|
||||
setSubtaskModalOpen(false);
|
||||
setSelectedSubtask(null);
|
||||
}}
|
||||
task={selectedSubtask}
|
||||
onSave={handleSubtaskSave}
|
||||
onDelete={handleSubtaskDelete}
|
||||
projects={projectList}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TaskModalProps> = ({
|
|||
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
|
||||
useState(true);
|
||||
const [isTimelineExpanded, setIsTimelineExpanded] = useState(false);
|
||||
const [subtasks, setSubtasks] = useState<Array<{id?: number; name: string; isNew?: boolean}>>([]);
|
||||
|
||||
// Collapsible section states
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
|
|
@ -80,11 +83,24 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
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<TaskModalProps> = ({
|
|||
|
||||
// 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<TaskModalProps> = ({
|
|||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave({ ...formData, tags: tags.map((tag) => ({ name: tag })) });
|
||||
onSave({
|
||||
...formData,
|
||||
tags: tags.map((tag) => ({ name: tag })),
|
||||
subtasks: subtasks
|
||||
} as any);
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.updated', 'Task')}{' '}
|
||||
|
|
@ -389,6 +414,31 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
};
|
||||
}, [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<TaskModalProps> = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expandedSections.subtasks && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4" data-section="subtasks">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t(
|
||||
'forms.task.subtasks',
|
||||
'Subtasks'
|
||||
)}
|
||||
</h3>
|
||||
<TaskSubtasksSection
|
||||
parentTaskId={task.id!}
|
||||
subtasks={subtasks}
|
||||
onSubtasksChange={setSubtasks}
|
||||
onSectionMount={scrollToSubtasksSection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -705,6 +772,27 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Subtasks Toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
toggleSection('subtasks')
|
||||
}
|
||||
className={`relative p-2 rounded-full transition-colors ${
|
||||
expandedSections.subtasks
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title={t(
|
||||
'forms.task.subtasks',
|
||||
'Subtasks'
|
||||
)}
|
||||
>
|
||||
<Squares2X2Icon className="h-5 w-5" />
|
||||
{subtasks.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right side: Timeline Toggle Button */}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,16 @@ export const fetchTaskByUuid = async (uuid: string): Promise<Task> => {
|
|||
return await response.json();
|
||||
};
|
||||
|
||||
export const fetchSubtasks = async (parentTaskId: number): Promise<Task[]> => {
|
||||
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<Task> => {
|
||||
const response = await fetch(`/api/task/${taskId}/toggle-today`, {
|
||||
method: 'PATCH',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue