Files
panel/src/app/projects/page.js

662 lines
25 KiB
JavaScript
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.

"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
import { Input } from "@/components/ui/Input";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import SearchBar from "@/components/ui/SearchBar";
import { LoadingState } from "@/components/ui/States";
import { formatDate } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
import { useSession } from "next-auth/react";
export default function ProjectListPage() {
const { t } = useTranslation();
const { data: session } = useSession();
const [projects, setProjects] = useState([]);
const [searchTerm, setSearchTerm] = useState("");
const [filteredProjects, setFilteredProjects] = useState([]);
const [filters, setFilters] = useState({
status: 'all',
type: 'all',
customer: 'all',
mine: false
});
const [customers, setCustomers] = useState([]);
const [filtersExpanded, setFiltersExpanded] = useState(true); // Start expanded on mobile so users know filters exist
useEffect(() => {
fetch("/api/projects")
.then((res) => res.json())
.then((data) => {
setProjects(data);
setFilteredProjects(data);
// Extract unique customers for filter
const uniqueCustomers = [...new Set(data.map(p => p.customer).filter(Boolean))];
setCustomers(uniqueCustomers);
});
}, []);
// Filter projects based on search term and filters
useEffect(() => {
let filtered = projects;
// Apply status filter
if (filters.status !== 'all') {
if (filters.status === 'not_finished') {
filtered = filtered.filter(project => project.project_status !== 'fulfilled');
} else {
filtered = filtered.filter(project => project.project_status === filters.status);
}
}
// Apply type filter
if (filters.type !== 'all') {
filtered = filtered.filter(project => project.project_type === filters.type);
}
// Apply customer filter
if (filters.customer !== 'all') {
filtered = filtered.filter(project => project.customer === filters.customer);
}
// Apply mine filter
if (filters.mine && session?.user?.id) {
filtered = filtered.filter(project => project.assigned_to === session.user.id);
}
// Apply search term
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter((project) => {
return (
project.project_name?.toLowerCase().includes(searchLower) ||
project.wp?.toLowerCase().includes(searchLower) ||
project.plot?.toLowerCase().includes(searchLower) ||
project.investment_number?.toLowerCase().includes(searchLower) ||
project.address?.toLowerCase().includes(searchLower) ||
project.customer?.toLowerCase().includes(searchLower) ||
project.investor?.toLowerCase().includes(searchLower)
);
});
}
setFilteredProjects(filtered);
}, [searchTerm, projects, filters, session]);
async function handleDelete(id) {
const confirmed = confirm(t('projects.deleteConfirm'));
if (!confirmed) return;
const res = await fetch(`/api/projects/${id}`, {
method: "DELETE",
});
if (res.ok) {
setProjects((prev) => prev.filter((p) => p.project_id !== id));
}
}
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
const handleFilterChange = (filterType, value) => {
setFilters(prev => ({
...prev,
[filterType]: value
}));
};
const clearAllFilters = () => {
setFilters({
status: 'all',
type: 'all',
customer: 'all',
mine: false
});
setSearchTerm('');
};
const handleExportExcel = async () => {
try {
const response = await fetch('/api/projects/export');
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
alert('Failed to export projects. Please try again.');
}
} catch (error) {
console.error('Export error:', error);
alert('An error occurred during export. Please try again.');
}
};
const toggleFilters = () => {
setFiltersExpanded(!filtersExpanded);
};
const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm.trim() !== '';
const getActiveFilterCount = () => {
let count = 0;
if (filters.status !== 'all') count++;
if (filters.type !== 'all') count++;
if (filters.customer !== 'all') count++;
if (filters.mine) count++;
if (searchTerm.trim()) count++;
return count;
};
const getStatusLabel = (status) => {
switch(status) {
case "registered": return t('projectStatus.registered');
case "in_progress_design": return t('projectStatus.in_progress_design');
case "in_progress_construction": return t('projectStatus.in_progress_construction');
case "fulfilled": return t('projectStatus.fulfilled');
case "cancelled": return t('projectStatus.cancelled');
default: return "-";
}
};
const getTypeLabel = (type) => {
switch(type) {
case "design": return t('projectType.design');
case "construction": return t('projectType.construction');
case "design+construction": return t('projectType.design+construction');
default: return "-";
}
};
return (
<PageContainer>
<PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
<div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-2 sm:gap-2">
<Link href="/projects/map" className="w-full sm:w-auto">
<Button variant="outline" size="lg" className="w-full">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
{t('projects.mapView') || 'Widok mapy'}
</Button>
</Link>
{session?.user && (
<Button
variant={filters.mine ? "primary" : "outline"}
size="lg"
className="w-full sm:w-auto"
onClick={() => handleFilterChange('mine', !filters.mine)}
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{t('projects.mine') || 'Moje'}
</Button>
)}
<Button
variant="outline"
size="lg"
className="w-full sm:w-auto"
onClick={handleExportExcel}
>
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
{t('projects.exportExcel') || 'Export to Excel'}
</Button>
<Link href="/projects/new" className="w-full sm:w-auto">
<Button variant="primary" size="lg" className="w-full">
<svg
className="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
{t('projects.newProject')}
</Button>
</Link>
</div>
</PageHeader>
<SearchBar
searchTerm={searchTerm}
onSearchChange={handleSearchChange}
placeholder={t('projects.searchPlaceholder')}
resultsCount={filteredProjects.length}
resultsText={t('projects.projects') || 'projektów'}
/>
{/* Filters */}
<Card className="mb-6">
{/* Mobile collapsible header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 transition-colors md:hidden"
onClick={toggleFilters}
>
<div className="flex items-center space-x-3">
<svg
className={`w-5 h-5 text-gray-500 transition-transform duration-200 ${filtersExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
<h3 className="text-sm font-medium text-gray-900">
{t('common.filters') || 'Filtry'}
{hasActiveFilters && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{getActiveFilterCount()}
</span>
)}
</h3>
</div>
<div className="flex items-center space-x-2">
{hasActiveFilters && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
clearAllFilters();
}}
className="text-xs"
>
{t('common.clearAll') || 'Wyczyść'}
</Button>
)}
<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>
</div>
</div>
{/* Mobile collapsible content */}
<div className={`overflow-hidden transition-all duration-300 ease-in-out md:hidden ${filtersExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'}`}>
<div className="px-4 pb-4 border-t border-gray-100">
<div className="flex flex-col space-y-4 md:flex-row md:flex-wrap md:gap-4 md:space-y-0 md:items-center pt-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('common.status') || 'Status'}:
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
<option value="not_finished">{t('projects.notFinished') || 'Nie zakończone'}</option>
<option value="registered">{t('projectStatus.registered')}</option>
<option value="in_progress_design">{t('projectStatus.in_progress_design')}</option>
<option value="in_progress_construction">{t('projectStatus.in_progress_construction')}</option>
<option value="fulfilled">{t('projectStatus.fulfilled')}</option>
</select>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('common.type') || 'Typ'}:
</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
<option value="design">{t('projectType.design')}</option>
<option value="construction">{t('projectType.construction')}</option>
<option value="design+construction">{t('projectType.design+construction')}</option>
</select>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('contracts.customer') || 'Klient'}:
</label>
<select
value={filters.customer}
onChange={(e) => handleFilterChange('customer', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
{customers.map((customer) => (
<option key={customer} value={customer}>
{customer}
</option>
))}
</select>
</div>
</div>
</div>
</div>
{/* Desktop always visible content */}
<div className="hidden md:block">
<div className="p-4">
<div className="flex flex-col space-y-4 md:flex-row md:flex-wrap md:gap-4 md:space-y-0 md:items-center">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('common.status') || 'Status'}:
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
<option value="not_finished">{t('projects.notFinished') || 'Nie zakończone'}</option>
<option value="registered">{t('projectStatus.registered')}</option>
<option value="in_progress_design">{t('projectStatus.in_progress_design')}</option>
<option value="in_progress_construction">{t('projectStatus.in_progress_construction')}</option>
<option value="fulfilled">{t('projectStatus.fulfilled')}</option>
</select>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('common.type') || 'Typ'}:
</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
<option value="design">{t('projectType.design')}</option>
<option value="construction">{t('projectType.construction')}</option>
<option value="design+construction">{t('projectType.design+construction')}</option>
</select>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('contracts.customer') || 'Klient'}:
</label>
<select
value={filters.customer}
onChange={(e) => handleFilterChange('customer', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
{customers.map((customer) => (
<option key={customer} value={customer}>
{customer}
</option>
))}
</select>
</div>
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || searchTerm) && (
<Button
variant="outline"
size="sm"
onClick={clearAllFilters}
className="text-xs self-start md:self-auto"
>
{t('common.clearAllFilters') || 'Wyczyść wszystkie filtry'}
</Button>
)}
<div className="text-sm text-gray-500 md:ml-auto md:text-right">
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
</div>
</div>
</div>
</div>
</Card>
{filteredProjects.length === 0 && searchTerm ? (
<Card>
<CardContent className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg
className="w-16 h-16 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{t('common.noResults')}
</h3>
<p className="text-gray-500 mb-6">
{t('projects.noMatchingResults') || 'Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.'}
</p>
<Button variant="outline" onClick={() => setSearchTerm("")}>
{t('common.clearSearch') || 'Wyczyść wyszukiwanie'}
</Button>
</CardContent>
</Card>
) : projects.length === 0 ? (
<Card>
<CardContent className="text-center py-12">
<div className="text-gray-400 mb-4">
<svg
className="w-16 h-16 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V8zm0 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1v-2z"
clipRule="evenodd"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{t('projects.noProjects')}
</h3>
<p className="text-gray-500 mb-6">
{t('projects.noProjectsMessage')}
</p>
<Link href="/projects/new">
<Button variant="primary">{t('projects.createFirstProject') || 'Utwórz pierwszy projekt'}</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{/* Mobile scroll container */}
<div className="overflow-x-auto">
<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>
</tr>
</thead>
<tbody>
{filteredProjects.map((project, index) => (
<tr
key={project.project_id}
className={`border-b dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
index % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-25 dark:bg-gray-750"
}`}
>
<td className="px-1 py-3">
<Badge variant="secondary" size="sm" className="text-xs">
{project.project_number}
</Badge>
</td>
<td className="px-2 py-3 w-[200px] md:w-[250px]">
<Link
href={`/projects/${project.project_id}`}
className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block"
title={project.project_name}
>
<span className="block sm:hidden">
{project.project_name.length > 20
? `${project.project_name.substring(0, 20)}...`
: project.project_name}
</span>
<span className="hidden sm:block">
{project.project_name}
</span>
</Link>
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden lg:table-cell"
title={project.address}
>
{project.address || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden sm:table-cell"
title={project.wp}
>
{project.wp || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden md:table-cell"
title={project.city}
>
{project.city || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden sm:table-cell"
title={project.plot}
>
{project.plot || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden md:table-cell"
title={project.finish_date}
>
{project.finish_date
? formatDate(project.finish_date)
: "N/A"}
</td>
<td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 text-center font-semibold">
{project.project_type === "design"
? "P"
: project.project_type === "construction"
? "B"
: project.project_type === "design+construction"
? "P+B"
: "-"}
</td>
<td className="px-2 py-3 text-xs text-gray-600">
<div className="flex justify-center items-center h-full">
{project.project_status === 'registered' ? (
<span className="text-red-500 font-bold text-sm" title={t('projectStatus.registered')}>N</span>
) : project.project_status === 'in_progress_design' ? (
<span className="inline-block w-3 h-3 bg-blue-500 rounded-full" title={t('projectStatus.in_progress_design')}></span>
) : project.project_status === 'in_progress_construction' ? (
<span className="inline-block w-3 h-3 bg-yellow-400 rounded-full" title={t('projectStatus.in_progress_construction')}></span>
) : project.project_status === 'fulfilled' ? (
<span className="inline-block w-3 h-3 bg-green-500 rounded-full" title={t('projectStatus.fulfilled')}></span>
) : project.project_status === 'cancelled' ? (
<span className="text-red-500 font-bold text-lg" title={t('projectStatus.cancelled')}>×</span>
) : (
<span title="Unknown status">-</span>
)}
</div>
</td>
<td className="px-2 py-3">
{project.assigned_to_initial ? (
<div className="flex items-center justify-center">
<span className="inline-flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-xs font-semibold">
{project.assigned_to_initial}
</span>
</div>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</PageContainer>
);
}