Seera-Unified-UI/asm_app/src/pages/ProjectList.tsx
Duradundi Hadimani d0aa68b37c feat(projects): restore project module, overview, export, and desk links
- 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
2026-03-27 12:32:50 +00:00

448 lines
19 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, 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 (AZ)</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;