import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useTimesheetList } from '../hooks/useProject'; import ListPagination from '../components/ListPagination'; import { buildDateRangeFilters, toFrappeFilterArray } from '../utils/listFilterUtils'; import { FaClock, FaPlus, FaSearch, FaFilter, FaSync, FaEye, FaChevronDown, FaChevronUp, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare } from 'react-icons/fa'; import type { Timesheet } from '../services/projectService'; import DynamicExportModal from '../components/DynamicExportModal'; import { fetchAllRowsForExport } from '../utils/frappeListExport'; import { useListPageSelection } from '../hooks/useListPageSelection'; const getStatusStyle = (s: string) => { switch (s?.toLowerCase()) { case 'submitted': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; case 'draft': return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'; case 'cancelled': return 'bg-red-100 text-red-700 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 TimesheetList: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const setSearchParamsRef = useRef(setSearchParams); const pageSize = 20; 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((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 [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 projectFromUrl = useMemo(() => searchParams.get('project')?.trim() || '', [searchParams]); const [projectDraft, setProjectDraft] = useState(projectFromUrl); useEffect(() => { setProjectDraft(projectFromUrl); }, [projectFromUrl]); const appendFilters = useMemo( () => (projectFromUrl ? [['Timesheet Detail', 'project', '=', projectFromUrl]] as any[] : []), [projectFromUrl], ); const apiFilters = useMemo(() => { const f: Record = {}; if (statusFilter) f.status = statusFilter; if (searchQuery) f.name = ['like', `%${searchQuery}%`]; Object.assign(f, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd)); return f; }, [statusFilter, searchQuery, dateFilterBy, dateStart, dateEnd]); const { timesheets, loading, error, totalCount, refetch } = useTimesheetList({ filters: apiFilters, appendFilters, limit_start: (currentPage - 1) * pageSize, limit_page_length: pageSize, order_by: sortBy, }); const selectionResetKey = useMemo( () => `${currentPage}|${sortBy}|${projectFromUrl}|${JSON.stringify(apiFilters)}|${JSON.stringify(appendFilters)}`, [currentPage, sortBy, projectFromUrl, apiFilters, appendFilters], ); const { selectedRows, toggleRow, toggleAllOnPage, allOnPageSelected, someOnPageSelected, } = useListPageSelection(timesheets, selectionResetKey); const timesheetExportFilters = useMemo(() => { let fa = toFrappeFilterArray(apiFilters); if (appendFilters.length) fa = [...fa, ...appendFilters]; return fa.length > 0 ? fa : {}; }, [apiFilters, appendFilters]); const fetchAllForExport = useCallback( () => fetchAllRowsForExport({ doctype: 'Timesheet', filters: timesheetExportFilters, orderBy: sortBy }), [timesheetExportFilters, 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(''); setSearchQuery(''); setDateFilterBy(''); setDateStart(''); setDateEnd(''); setSortBy('creation desc'); setProjectDraft(''); setSearchParams(prev => { const n = new URLSearchParams(prev); ['status','q','date_filter_by','date_start','date_end','sort_by','project'].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'); 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'); const p = projectDraft.trim(); p ? n.set('project', p) : n.delete('project'); n.set('page', '1'); return n; }); }, [statusFilter, dateFilterBy, dateStart, dateEnd, sortBy, projectDraft]); 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 || searchQuery || projectFromUrl || (dateFilterBy && (dateStart || dateEnd))); const handleEdit = (timesheetName: string) => navigate(`/projects/timesheets/${encodeURIComponent(timesheetName)}?edit=1`); const handleDuplicate = (timesheetName: string) => navigate(`/projects/timesheets/new?duplicate=${encodeURIComponent(timesheetName)}`); return (
/

{t('projects.timesheetDoctype')}

setShowExportModal(false)} doctype="Timesheet" selectedCount={selectedRows.size} pageCount={timesheets.length} totalCount={totalCount} pageData={timesheets} selectedRows={selectedRows} rowKey="name" onFetchAll={fetchAllForExport} fileNamePrefix="timesheets" /> {/* ── Filter Panel ── */}
{/* Header */}
Filters
{hasActiveFilters && ( {[statusFilter, searchQuery, projectFromUrl, dateFilterBy && dateStart].filter(Boolean).length} )}
{hasActiveFilters && (
{searchQuery && ( ID: {searchQuery} )} {statusFilter && ( Status: {statusFilter} )} {projectFromUrl && ( Project: {projectFromUrl} )} {dateFilterBy && dateStart && ( {dateFilterBy === 'creation' ? 'Created' : 'Modified'}: {dateStart}{dateEnd ? ` – ${dateEnd}` : ''} )}
)}
{hasActiveFilters && ( )}
{/* Expanded body */} {isFilterExpanded && (
setProjectDraft(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Filter by project…" 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" />
setSearchQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && e.preventDefault()} placeholder="Search by ID…" 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" />
{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" />
)}
)}
{error && (
{error}
)}
{loading ? (
{t('common.loading')}
) : timesheets.length === 0 ? (

{t('projects.noTimesheets')}

) : (
{[t('projects.timesheetId'), t('commonFields.status'), t('projects.totalHours'), 'Billable Hrs', 'Billing Amt', 'Costing Amt', 'Created', ''].map(h => ( ))} {timesheets.map((ts: Timesheet) => ( navigate(`/projects/timesheets/${ts.name}`)} > ))}
{h}
e.stopPropagation()}>
{ts.name}
{ts.modified ? new Date(ts.modified).toLocaleDateString() : ''}
{ts.status || 'Draft'} {ts.total_hours ?? 0} hrs {ts.total_billable_hours ?? 0} hrs {ts.total_billable_amount ?? '-'} {ts.total_costing_amount ?? '-'} {ts.creation ? new Date(ts.creation).toLocaleDateString() : '-'} e.stopPropagation()}>
)} {totalPages > 1 && (
)}
); }; export default TimesheetList;