2026-06-11 19:56:20 +05:30

449 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number | null>(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<string, any> = {};
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 (
<div className="p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<button onClick={() => navigate('/projects')} className="text-sm text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-400">{t('projects.moduleTitle')}</button>
<span className="text-gray-400">/</span>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<FaClock className="text-indigo-500" /> {t('projects.timesheetDoctype')}
</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setShowExportModal(true)}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all disabled:opacity-50"
disabled={totalCount === 0 && selectedRows.size === 0}
>
<FaFileExport /> <span className="font-medium">{t('listPages.export')}</span>
{selectedRows.size > 0 && (
<span className="bg-white/25 px-1.5 py-0.5 rounded text-xs font-bold">{selectedRows.size}</span>
)}
</button>
<button
type="button"
onClick={() => {
const p = new URLSearchParams();
if (projectFromUrl) p.set('project', projectFromUrl);
const qs = p.toString();
navigate(qs ? `/projects/timesheets/new?${qs}` : '/projects/timesheets/new');
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
<FaPlus /> {t('projects.newTimesheet')}
</button>
</div>
</div>
<DynamicExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
doctype="Timesheet"
selectedCount={selectedRows.size}
pageCount={timesheets.length}
totalCount={totalCount}
pageData={timesheets}
selectedRows={selectedRows}
rowKey="name"
onFetchAll={fetchAllForExport}
fileNamePrefix="timesheets"
/>
{/* ── Filter Panel ── */}
<div className="isolate bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 mb-6">
{/* Header */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-2.5 rounded-t-lg">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-shrink-0">
<button onClick={() => setIsFilterExpanded(v => !v)} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all">
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
</button>
<div className="flex items-center gap-2">
<FaFilter className="text-white" size={13} />
<span className="text-white font-semibold text-sm">Filters</span>
</div>
{hasActiveFilters && (
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
{[statusFilter, searchQuery, projectFromUrl, dateFilterBy && dateStart].filter(Boolean).length}
</span>
)}
</div>
{hasActiveFilters && (
<div className="flex-1 overflow-x-auto mx-2">
<div className="flex items-center gap-2 py-0.5">
{searchQuery && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-gray-700 rounded-full text-[10px] font-medium whitespace-nowrap">
<span className="font-semibold">ID:</span> {searchQuery}
<button onClick={() => setSearchQuery('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
</span>
)}
{statusFilter && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
<span className="font-semibold">Status:</span> {statusFilter}
<button onClick={() => setStatusFilter('')}><FaTimes className="text-[9px] hover:text-red-500" /></button>
</span>
)}
{projectFromUrl && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
<span className="font-semibold">Project:</span> {projectFromUrl}
<button type="button" onClick={() => { setProjectDraft(''); setSearchParams(prev => { const n = new URLSearchParams(prev); n.delete('project'); n.set('page', '1'); return n; }); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
</span>
)}
{dateFilterBy && dateStart && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-indigo-700 rounded-full text-[10px] font-medium whitespace-nowrap">
<span className="font-semibold">{dateFilterBy === 'creation' ? 'Created' : 'Modified'}:</span> {dateStart}{dateEnd ? ` ${dateEnd}` : ''}
<button type="button" onClick={() => { setDateFilterBy(''); setDateStart(''); setDateEnd(''); }}><FaTimes className="text-[9px] hover:text-red-500" /></button>
</span>
)}
</div>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{hasActiveFilters && (
<button onClick={clearFilters} className="text-white/80 hover:text-white text-xs underline whitespace-nowrap">Clear all</button>
)}
<button onClick={() => refetch()} className="text-white hover:bg-white/20 p-1.5 rounded-lg transition-all" title="Refresh">
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
</div>
{/* Expanded body */}
{isFilterExpanded && (
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Project</label>
<input
type="text"
value={projectDraft}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Timesheet ID</label>
<div className="relative">
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
<input type="text" value={searchQuery} onChange={e => 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" />
</div>
</div>
<div>
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Status</label>
<select value={statusFilter} onChange={e => setStatusFilter(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">
<option value="">All Status</option>
<option value="Draft">Draft</option>
<option value="Submitted">Submitted</option>
<option value="Cancelled">Cancelled</option>
</select>
</div>
<div>
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Date Filter By</label>
<select value={dateFilterBy} onChange={e => setDateFilterBy(e.target.value as '' | 'creation' | 'modified')} 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">
<option value="">None</option>
<option value="creation">Created</option>
<option value="modified">Modified</option>
</select>
</div>
<div>
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Sort By</label>
<select value={sortBy} onChange={e => setSortBy(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">
<option value="creation desc">Created (newest)</option>
<option value="creation asc">Created (oldest)</option>
<option value="modified desc">Modified (newest)</option>
<option value="total_hours desc">Hours (highest)</option>
<option value="total_hours asc">Hours (lowest)</option>
</select>
</div>
{dateFilterBy && (
<>
<div>
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">From</label>
<input type="date" value={dateStart} onChange={e => 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" />
</div>
<div>
<label className="block text-[10px] font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">To</label>
<input type="date" value={dateEnd} onChange={e => 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" />
</div>
</>
)}
</div>
</div>
)}
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">{error}</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
{loading ? (
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
) : timesheets.length === 0 ? (
<div className="p-12 text-center">
<FaClock className="text-4xl text-gray-300 dark:text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 dark:text-gray-400">{t('projects.noTimesheets')}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-100 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th className="w-10 px-4 py-3 text-left">
<button
type="button"
onClick={toggleAllOnPage}
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
aria-label="Select all on page"
>
{allOnPageSelected
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
: someOnPageSelected
? (
<div className="relative inline-block">
<FaSquare size={18} />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-0.5 bg-current" />
</div>
</div>
)
: <FaSquare size={18} />}
</button>
</th>
{[t('projects.timesheetId'), t('commonFields.status'), t('projects.totalHours'), 'Billable Hrs', 'Billing Amt', 'Costing Amt', 'Created', ''].map(h => (
<th key={h} className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{timesheets.map((ts: Timesheet) => (
<tr
key={ts.name}
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(ts.name) ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`}
onClick={() => navigate(`/projects/timesheets/${ts.name}`)}
>
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
<button
type="button"
onClick={() => toggleRow(ts.name)}
className="text-gray-500 dark:text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
aria-label={`Select ${ts.name}`}
>
{selectedRows.has(ts.name)
? <FaCheckSquare className="text-indigo-600 dark:text-indigo-400" size={18} />
: <FaSquare size={18} />}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-medium text-gray-900 dark:text-white">{ts.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{ts.modified ? new Date(ts.modified).toLocaleDateString() : ''}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(ts.status || '')}`}>{ts.status || 'Draft'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">{ts.total_hours ?? 0} hrs</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_billable_hours ?? 0} hrs</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_billable_amount ?? '-'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{ts.total_costing_amount ?? '-'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{ts.creation ? new Date(ts.creation).toLocaleDateString() : '-'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-1">
<button onClick={() => navigate(`/projects/timesheets/${ts.name}`)} className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-300 p-2 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 rounded transition-colors" title="View">
<FaEye />
</button>
<button onClick={() => handleEdit(ts.name)} className="text-green-600 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 p-2 hover:bg-green-50 dark:hover:bg-green-900/30 rounded transition-colors" title="Edit">
<FaEdit />
</button>
<button onClick={() => handleDuplicate(ts.name)} className="text-purple-600 dark:text-purple-400 hover:text-purple-900 dark:hover:text-purple-300 p-2 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded transition-colors" title="Duplicate">
<FaCopy />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3">
<ListPagination currentPage={currentPage} totalPages={totalPages} totalCount={totalCount} pageSize={pageSize} onPageChange={setCurrentPage} />
</div>
)}
</div>
</div>
);
};
export default TimesheetList;