import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useTaskList } from '../hooks/useProject'; import ListPagination from '../components/ListPagination'; import LinkField from '../components/LinkField'; import { buildDateRangeFilters } from '../utils/listFilterUtils'; import { FaTasks, FaPlus, FaSearch, FaFilter, FaSync, FaEye, FaChevronDown, FaChevronUp, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare, } from 'react-icons/fa'; import { useListPageSelection } from '../hooks/useListPageSelection'; import type { Task } from '../services/projectService'; import DynamicExportModal from '../components/DynamicExportModal'; import { fetchAllRowsForExport } from '../utils/frappeListExport'; const getStatusStyle = (s: string) => { switch (s?.toLowerCase()) { case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'; case 'working': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'; case 'completed': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; case 'cancelled': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'; case 'overdue': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'; default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'; } }; const getPriorityStyle = (p: string) => { switch (p?.toLowerCase()) { case 'high': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'; case 'medium': return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300'; case 'low': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'; } }; const TaskList: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const pageSize = 20; const currentPage = useMemo(() => { const p = parseInt(searchParams.get('page') || '1', 10); return Number.isNaN(p) || p < 1 ? 1 : p; }, [searchParams]); const setCurrentPage = useCallback((v: number | ((p: number) => number)) => { const next = typeof v === 'function' ? v(currentPage) : v; setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(next)); return n; }); }, [currentPage, setSearchParams]); const [isFilterExpanded, setIsFilterExpanded] = useState(false); const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || ''); const [priorityFilter, setPriorityFilter] = useState(searchParams.get('priority') || ''); const [projectFilter, setProjectFilter] = useState(searchParams.get('project') || ''); const [searchQuery, setSearchQuery] = useState(searchParams.get('q') || ''); const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>( (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || '' ); const [dateStart, setDateStart] = useState(searchParams.get('date_start') || ''); const [dateEnd, setDateEnd] = useState(searchParams.get('date_end') || ''); const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || 'creation desc'); const [showExportModal, setShowExportModal] = useState(false); const didInitUrlSync = useRef(false); const skipInitialSearchUrlSync = useRef(true); const searchDebounceRef = useRef(null); const setSearchParamsRef = useRef(setSearchParams); useEffect(() => { setSearchParamsRef.current = setSearchParams; }, [setSearchParams]); const apiFilters = useMemo(() => { const f: Record = {}; if (statusFilter) f.status = statusFilter; if (priorityFilter) f.priority = priorityFilter; if (projectFilter) f.project = projectFilter; if (searchQuery) f.subject = ['like', `%${searchQuery}%`]; Object.assign(f, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd)); return f; }, [statusFilter, priorityFilter, projectFilter, searchQuery, dateFilterBy, dateStart, dateEnd]); const { tasks, loading, error, totalCount, refetch } = useTaskList({ filters: apiFilters, limit_start: (currentPage - 1) * pageSize, limit_page_length: pageSize, order_by: sortBy, }); const selectionResetKey = useMemo( () => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`, [currentPage, sortBy, apiFilters], ); const { selectedRows, toggleRow, toggleAllOnPage, allOnPageSelected, someOnPageSelected, } = useListPageSelection(tasks, selectionResetKey); const fetchAllForExport = useCallback( () => fetchAllRowsForExport({ doctype: 'Task', filters: apiFilters, orderBy: sortBy }), [apiFilters, sortBy], ); const totalPages = Math.ceil(totalCount / pageSize); const formatDate = (d: string) => d ? new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-'; const clearFilters = () => { setStatusFilter(''); setPriorityFilter(''); setProjectFilter(''); setSearchQuery(''); setDateFilterBy(''); setDateStart(''); setDateEnd(''); setSortBy('creation desc'); setSearchParams(prev => { const n = new URLSearchParams(prev); ['status','priority','project','q','date_filter_by','date_start','date_end','sort_by'].forEach(k => n.delete(k)); n.set('page','1'); return n; }); }; // Auto-sync filters (no "Apply Filters" button for Project Management pages) useEffect(() => { if (!didInitUrlSync.current) { didInitUrlSync.current = true; return; } setSearchParamsRef.current(prev => { const n = new URLSearchParams(prev); statusFilter ? n.set('status', statusFilter) : n.delete('status'); priorityFilter ? n.set('priority', priorityFilter) : n.delete('priority'); projectFilter ? n.set('project', projectFilter) : n.delete('project'); dateFilterBy ? n.set('date_filter_by', dateFilterBy) : n.delete('date_filter_by'); dateStart ? n.set('date_start', dateStart) : n.delete('date_start'); dateEnd ? n.set('date_end', dateEnd) : n.delete('date_end'); sortBy !== 'creation desc' ? n.set('sort_by', sortBy) : n.delete('sort_by'); n.set('page', '1'); return n; }); }, [statusFilter, priorityFilter, projectFilter, dateFilterBy, dateStart, dateEnd, sortBy]); useEffect(() => { if (!didInitUrlSync.current) return; if (skipInitialSearchUrlSync.current) { skipInitialSearchUrlSync.current = false; return; } if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current); searchDebounceRef.current = window.setTimeout(() => { setSearchParamsRef.current(prev => { const n = new URLSearchParams(prev); searchQuery ? n.set('q', searchQuery) : n.delete('q'); n.set('page', '1'); return n; }); }, 450); return () => { if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current); }; }, [searchQuery]); const hasActiveFilters = !!(statusFilter || priorityFilter || projectFilter || searchQuery || (dateFilterBy && (dateStart || dateEnd))); const handleEdit = (taskName: string) => navigate(`/projects/tasks/${encodeURIComponent(taskName)}?edit=1`); const handleDuplicate = (taskName: string) => navigate(`/projects/tasks/new?duplicate=${encodeURIComponent(taskName)}`); return (
/

{t('projects.tasksDoctype')}

setShowExportModal(false)} doctype="Task" selectedCount={selectedRows.size} pageCount={tasks.length} totalCount={totalCount} pageData={tasks} selectedRows={selectedRows} rowKey="name" onFetchAll={fetchAllForExport} fileNamePrefix="tasks" /> {/* ── Filter Panel ── */}
{/* Header */}
{/* Left: toggle + title + count */}
Filters
{hasActiveFilters && ( {[searchQuery, statusFilter, priorityFilter, projectFilter, dateFilterBy && dateStart].filter(Boolean).length} )}
{/* Center: active filter pills */} {hasActiveFilters && (
{searchQuery && ( Subject: {searchQuery} )} {statusFilter && ( Status: {statusFilter} )} {priorityFilter && ( Priority: {priorityFilter} )} {projectFilter && ( Project: {projectFilter} )} {dateFilterBy && dateStart && ( {dateFilterBy === 'creation' ? 'Created' : 'Modified'}: {dateStart}{dateEnd ? ` – ${dateEnd}` : ''} )}
)} {/* Right: clear + refresh */}
{hasActiveFilters && ( )}
{/* Expanded filter body */} {isFilterExpanded && (
{/* Search */}
setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search by task subject…" className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
{/* Status */}
{/* Priority */}
{/* Project */}
{/* Date filter by */}
{dateFilterBy && ( <>
setDateStart(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
setDateEnd(e.target.value)} className="w-full px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-400 focus:outline-none" />
)} {/* Sort by */}
)}
{error && (
{error}
)}
{loading ? (
{t('common.loading')}
) : tasks.length === 0 ? (

{t('projects.noTasks')}

) : (
{[t('projects.taskColumn'), t('projects.project'), t('commonFields.status'), t('commonFields.priority'), t('projects.assignedTo'), t('projects.dueDate'), 'Exp. Time', ''].map(h => ( ))} {tasks.map((task: Task) => ( navigate(`/projects/tasks/${task.name}`)} > ))}
{h}
e.stopPropagation()}>
{task.subject || task.name}
{task.name}
{task.project ? ( ) : -} {task.status || '-'} {task.priority || '-'} {task._assign ? (JSON.parse(task._assign)[0] || '-') : '-'} {formatDate(task.exp_end_date || '')} {task.expected_time ? `${task.expected_time}h` : '-'} e.stopPropagation()}>
)} {totalPages > 1 && (
)}
); }; export default TaskList;