424 lines
No EOL
20 KiB
TypeScript
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; |