- Added dark mode styles to TaskStatusDropdown, TaskStatusDropdownDebug, and TaskStatusDropdownSimple components. - Introduced ThemeProvider and useTheme hook for managing theme state. - Updated Button, Card, Input, Loading, Navigation, PageContainer, PageHeader, ProjectCalendarWidget, ProjectMap, SearchBar, States, Tooltip, and other UI components to support dark mode. - Created ThemeToggle component for switching between light and dark modes. - Enhanced i18n translations for settings related to theme and language preferences. - Configured Tailwind CSS to support dark mode with class-based toggling.
332 lines
9.2 KiB
JavaScript
332 lines
9.2 KiB
JavaScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { Card, CardHeader, CardContent } from "./Card";
|
|
import Badge from "./Badge";
|
|
import Button from "./Button";
|
|
import { formatDate } from "@/lib/utils";
|
|
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",
|
|
fulfilled: "bg-gray-100 text-gray-800",
|
|
cancelled: "bg-red-100 text-red-800",
|
|
};
|
|
|
|
const statusTranslations = {
|
|
registered: "Zarejestrowany",
|
|
approved: "Zatwierdzony",
|
|
pending: "Oczekujący",
|
|
in_progress: "W trakcie",
|
|
fulfilled: "Zakończony",
|
|
cancelled: "Wycofany",
|
|
};
|
|
|
|
export default function ProjectCalendarWidget({
|
|
projects = [],
|
|
compact = false,
|
|
showUpcoming = true,
|
|
maxUpcoming = 5
|
|
}) {
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [viewMode, setViewMode] = useState(compact ? 'upcoming' : 'mini-calendar');
|
|
|
|
// Filter projects that have finish dates and are not fulfilled
|
|
const activeProjects = projects.filter(p =>
|
|
p.finish_date && p.project_status !== 'fulfilled'
|
|
);
|
|
|
|
const getProjectsForDate = (date) => {
|
|
return activeProjects.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, 4);
|
|
|
|
return activeProjects
|
|
.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;
|
|
})
|
|
.slice(0, maxUpcoming);
|
|
};
|
|
|
|
const getOverdueProjects = () => {
|
|
const today = startOfDay(new Date());
|
|
|
|
return activeProjects
|
|
.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
|
|
})
|
|
.slice(0, maxUpcoming);
|
|
};
|
|
|
|
const renderMiniCalendar = () => {
|
|
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 = ['P', 'W', 'Ś', 'C', 'P', 'S', 'N'];
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Calendar Header */}
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-gray-900">
|
|
{format(currentDate, 'LLLL yyyy', { locale: pl })}
|
|
</h3>
|
|
<div className="flex space-x-1">
|
|
<button
|
|
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
|
|
className="p-1 hover:bg-gray-100 rounded"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
|
|
className="p-1 hover:bg-gray-100 rounded"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Weekday Headers */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{weekdays.map(weekday => (
|
|
<div key={weekday} className="text-xs font-medium text-gray-500 text-center p-1">
|
|
{weekday}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Calendar Grid */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{days.map((day, index) => {
|
|
const dayProjects = getProjectsForDate(day);
|
|
const isCurrentMonth = isSameMonth(day, currentDate);
|
|
const isToday = isSameDay(day, new Date());
|
|
const hasProjects = dayProjects.length > 0;
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={`text-xs p-1 text-center relative ${
|
|
!isCurrentMonth ? 'text-gray-300' :
|
|
isToday ? 'bg-blue-100 text-blue-700 font-semibold rounded' :
|
|
'text-gray-700'
|
|
} ${hasProjects && isCurrentMonth ? 'font-semibold' : ''}`}
|
|
title={hasProjects ? `${dayProjects.length} projekt(ów): ${dayProjects.map(p => p.project_name).join(', ')}` : ''}
|
|
>
|
|
{format(day, 'd')}
|
|
{hasProjects && isCurrentMonth && (
|
|
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-1 h-1 bg-red-500 rounded-full"></div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderUpcomingList = () => {
|
|
const upcomingProjects = getUpcomingProjects();
|
|
const overdueProjects = getOverdueProjects();
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Overdue Projects */}
|
|
{overdueProjects.length > 0 && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-red-600 mb-2">
|
|
Przeterminowane ({overdueProjects.length})
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{overdueProjects.map(project => (
|
|
<Link
|
|
key={project.project_id}
|
|
href={`/projects/${project.project_id}`}
|
|
className="block p-2 bg-red-50 rounded border border-red-200 hover:bg-red-100 transition-colors"
|
|
>
|
|
<div className="text-sm font-medium text-gray-900 truncate">
|
|
{project.project_name}
|
|
</div>
|
|
<div className="text-xs text-red-600">
|
|
{formatDate(project.finish_date)}
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upcoming Projects */}
|
|
{upcomingProjects.length > 0 && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-900 mb-2">
|
|
Nadchodzące ({upcomingProjects.length})
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{upcomingProjects.map(project => {
|
|
const daysUntilDeadline = Math.ceil((parseISO(project.finish_date) - new Date()) / (1000 * 60 * 60 * 24));
|
|
|
|
return (
|
|
<Link
|
|
key={project.project_id}
|
|
href={`/projects/${project.project_id}`}
|
|
className="block p-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors"
|
|
>
|
|
<div className="text-sm font-medium text-gray-900 truncate">
|
|
{project.project_name}
|
|
</div>
|
|
<div className="text-xs text-gray-600">
|
|
{formatDate(project.finish_date)} • za {daysUntilDeadline} dni
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{upcomingProjects.length === 0 && overdueProjects.length === 0 && (
|
|
<div className="text-sm text-gray-500 text-center py-4">
|
|
Brak nadchodzących terminów
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
if (activeProjects.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">Kalendarz projektów</h3>
|
|
<Link href="/calendar">
|
|
<Button variant="outline" size="sm">
|
|
Zobacz pełny kalendarz
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
Brak aktywnych projektów z terminami
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">Kalendarz projektów</h3>
|
|
<div className="flex items-center space-x-2">
|
|
{!compact && (
|
|
<div className="flex rounded-md bg-gray-100 p-1">
|
|
<button
|
|
onClick={() => setViewMode('mini-calendar')}
|
|
className={`px-2 py-1 text-xs rounded ${
|
|
viewMode === 'mini-calendar'
|
|
? 'bg-white text-gray-900 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
Kalendarz
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('upcoming')}
|
|
className={`px-2 py-1 text-xs rounded ${
|
|
viewMode === 'upcoming'
|
|
? 'bg-white text-gray-900 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
Lista
|
|
</button>
|
|
</div>
|
|
)}
|
|
<Link href="/calendar">
|
|
<Button variant="outline" size="sm">
|
|
Pełny kalendarz
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{viewMode === 'mini-calendar' ? renderMiniCalendar() : renderUpcomingList()}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|