tududi/frontend/components/Task/GroupedTaskList.tsx
2025-08-13 19:00:37 +03:00

424 lines
No EOL
20 KiB
TypeScript

import React, { useState, useMemo, useEffect } from 'react';
import { ChevronRightIcon, ChevronDownIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next';
import TaskItem from './TaskItem';
import { Project } from '../../entities/Project';
import { Task } from '../../entities/Task';
import { GroupedTasks } from '../../utils/tasksService';
interface GroupedTaskListProps {
tasks: Task[];
groupedTasks?: GroupedTasks | null;
onTaskUpdate: (task: Task) => Promise<void>;
onTaskCompletionToggle?: (task: Task) => void;
onTaskCreate?: (task: Task) => void;
onTaskDelete: (taskId: number) => void;
projects: Project[];
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
showCompletedTasks?: boolean;
}
interface TaskGroup {
template: Task;
instances: Task[];
}
const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
tasks,
groupedTasks,
onTaskUpdate,
onTaskCompletionToggle,
onTaskDelete,
projects,
hideProjectName = false,
onToggleToday,
showCompletedTasks = false,
}) => {
const { t } = useTranslation();
// Initialize with all groups expanded by default
const getInitialExpandedGroups = () => {
const expanded = new Set<string>();
if (groupedTasks) {
Object.keys(groupedTasks).forEach(groupName => {
expanded.add(groupName); // Expand all groups by default
});
}
return expanded;
};
const [expandedDayGroups, setExpandedDayGroups] = useState<Set<string>>(getInitialExpandedGroups);
const [expandedRecurringGroups, setExpandedRecurringGroups] = useState<Set<number>>(new Set());
// If we have day-based groupedTasks from API, use those instead of recurring groups
const shouldUseDayGrouping = groupedTasks && Object.keys(groupedTasks).length > 0;
// Update expanded groups when groupedTasks changes (expand all by default)
useEffect(() => {
if (shouldUseDayGrouping && groupedTasks) {
const newExpanded = new Set<string>();
Object.keys(groupedTasks).forEach(groupName => {
newExpanded.add(groupName); // Expand all groups by default
});
setExpandedDayGroups(newExpanded);
}
}, [groupedTasks, shouldUseDayGrouping]);
// Group tasks by recurring template (legacy behavior)
const { recurringGroups, standaloneTask } = useMemo(() => {
if (shouldUseDayGrouping) {
// For day grouping, we don't need recurring groups
return { recurringGroups: [], standaloneTask: [] };
}
// Filter completed tasks if needed
const filteredTasks = showCompletedTasks
? tasks
: tasks.filter((task) => {
const isCompleted =
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3;
return !isCompleted;
});
const groups = new Map<number, TaskGroup>();
const standalone: Task[] = [];
filteredTasks.forEach((task) => {
if (task.recurring_parent_id) {
// This is a recurring instance
const parentId = task.recurring_parent_id;
if (!groups.has(parentId)) {
// Find the template task in the current results
let template = filteredTasks.find(t => t.id === parentId);
// If template not found in results, create a placeholder using the instance data
if (!template) {
// Create a virtual template task based on the instance
template = {
...task,
id: parentId,
recurring_parent_id: null, // This makes it the template
due_date: null, // Templates don't have specific due dates
name: task.name, // Keep the same name
isVirtualTemplate: true, // Flag to identify virtual templates
} as Task & { isVirtualTemplate?: boolean };
}
groups.set(parentId, { template, instances: [] });
}
const group = groups.get(parentId);
if (group) {
group.instances.push(task);
}
} else if (task.recurrence_type && task.recurrence_type !== 'none') {
// This is a recurring template - check if it has instances
const instances = filteredTasks.filter(t => t.recurring_parent_id === task.id);
if (instances.length > 0) {
groups.set(task.id!, { template: task, instances });
} else {
// Template without instances, show as standalone
standalone.push(task);
}
} else {
// Regular task
standalone.push(task);
}
});
return { recurringGroups: Array.from(groups.values()), standaloneTask: standalone };
}, [tasks, showCompletedTasks, shouldUseDayGrouping]);
// Filter grouped tasks for completed status
const filteredGroupedTasks = useMemo(() => {
if (!shouldUseDayGrouping || !groupedTasks) return {};
const filtered: GroupedTasks = {};
Object.entries(groupedTasks).forEach(([groupName, groupTasks]) => {
const filteredTasks = showCompletedTasks
? groupTasks
: groupTasks.filter((task) => {
const isCompleted =
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3;
return !isCompleted;
});
if (filteredTasks.length > 0) {
filtered[groupName] = filteredTasks;
}
});
return filtered;
}, [groupedTasks, showCompletedTasks, shouldUseDayGrouping]);
const toggleRecurringGroup = (templateId: number) => {
setExpandedRecurringGroups(prev => {
const newSet = new Set(prev);
if (newSet.has(templateId)) {
newSet.delete(templateId);
} else {
newSet.add(templateId);
}
return newSet;
});
};
const toggleDayGroup = (groupName: string) => {
setExpandedDayGroups(prev => {
const newSet = new Set(prev);
if (newSet.has(groupName)) {
newSet.delete(groupName);
} else {
newSet.add(groupName);
}
return newSet;
});
};
const formatRecurrence = (recurrenceType: string) => {
switch (recurrenceType) {
case 'daily':
return t('recurrence.daily', 'Daily');
case 'weekly':
return t('recurrence.weekly', 'Weekly');
case 'monthly':
return t('recurrence.monthly', 'Monthly');
default:
return t('recurrence.recurring', 'Recurring');
}
};
// Render day-based grouping if available
if (shouldUseDayGrouping) {
return (
<div className="task-list-container">
{Object.entries(filteredGroupedTasks).map(([groupName, dayTasks]) => {
const isExpanded = expandedDayGroups.has(groupName);
const taskCount = dayTasks.length;
return (
<div key={groupName} className="day-group mb-6">
{/* Day header */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => toggleDayGroup(groupName)}
className="flex items-center space-x-3 text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg p-2 transition-colors group"
>
<div className="flex items-center space-x-2">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 group-hover:text-blue-700 dark:group-hover:text-blue-300">
{groupName}
</h3>
<span className="text-sm text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded-full">
{taskCount}
</span>
</div>
<div className="flex items-center">
{isExpanded ? (
<ChevronDownIcon className="h-4 w-4 text-gray-400 group-hover:text-blue-500" />
) : (
<ChevronRightIcon className="h-4 w-4 text-gray-400 group-hover:text-blue-500" />
)}
</div>
</button>
</div>
{/* Day tasks - only show when expanded */}
{isExpanded && (
<div className="ml-4 space-y-2">
{dayTasks.map((task) => (
<div
key={task.id}
className="task-item-wrapper transition-all duration-200 ease-in-out"
>
<TaskItem
task={task}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={onTaskCompletionToggle}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
</div>
))}
</div>
)}
</div>
);
})}
{Object.keys(filteredGroupedTasks).length === 0 && (
<div className="flex justify-center items-center mt-4">
<div className="w-full max-w bg-black/2 dark:bg-gray-900/25 rounded-l px-10 py-24 flex flex-col items-center opacity-95">
<svg
className="h-20 w-20 text-gray-400 opacity-30 mb-6"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
<p className="text-2xl font-light text-center text-gray-600 dark:text-gray-300 mb-2">
{t('tasks.noTasksAvailable', 'No tasks available.')}
</p>
<p className="text-base text-center text-gray-400 dark:text-gray-400">
{t(
'tasks.blankSlateHint',
'Start by creating a new task or changing your filters.'
)}
</p>
</div>
</div>
)}
</div>
);
}
// Legacy: Render recurring task grouping
return (
<div className="task-list-container">
{/* Standalone tasks */}
{standaloneTask.map((task) => (
<div
key={task.id}
className="task-item-wrapper transition-all duration-200 ease-in-out"
>
<TaskItem
task={task}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={onTaskCompletionToggle}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
</div>
))}
{/* Grouped recurring tasks */}
{recurringGroups.map((group) => {
const isVirtualTemplate = (group.template as any).isVirtualTemplate;
const isExpanded = expandedRecurringGroups.has(group.template.id!) || isVirtualTemplate; // Auto-expand virtual templates
return (
<div key={group.template.id} className="recurring-task-group mb-2">
{/* Show template only if it's not virtual */}
{!isVirtualTemplate && (
<div className="relative">
<div className="flex items-center">
<div className="flex-1">
<TaskItem
task={group.template}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={onTaskCompletionToggle}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
</div>
</div>
{/* Recurring instances count and expand button */}
{group.instances.length > 0 && (
<button
onClick={() => toggleRecurringGroup(group.template.id!)}
className="absolute top-3 right-3 flex items-center space-x-2 px-3 py-1 text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-full hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors"
>
<ArrowPathIcon className="h-3 w-3" />
<span>
{group.instances.length} {t('task.upcoming', 'upcoming')}
</span>
{isExpanded ? (
<ChevronDownIcon className="h-3 w-3" />
) : (
<ChevronRightIcon className="h-3 w-3" />
)}
</button>
)}
</div>
)}
{/* For virtual templates, show a simple header */}
{isVirtualTemplate && group.instances.length > 0 && (
<div className="mb-2 flex items-center space-x-2 px-4 py-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<ArrowPathIcon className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{group.template.name} - {formatRecurrence(group.template.recurrence_type!)}
</span>
<span className="text-xs text-blue-600 dark:text-blue-400">
{group.instances.length} upcoming
</span>
</div>
)}
{/* Expanded instances */}
{isExpanded && group.instances.length > 0 && (
<div className={`mt-2 space-y-1 border-l-2 border-blue-200 dark:border-blue-800 pl-4 ${!isVirtualTemplate ? 'ml-8' : 'ml-4'}`}>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2 flex items-center">
<ArrowPathIcon className="h-3 w-3 mr-1" />
{formatRecurrence(group.template.recurrence_type!)} instances
</div>
{group.instances
.sort((a, b) => new Date(a.due_date || '').getTime() - new Date(b.due_date || '').getTime())
.map((instance) => (
<div key={instance.id} className="opacity-75 hover:opacity-100 transition-opacity">
<TaskItem
task={instance}
onTaskUpdate={onTaskUpdate}
onTaskCompletionToggle={onTaskCompletionToggle}
onTaskDelete={onTaskDelete}
projects={projects}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
/>
</div>
))}
</div>
)}
</div>
);
})}
{standaloneTask.length === 0 && recurringGroups.length === 0 && (
<div className="flex justify-center items-center mt-4">
<div className="w-full max-w bg-black/2 dark:bg-gray-900/25 rounded-l px-10 py-24 flex flex-col items-center opacity-95">
<svg
className="h-20 w-20 text-gray-400 opacity-30 mb-6"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
<p className="text-2xl font-light text-center text-gray-600 dark:text-gray-300 mb-2">
{t('tasks.noTasksAvailable', 'No tasks available.')}
</p>
<p className="text-base text-center text-gray-400 dark:text-gray-400">
{t(
'tasks.blankSlateHint',
'Start by creating a new task or changing your filters.'
)}
</p>
</div>
</div>
)}
</div>
);
};
export default GroupedTaskList;