- Add Project list/detail, module home, overview report (tasks/issues) - Customer & sales order panel with ERPNext desk navigation - Sidebar Projects entry; role visibility for end users & technicians - Login: send Frappe CSRF for POST; clearer API errors; API base from origin fallback Made-with: Cursor
448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
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<Project[]>([]);
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||
const [showExportModal, setShowExportModal] = useState(false);
|
||
const [duplicating, setDuplicating] = useState<string | null>(null);
|
||
|
||
const apiFilters = useMemo(() => {
|
||
const f: Record<string, any> = {};
|
||
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 (
|
||
<div className="p-6">
|
||
<ToastContainer position="top-right" autoClose={3000} />
|
||
|
||
<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">
|
||
<div className="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center shadow-md">
|
||
<FaFolderOpen className="text-white text-xl" />
|
||
</div>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">{t('projects.listTitle', 'Projects')}</h1>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
{totalCount} {t('projects.total', 'total')}
|
||
{someSelected && (
|
||
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||
• {selectedRows.size} {t('listPages.selected', 'selected')}
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowExportModal(true)}
|
||
disabled={totalCount === 0}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold shadow"
|
||
>
|
||
<FaFileExport size={14} /> {t('listPages.export', 'Export')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/projects/list/new')}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold shadow"
|
||
>
|
||
<FaPlus size={14} /> {t('projects.newProject', 'New project')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm mb-6 overflow-hidden">
|
||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 px-4 py-2.5">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsFilterExpanded(v => !v)}
|
||
className="text-white hover:bg-white/20 p-1.5 rounded-lg"
|
||
>
|
||
{isFilterExpanded ? <FaChevronUp size={12} /> : <FaChevronDown size={12} />}
|
||
</button>
|
||
<FaFilter className="text-white" size={14} />
|
||
<span className="text-white font-semibold text-sm">{t('listPages.filters', 'Filters')}</span>
|
||
{hasActiveFilters && (
|
||
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||
{[searchQuery, statusFilter, priorityFilter].filter(Boolean).length}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{hasActiveFilters && (
|
||
<button type="button" onClick={clearFilters} className="text-xs text-white/90 underline">
|
||
{t('listPages.clearFilters', 'Clear all')}
|
||
</button>
|
||
)}
|
||
<button type="button" onClick={() => load()} className="text-white hover:bg-white/20 p-1.5 rounded-lg" title="Refresh">
|
||
<FaSync size={12} className={loading ? 'animate-spin' : ''} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{isFilterExpanded && (
|
||
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 border-t border-gray-100 dark:border-gray-700">
|
||
<div>
|
||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Search</label>
|
||
<div className="relative">
|
||
<FaSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
|
||
<input
|
||
value={searchQuery}
|
||
onChange={e => 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…"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Status</label>
|
||
<select
|
||
value={statusFilter}
|
||
onChange={e => setStatusFilter(e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||
>
|
||
<option value="">All</option>
|
||
<option value="Open">Open</option>
|
||
<option value="Completed">Completed</option>
|
||
<option value="Cancelled">Cancelled</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Priority</label>
|
||
<select
|
||
value={priorityFilter}
|
||
onChange={e => setPriorityFilter(e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||
>
|
||
<option value="">All</option>
|
||
<option value="High">High</option>
|
||
<option value="Medium">Medium</option>
|
||
<option value="Low">Low</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[10px] font-semibold text-gray-500 uppercase mb-1">Sort</label>
|
||
<select
|
||
value={sortBy}
|
||
onChange={e => setSortBy(e.target.value)}
|
||
className="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||
>
|
||
<option value="modified desc">Modified (newest)</option>
|
||
<option value="modified asc">Modified (oldest)</option>
|
||
<option value="creation desc">Created (newest)</option>
|
||
<option value="name asc">ID (A–Z)</option>
|
||
</select>
|
||
</div>
|
||
<div className="sm:col-span-2 lg:col-span-4 flex justify-end gap-2 pt-2">
|
||
<button type="button" onClick={applyFilters} className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700">
|
||
{t('listPages.applyFilters', 'Apply')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden shadow-sm">
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full text-sm">
|
||
<thead>
|
||
<tr className="bg-blue-600 text-left">
|
||
<th className="px-3 py-3 w-12">
|
||
<button type="button" onClick={toggleSelectAllPage} className="text-white/90 hover:text-white p-1">
|
||
{allOnPageSelected ? <FaCheckSquare size={16} /> : <FaSquare size={16} />}
|
||
</button>
|
||
</th>
|
||
{['Project', 'Status', 'Priority', 'Customer', 'Progress', 'Dates', 'Actions'].map(h => (
|
||
<th key={h} className="px-4 py-3 text-[10px] font-semibold text-white/90 uppercase tracking-wider">
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||
{loading ? (
|
||
<tr>
|
||
<td colSpan={8} className="text-center py-12 text-gray-400">
|
||
Loading…
|
||
</td>
|
||
</tr>
|
||
) : projects.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={8} className="text-center py-12 text-gray-400">
|
||
No projects found
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
projects.map(p => (
|
||
<tr
|
||
key={p.name}
|
||
className="hover:bg-blue-50/50 dark:hover:bg-gray-700/30 cursor-pointer"
|
||
onClick={() => navigate(`/projects/list/${encodeURIComponent(p.name)}`)}
|
||
>
|
||
<td className="px-3 py-3" onClick={e => e.stopPropagation()}>
|
||
<button type="button" onClick={() => toggleRow(p.name)} className="text-gray-500 hover:text-blue-600 p-1">
|
||
{selectedRows.has(p.name) ? <FaCheckSquare className="text-blue-600" /> : <FaSquare />}
|
||
</button>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className="font-medium text-blue-600 dark:text-blue-400">{p.project_name || p.name}</span>
|
||
<div className="text-xs text-gray-400">{p.name}</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
|
||
{p.status || '-'}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200">
|
||
{p.priority || '-'}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{p.customer_name || p.customer || '—'}</td>
|
||
<td className="px-4 py-3 text-gray-600">{p.percent_complete ?? 0}%</td>
|
||
<td className="px-4 py-3 text-xs text-gray-500">
|
||
{p.expected_start_date || '—'} → {p.expected_end_date || '—'}
|
||
</td>
|
||
<td className="px-4 py-3" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
title="View"
|
||
onClick={() => navigate(`/projects/list/${encodeURIComponent(p.name)}`)}
|
||
className="p-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/30 text-blue-600"
|
||
>
|
||
<FaEye size={15} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="Edit"
|
||
onClick={() => navigate(`/projects/list/${encodeURIComponent(p.name)}?edit=1`)}
|
||
className="p-1.5 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/30 text-green-600"
|
||
>
|
||
<FaEdit size={14} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
title="Duplicate"
|
||
disabled={duplicating === p.name}
|
||
onClick={e => handleDuplicate(e, p.name)}
|
||
className="p-1.5 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/30 text-purple-600 disabled:opacity-50"
|
||
>
|
||
<FaCopy size={14} />
|
||
</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>
|
||
|
||
<DynamicExportModal
|
||
isOpen={showExportModal}
|
||
onClose={() => 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']}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ProjectList;
|