Seera-Unified-UI/asm_app/src/pages/ProjectTemplateList.tsx

225 lines
9.6 KiB
TypeScript

import React, { useMemo, useState, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { FaPlus, FaSearch, FaSync, FaClone, FaEye, FaFileExport } from 'react-icons/fa';
import { useProjectTemplates } from '../hooks/useProject';
import DynamicExportModal from '../components/DynamicExportModal';
import { fetchAllRowsForExport } from '../utils/frappeListExport';
import { useListPageSelection } from '../hooks/useListPageSelection';
const PAGE_SIZE = 20;
const ProjectTemplateList: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const [showExportModal, setShowExportModal] = useState(false);
const apiFilters = useMemo(() => {
const f: Record<string, unknown> = {};
if (search.trim()) f.name = ['like', `%${search.trim()}%`];
return f;
}, [search]);
const { templates, loading, totalCount, refetch } = useProjectTemplates({
filters: apiFilters,
limit_start: page * PAGE_SIZE,
limit_page_length: PAGE_SIZE,
order_by: 'name asc',
});
// Ensure pagination always triggers data reload (some environments cache identical queries).
useEffect(() => { refetch(); }, [page, apiFilters, refetch]);
const selectionResetKey = useMemo(() => `${page}|${JSON.stringify(apiFilters)}`, [page, apiFilters]);
const {
selectedRows,
toggleRow,
toggleAllOnPage,
allOnPageSelected,
someOnPageSelected,
} = useListPageSelection(templates, selectionResetKey);
const fetchAllForExport = useCallback(
() => fetchAllRowsForExport({ doctype: 'Project Template', filters: apiFilters, orderBy: 'name asc' }),
[apiFilters],
);
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
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
type="button"
onClick={() => navigate('/projects')}
className="text-sm text-gray-500 hover:text-violet-600 dark:text-gray-400 dark:hover:text-violet-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">
<FaClone className="text-violet-500" /> {t('projects.projectTemplateDoctype')}
</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 text-sm font-medium disabled:opacity-50"
disabled={totalCount === 0 && selectedRows.size === 0}
>
<FaFileExport /> {t('listPages.export')}
{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={() => refetch()}
className="p-2 text-gray-500 border border-gray-200 dark:border-gray-600 rounded-lg hover:text-violet-600"
aria-label="Refresh"
>
<FaSync size={14} className={loading ? 'animate-spin' : ''} />
</button>
<button
type="button"
onClick={() => navigate('/projects/templates/new')}
className="flex items-center gap-2 px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 text-sm font-medium"
>
<FaPlus size={12} /> New
</button>
</div>
</div>
<div className="mb-4 flex flex-wrap gap-3 items-center">
<div className="relative flex-1 min-w-[200px] max-w-md">
<FaSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-xs" />
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
placeholder="Search template…"
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<span className="text-xs text-gray-500">{totalCount} total</span>
</div>
<DynamicExportModal
isOpen={showExportModal}
onClose={() => setShowExportModal(false)}
doctype="Project Template"
selectedCount={selectedRows.size}
pageCount={templates.length}
totalCount={totalCount}
pageData={templates}
selectedRows={selectedRows}
rowKey="name"
onFetchAll={fetchAllForExport}
fileNamePrefix="project_templates"
/>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
<th className="w-10 px-2 py-3">
<input
type="checkbox"
className="rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
checked={allOnPageSelected}
ref={el => {
if (el) el.indeterminate = someOnPageSelected;
}}
onChange={toggleAllOnPage}
aria-label="Select all on page"
/>
</th>
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Name</th>
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Project type</th>
<th className="text-left text-[10px] font-semibold text-gray-500 uppercase py-3 px-4">Modified</th>
<th className="text-right text-[10px] font-semibold text-gray-500 uppercase py-3 px-4 w-24"> </th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{loading ? (
<tr>
<td colSpan={5} className="text-center py-10 text-gray-400">Loading</td>
</tr>
) : templates.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-gray-400">No templates found</td>
</tr>
) : (
templates.map((row) => (
<tr
key={row.name}
className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${selectedRows.has(row.name) ? 'bg-violet-50/80 dark:bg-violet-900/20' : ''}`}
onClick={() => navigate(`/projects/templates/${encodeURIComponent(row.name)}`)}
>
<td className="w-10 px-2 py-3" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
className="rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
checked={selectedRows.has(row.name)}
onChange={() => toggleRow(row.name)}
aria-label={`Select ${row.name}`}
/>
</td>
<td className="py-3 px-4 font-medium text-violet-600">{row.name}</td>
<td className="py-3 px-4 text-gray-600 dark:text-gray-300">{row.project_type || '—'}</td>
<td className="py-3 px-4 text-gray-500 text-xs">{row.modified ? new Date(row.modified).toLocaleDateString() : '—'}</td>
<td className="py-3 px-4 text-right">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
navigate(`/projects/templates/${encodeURIComponent(row.name)}`);
}}
className="text-violet-600 hover:text-violet-800 p-1"
aria-label="View"
>
<FaEye />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalCount > PAGE_SIZE && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100 dark:border-gray-700">
<span className="text-xs text-gray-500">
Page {page + 1} of {totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
>
Prev
</button>
<button
type="button"
disabled={(page + 1) * PAGE_SIZE >= totalCount}
onClick={() => setPage((p) => p + 1)}
className="px-3 py-1 text-xs border rounded disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default ProjectTemplateList;