feat: Implement sortable project list with dynamic sorting functionality
This commit is contained in:
@@ -29,6 +29,10 @@ export default function ProjectListPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [customers, setCustomers] = useState([]);
|
const [customers, setCustomers] = useState([]);
|
||||||
|
const [sortConfig, setSortConfig] = useState({
|
||||||
|
key: 'finish_date',
|
||||||
|
direction: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
|
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,8 +129,44 @@ export default function ProjectListPage() {
|
|||||||
setSearchMatchType(null);
|
setSearchMatchType(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
if (sortConfig.key) {
|
||||||
|
filtered = [...filtered].sort((a, b) => {
|
||||||
|
let aVal = a[sortConfig.key];
|
||||||
|
let bVal = b[sortConfig.key];
|
||||||
|
|
||||||
|
// Handle null/undefined
|
||||||
|
if (!aVal && !bVal) return 0;
|
||||||
|
if (!aVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
if (!bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
|
||||||
|
// Handle dates
|
||||||
|
if (sortConfig.key === 'finish_date') {
|
||||||
|
aVal = new Date(aVal);
|
||||||
|
bVal = new Date(bVal);
|
||||||
|
}
|
||||||
|
// Handle numbers (project_number)
|
||||||
|
else if (sortConfig.key === 'project_number') {
|
||||||
|
// Extract numeric part if it's a string like "P-123" or "123"
|
||||||
|
const aNum = parseInt(String(aVal).replace(/\D/g, '')) || 0;
|
||||||
|
const bNum = parseInt(String(bVal).replace(/\D/g, '')) || 0;
|
||||||
|
aVal = aNum;
|
||||||
|
bVal = bNum;
|
||||||
|
}
|
||||||
|
// Handle strings
|
||||||
|
else if (typeof aVal === 'string') {
|
||||||
|
aVal = aVal.toLowerCase();
|
||||||
|
bVal = String(bVal).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setFilteredProjects(filtered);
|
setFilteredProjects(filtered);
|
||||||
}, [searchTerm, projects, filters, session]);
|
}, [searchTerm, projects, filters, session, sortConfig]);
|
||||||
|
|
||||||
async function handleDelete(id) {
|
async function handleDelete(id) {
|
||||||
const confirmed = confirm(t('projects.deleteConfirm'));
|
const confirmed = confirm(t('projects.deleteConfirm'));
|
||||||
@@ -171,6 +211,13 @@ export default function ProjectListPage() {
|
|||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (key) => {
|
||||||
|
setSortConfig(prev => ({
|
||||||
|
key,
|
||||||
|
direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleExportExcel = async () => {
|
const handleExportExcel = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/projects/export');
|
const response = await fetch('/api/projects/export');
|
||||||
@@ -228,6 +275,42 @@ export default function ProjectListPage() {
|
|||||||
default: return "-";
|
default: return "-";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sortable header component
|
||||||
|
const SortableHeader = ({ columnKey, label, className = "" }) => {
|
||||||
|
const isSorted = sortConfig.key === columnKey;
|
||||||
|
const direction = isSorted ? sortConfig.direction : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={`text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors select-none ${className}`}
|
||||||
|
onClick={() => handleSort(columnKey)}
|
||||||
|
title={`Sort by ${label}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="text-gray-400 flex-shrink-0">
|
||||||
|
{!isSorted && (
|
||||||
|
<svg className="w-3 h-3 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isSorted && direction === 'asc' && (
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 14l5-5 5 5H7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isSorted && direction === 'desc' && (
|
||||||
|
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 10l5 5 5-5H7z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
|
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
|
||||||
@@ -606,8 +689,29 @@ export default function ProjectListPage() {
|
|||||||
|
|
||||||
{/* Results and clear button row */}
|
{/* Results and clear button row */}
|
||||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="flex items-center gap-4">
|
||||||
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
|
<div className="text-sm text-gray-500">
|
||||||
|
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
|
||||||
|
</div>
|
||||||
|
{sortConfig.key && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<span>•</span>
|
||||||
|
<span>Sortowanie:</span>
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{sortConfig.key === 'project_number' && 'Nr.'}
|
||||||
|
{sortConfig.key === 'project_name' && t('projects.projectName')}
|
||||||
|
{sortConfig.key === 'address' && t('projects.address')}
|
||||||
|
{sortConfig.key === 'wp' && 'WP'}
|
||||||
|
{sortConfig.key === 'city' && t('projects.city')}
|
||||||
|
{sortConfig.key === 'plot' && t('projects.plot')}
|
||||||
|
{sortConfig.key === 'finish_date' && t('projects.finishDate')}
|
||||||
|
{sortConfig.key === 'project_type' && t('common.type')}
|
||||||
|
{sortConfig.key === 'project_status' && t('common.status')}
|
||||||
|
{sortConfig.key === 'assigned_to' && t('projects.assigned')}
|
||||||
|
</span>
|
||||||
|
<span>{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && (
|
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && (
|
||||||
@@ -699,36 +803,56 @@ export default function ProjectListPage() {
|
|||||||
<table className="w-full min-w-[600px] table-fixed">
|
<table className="w-full min-w-[600px] table-fixed">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-100 dark:bg-gray-700 border-b dark:border-gray-600">
|
<tr className="bg-gray-100 dark:bg-gray-700 border-b dark:border-gray-600">
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-20 md:w-24">
|
<SortableHeader
|
||||||
Nr.
|
columnKey="project_number"
|
||||||
</th>
|
label="Nr."
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-[200px] md:w-[250px]">
|
className="w-20 md:w-24"
|
||||||
{t('projects.projectName')}
|
/>
|
||||||
</th>
|
<SortableHeader
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-20 md:w-24 hidden lg:table-cell">
|
columnKey="project_name"
|
||||||
{t('projects.address')}
|
label={t('projects.projectName')}
|
||||||
</th>
|
className="w-[200px] md:w-[250px]"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-16 md:w-20 hidden sm:table-cell">
|
/>
|
||||||
WP
|
<SortableHeader
|
||||||
</th>
|
columnKey="address"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16 hidden md:table-cell">
|
label={t('projects.address')}
|
||||||
{t('projects.city')}
|
className="w-20 md:w-24 hidden lg:table-cell"
|
||||||
</th>
|
/>
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16 hidden sm:table-cell">
|
<SortableHeader
|
||||||
{t('projects.plot')}
|
columnKey="wp"
|
||||||
</th>
|
label="WP"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-18 md:w-20 hidden md:table-cell">
|
className="w-16 md:w-20 hidden sm:table-cell"
|
||||||
{t('projects.finishDate')}
|
/>
|
||||||
</th>
|
<SortableHeader
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
|
columnKey="city"
|
||||||
{t('common.type') || 'Typ'}
|
label={t('projects.city')}
|
||||||
</th>
|
className="w-14 md:w-16 hidden md:table-cell"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
|
/>
|
||||||
{t('common.status') || 'Status'}
|
<SortableHeader
|
||||||
</th>
|
columnKey="plot"
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16">
|
label={t('projects.plot')}
|
||||||
{t('projects.assigned') || 'Przypisany'}
|
className="w-14 md:w-16 hidden sm:table-cell"
|
||||||
</th>
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
columnKey="finish_date"
|
||||||
|
label={t('projects.finishDate')}
|
||||||
|
className="w-18 md:w-20 hidden md:table-cell"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
columnKey="project_type"
|
||||||
|
label={t('common.type') || 'Typ'}
|
||||||
|
className="w-10"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
columnKey="project_status"
|
||||||
|
label={t('common.status') || 'Status'}
|
||||||
|
className="w-10"
|
||||||
|
/>
|
||||||
|
<SortableHeader
|
||||||
|
columnKey="assigned_to"
|
||||||
|
label={t('projects.assigned') || 'Przypisany'}
|
||||||
|
className="w-14 md:w-16"
|
||||||
|
/>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ const translations = {
|
|||||||
plot: "Działka",
|
plot: "Działka",
|
||||||
district: "Jednostka ewidencyjna",
|
district: "Jednostka ewidencyjna",
|
||||||
unit: "Obręb",
|
unit: "Obręb",
|
||||||
finishDate: "Data zakończenia",
|
finishDate: "Termin zakończenia",
|
||||||
type: "Typ",
|
type: "Typ",
|
||||||
contact: "Kontakt",
|
contact: "Kontakt",
|
||||||
coordinates: "Współrzędne",
|
coordinates: "Współrzędne",
|
||||||
@@ -294,7 +294,7 @@ const translations = {
|
|||||||
customer: "Klient",
|
customer: "Klient",
|
||||||
investor: "Inwestor",
|
investor: "Inwestor",
|
||||||
dateSigned: "Data zawarcia",
|
dateSigned: "Data zawarcia",
|
||||||
finishDate: "Data zakończenia",
|
finishDate: "Termin zakończenia",
|
||||||
searchPlaceholder: "Szukaj umów po numerze, nazwie, kliencie lub inwestorze...",
|
searchPlaceholder: "Szukaj umów po numerze, nazwie, kliencie lub inwestorze...",
|
||||||
noContracts: "Brak umów",
|
noContracts: "Brak umów",
|
||||||
noContractsMessage: "Rozpocznij od utworzenia swojej pierwszej umowy.",
|
noContractsMessage: "Rozpocznij od utworzenia swojej pierwszej umowy.",
|
||||||
@@ -325,7 +325,7 @@ const translations = {
|
|||||||
customer: "Klient",
|
customer: "Klient",
|
||||||
investor: "Inwestor",
|
investor: "Inwestor",
|
||||||
dateSigned: "Data zawarcia",
|
dateSigned: "Data zawarcia",
|
||||||
finishDate: "Data zakończenia",
|
finishDate: "Termin zakończenia",
|
||||||
summary: "Podsumowanie",
|
summary: "Podsumowanie",
|
||||||
projectsCount: "Liczba projektów",
|
projectsCount: "Liczba projektów",
|
||||||
projects: "projektów",
|
projects: "projektów",
|
||||||
@@ -534,7 +534,7 @@ const translations = {
|
|||||||
dateCreated: "Data utworzenia",
|
dateCreated: "Data utworzenia",
|
||||||
dateModified: "Data modyfikacji",
|
dateModified: "Data modyfikacji",
|
||||||
startDate: "Data rozpoczęcia",
|
startDate: "Data rozpoczęcia",
|
||||||
finishDate: "Data zakończenia"
|
finishDate: "Termin zakończenia"
|
||||||
},
|
},
|
||||||
|
|
||||||
// Date formats
|
// Date formats
|
||||||
|
|||||||
Reference in New Issue
Block a user