225 lines
9.6 KiB
TypeScript
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;
|