import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useProjectList } from '../hooks/useProject'; import ListPagination from '../components/ListPagination'; import { FaSearch, FaFilter, FaChevronDown, FaChevronUp, FaSync, FaEye, FaPlus, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare, FaMicrophone } from 'react-icons/fa'; import { useListPageSelection } from '../hooks/useListPageSelection'; import { buildDateRangeFilters } from '../utils/listFilterUtils'; import type { Project } from '../services/projectService'; import DynamicExportModal from '../components/DynamicExportModal'; import { fetchAllRowsForExport } from '../utils/frappeListExport'; import VoiceStatusModal, { PROJECT_STATUS_OPTIONS } from '../components/VoiceStatusModal'; const getStatusStyle = (status: string) => { switch (status?.toLowerCase()) { case 'open': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-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-800 dark:bg-gray-700 dark:text-gray-300'; default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; } }; const getPriorityStyle = (priority: string) => { switch (priority?.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-800 dark:bg-gray-700 dark:text-gray-300'; } }; const ProjectList: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const setSearchParamsRef = useRef(setSearchParams); useEffect(() => { setSearchParamsRef.current = setSearchParams; }, [setSearchParams]); const currentPage = useMemo(() => { const p = parseInt(searchParams.get('page') || '1', 10); return Number.isNaN(p) || p < 1 ? 1 : p; }, [searchParams]); const setCurrentPage = useCallback( (pageOrUpdater: number | ((p: number) => number)) => { const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater; setSearchParams((prev) => { const nextParams = new URLSearchParams(prev); nextParams.set('page', String(next)); return nextParams; }); }, [currentPage, setSearchParams] ); const pageSize = 20; 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 [showExportModal, setShowExportModal] = useState(false); // ── Voice Command Assist ────────────────────────────────────────── const [showVoiceModal, setShowVoiceModal] = useState(false); // ───────────────────────────────────────────────────────────────── const didInitUrlSync = useRef(false); const skipInitialSearchUrlSync = useRef(true); const searchDebounceRef = useRef(null); const apiFilters = useMemo(() => { const filters: Record = {}; if (statusFilter) filters['status'] = statusFilter; if (priorityFilter) filters['priority'] = priorityFilter; if (searchQuery) filters['project_name'] = ['like', `%${searchQuery}%`]; Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd)); return filters; }, [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 { projects, loading, error, totalCount, refetch } = useProjectList({ filters: apiFilters, limit_start: (currentPage - 1) * pageSize, limit_page_length: pageSize, order_by: orderBy, }); const selectionResetKey = useMemo( () => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`, [currentPage, sortBy, apiFilters], ); const { selectedRows, toggleRow, toggleAllOnPage, allOnPageSelected, someOnPageSelected, } = useListPageSelection(projects, selectionResetKey); const fetchAllForExport = useCallback( () => fetchAllRowsForExport({ doctype: 'Project', filters: apiFilters, orderBy }), [apiFilters, orderBy], ); const totalPages = Math.ceil(totalCount / pageSize); const formatDate = (dateStr: string) => dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-'; const clearFilters = () => { setStatusFilter(''); setPriorityFilter(''); setSearchQuery(''); setDateFilterBy(''); setDateStart(''); setDateEnd(''); setSortBy('modified desc'); setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('status'); next.delete('priority'); next.delete('q'); next.delete('date_filter_by'); next.delete('date_start'); next.delete('date_end'); next.delete('sort_by'); next.set('page', '1'); return next; }); }; const hasActiveFilters = !!statusFilter || !!priorityFilter || !!searchQuery || !!(dateFilterBy && (dateStart || dateEnd)); const syncFiltersToUrl = useCallback(() => { setSearchParams((prev) => { const next = new URLSearchParams(prev); if (statusFilter) next.set('status', statusFilter); else next.delete('status'); if (priorityFilter) next.set('priority', priorityFilter); else next.delete('priority'); if (searchQuery) next.set('q', searchQuery); else next.delete('q'); if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); if (dateStart) next.set('date_start', dateStart); else next.delete('date_start'); if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end'); if (sortBy !== 'modified desc') next.set('sort_by', sortBy); else next.delete('sort_by'); next.set('page', '1'); return next; }); }, [statusFilter, priorityFilter, searchQuery, dateFilterBy, dateStart, dateEnd, sortBy, setSearchParams]); useEffect(() => { if (!didInitUrlSync.current) { didInitUrlSync.current = true; return; } setSearchParamsRef.current((prev) => { const next = new URLSearchParams(prev); if (statusFilter) next.set('status', statusFilter); else next.delete('status'); if (priorityFilter) next.set('priority', priorityFilter); else next.delete('priority'); if (dateFilterBy) next.set('date_filter_by', dateFilterBy); else next.delete('date_filter_by'); if (dateStart) next.set('date_start', dateStart); else next.delete('date_start'); if (dateEnd) next.set('date_end', dateEnd); else next.delete('date_end'); if (sortBy !== 'modified desc') next.set('sort_by', sortBy); else next.delete('sort_by'); next.set('page', '1'); return next; }); }, [statusFilter, priorityFilter, 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 next = new URLSearchParams(prev); if (searchQuery) next.set('q', searchQuery); else next.delete('q'); next.set('page', '1'); return next; }); }, 450); return () => { if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current); }; }, [searchQuery]); const handleEdit = (projectName: string) => navigate(`/projects/list/${encodeURIComponent(projectName)}?edit=1`); const handleDuplicate = (projectName: string) => navigate(`/projects/list/new?duplicate=${encodeURIComponent(projectName)}`); return (
/ {t('projects.projectsDoctype')}
{/* ── Page Header ──────────────────────────────────────────────── */}

{t('projects.title')}

{t('projects.listTotal')} {totalCount} {totalCount !== 1 ? t('projects.listProjects') : t('projects.listProject')} {selectedRows.size > 0 && ( • {selectedRows.size} {t('common.selected')} )} {loading && ( {t('common.updating')} )}

{/* ── Voice Command Assist ───────────────────────────────── */} {/* ─────────────────────────────────────────────────────── */}
{/* ── Export Modal ─────────────────────────────────────────────── */} setShowExportModal(false)} doctype="Project" selectedCount={selectedRows.size} pageCount={projects.length} totalCount={totalCount} pageData={projects} selectedRows={selectedRows} rowKey="name" onFetchAll={fetchAllForExport} fileNamePrefix="projects" /> {/* ── Voice Status Modal ───────────────────────────────────────── */} setShowVoiceModal(false)} selectedRows={selectedRows} onUpdateSuccess={() => { refetch(); }} doctype="Project" fieldname="status" statusOptions={PROJECT_STATUS_OPTIONS} widgetTitle="Voice Project Status Update" showLanguageToggle={true} noSelectionLabel="project" /> {/* ── Filter Panel ─────────────────────────────────────────────── */}
{t('listPages.filters')}
{hasActiveFilters && ( {[searchQuery, statusFilter, priorityFilter, dateFilterBy && dateStart].filter(Boolean).length} )}
{hasActiveFilters && (
{searchQuery && ( Name: {searchQuery} )} {statusFilter && ( Status: {statusFilter} )} {priorityFilter && ( Priority: {priorityFilter} )} {dateFilterBy && dateStart && ( {dateFilterBy === 'creation' ? 'Created' : 'Modified'}: {dateStart}{dateEnd ? ` – ${dateEnd}` : ''} )}
)}
{hasActiveFilters && ( )}
{isFilterExpanded && (
setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && syncFiltersToUrl()} placeholder={t('projects.searchPlaceholder')} 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-blue-400 focus:outline-none" />
{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-blue-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-blue-400 focus:outline-none" />
)}
)}
{error && (
{error}
)} {/* ── Table ────────────────────────────────────────────────────── */}
{loading ? (
{t('common.loading')}
) : projects.length === 0 ? (
{t('projects.noProjects')}
) : (
{projects.map((project: Project) => ( navigate(`/projects/list/${project.name}`)} > ))}
{t('projects.projectName')} {t('commonFields.status')} {t('commonFields.priority')} {t('projects.customer')} {t('projects.expectedEnd')} {t('projects.progress')} {t('common.actions')}
e.stopPropagation()}> {project.project_name || project.name} {project.name} {project.status || '-'} {project.priority || '-'} {project.customer || '-'} {formatDate(project.expected_end_date || '')}
{project.percent_complete ?? 0}%
e.stopPropagation()}>
)} {totalPages > 1 && (
)}
); }; export default ProjectList;