435 lines
13 KiB
JavaScript
435 lines
13 KiB
JavaScript
"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 PageContainer from "@/components/ui/PageContainer";
|
|
import PageHeader from "@/components/ui/PageHeader";
|
|
import { LoadingState } from "@/components/ui/States";
|
|
import { formatDate } from "@/lib/utils";
|
|
import { useTranslation } from "@/lib/i18n";
|
|
import {
|
|
format,
|
|
startOfMonth,
|
|
endOfMonth,
|
|
startOfWeek,
|
|
endOfWeek,
|
|
addDays,
|
|
isSameMonth,
|
|
isSameDay,
|
|
addMonths,
|
|
subMonths,
|
|
parseISO,
|
|
isAfter,
|
|
isBefore,
|
|
startOfDay,
|
|
addWeeks
|
|
} from "date-fns";
|
|
import { pl } from "date-fns/locale";
|
|
|
|
const statusColors = {
|
|
registered: "bg-blue-100 text-blue-800",
|
|
approved: "bg-green-100 text-green-800",
|
|
pending: "bg-yellow-100 text-yellow-800",
|
|
in_progress: "bg-orange-100 text-orange-800",
|
|
in_progress_design: "bg-purple-100 text-purple-800",
|
|
in_progress_construction: "bg-indigo-100 text-indigo-800",
|
|
fulfilled: "bg-gray-100 text-gray-800",
|
|
cancelled: "bg-red-100 text-red-800",
|
|
};
|
|
|
|
const getStatusTranslation = (status) => {
|
|
const translations = {
|
|
registered: "Zarejestrowany",
|
|
approved: "Zatwierdzony",
|
|
pending: "Oczekujący",
|
|
in_progress: "W trakcie",
|
|
in_progress_design: "W realizacji (projektowanie)",
|
|
in_progress_construction: "W realizacji (realizacja)",
|
|
fulfilled: "Zakończony",
|
|
cancelled: "Wycofany",
|
|
};
|
|
return translations[status] || status;
|
|
};
|
|
|
|
export default function ProjectCalendarPage() {
|
|
const { t } = useTranslation();
|
|
const [projects, setProjects] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [viewMode, setViewMode] = useState('month'); // 'month' or 'upcoming'
|
|
const [downloading, setDownloading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetch("/api/projects")
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
// Filter projects that have finish dates and are not fulfilled
|
|
const projectsWithDates = data.filter(p =>
|
|
p.finish_date && p.project_status !== 'fulfilled'
|
|
);
|
|
setProjects(projectsWithDates);
|
|
setLoading(false);
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error fetching projects:", error);
|
|
setLoading(false);
|
|
});
|
|
}, []);
|
|
|
|
const getProjectsForDate = (date) => {
|
|
return projects.filter(project => {
|
|
if (!project.finish_date) return false;
|
|
try {
|
|
const projectDate = parseISO(project.finish_date);
|
|
return isSameDay(projectDate, date);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
});
|
|
};
|
|
|
|
const getUpcomingProjects = () => {
|
|
const today = startOfDay(new Date());
|
|
const nextMonth = addWeeks(today, 5); // Next 5 weeks
|
|
|
|
return projects
|
|
.filter(project => {
|
|
if (!project.finish_date) return false;
|
|
try {
|
|
const projectDate = parseISO(project.finish_date);
|
|
return isAfter(projectDate, today) && isBefore(projectDate, nextMonth);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
})
|
|
.sort((a, b) => {
|
|
const dateA = parseISO(a.finish_date);
|
|
const dateB = parseISO(b.finish_date);
|
|
return dateA - dateB;
|
|
});
|
|
};
|
|
|
|
const getOverdueProjects = () => {
|
|
const today = startOfDay(new Date());
|
|
|
|
return projects
|
|
.filter(project => {
|
|
if (!project.finish_date) return false;
|
|
try {
|
|
const projectDate = parseISO(project.finish_date);
|
|
return isBefore(projectDate, today);
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
})
|
|
.sort((a, b) => {
|
|
const dateA = parseISO(a.finish_date);
|
|
const dateB = parseISO(b.finish_date);
|
|
return dateB - dateA; // Most recently overdue first
|
|
});
|
|
};
|
|
|
|
const handleDownloadReport = async () => {
|
|
setDownloading(true);
|
|
try {
|
|
const response = await fetch('/api/reports/upcoming-projects');
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to download report');
|
|
}
|
|
|
|
// Get the blob from the response
|
|
const blob = await response.blob();
|
|
|
|
// Create a download link
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
|
|
// Clean up
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error('Error downloading report:', error);
|
|
alert('Błąd podczas pobierania raportu');
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
};
|
|
|
|
const renderCalendarGrid = () => {
|
|
const monthStart = startOfMonth(currentDate);
|
|
const monthEnd = endOfMonth(currentDate);
|
|
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
|
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
|
|
|
const days = [];
|
|
let day = calendarStart;
|
|
|
|
while (day <= calendarEnd) {
|
|
days.push(day);
|
|
day = addDays(day, 1);
|
|
}
|
|
|
|
const weekdays = ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Nie'];
|
|
|
|
return (
|
|
<div className="bg-white rounded-lg shadow">
|
|
{/* Calendar Header */}
|
|
<div className="p-4 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
{format(currentDate, 'LLLL yyyy', { locale: pl })}
|
|
</h2>
|
|
<div className="flex space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
|
|
>
|
|
←
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentDate(new Date())}
|
|
>
|
|
Dziś
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
|
|
>
|
|
→
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Weekday Headers */}
|
|
<div className="grid grid-cols-7 border-b border-gray-200">
|
|
{weekdays.map(weekday => (
|
|
<div key={weekday} className="p-2 text-sm font-medium text-gray-500 text-center">
|
|
{weekday}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Calendar Grid */}
|
|
<div className="grid grid-cols-7">
|
|
{days.map((day, index) => {
|
|
const dayProjects = getProjectsForDate(day);
|
|
const isCurrentMonth = isSameMonth(day, currentDate);
|
|
const isToday = isSameDay(day, new Date());
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={`min-h-[120px] p-2 border-r border-b border-gray-100 ${
|
|
!isCurrentMonth ? 'bg-gray-50' : 'bg-white'
|
|
} ${isToday ? 'bg-blue-50' : ''}`}
|
|
>
|
|
<div className={`text-sm font-medium mb-2 ${
|
|
!isCurrentMonth ? 'text-gray-400' : isToday ? 'text-blue-600' : 'text-gray-900'
|
|
}`}>
|
|
{format(day, 'd')}
|
|
</div>
|
|
|
|
{dayProjects.length > 0 && (
|
|
<div className="space-y-1">
|
|
{dayProjects.slice(0, 3).map(project => (
|
|
<Link
|
|
key={project.project_id}
|
|
href={`/projects/${project.project_id}`}
|
|
className="block"
|
|
>
|
|
<div className={`text-xs p-1 rounded truncate ${
|
|
statusColors[project.project_status] || statusColors.registered
|
|
} hover:opacity-80 transition-opacity`}>
|
|
{project.project_name}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
{dayProjects.length > 3 && (
|
|
<div className="relative group">
|
|
<div className="text-xs text-gray-500 p-1 cursor-pointer">
|
|
+{dayProjects.length - 3} więcej
|
|
</div>
|
|
<div className="absolute left-0 top-full mt-1 bg-white border border-gray-200 rounded shadow-lg p-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto max-w-xs">
|
|
<div className="space-y-1">
|
|
{dayProjects.slice(3).map(project => (
|
|
<Link
|
|
key={project.project_id}
|
|
href={`/projects/${project.project_id}`}
|
|
className="block"
|
|
>
|
|
<div className={`text-xs p-1 rounded truncate ${
|
|
statusColors[project.project_status] || statusColors.registered
|
|
} hover:opacity-80 transition-opacity`}>
|
|
{project.project_name}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderUpcomingView = () => {
|
|
const upcomingProjects = getUpcomingProjects().filter(project => project.project_status !== 'cancelled');
|
|
const overdueProjects = getOverdueProjects().filter(project => project.project_status !== 'cancelled');
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Upcoming Projects */}
|
|
<Card>
|
|
<CardHeader>
|
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
Nadchodzące terminy ({upcomingProjects.length})
|
|
</h3>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{upcomingProjects.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{upcomingProjects.map(project => {
|
|
const daysUntilDeadline = Math.ceil((parseISO(project.finish_date) - new Date()) / (1000 * 60 * 60 * 24));
|
|
|
|
return (
|
|
<div key={project.project_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
|
<div className="flex-1">
|
|
<Link
|
|
href={`/projects/${project.project_id}`}
|
|
className="font-medium text-gray-900 hover:text-blue-600"
|
|
>
|
|
{project.project_name}
|
|
</Link>
|
|
<div className="text-sm text-gray-600 mt-1">
|
|
{project.customer && `${project.customer} • `}
|
|
{project.address}
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{formatDate(project.finish_date)}
|
|
</div>
|
|
<div className="text-xs text-gray-500">
|
|
za {daysUntilDeadline} dni
|
|
</div>
|
|
<Badge className={statusColors[project.project_status] || statusColors.registered}>
|
|
{getStatusTranslation(project.project_status) || project.project_status}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-center py-8">
|
|
Brak nadchodzących projektów w następnych 4 tygodniach
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Overdue Projects */}
|
|
{overdueProjects.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<h3 className="text-lg font-semibold text-red-600">
|
|
Projekty przeterminowane ({overdueProjects.length})
|
|
</h3>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{overdueProjects.map(project => (
|
|
<div key={project.project_id} className="flex items-center justify-between p-3 bg-red-50 rounded-lg border border-red-200">
|
|
<div className="flex-1">
|
|
<Link
|
|
href={`/projects/${project.project_id}`}
|
|
className="font-medium text-gray-900 hover:text-blue-600"
|
|
>
|
|
{project.project_name}
|
|
</Link>
|
|
<div className="text-sm text-gray-600 mt-1">
|
|
{project.customer && `${project.customer} • `}
|
|
{project.address}
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-sm font-medium text-red-600">
|
|
{formatDate(project.finish_date)}
|
|
</div>
|
|
<Badge className={statusColors[project.project_status] || statusColors.registered}>
|
|
{getStatusTranslation(project.project_status) || project.project_status}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Kalendarz projektów"
|
|
subtitle={`${projects.length} aktywnych projektów z terminami`}
|
|
>
|
|
<div className="flex space-x-2">
|
|
<Button
|
|
variant={viewMode === 'month' ? 'primary' : 'outline'}
|
|
onClick={() => setViewMode('month')}
|
|
>
|
|
Kalendarz
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'upcoming' ? 'primary' : 'outline'}
|
|
onClick={() => setViewMode('upcoming')}
|
|
>
|
|
Lista terminów
|
|
</Button>
|
|
</div>
|
|
</PageHeader>
|
|
|
|
<div className="mb-4 flex justify-end">
|
|
<button
|
|
onClick={handleDownloadReport}
|
|
disabled={downloading}
|
|
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed rounded transition-colors"
|
|
title={downloading ? 'Pobieranie...' : 'Eksportuj raport nadchodzących projektów do Excel'}
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
|
|
<path strokeLinecap="round" strokeLinejoin="round" 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>
|
|
</button>
|
|
</div>
|
|
|
|
{viewMode === 'month' ? renderCalendarGrid() : renderUpcomingView()}
|
|
</PageContainer>
|
|
);
|
|
}
|