tududi/frontend/components/Task/TaskItem.tsx

464 lines
18 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import TaskHeader from './TaskHeader';
import { useToast } from '../Shared/ToastContext';
import TaskPriorityIcon from '../Shared/Icons/TaskPriorityIcon';
import { isTaskCompleted } from '../../constants/taskStatus';
// Import SubtasksDisplay component from TaskHeader
interface SubtasksDisplayProps {
loadingSubtasks: boolean;
subtasks: Task[];
onTaskClick: (e: React.MouseEvent, task: Task) => void;
loadSubtasks: () => Promise<void>;
onSubtaskUpdate: (updatedSubtask: Task) => void;
}
const getPriorityBorderClassName = (
priority?: Task['priority'] | number
): string => {
let normalizedPriority = priority;
if (typeof normalizedPriority === 'number') {
const priorityNames: Array<'low' | 'medium' | 'high'> = [
'low',
'medium',
'high',
];
normalizedPriority = priorityNames[normalizedPriority] || undefined;
}
switch (normalizedPriority) {
case 'high':
return 'border-l-4 border-l-red-500';
case 'medium':
return 'border-l-4 border-l-yellow-400';
case 'low':
return 'border-l-4 border-l-blue-400';
default:
return 'border-l-4 border-l-transparent';
}
};
const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
loadingSubtasks,
subtasks,
onTaskClick,
loadSubtasks,
onSubtaskUpdate,
}) => {
const { t } = useTranslation();
if (loadingSubtasks) {
return (
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
{t('loading.subtasks', 'Loading subtasks...')}
</div>
);
}
if (subtasks.length === 0) {
return (
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
{t('subtasks.noSubtasks', 'No subtasks found')}
</div>
);
}
return (
<div className="mt-1 space-y-1 relative z-0">
{subtasks.map((subtask) => {
const borderClass = isTaskCompleted(subtask.status)
? 'border-l-4 border-l-green-500'
: getPriorityBorderClassName(subtask.priority);
return (
<div key={subtask.id} className="ml-[10%]">
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-colors duration-200 ease-in-out hover:ring-1 hover:ring-gray-200 dark:hover:ring-gray-700 cursor-pointer ${borderClass}`}
onClick={(e) => {
e.stopPropagation();
onTaskClick(e, subtask);
}}
>
<div className="px-3 py-2.5 flex items-center justify-between">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<TaskPriorityIcon
priority={subtask.priority || 'low'}
status={subtask.status || 'not_started'}
onToggleCompletion={async () => {
if (subtask.uid) {
try {
const updatedSubtask =
await toggleTaskCompletion(
subtask.uid,
subtask
);
if (
updatedSubtask.parent_child_logic_executed
) {
setTimeout(() => {
window.location.reload();
}, 200);
return;
}
onSubtaskUpdate(
updatedSubtask
);
} catch (error) {
console.error(
'Error toggling subtask completion:',
error
);
await loadSubtasks();
}
}
}}
/>
<span
className={`text-sm truncate min-w-0 ${
isTaskCompleted(subtask.status)
? 'text-gray-500 dark:text-gray-400 line-through'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{subtask.original_name || subtask.name}
</span>
</div>
{isTaskCompleted(subtask.status) && (
<span className="text-xs text-green-600 dark:text-green-400">
</span>
)}
</div>
</div>
</div>
);
})}
</div>
);
};
import { toggleTaskCompletion, fetchSubtasks } from '../../utils/tasksService';
import { isTaskOverdueInTodayPlan } from '../../utils/dateUtils';
import { useTranslation } from 'react-i18next';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { getApiPath } from '../../config/paths';
interface TaskItemProps {
task: Task;
onTaskUpdate: (task: Task) => Promise<void>;
onTaskCompletionToggle?: (task: Task) => void;
onTaskDelete: (taskUid: string) => void;
projects: Project[];
hideProjectName?: boolean;
onToggleToday?: (taskId: number, task?: Task) => Promise<void>;
isUpcomingView?: boolean;
showCompletedTasks?: boolean;
}
const TaskItem: React.FC<TaskItemProps> = ({
task,
onTaskUpdate,
onTaskCompletionToggle,
onTaskDelete,
projects,
hideProjectName = false,
onToggleToday,
isUpcomingView = false,
showCompletedTasks = false,
}) => {
const navigate = useNavigate();
const location = useLocation();
const { t } = useTranslation();
const [projectList, setProjectList] = useState<Project[]>(projects);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const { showErrorToast } = useToast();
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
// Status menu state
const [isStatusMenuOpen, setIsStatusMenuOpen] = useState(false);
// Subtasks state
const [subtasks, setSubtasks] = useState<Task[]>([]);
const [loadingSubtasks, setLoadingSubtasks] = useState(false);
const [showSubtasks, setShowSubtasks] = useState(false);
// Update projectList when projects prop changes
useEffect(() => {
setProjectList(projects);
}, [projects]);
const loadSubtasks = useCallback(async () => {
if (!task.uid) return;
setLoadingSubtasks(true);
try {
const subtasksData = await fetchSubtasks(task.uid);
setSubtasks(subtasksData);
} catch (error) {
console.error('Failed to load subtasks:', error);
setSubtasks([]);
} finally {
setLoadingSubtasks(false);
}
}, [task.id]);
// Calculate completion percentage
const calculateCompletionPercentage = () => {
if (subtasks.length === 0) return 0;
const completedCount = subtasks.filter(
(subtask) =>
subtask.status === 'done' ||
subtask.status === 2 ||
subtask.status === 'archived' ||
subtask.status === 3
).length;
return Math.round((completedCount / subtasks.length) * 100);
};
const completionPercentage = calculateCompletionPercentage();
const hasInitialSubtasks = task.subtasks && task.subtasks.length > 0;
const shouldShowSubtasksIcon =
hasInitialSubtasks || subtasks.length > 0 || loadingSubtasks;
useEffect(() => {
const subtasksData = task.subtasks || [];
setSubtasks(subtasksData);
}, [task.id, task.subtasks]);
useEffect(() => {
setShowSubtasks(false);
}, [task.id]);
const fromState = { state: { from: location.pathname + location.search } };
const handleTaskClick = () => {
if (task.uid) {
if (task.habit_mode) {
navigate(`/habit/${task.uid}`);
} else {
navigate(`/task/${task.uid}`, fromState);
}
}
};
const handleSubtaskClick = async () => {
// Navigate to the parent task URL (not the subtask URL)
if (task.uid) {
navigate(`/task/${task.uid}`, fromState);
}
};
const handleSubtasksToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!showSubtasks) {
if (subtasks.length === 0) {
await loadSubtasks();
}
setShowSubtasks(true);
} else {
setShowSubtasks(false);
}
};
const handleEdit = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (task.uid) {
navigate(`/task/${task.uid}`, fromState);
}
};
const handleDeleteClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsConfirmDialogOpen(true);
};
const handleConfirmDelete = () => {
setIsConfirmDialogOpen(false);
handleDelete();
};
const handleDelete = async () => {
if (task.uid) {
try {
await onTaskDelete(task.uid);
} catch (error: any) {
console.error('Task delete failed:', error);
showErrorToast(
t('errors.permissionDenied', 'Permission denied')
);
}
}
};
const handleToggleCompletion = async () => {
if (task.id) {
try {
// Check if task is being completed (not uncompleted)
const isCompletingTask =
task.status !== 'done' &&
task.status !== 2 &&
task.status !== 'archived' &&
task.status !== 3;
// If completing the task in upcoming view and not showing completed tasks, trigger animation
if (isCompletingTask && isUpcomingView && !showCompletedTasks) {
setIsAnimatingOut(true);
// Wait for animation to complete before updating state
await new Promise((resolve) => setTimeout(resolve, 300));
}
const response = await toggleTaskCompletion(task.uid!, task);
// Handle the updated task
if (onTaskCompletionToggle) {
onTaskCompletionToggle(response);
} else {
// Merge the response with existing task data to preserve subtasks
const mergedTask = {
...task,
...response,
// Explicitly preserve subtasks data from original task
subtasks: response.subtasks || task.subtasks || [],
};
await onTaskUpdate(mergedTask);
}
// Only refresh if parent-child logic was executed (affecting other tasks)
if (response.parent_child_logic_executed) {
// Instead of refreshing, let's refetch and update the task data
setTimeout(async () => {
try {
// Refetch the current task with updated subtasks
const updatedTaskResponse = await fetch(
getApiPath(`task/${task.uid}`)
);
if (updatedTaskResponse.ok) {
const updatedTaskData =
await updatedTaskResponse.json();
await onTaskUpdate(updatedTaskData);
}
} catch (error) {
console.error(
'Error refetching task after parent-child logic:',
error
);
// Fallback to refresh if API call fails
window.location.reload();
}
}, 200);
}
} catch (error) {
console.error('Error toggling task completion:', error);
setIsAnimatingOut(false); // Reset animation state on error
}
}
};
// Use the project from the task's included data if available, otherwise find from projectList
let project =
task.Project || projectList.find((p) => p.id === task.project_id);
// If project exists but doesn't have an ID, add the ID from task.project_id
if (project && !project.id && task.project_id) {
project = { ...project, id: task.project_id };
}
// Check if task is in progress to apply pulsing border animation
const isInProgress = task.status === 'in_progress' || task.status === 1;
// Check if task is overdue (created yesterday or earlier and not completed)
const isOverdue = isTaskOverdueInTodayPlan(task);
const priorityBorderClass = isTaskCompleted(task.status)
? 'border-l-4 border-l-green-500'
: getPriorityBorderClassName(task.priority);
return (
<div className={`relative ${isStatusMenuOpen ? 'z-[10001]' : ''}`}>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-colors duration-200 ease-in-out hover:ring-1 hover:ring-gray-200 dark:hover:ring-gray-700 ${priorityBorderClass} ${
isInProgress
? 'ring-1 ring-blue-500/60 dark:ring-blue-600/60'
: ''
} ${isAnimatingOut ? 'opacity-0' : 'opacity-100'}`}
>
<TaskHeader
task={task}
project={project}
onTaskClick={handleTaskClick}
onToggleCompletion={handleToggleCompletion}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
onTaskUpdate={onTaskUpdate}
isOverdue={isOverdue}
showSubtasks={showSubtasks}
hasSubtasks={shouldShowSubtasksIcon}
onSubtasksToggle={
shouldShowSubtasksIcon
? handleSubtasksToggle
: undefined
}
onEdit={handleEdit}
onDelete={handleDeleteClick}
isUpcomingView={isUpcomingView}
onMenuOpenChange={setIsStatusMenuOpen}
/>
{/* Progress bar at bottom of parent task */}
{subtasks.length > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 opacity-100">
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 ml-1 rounded-r-lg overflow-hidden">
<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>
{/* Hide subtasks display for archived tasks */}
{showSubtasks &&
(subtasks.length > 0 || loadingSubtasks) &&
!(task.status === 'archived' || task.status === 3) && (
<SubtasksDisplay
loadingSubtasks={loadingSubtasks}
subtasks={subtasks}
onTaskClick={(e) => {
e.stopPropagation();
handleSubtaskClick();
}}
loadSubtasks={loadSubtasks}
onSubtaskUpdate={(updatedSubtask) => {
setSubtasks((prev) =>
prev.map((st) =>
st.id === updatedSubtask.id
? updatedSubtask
: st
)
);
}}
/>
)}
{/* Confirm Delete Dialog */}
{isConfirmDialogOpen && (
<ConfirmDialog
title={t('tasks.deleteConfirmTitle', 'Delete Task')}
message={t(
'tasks.deleteConfirmMessage',
`Are you sure you want to delete "${task.name}"? This action cannot be undone.`
)}
onConfirm={handleConfirmDelete}
onCancel={() => setIsConfirmDialogOpen(false)}
/>
)}
</div>
);
};
export default React.memo(TaskItem);