feat: Implement sortable project list with dynamic sorting functionality

This commit is contained in:
2026-01-22 21:43:08 +01:00
parent d49bea8f15
commit 84f63c37ce
2 changed files with 161 additions and 37 deletions

View File

@@ -29,6 +29,10 @@ export default function ProjectListPage() {
});
const [customers, setCustomers] = useState([]);
const [sortConfig, setSortConfig] = useState({
key: 'finish_date',
direction: 'desc'
});
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
useEffect(() => {
@@ -125,8 +129,44 @@ export default function ProjectListPage() {
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);
}, [searchTerm, projects, filters, session]);
}, [searchTerm, projects, filters, session, sortConfig]);
async function handleDelete(id) {
const confirmed = confirm(t('projects.deleteConfirm'));
@@ -171,6 +211,13 @@ export default function ProjectListPage() {
setSearchTerm('');
};
const handleSort = (key) => {
setSortConfig(prev => ({
key,
direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc'
}));
};
const handleExportExcel = async () => {
try {
const response = await fetch('/api/projects/export');
@@ -228,6 +275,42 @@ export default function ProjectListPage() {
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 (
<PageContainer>
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
@@ -606,9 +689,30 @@ export default function ProjectListPage() {
{/* Results and clear button row */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div className="flex items-center gap-4">
<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>
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && (
<Button
@@ -699,36 +803,56 @@ export default function ProjectListPage() {
<table className="w-full min-w-[600px] table-fixed">
<thead>
<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">
Nr.
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-[200px] md:w-[250px]">
{t('projects.projectName')}
</th>
<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">
{t('projects.address')}
</th>
<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
</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 md:table-cell">
{t('projects.city')}
</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">
{t('projects.plot')}
</th>
<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">
{t('projects.finishDate')}
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
{t('common.type') || 'Typ'}
</th>
<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'}
</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">
{t('projects.assigned') || 'Przypisany'}
</th>
<SortableHeader
columnKey="project_number"
label="Nr."
className="w-20 md:w-24"
/>
<SortableHeader
columnKey="project_name"
label={t('projects.projectName')}
className="w-[200px] md:w-[250px]"
/>
<SortableHeader
columnKey="address"
label={t('projects.address')}
className="w-20 md:w-24 hidden lg:table-cell"
/>
<SortableHeader
columnKey="wp"
label="WP"
className="w-16 md:w-20 hidden sm:table-cell"
/>
<SortableHeader
columnKey="city"
label={t('projects.city')}
className="w-14 md:w-16 hidden md:table-cell"
/>
<SortableHeader
columnKey="plot"
label={t('projects.plot')}
className="w-14 md:w-16 hidden sm:table-cell"
/>
<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>
</thead>
<tbody>

View File

@@ -186,7 +186,7 @@ const translations = {
plot: "Działka",
district: "Jednostka ewidencyjna",
unit: "Obręb",
finishDate: "Data zakończenia",
finishDate: "Termin zakończenia",
type: "Typ",
contact: "Kontakt",
coordinates: "Współrzędne",
@@ -294,7 +294,7 @@ const translations = {
customer: "Klient",
investor: "Inwestor",
dateSigned: "Data zawarcia",
finishDate: "Data zakończenia",
finishDate: "Termin zakończenia",
searchPlaceholder: "Szukaj umów po numerze, nazwie, kliencie lub inwestorze...",
noContracts: "Brak umów",
noContractsMessage: "Rozpocznij od utworzenia swojej pierwszej umowy.",
@@ -325,7 +325,7 @@ const translations = {
customer: "Klient",
investor: "Inwestor",
dateSigned: "Data zawarcia",
finishDate: "Data zakończenia",
finishDate: "Termin zakończenia",
summary: "Podsumowanie",
projectsCount: "Liczba projektów",
projects: "projektów",
@@ -534,7 +534,7 @@ const translations = {
dateCreated: "Data utworzenia",
dateModified: "Data modyfikacji",
startDate: "Data rozpoczęcia",
finishDate: "Data zakończenia"
finishDate: "Termin zakończenia"
},
// Date formats