import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { toast, ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; import { FaFolderOpen, FaPlus, FaSync, FaEye, FaEdit, FaCopy, FaFileExport, FaCheckSquare, FaSquare, FaFilter, FaChevronDown, FaChevronUp, FaSearch, } from 'react-icons/fa'; import ListPagination from '../components/ListPagination'; import DynamicExportModal from '../components/DynamicExportModal'; import projectService, { type Project } from '../services/projectService'; import { buildDateRangeFilters } from '../utils/listFilterUtils'; import { fetchAllRowsForExport } from '../utils/frappeListExport'; const pageSize = 20; const ProjectList: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); 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 [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') || 'modified desc'); const [projects, setProjects] = useState([]); const [totalCount, setTotalCount] = useState(0); const [loading, setLoading] = useState(true); const [selectedRows, setSelectedRows] = useState>(new Set()); const [showExportModal, setShowExportModal] = useState(false); const [duplicating, setDuplicating] = useState(null); const apiFilters = useMemo(() => { const f: Record = {}; if (statusFilter) f.status = statusFilter; if (priorityFilter) f.priority = priorityFilter; if (searchQuery) f.project_name = ['like', `%${searchQuery}%`]; Object.assign(f, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd)); return f; }, [statusFilter, priorityFilter, searchQuery, dateFilterBy, dateStart, dateEnd]); const orderBy = ['creation desc', 'creation asc', 'modified desc', 'modified asc', 'name asc', 'name desc'].includes(sortBy) ? sortBy : 'modified desc'; const fetchAllForExport = useCallback( () => fetchAllRowsForExport({ doctype: 'Project', filters: apiFilters, order_by: orderBy }), [apiFilters, orderBy] ); const load = useCallback(async () => { setLoading(true); try { const [res, cnt] = await Promise.all([ projectService.getProjects({ filters: apiFilters, limit_start: (currentPage - 1) * pageSize, limit_page_length: pageSize, order_by: orderBy, }), projectService.getProjectCount(apiFilters), ]); setProjects(res.data); setTotalCount(cnt); } catch (e: any) { toast.error(e.message || 'Failed to load projects'); } finally { setLoading(false); } }, [apiFilters, currentPage, orderBy]); useEffect(() => { load(); }, [load]); const totalPages = Math.ceil(totalCount / pageSize); const toggleRow = (name: string) => { setSelectedRows(prev => { const n = new Set(prev); if (n.has(name)) n.delete(name); else n.add(name); return n; }); }; const allOnPageSelected = projects.length > 0 && projects.every(p => selectedRows.has(p.name)); const someSelected = selectedRows.size > 0; const toggleSelectAllPage = () => { if (allOnPageSelected) { setSelectedRows(prev => { const n = new Set(prev); projects.forEach(p => n.delete(p.name)); return n; }); } else { setSelectedRows(prev => { const n = new Set(prev); projects.forEach(p => n.add(p.name)); return n; }); } }; const handleDuplicate = async (e: React.MouseEvent, name: string) => { e.stopPropagation(); try { setDuplicating(name); const newName = await projectService.copyProject(name); toast.success(`Duplicated: ${newName}`); await load(); navigate(`/projects/list/${encodeURIComponent(newName)}`); } catch (err: any) { toast.error(err.message || 'Duplicate failed'); } finally { setDuplicating(null); } }; const applyFilters = () => { setSearchParams(prev => { const n = new URLSearchParams(prev); statusFilter ? n.set('status', statusFilter) : n.delete('status'); priorityFilter ? n.set('priority', priorityFilter) : n.delete('priority'); searchQuery ? n.set('q', searchQuery) : n.delete('q'); 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 !== 'modified desc' ? n.set('sort_by', sortBy) : n.delete('sort_by'); n.set('page', '1'); return n; }); }; const clearFilters = () => { setStatusFilter(''); setPriorityFilter(''); setSearchQuery(''); setDateFilterBy(''); setDateStart(''); setDateEnd(''); setSortBy('modified desc'); setSearchParams({ page: '1' }); }; const hasActiveFilters = !!(statusFilter || priorityFilter || searchQuery || (dateFilterBy && (dateStart || dateEnd))); return (

{t('projects.listTitle', 'Projects')}

{totalCount} {t('projects.total', 'total')} {someSelected && ( • {selectedRows.size} {t('listPages.selected', 'selected')} )}

{t('listPages.filters', 'Filters')} {hasActiveFilters && ( {[searchQuery, statusFilter, priorityFilter].filter(Boolean).length} )}
{hasActiveFilters && ( )}
{isFilterExpanded && (
setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && applyFilters()} className="w-full pl-8 pr-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600" placeholder="Project name…" />
)}
{['Project', 'Status', 'Priority', 'Customer', 'Progress', 'Dates', 'Actions'].map(h => ( ))} {loading ? ( ) : projects.length === 0 ? ( ) : ( projects.map(p => ( navigate(`/projects/list/${encodeURIComponent(p.name)}`)} > )) )}
{h}
Loading…
No projects found
e.stopPropagation()}> {p.project_name || p.name}
{p.name}
{p.status || '-'} {p.priority || '-'} {p.customer_name || p.customer || '—'} {p.percent_complete ?? 0}% {p.expected_start_date || '—'} → {p.expected_end_date || '—'} e.stopPropagation()}>
{totalPages > 1 && (
)}
setShowExportModal(false)} doctype="Project" selectedCount={selectedRows.size} pageCount={projects.length} totalCount={totalCount} pageData={projects} selectedRows={selectedRows} rowKey="name" onFetchAll={fetchAllForExport} fileNamePrefix="projects" defaultColumns={['name', 'project_name', 'status', 'priority', 'customer', 'customer_name', 'percent_complete', 'company', 'modified']} hiddenColumns={['docstatus', 'idx', 'naming_series']} />
); }; export default ProjectList;