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

609 lines
32 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, { 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 AZ</option>
<option value="name desc">Name ZA</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;