609 lines
32 KiB
TypeScript
609 lines
32 KiB
TypeScript
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { useProjectList } from '../hooks/useProject';
|
||
import ListPagination from '../components/ListPagination';
|
||
import { FaSearch, FaFilter, FaChevronDown, FaChevronUp, FaSync, FaEye, FaPlus, FaTimes, FaFileExport, FaEdit, FaCopy, FaCheckSquare, FaSquare, FaMicrophone } from 'react-icons/fa';
|
||
import { useListPageSelection } from '../hooks/useListPageSelection';
|
||
import { buildDateRangeFilters } from '../utils/listFilterUtils';
|
||
import type { Project } from '../services/projectService';
|
||
import DynamicExportModal from '../components/DynamicExportModal';
|
||
import { fetchAllRowsForExport } from '../utils/frappeListExport';
|
||
import VoiceStatusModal, { PROJECT_STATUS_OPTIONS } from '../components/VoiceStatusModal';
|
||
|
||
const getStatusStyle = (status: string) => {
|
||
switch (status?.toLowerCase()) {
|
||
case 'open':
|
||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||
case 'completed':
|
||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||
case 'cancelled':
|
||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||
default:
|
||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||
}
|
||
};
|
||
|
||
const getPriorityStyle = (priority: string) => {
|
||
switch (priority?.toLowerCase()) {
|
||
case 'high':
|
||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||
case 'medium':
|
||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
||
case 'low':
|
||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||
default:
|
||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||
}
|
||
};
|
||
|
||
const ProjectList: React.FC = () => {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const setSearchParamsRef = useRef(setSearchParams);
|
||
|
||
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(
|
||
(pageOrUpdater: number | ((p: number) => number)) => {
|
||
const next = typeof pageOrUpdater === 'function' ? pageOrUpdater(currentPage) : pageOrUpdater;
|
||
setSearchParams((prev) => {
|
||
const nextParams = new URLSearchParams(prev);
|
||
nextParams.set('page', String(next));
|
||
return nextParams;
|
||
});
|
||
},
|
||
[currentPage, setSearchParams]
|
||
);
|
||
|
||
const pageSize = 20;
|
||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||
const [statusFilter, setStatusFilter] = useState<string>(() => searchParams.get('status') || '');
|
||
const [priorityFilter, setPriorityFilter] = useState<string>(() => searchParams.get('priority') || '');
|
||
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams.get('q') || '');
|
||
const [dateFilterBy, setDateFilterBy] = useState<'' | 'creation' | 'modified'>(
|
||
() => (searchParams.get('date_filter_by') as '' | 'creation' | 'modified') || ''
|
||
);
|
||
const [dateStart, setDateStart] = useState<string>(() => searchParams.get('date_start') || '');
|
||
const [dateEnd, setDateEnd] = useState<string>(() => searchParams.get('date_end') || '');
|
||
const [sortBy, setSortBy] = useState<string>(() => searchParams.get('sort_by') || 'modified desc');
|
||
const [showExportModal, setShowExportModal] = useState(false);
|
||
|
||
// ── Voice Command Assist ──────────────────────────────────────────
|
||
const [showVoiceModal, setShowVoiceModal] = useState(false);
|
||
// ─────────────────────────────────────────────────────────────────
|
||
|
||
const didInitUrlSync = useRef(false);
|
||
const skipInitialSearchUrlSync = useRef(true);
|
||
const searchDebounceRef = useRef<number | null>(null);
|
||
|
||
const apiFilters = useMemo(() => {
|
||
const filters: Record<string, any> = {};
|
||
if (statusFilter) filters['status'] = statusFilter;
|
||
if (priorityFilter) filters['priority'] = priorityFilter;
|
||
if (searchQuery) filters['project_name'] = ['like', `%${searchQuery}%`];
|
||
Object.assign(filters, buildDateRangeFilters(dateFilterBy, dateStart, dateEnd));
|
||
return filters;
|
||
}, [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 { projects, loading, error, totalCount, refetch } = useProjectList({
|
||
filters: apiFilters,
|
||
limit_start: (currentPage - 1) * pageSize,
|
||
limit_page_length: pageSize,
|
||
order_by: orderBy,
|
||
});
|
||
|
||
const selectionResetKey = useMemo(
|
||
() => `${currentPage}|${sortBy}|${JSON.stringify(apiFilters)}`,
|
||
[currentPage, sortBy, apiFilters],
|
||
);
|
||
const {
|
||
selectedRows,
|
||
toggleRow,
|
||
toggleAllOnPage,
|
||
allOnPageSelected,
|
||
someOnPageSelected,
|
||
} = useListPageSelection(projects, selectionResetKey);
|
||
|
||
const fetchAllForExport = useCallback(
|
||
() => fetchAllRowsForExport({ doctype: 'Project', filters: apiFilters, orderBy }),
|
||
[apiFilters, orderBy],
|
||
);
|
||
|
||
const totalPages = Math.ceil(totalCount / pageSize);
|
||
const formatDate = (dateStr: string) =>
|
||
dateStr ? new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '-';
|
||
|
||
const clearFilters = () => {
|
||
setStatusFilter('');
|
||
setPriorityFilter('');
|
||
setSearchQuery('');
|
||
setDateFilterBy('');
|
||
setDateStart('');
|
||
setDateEnd('');
|
||
setSortBy('modified desc');
|
||
setSearchParams((prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
next.delete('status');
|
||
next.delete('priority');
|
||
next.delete('q');
|
||
next.delete('date_filter_by');
|
||
next.delete('date_start');
|
||
next.delete('date_end');
|
||
next.delete('sort_by');
|
||
next.set('page', '1');
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const hasActiveFilters = !!statusFilter || !!priorityFilter || !!searchQuery || !!(dateFilterBy && (dateStart || dateEnd));
|
||
|
||
const syncFiltersToUrl = useCallback(() => {
|
||
setSearchParams((prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
if (statusFilter) next.set('status', statusFilter);
|
||
else next.delete('status');
|
||
if (priorityFilter) next.set('priority', priorityFilter);
|
||
else next.delete('priority');
|
||
if (searchQuery) next.set('q', searchQuery);
|
||
else next.delete('q');
|
||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy);
|
||
else next.delete('date_filter_by');
|
||
if (dateStart) next.set('date_start', dateStart);
|
||
else next.delete('date_start');
|
||
if (dateEnd) next.set('date_end', dateEnd);
|
||
else next.delete('date_end');
|
||
if (sortBy !== 'modified desc') next.set('sort_by', sortBy);
|
||
else next.delete('sort_by');
|
||
next.set('page', '1');
|
||
return next;
|
||
});
|
||
}, [statusFilter, priorityFilter, searchQuery, dateFilterBy, dateStart, dateEnd, sortBy, setSearchParams]);
|
||
|
||
useEffect(() => {
|
||
if (!didInitUrlSync.current) {
|
||
didInitUrlSync.current = true;
|
||
return;
|
||
}
|
||
setSearchParamsRef.current((prev) => {
|
||
const next = new URLSearchParams(prev);
|
||
if (statusFilter) next.set('status', statusFilter);
|
||
else next.delete('status');
|
||
if (priorityFilter) next.set('priority', priorityFilter);
|
||
else next.delete('priority');
|
||
if (dateFilterBy) next.set('date_filter_by', dateFilterBy);
|
||
else next.delete('date_filter_by');
|
||
if (dateStart) next.set('date_start', dateStart);
|
||
else next.delete('date_start');
|
||
if (dateEnd) next.set('date_end', dateEnd);
|
||
else next.delete('date_end');
|
||
if (sortBy !== 'modified desc') next.set('sort_by', sortBy);
|
||
else next.delete('sort_by');
|
||
next.set('page', '1');
|
||
return next;
|
||
});
|
||
}, [statusFilter, priorityFilter, dateFilterBy, dateStart, dateEnd, sortBy]);
|
||
|
||
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 next = new URLSearchParams(prev);
|
||
if (searchQuery) next.set('q', searchQuery);
|
||
else next.delete('q');
|
||
next.set('page', '1');
|
||
return next;
|
||
});
|
||
}, 450);
|
||
return () => {
|
||
if (searchDebounceRef.current) window.clearTimeout(searchDebounceRef.current);
|
||
};
|
||
}, [searchQuery]);
|
||
|
||
const handleEdit = (projectName: string) => navigate(`/projects/list/${encodeURIComponent(projectName)}?edit=1`);
|
||
const handleDuplicate = (projectName: string) => navigate(`/projects/list/new?duplicate=${encodeURIComponent(projectName)}`);
|
||
|
||
return (
|
||
<div className="p-6 bg-gray-50 dark:bg-gray-900 min-h-screen">
|
||
<div className="flex items-center gap-2 text-sm mb-4">
|
||
<button onClick={() => navigate('/projects')} className="text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400">{t('projects.moduleTitle')}</button>
|
||
<span className="text-gray-400">/</span>
|
||
<span className="text-gray-700 dark:text-gray-300">{t('projects.projectsDoctype')}</span>
|
||
</div>
|
||
|
||
{/* ── Page Header ──────────────────────────────────────────────── */}
|
||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:justify-between sm:items-center">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">{t('projects.title')}</h1>
|
||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||
{t('projects.listTotal')}
|
||
{totalCount} {totalCount !== 1 ? t('projects.listProjects') : t('projects.listProject')}
|
||
{selectedRows.size > 0 && (
|
||
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||
• {selectedRows.size} {t('common.selected')}
|
||
</span>
|
||
)}
|
||
{loading && (
|
||
<span className="ml-2 inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
|
||
<FaSync className="animate-spin h-3 w-3" />
|
||
{t('common.updating')}
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-3">
|
||
{/* ── Voice Command Assist ───────────────────────────────── */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowVoiceModal(true)}
|
||
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow transition-all"
|
||
title="Bulk-update project status by voice"
|
||
>
|
||
<FaMicrophone />
|
||
<span className="font-medium">Voice Command Assist</span>
|
||
</button>
|
||
{/* ─────────────────────────────────────────────────────── */}
|
||
|
||
<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/20 px-1.5 py-0.5 rounded text-xs">{selectedRows.size}</span>
|
||
)}
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/projects/list/new')}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 shadow-lg transition-all hover:shadow-xl"
|
||
>
|
||
<FaPlus />
|
||
<span className="font-medium">{t('projects.newProject')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Export Modal ─────────────────────────────────────────────── */}
|
||
<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"
|
||
/>
|
||
|
||
{/* ── Voice Status Modal ───────────────────────────────────────── */}
|
||
<VoiceStatusModal
|
||
isOpen={showVoiceModal}
|
||
onClose={() => setShowVoiceModal(false)}
|
||
selectedRows={selectedRows}
|
||
onUpdateSuccess={() => {
|
||
refetch();
|
||
}}
|
||
doctype="Project"
|
||
fieldname="status"
|
||
statusOptions={PROJECT_STATUS_OPTIONS}
|
||
widgetTitle="Voice Project Status Update"
|
||
showLanguageToggle={true}
|
||
noSelectionLabel="project"
|
||
/>
|
||
|
||
{/* ── 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">
|
||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 dark:from-blue-600 dark:to-blue-700 px-4 py-3 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-2 rounded-lg transition-all">
|
||
{isFilterExpanded ? <FaChevronUp size={14} /> : <FaChevronDown size={14} />}
|
||
</button>
|
||
<div className="flex items-center gap-2">
|
||
<FaFilter className="text-white" size={16} />
|
||
<span className="text-white font-semibold text-sm">{t('listPages.filters')}</span>
|
||
</div>
|
||
{hasActiveFilters && (
|
||
<span className="bg-white text-blue-600 px-2 py-0.5 rounded-full text-xs font-bold">
|
||
{[searchQuery, statusFilter, priorityFilter, 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-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||
<span className="font-semibold">Name:</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-blue-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>
|
||
)}
|
||
{priorityFilter && (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-white/90 text-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||
<span className="font-semibold">Priority:</span> {priorityFilter}
|
||
<button onClick={() => setPriorityFilter('')}><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-blue-700 rounded-full text-[10px] font-medium whitespace-nowrap">
|
||
<span className="font-semibold">{dateFilterBy === 'creation' ? 'Created' : 'Modified'}:</span> {dateStart}{dateEnd ? ` – ${dateEnd}` : ''}
|
||
<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>
|
||
|
||
{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">Search</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' && syncFiltersToUrl()} placeholder={t('projects.searchPlaceholder')}
|
||
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-blue-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-blue-400 focus:outline-none">
|
||
<option value="">All Status</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-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide mb-1">Priority</label>
|
||
<select value={priorityFilter} onChange={e => setPriorityFilter(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-blue-400 focus:outline-none">
|
||
<option value="">All Priority</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-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-blue-400 focus:outline-none">
|
||
<option value="">None</option>
|
||
<option value="creation">Created</option>
|
||
<option value="modified">Modified</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-blue-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-blue-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">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-blue-400 focus:outline-none">
|
||
<option value="modified desc">Modified (newest)</option>
|
||
<option value="creation desc">Created (newest)</option>
|
||
<option value="modified asc">Modified (oldest)</option>
|
||
<option value="creation asc">Created (oldest)</option>
|
||
<option value="name asc">Name A–Z</option>
|
||
<option value="name desc">Name Z–A</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-4 p-4 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">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Table ────────────────────────────────────────────────────── */}
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden relative">
|
||
{loading ? (
|
||
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('common.loading')}</div>
|
||
) : projects.length === 0 ? (
|
||
<div className="p-12 text-center text-gray-500 dark:text-gray-400">{t('projects.noProjects')}</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-blue-600 dark:hover:text-blue-400 transition-colors"
|
||
title={allOnPageSelected ? 'Deselect all' : 'Select all'}
|
||
aria-label="Select all on page"
|
||
>
|
||
{allOnPageSelected
|
||
? <FaCheckSquare className="text-blue-600 dark:text-blue-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>
|
||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
||
{t('projects.projectName')}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
{t('commonFields.status')}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
{t('commonFields.priority')}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
{t('projects.customer')}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
{t('projects.expectedEnd')}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
{t('projects.progress')}
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
{t('common.actions')}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||
{projects.map((project: Project) => (
|
||
<tr
|
||
key={project.name}
|
||
className={`hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${selectedRows.has(project.name) ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
|
||
onClick={() => navigate(`/projects/list/${project.name}`)}
|
||
>
|
||
<td className="w-10 px-4 py-3" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
type="button"
|
||
onClick={() => toggleRow(project.name)}
|
||
className="text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||
aria-label={`Select ${project.name}`}
|
||
>
|
||
{selectedRows.has(project.name)
|
||
? <FaCheckSquare className="text-blue-600 dark:text-blue-400" size={18} />
|
||
: <FaSquare size={18} />}
|
||
</button>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className="text-[15px] font-medium text-gray-900 dark:text-white hover:underline">
|
||
{project.project_name || project.name}
|
||
</span>
|
||
<span className="block text-xs text-gray-500 dark:text-gray-400">{project.name}</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getStatusStyle(project.status || '')}`}>
|
||
{project.status || '-'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getPriorityStyle(project.priority || '')}`}>
|
||
{project.priority || '-'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{project.customer || '-'}</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{formatDate(project.expected_end_date || '')}</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden max-w-[80px]">
|
||
<div
|
||
className="h-full bg-blue-500 rounded-full"
|
||
style={{ width: `${project.percent_complete ?? 0}%` }}
|
||
/>
|
||
</div>
|
||
<span className="text-xs text-gray-600 dark:text-gray-400">{project.percent_complete ?? 0}%</span>
|
||
</div>
|
||
</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
|
||
type="button"
|
||
onClick={() => navigate(`/projects/list/${project.name}`)}
|
||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 p-2 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded transition-colors"
|
||
title={t('common.view')}
|
||
aria-label={t('common.view')}
|
||
>
|
||
<FaEye />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleEdit(project.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={t('common.edit', 'Edit')}
|
||
aria-label={t('common.edit', 'Edit')}
|
||
>
|
||
<FaEdit />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDuplicate(project.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={t('common.duplicate', 'Duplicate')}
|
||
aria-label={t('common.duplicate', '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 ProjectList;
|